From afa8122a39cff342340872056ff42925606373e3 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:24:45 +0200 Subject: [PATCH 001/266] feat: custom-theme api (#2359) * feat: custom-theme api * fix: openapi schema * fix: review * refactor: schema validation --- .../status-page/__tests__/status-page.test.ts | 193 ++++++++++++++++++ .../rpc/handlers/status-page/converters.ts | 26 +++ .../routes/rpc/handlers/status-page/index.ts | 51 ++++- .../routes/rpc/handlers/status-page/limits.ts | 10 + apps/server/static/openapi.yaml | 64 ++++++ packages/db/src/schema/pages/validation.ts | 44 +++- .../openstatus/status_page/v1/service.proto | 8 + .../status_page/v1/status_page.proto | 12 ++ packages/proto/gen/openapi.yaml | 64 ++++++ .../openstatus/status_page/v1/service_pb.ts | 20 +- .../status_page/v1/status_page_pb.ts | 41 +++- packages/services/src/page/schemas.ts | 37 +--- 12 files changed, 524 insertions(+), 46 deletions(-) diff --git a/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts index d153fd31..801187e3 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts @@ -67,6 +67,9 @@ beforeAll(async () => { await db.run( sql`UPDATE workspace SET limits = json_set(COALESCE(limits, '{}'), '$."status-subscribers"', json('true')) WHERE id = 1`, ); + await db.run( + sql`UPDATE workspace SET limits = json_set(COALESCE(limits, '{}'), '$."custom-theme"', json('true')) WHERE id = 1`, + ); // Clean up any existing test data await db @@ -427,6 +430,45 @@ describe("StatusPageService.CreateStatusPage", () => { await db.delete(page).where(eq(page.id, firstPage.id)); } }); + + test("creates a status page with a custom theme", async () => { + const res = await connectRequest( + "CreateStatusPage", + { + title: `${TEST_PREFIX}-custom-theme`, + slug: `${TEST_PREFIX}-custom-theme-slug`, + customTheme: { light: { "--primary": "hsl(24 94% 50%)" } }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.statusPage.customTheme?.light).toEqual({ + "--primary": "hsl(24 94% 50%)", + }); + + // Clean up + await db.delete(page).where(eq(page.id, Number(data.statusPage.id))); + }); + + test("returns 403 when creating with custom theme on free plan", async () => { + const res = await connectRequest( + "CreateStatusPage", + { + title: `${TEST_PREFIX}-custom-theme-denied`, + slug: `${TEST_PREFIX}-custom-theme-denied-slug`, + customTheme: { light: { "--primary": "red" } }, + }, + { "x-openstatus-key": "2" }, + ); + + expect(res.status).toBe(403); + + const data = await res.json(); + expect(data.message).toContain("Upgrade for custom theme"); + }); }); describe("StatusPageService.GetStatusPage", () => { @@ -705,6 +747,157 @@ describe("StatusPageService.UpdateStatusPage", () => { .set({ defaultLocale: "en", locales: null }) .where(eq(page.id, testPageToUpdateId)); }); + + test("updates and clears the custom theme", async () => { + const res = await connectRequest( + "UpdateStatusPage", + { + id: String(testPageToUpdateId), + customTheme: { + light: { "--primary": "hsl(24 94% 50%)" }, + dark: { "--background": "oklch(0.2 0 0)" }, + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.statusPage.customTheme?.light).toEqual({ + "--primary": "hsl(24 94% 50%)", + }); + expect(data.statusPage.customTheme?.dark).toEqual({ + "--background": "oklch(0.2 0 0)", + }); + + // Empty message clears the stored overrides + const clearRes = await connectRequest( + "UpdateStatusPage", + { + id: String(testPageToUpdateId), + customTheme: {}, + }, + { "x-openstatus-key": "1" }, + ); + + expect(clearRes.status).toBe(200); + + const clearData = await clearRes.json(); + expect(clearData.statusPage.customTheme).toBeUndefined(); + }); + + test("keeps the custom theme when the field is omitted or null", async () => { + await db + .update(page) + .set({ customTheme: { light: { "--primary": "red" } } }) + .where(eq(page.id, testPageToUpdateId)); + + try { + // Omitted field — unrelated update must not touch the stored theme + const omitRes = await connectRequest( + "UpdateStatusPage", + { + id: String(testPageToUpdateId), + title: `${TEST_PREFIX}-page-to-update`, + }, + { "x-openstatus-key": "1" }, + ); + + expect(omitRes.status).toBe(200); + + const omitData = await omitRes.json(); + expect(omitData.statusPage.customTheme?.light).toEqual({ + "--primary": "red", + }); + + // Explicit null — proto3 JSON treats null as absent, so it keeps too + const nullRes = await connectRequest( + "UpdateStatusPage", + { + id: String(testPageToUpdateId), + customTheme: null, + }, + { "x-openstatus-key": "1" }, + ); + + expect(nullRes.status).toBe(200); + + const nullData = await nullRes.json(); + expect(nullData.statusPage.customTheme?.light).toEqual({ + "--primary": "red", + }); + } finally { + await db + .update(page) + .set({ customTheme: null }) + .where(eq(page.id, testPageToUpdateId)); + } + }); + + test("rejects unsafe custom theme values", async () => { + for (const value of [ + "", + "red;} body { background: red", + "", + ]) { + const res = await connectRequest( + "UpdateStatusPage", + { + id: String(testPageToUpdateId), + customTheme: { light: { "--primary": value } }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + } + }); + + test("rejects unknown custom theme variables", async () => { + const res = await connectRequest( + "UpdateStatusPage", + { + id: String(testPageToUpdateId), + customTheme: { light: { "--not-a-var": "red" } }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + }); + + test("returns 403 when updating custom theme on free plan", async () => { + const freePage = await db + .insert(page) + .values({ + workspaceId: 2, + title: `${TEST_PREFIX}-free-theme`, + slug: `${TEST_PREFIX}-free-theme-slug`, + description: "", + customDomain: "", + }) + .returning() + .get(); + + try { + const res = await connectRequest( + "UpdateStatusPage", + { + id: String(freePage.id), + customTheme: { light: { "--primary": "red" } }, + }, + { "x-openstatus-key": "2" }, + ); + + expect(res.status).toBe(403); + + const data = await res.json(); + expect(data.message).toContain("Upgrade for custom theme"); + } finally { + await db.delete(page).where(eq(page.id, freePage.id)); + } + }); }); // ========================================================================== diff --git a/apps/server/src/routes/rpc/handlers/status-page/converters.ts b/apps/server/src/routes/rpc/handlers/status-page/converters.ts index 5452c018..7c29b732 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/converters.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/converters.ts @@ -1,5 +1,6 @@ import type { Locale } from "@openstatus/locales"; import type { + CustomTheme, PageComponent, PageComponentGroup, PageSubscriber, @@ -18,6 +19,10 @@ import { Locale as ProtoLocale, SubscriberSource, } from "@openstatus/proto/status_page/v1"; +import { + type CustomTheme as DbCustomTheme, + hasCustomTheme, +} from "@openstatus/theme-store"; /** * Database types @@ -40,6 +45,7 @@ type DBPage = { defaultLocale: Locale; locales: Locale[] | null; allowIndex: boolean; + customTheme?: DbCustomTheme | null; createdAt: Date | null; updatedAt: Date | null; }; @@ -304,6 +310,26 @@ export function dbPageToProto(page: DBPage): StatusPage { authEmailDomains: page.authEmailDomains?.split(",").filter(Boolean) ?? [], allowIndex: page.allowIndex ?? true, allowedIpRanges: page.allowedIpRanges ?? "", + customTheme: dbCustomThemeToProto(page.customTheme), + }; +} + +export function dbCustomThemeToProto( + customTheme: DbCustomTheme | null | undefined, +): CustomTheme | undefined { + if (!hasCustomTheme(customTheme)) return undefined; + // ThemeVars values are `string | undefined`; proto maps require `string`. + const pick = (vars?: Record) => { + const out: Record = {}; + for (const [name, value] of Object.entries(vars ?? {})) { + if (typeof value === "string") out[name] = value; + } + return out; + }; + return { + $typeName: "openstatus.status_page.v1.CustomTheme" as const, + light: pick(customTheme.light), + dark: pick(customTheme.dark), }; } diff --git a/apps/server/src/routes/rpc/handlers/status-page/index.ts b/apps/server/src/routes/rpc/handlers/status-page/index.ts index f16f314e..82f9a3a4 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/index.ts @@ -27,6 +27,7 @@ import { import type { ComponentDayBucket, ComponentEvent, + CustomTheme, GetPageComponentDailySummaryResponse, PageComponentDailySummary, StatusPageService, @@ -50,6 +51,7 @@ import { listPages, updatePageAppearance, updatePageCustomDomain, + updatePageCustomTheme, updatePageGeneral, updatePageLinks, updatePageLocales, @@ -64,7 +66,11 @@ import { upsertSelfSignupSubscriber, } from "@openstatus/services/page-subscriber"; import { getChannel } from "@openstatus/subscriptions"; -import { THEME_KEYS, type ThemeKey } from "@openstatus/theme-store"; +import { + THEME_KEYS, + type ThemeKey, + validateCustomTheme, +} from "@openstatus/theme-store"; import { toConnectError, toServiceCtx } from "../../adapter"; import { getRpcContext } from "../../interceptors"; @@ -107,6 +113,7 @@ import { } from "./errors"; import { checkCustomDomainLimit, + checkCustomThemeLimit, checkEmailDomainProtectionLimit, checkIpRestrictionLimit, checkNoIndexLimit, @@ -244,6 +251,24 @@ function validateAllowedIpRanges(ranges: string): string[] { return normalized; } +// Proto map values arrive unvalidated; check var names / safe values at the +// handler so callers get a readable InvalidArgument instead of the service's +// raw zod message. +function validateProtoCustomTheme(customTheme: CustomTheme): { + light: Record; + dark: Record; +} { + const input = { + light: { ...customTheme.light }, + dark: { ...customTheme.dark }, + }; + const result = validateCustomTheme(input); + if (!result.valid) { + throw new ConnectError(result.errors.join(" "), Code.InvalidArgument); + } + return input; +} + /** * Helper to get a component by ID with workspace scope. */ @@ -397,6 +422,12 @@ export const statusPageServiceImpl: ServiceImpl = { checkNoIndexLimit(limits); } + let customTheme: ReturnType | undefined; + if (req.customTheme !== undefined) { + checkCustomThemeLimit(limits); + customTheme = validateProtoCustomTheme(req.customTheme); + } + // `published` relies on DB default (false). The service's // CreatePageInput type doesn't surface the column, and its behavior // matches the legacy `published: false` write on create. @@ -425,6 +456,7 @@ export const statusPageServiceImpl: ServiceImpl = { defaultLocale, locales, allowIndex, + customTheme, }, }).catch((err) => { // Same handler-layer remap as the `i18n` pre-check above — @@ -639,6 +671,16 @@ export const statusPageServiceImpl: ServiceImpl = { req.customDomain !== undefined ? req.customDomain : undefined; const accessChanged = hasAccessType || hasAllowIndex; + // Omitted = keep; empty message = clear (the service maps an empty + // var set to null). + let customThemeForUpdate: + | ReturnType + | undefined; + if (req.customTheme !== undefined) { + checkCustomThemeLimit(limits); + customThemeForUpdate = validateProtoCustomTheme(req.customTheme); + } + // Wrap all per-section updates in a single transaction so partial // failures don't leave the page in a half-updated state. Each // per-section service call's internal `withTransaction` detects @@ -734,6 +776,13 @@ export const statusPageServiceImpl: ServiceImpl = { }); } + if (customThemeForUpdate !== undefined) { + await updatePageCustomTheme({ + ctx: txCtx, + input: { id: pageId, customTheme: customThemeForUpdate }, + }); + } + if (localesChanged) { await updatePageLocales({ ctx: txCtx, diff --git a/apps/server/src/routes/rpc/handlers/status-page/limits.ts b/apps/server/src/routes/rpc/handlers/status-page/limits.ts index 2ff3b262..86c5f72a 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/limits.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/limits.ts @@ -86,6 +86,16 @@ export function checkNoIndexLimit(limits: Limits): void { } } +/** + * Check if the custom theme feature is available on the workspace plan. + * Throws ConnectError with PermissionDenied if not available. + */ +export function checkCustomThemeLimit(limits: Limits): void { + if (!limits["custom-theme"]) { + throw new ConnectError("Upgrade for custom theme", Code.PermissionDenied); + } +} + export function checkStatusSubscribersLimit(limits: Limits): void { if (!limits["status-subscribers"]) { throw new ConnectError( diff --git a/apps/server/static/openapi.yaml b/apps/server/static/openapi.yaml index 820448a7..375b2872 100644 --- a/apps/server/static/openapi.yaml +++ b/apps/server/static/openapi.yaml @@ -2745,6 +2745,14 @@ components: - "null" title: allowed_ip_ranges description: Comma-separated IPv4 CIDR ranges (required when access_type is IP_RESTRICTED). + customTheme: + oneOf: + - $ref: '#/components/schemas/openstatus.status_page.v1.CustomTheme' + - type: "null" + title: custom_theme + description: |- + Per-mode CSS variable overrides merged over the theme (optional). + Only supported variable names are accepted. Requires the custom-theme plan feature. title: CreateStatusPageRequest additionalProperties: false description: CreateStatusPageRequest is the request to create a new status page. @@ -2758,6 +2766,48 @@ components: title: CreateStatusPageResponse additionalProperties: false description: CreateStatusPageResponse is the response after creating a status page. + openstatus.status_page.v1.CustomTheme: + type: object + properties: + light: + type: object + title: light + additionalProperties: + type: string + title: value + description: 'CSS variable overrides applied in light mode, keyed by variable name (e.g. "--primary": "hsl(24 94% 50%)").' + dark: + type: object + title: dark + additionalProperties: + type: string + title: value + description: CSS variable overrides applied in dark mode, keyed by variable name. + title: CustomTheme + additionalProperties: false + description: CustomTheme holds per-mode CSS variable overrides merged over the page theme. + openstatus.status_page.v1.CustomTheme.DarkEntry: + type: object + properties: + key: + type: string + title: key + value: + type: string + title: value + title: DarkEntry + additionalProperties: false + openstatus.status_page.v1.CustomTheme.LightEntry: + type: object + properties: + key: + type: string + title: key + value: + type: string + title: value + title: LightEntry + additionalProperties: false openstatus.status_page.v1.DeleteComponentGroupRequest: type: object properties: @@ -3419,6 +3469,12 @@ components: type: string title: allowed_ip_ranges description: Comma-separated IPv4 CIDR ranges (only set when access_type is IP_RESTRICTED). + customTheme: + oneOf: + - $ref: '#/components/schemas/openstatus.status_page.v1.CustomTheme' + - type: "null" + title: custom_theme + description: Per-mode CSS variable overrides merged over the theme (only set when configured). title: StatusPage additionalProperties: false description: StatusPage represents a full status page with all details. @@ -3734,6 +3790,14 @@ components: - "null" title: allowed_ip_ranges description: Comma-separated IPv4 CIDR ranges (required when access_type is IP_RESTRICTED). + customTheme: + oneOf: + - $ref: '#/components/schemas/openstatus.status_page.v1.CustomTheme' + - type: "null" + title: custom_theme + description: |- + New per-mode CSS variable overrides (optional). Omit to keep the current + value; send an empty message to clear. Requires the custom-theme plan feature. title: UpdateStatusPageRequest additionalProperties: false description: UpdateStatusPageRequest is the request to update a status page. diff --git a/packages/db/src/schema/pages/validation.ts b/packages/db/src/schema/pages/validation.ts index 765bca7b..0aa0511e 100644 --- a/packages/db/src/schema/pages/validation.ts +++ b/packages/db/src/schema/pages/validation.ts @@ -1,6 +1,11 @@ import { locales } from "@openstatus/locales"; import type { ThemeKey } from "@openstatus/theme-store"; -import { THEME_KEYS } from "@openstatus/theme-store"; +import { + hasCustomTheme, + sanitizeCustomTheme, + THEME_KEYS, + validateCustomTheme, +} from "@openstatus/theme-store"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { z } from "zod"; @@ -35,6 +40,33 @@ const stringToArray = z.preprocess((val) => { return []; }, z.array(z.string())); +// Loose { light, dark } var maps — write paths enforce the supported var +// names / safe values; `.catch(null)` degrades corrupt stored json to "no +// overrides" instead of tanking every page read. +const themeVarsSchema = z.record(z.string(), z.string()); +export const customThemeSchema = z.object({ + light: themeVarsSchema.optional(), + dark: themeVarsSchema.optional(), +}); + +// Strict write-side counterpart: only supported var names and values that +// can't break out of the inline + + + + + + diff --git a/apps/dashboard/src/proxy.ts b/apps/dashboard/src/proxy.ts index 9eaf567c..7ed7fbc8 100644 --- a/apps/dashboard/src/proxy.ts +++ b/apps/dashboard/src/proxy.ts @@ -130,6 +130,6 @@ export default auth(async (req) => { export const config = { matcher: [ - "/((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", + "/((?!api|assets|_next/static|_next/image|favicon.ico|icon.svg|sitemap.xml|robots.txt).*)", ], }; -- 2.51.2 From 274784cb0ee7e0750221ee871a35ae6b1132a0e6 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:21:50 +0200 Subject: [PATCH 137/266] chore: improve repo agent experience (#2569) * chore: improve repo agent experience * fix: review * wip: --- .github/workflows/buf-push.yml | 2 +- .github/workflows/check.yml | 11 +- .github/workflows/dx.yml | 4 +- .github/workflows/lint.yml | 2 +- .github/workflows/migrate.yml | 4 +- .github/workflows/proto-check.yml | 2 +- .github/workflows/test.yml | 4 +- AGENTS.md | 91 +++++++++ CLAUDE.md | 163 +--------------- CONTRIBUTING.MD => CONTRIBUTING.md | 11 +- apps/checker/AGENTS.md | 19 ++ apps/dashboard/AGENTS.md | 33 ++++ .../components/forms/monitor/form-general.tsx | 2 +- .../form-status-report.tsx | 6 +- .../forms/status-report-update/form.tsx | 2 +- .../components/forms/status-report/form.tsx | 2 +- apps/server/AGENTS.md | 36 ++++ apps/status-page/AGENTS.md | 39 ++++ apps/web/AGENTS.md | 24 +++ apps/workflows/AGENTS.md | 17 ++ devbox.json | 8 +- devbox.lock | 158 ++++++++++------ docs/adr/README.md | 41 ++-- oxlint.config.ts | 29 +++ package.json | 3 + packages/api/src/router/import.test.ts | 2 +- packages/api/src/router/maintenance.test.ts | 2 +- packages/api/src/router/statusReport.test.ts | 2 +- .../db/src/schema/status_reports/constants.ts | 8 + .../schema/status_reports/status_reports.ts | 8 +- packages/services/AGENTS.md | 82 ++++++++ packages/services/src/chat-session/create.ts | 4 + packages/services/src/chat-session/remove.ts | 4 + packages/services/src/chat-session/set.ts | 4 + packages/services/src/import/run.ts | 2 + packages/services/src/member/delete.ts | 3 + packages/services/src/monitor/relations.ts | 4 + .../src/page-subscriber/send-test-webhook.ts | 3 + .../services/src/page-subscriber/slack.ts | 6 + .../src/page-subscriber/unsubscribe.ts | 3 + .../src/page-subscriber/update-scope.ts | 3 + .../services/src/page-subscriber/upsert.ts | 3 + .../services/src/page-subscriber/verify.ts | 3 + packages/services/src/workspace/downgrade.ts | 3 + packages/services/test/helpers.ts | 2 +- packages/ui/AGENTS.md | 69 +++++++ ralph/.gitignore | 4 - ralph/README.md | 34 ---- ralph/afk-ralph.sh | 25 --- ralph/ralph-once.sh | 8 - scripts/check-doc-refs.mts | 133 +++++++++++++ scripts/oxlint-plugin-openstatus.js | 175 ++++++++++++++++++ 52 files changed, 964 insertions(+), 348 deletions(-) create mode 100644 AGENTS.md rename CONTRIBUTING.MD => CONTRIBUTING.md (83%) create mode 100644 apps/checker/AGENTS.md create mode 100644 apps/dashboard/AGENTS.md create mode 100644 apps/server/AGENTS.md create mode 100644 apps/status-page/AGENTS.md create mode 100644 apps/web/AGENTS.md create mode 100644 apps/workflows/AGENTS.md create mode 100644 packages/db/src/schema/status_reports/constants.ts create mode 100644 packages/services/AGENTS.md create mode 100644 packages/ui/AGENTS.md delete mode 100644 ralph/.gitignore delete mode 100644 ralph/README.md delete mode 100755 ralph/afk-ralph.sh delete mode 100755 ralph/ralph-once.sh create mode 100644 scripts/check-doc-refs.mts create mode 100644 scripts/oxlint-plugin-openstatus.js diff --git a/.github/workflows/buf-push.yml b/.github/workflows/buf-push.yml index 12ff5ab5..33e03959 100644 --- a/.github/workflows/buf-push.yml +++ b/.github/workflows/buf-push.yml @@ -32,7 +32,7 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.12.0 cache: "pnpm" - name: 📥 Download deps diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 19510a39..a19ed3e0 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -24,16 +24,23 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.12.0 cache: "pnpm" - name: 🦕 Setup Deno uses: denoland/setup-deno@v2 with: - deno-version: v2.x + deno-version: 2.9.4 - name: 📥 Download deps run: pnpm install + # Blocking, unlike autofix.ci, which only commits formatting back. + - name: 🔬 Lint + run: pnpm lint + + - name: 🔗 Doc references + run: pnpm check:docs + - name: 🔍 Check run: pnpm check diff --git a/.github/workflows/dx.yml b/.github/workflows/dx.yml index 766bae46..6ddfb91d 100644 --- a/.github/workflows/dx.yml +++ b/.github/workflows/dx.yml @@ -35,7 +35,7 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.12.0 cache: "pnpm" - name: 📥 Download deps @@ -44,7 +44,7 @@ jobs: - name: 🦕 Setup Deno uses: denoland/setup-deno@v2 with: - deno-version: v2.x + deno-version: 2.9.4 - name: 🔥 DX task run: pnpm dx diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7169323c..32aec6e4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.12.0 cache: "pnpm" - name: 📥 Download deps diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 1770625c..5d80891b 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -23,13 +23,13 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.12.0 cache: "pnpm" - name: 🦕 Install deno uses: denoland/setup-deno@v2 with: - deno-version: v2.x + deno-version: 2.9.4 - name: 📥 Download deps run: pnpm install diff --git a/.github/workflows/proto-check.yml b/.github/workflows/proto-check.yml index 3568dcab..2ee4684a 100644 --- a/.github/workflows/proto-check.yml +++ b/.github/workflows/proto-check.yml @@ -32,7 +32,7 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.12.0 cache: "pnpm" - name: 🔥 Install bun diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9e5c5fac..b46e29eb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,13 +66,13 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.12.0 cache: "pnpm" - name: 🦕 Setup Deno uses: denoland/setup-deno@v2 with: - deno-version: v2.x + deno-version: 2.9.4 - name: 📥 Download deps run: pnpm install diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..da50576a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,91 @@ +# AGENTS.md + +Cross-cutting truth for openstatus. Package-scoped rules live in the nested +`AGENTS.md` files listed at the bottom. Setup and how-to-run belong in READMEs — +this file never restates them. + +## Verify your change + +```sh +pnpm verify # oxfmt + oxlint + doc refs + deno check. No database, seconds. +pnpm verify:test # tests for packages affected by your diff. Needs a database. +``` + +`pnpm verify` must be green before you hand work back — it is what CI's `Check` +job runs. For `verify:test`, start the local libSQL and seed it first; the steps +are in `apps/dashboard/README.md`. + +## Toolchain + +`devbox.json` pins exact node, deno, bun, turso-cli and sqld versions; the CI +workflows pin the same node and deno. Never float one back to `@latest` or +`v2.x` — `deno check` results differ by deno version, so a drifting pin makes +`pnpm verify` disagree with CI for reasons unrelated to your change. + +## Architecture + +- **`packages/services`** owns every workspace-scoped mutation. tRPC routers, + Hono routes, MCP tools and jobs are thin adapters over it. Inline DB access in + a router is a defect — see `packages/services/AGENTS.md`. +- **Turso (libSQL) holds application data**, through Drizzle in `packages/db`. + **Tinybird holds monitoring time-series**, through `packages/tinybird`. The two + are linked by id only: no cross-store transaction, no join across the boundary, + no referential integrity. +- **Go is confined to the probing tier** (`apps/checker`, `apps/private-location`). + Product logic stays in TypeScript. Anything duplicated across that boundary — + assertion evaluation, region codes — must be changed on both sides in one PR. +- **Shared UI comes from `@openstatus/ui`.** Do not fork a primitive into an app. + +## Tests + +- CI gives every DB-touching package its own database (the matrix in + `.github/workflows/test.yml`). Locally there is one shared libSQL, which is + why `verify:test` runs the affected packages one at a time. Cross-package + failures that vanish on a re-run of the single package are that sharing, not + your change — confirm with `turbo run test --filter=@openstatus/services`. +- Suites mint their own workspace via `createTestWorkspace` + (`packages/db/src/test/factories.ts`). Never load a shared seeded workspace, + and never wipe a table globally — scope every cleanup to your own workspace id. +- The `external_service` suites are not workspace-scoped; an aborted run leaves + rows that fail the *next* local run on a foreign key. Reseed to recover. +- The `test` turbo task is deliberately uncached — results depend on database + state that is not in the input hash. Do not "fix" it. + +## Comment discipline + +Default to no comments. Code and identifiers already say *what*. Write a comment +only when the *why* is not visible: a non-obvious invariant, a workaround for a +specific bug, a constraint imposed from outside the file, a `// safe because …` +above an unavoidable cast. + +- 1 short line where possible, 3 lines max. Never multi-paragraph JSDoc. +- Strip: restatements of the code, the name of the caller, history ("added for + X"), PR or task context. That belongs in the commit message. +- JSDoc on an exported symbol is fine when the signature alone is ambiguous — + one sentence, not a tutorial. + +## Type cast discipline + +`as unknown as X`, `as never` and `as any` are sometimes unavoidable at +boundaries with external SDKs or at registry-style dispatch. When you need one: + +- **Centralize it in a named helper** whose name states the intent + (`asUIMessages`, `renderToolDraft`). Do not scatter the same cast. +- **Comment the runtime guarantee** above the helper, so a future reader can + check whether it still holds. +- A scattered `as never` is usually a missing helper. + +## Package context + +- `packages/services/AGENTS.md` — service verbs, audit log, scope enforcement +- `packages/ui/AGENTS.md` — stock shadcn vs. the published blocks registry +- `apps/dashboard/AGENTS.md` — Next.js runtimes, client boundary, UI verification +- `apps/server/AGENTS.md` — Hono API, API-key scopes +- `apps/status-page/AGENTS.md` — public surfaces and gated content +- `apps/workflows/AGENTS.md` — Deno runtime constraints +- `apps/checker/AGENTS.md` — Go probing tier +- `apps/web/AGENTS.md` — marketing site, `.well-known`, search, content pages + +`docs/adr/` is frozen background on *why* some of these decisions were made. It +is history, not current state; this file and its nested siblings are current +state. Do not add new ADRs. diff --git a/CLAUDE.md b/CLAUDE.md index a621d75c..af50da6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,161 +1,4 @@ -# Agent.md +# CLAUDE.md -This file provides a comprehensive overview of the OpenStatus project, its architecture, and development conventions to be used as instructional context for future interactions. - -## Project Overview - -OpenStatus is an open-source synthetic monitoring platform. It allows users to monitor their websites and APIs from multiple locations and receive notifications when they are down or slow. - -The project is a monorepo managed with pnpm workspaces and Turborepo. It consists of several applications and packages that work together to provide a complete monitoring solution. - -### Core Technologies - -- **Frontend:** - - Next.js (with Turbopack) - - React - - Tailwind CSS - - shadcn/ui - - tRPC -- **Backend:** - - Hono (Node.js framework) - - Go -- **Database:** - - Turso (libSQL) - - Drizzle ORM -- **Data Analytics:** - - Tinybird -- **Authentication:** - - NextAuth.js -- **Build System:** - - Turborepo - -### Architecture - -The OpenStatus platform is composed of three main applications: - -- **`apps/dashboard`**: A Next.js application that provides the main user interface for managing monitors, viewing status pages, and configuring notifications. -- **`apps/server`**: A Hono-based backend server that provides the API for the dashboard application. -- **`apps/checker`**: A Go application responsible for performing the actual monitoring checks from different locations. - -These applications are supported by a collection of shared packages in the `packages/` directory, which provide common functionality such as database access, UI components, and utility functions. - -## Building and Running - -The project can be run using Docker (recommended) or a manual setup. - -### With Docker - -1. Copy the example environment file: - ```sh - cp .env.docker.example .env.docker - ``` -2. Start all services: - ```sh - docker compose up -d - ``` -3. Access the applications: - - Dashboard: `http://localhost:3002` - - Status Pages: `http://localhost:3003` - -### Manual Setup - -1. Install dependencies: - ```sh - pnpm install - ``` -2. Initialize the development environment: - ```sh - pnpm dx - ``` -3. Run a specific application: - ```sh - pnpm dev:dashboard - pnpm dev:status-page - pnpm dev:web - ``` - -### Running Tests - -To run the test suite, use the following command: - -Before running the test you should launch turso dev in a separate terminal: -```sh -turso dev -``` - -Then, seed the database with test data: - -```sh -cd packages/db -pnpm migrate -pnpm seed -``` - -Then run the tests with: - -```sh -pnpm test -``` - -## Development Conventions - -- **Monorepo:** The project is organized as a monorepo using pnpm workspaces. All applications and packages are located in the `apps/` and `packages/` directories, respectively. -- **Build System:** Turborepo is used to manage the build process. The `turbo.json` file defines the build pipeline and dependencies between tasks. -- **Linting and Formatting:** The project uses oxlint for linting and oxfmt for formatting. The configuration can be found in `oxlint.config.ts` and `oxfmt.config.ts`. -- **Code Generation:** The project uses `drizzle-kit` for database schema migrations. -- **API:** The backend API is built using Hono and tRPC. The API is documented using OpenAPI. - -## Comment Discipline - -Default to writing no comments. The code and identifiers should explain *what* — the reader can see that. Only write a comment when the *why* would not be obvious from reading the code: a non-obvious invariant, a workaround for a specific bug, a runtime guarantee that justifies a cast, a constraint imposed from outside this file. - -- **Keep them to 1 short line where possible**, 3 lines max. Never write multi-paragraph JSDoc blocks. -- **Strip these every time:** restating what the code does, naming the caller / surface that uses the helper, history ("added for X", "used by the Y flow"), and PR/task context. That belongs in commit messages, not source. -- **Keep these:** the WHY behind a non-obvious choice, an invariant that callers must uphold, a `// safe because …` line above an unavoidable cast, a `// workaround: ` for a known issue. -- **JSDoc:** allowed on exported symbols when the type signature alone is ambiguous — but one sentence, not a tutorial. Don't enumerate every branch of a function in prose. - -If you find yourself writing a comment that explains *what just changed* or *what you did*, delete it. - -## Type Cast Discipline - -`as unknown as X`, `as never`, and `as any` are sometimes unavoidable — usually at boundaries with external SDKs (AI SDK, third-party libs) or at registry-style dispatch where TypeScript can't link a runtime string to a literal-keyed map. When you need one: - -- **Centralize the cast in a named helper.** Don't scatter the same cast across call sites. Wrap it in a small function whose name describes the intent (`asUIMessages`, `findRenderer`, `renderToolDraft`). -- **Comment the runtime guarantee.** Above the helper, write one or two lines explaining *why the cast is safe at runtime* (e.g. "the persisted shape is validated on write by `storedMessageSchema`, so reads return SDK-conforming rows"). Future readers can verify the invariant or notice when it breaks. -- **Examples:** `apps/dashboard/src/components/chat/use-chat-session.ts` (`asUIMessages`); `apps/dashboard/src/components/chat/tool-renderers/index.tsx` (`renderToolDraft` / `renderToolResult` / `summarizeToolOutput`). Both eliminate scattered casts in the consuming components. - -A scattered `as never` is usually a missing helper. - -## Services & Audit Log Pattern - -All workspace-scoped business logic lives in `packages/services` — **not** in tRPC routers. Routers stay thin: validate input, call a service verb, map errors. This keeps logic reusable across tRPC, Hono, and background jobs. - -Note on runtimes: the dashboard's tRPC handler runs on the **Node.js** runtime (see the comment in `apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts` — several routers pull Node-only deps), and no Edge route imports `@openstatus/services`. Node-only dependencies in services are therefore acceptable. What *is* enforced: `apps/workflows` runs on Deno and imports services, so `pnpm check` (`deno check --sloppy-imports`) must pass in both packages. - -Conventions for any new mutation: - -- **One file per verb** under `packages/services/src//` (e.g. `create.ts`, `update.ts`, `remove.ts`), re-exported from the entity's `index.ts`. Routers import from `@openstatus/services/`. -- **Standard signature:** `async function verbEntity(args: { ctx: ServiceContext; input: VerbInput }): Promise<...>`. `ctx` carries `workspace`, `actor`, and an optional `db`/transaction. Parse input with the schema at the top of the function. -- **Wrap mutations in `withTransaction(ctx, async (tx) => { ... })`** — it reuses an outer tx if present, otherwise opens one. Always pass `tx` (not `defaultDb`) to writes inside the block. -- **Workspace scoping is mandatory.** Every read/write filters by `ctx.workspace.id`. Use the `getXInWorkspace` helpers in `internal.ts` for fetch-or-throw. -- **Throw `ServiceError` subclasses** (`NotFoundError`, `ForbiddenError`, etc. from `./errors`). Routers convert them via `toTRPCError`. -- **Emit an audit row for every mutation** via `emitAudit(tx, ctx, entry)` inside the same transaction. Fail-closed: a failed audit insert rolls back the mutation. See `packages/services/src/audit/emit.ts`. - - For updates, pass both `before` (pre-mutation snapshot) and `after` (post-`.returning()` row). `changed_fields` is auto-diffed; no-op updates are skipped. - - For creates/deletes, pass only `after` or only `before`. - - **Strip secrets** from snapshots before emitting (e.g. `credential`, bot tokens, raw API keys) — see `integration/remove.ts` for the pattern. - - Action names follow `{entity}.{verb}` (`monitor.update`, `integration.delete`). Add new variants to the discriminated union in `@openstatus/db/src/schema/audit_logs/validation.ts`. -- **Tests live in `packages/services/src//__tests__/`** and use `expectAuditRow({ workspaceId, action, entityId, ... })` from `packages/services/test/helpers.ts` to assert the audit side-effect. Each suite scopes to its own workspace and clears `audit_log` between cases. - -When adding a router endpoint, the default answer is "write the service verb first, then call it from the router." Inline DB access in routers is a smell — it bypasses the audit log. - -## Scope Enforcement (API key RBAC) - -API keys carry **scopes** (`'read'` / `'write'`) that gate write access. See `packages/services/src/auth/`. - -- **Call `requireScope(ctx, "write")` as the first line** of every write verb — before `Input.parse(...)` and `withTransaction`. Import from `../auth`. -- **"Write"** = any DB mutation **or** side-effecting external call (probes, webhooks, notifications). Everything else is read. -- No-op for `user` / `system` / `slack` / `webhook` / `subscriber` actors; active for `apiKey` and `mcp`. Throws `ForbiddenError` on denial. -- **Tests:** every entity's `__tests__/` includes a `'rejects read-only actor'` case via `makeApiKeyCtx(workspace, { keyId: "k", userId: 1, scopes: ["read"] })`. `requireScope` fires before DB lookup, so fake ids work for delete/update verbs. -- **MCP tools** declare `scope: 'read' | 'write'` and register via `registerScopedTool` — read-only keys never see write tools. - -Treat a missing `requireScope` the same as a missing `emitAudit` — mandatory for every mutation. +Read `AGENTS.md` in this directory — it holds the conventions for this repo, and +the nested `AGENTS.md` files it links carry the package-specific ones. diff --git a/CONTRIBUTING.MD b/CONTRIBUTING.md similarity index 83% rename from CONTRIBUTING.MD rename to CONTRIBUTING.md index 1f0b9e60..0625cf94 100644 --- a/CONTRIBUTING.MD +++ b/CONTRIBUTING.md @@ -29,10 +29,13 @@ To contribute code changes, follow these steps: 1. Fork the repository and create a new branch for your changes. 2. Ensure that your code follows the project's coding conventions and style guide. -3. Make commits with clear and descriptive messages. Each commit should have a single logical purpose. -4. Push your branch to your forked repository. -5. Open a pull request (PR) from your branch to the original repository's `main` branch. -6. Provide a detailed description of your changes in the PR, including any related issues or feature requests. +3. Run `pnpm verify` — formatting, lint, doc references and type checks. It needs + no database and takes seconds. `pnpm verify:test` runs the tests for the + packages your diff touches; those do need a local libSQL server. +4. Make commits with clear and descriptive messages. Each commit should have a single logical purpose. +5. Push your branch to your forked repository. +6. Open a pull request (PR) from your branch to the original repository's `main` branch. +7. Provide a detailed description of your changes in the PR, including any related issues or feature requests. A project maintainer will review your PR, provide feedback if necessary, and merge it once it meets the project's standards. diff --git a/apps/checker/AGENTS.md b/apps/checker/AGENTS.md new file mode 100644 index 00000000..97a797ce --- /dev/null +++ b/apps/checker/AGENTS.md @@ -0,0 +1,19 @@ +# AGENTS.md — apps/checker + +Go 1.25, the only non-TypeScript tier in the repo along with +`apps/private-location`. It probes customer endpoints from ~35 fly.io regions on +512 MB VMs and writes results straight to Tinybird. + +Constraints: + +- **Keep Go scoped to probing.** Product and workspace logic belongs in + `packages/services`. The two sides talk through data contracts only — Tinybird + rows and protobuf (`packages/proto`) — never shared in-process code. +- **Assertion evaluation exists twice**: `apps/checker/pkg/assertions` (Go) and + `packages/assertions` (TypeScript). So do region codes. Changing one without + the other silently diverges what the probe checks from what the dashboard + shows — move both in the same PR and say so in the description. +- `pnpm verify` does not cover this app. Run `go test ./...` from + `apps/checker`; CI runs it in `.github/workflows/go-tests.yml`. +- Per-phase timings (DNS, connect, TLS, TTFB, transfer) come from `httptrace`. + They are a product surface, not diagnostics — do not drop or rename fields. diff --git a/apps/dashboard/AGENTS.md b/apps/dashboard/AGENTS.md new file mode 100644 index 00000000..b5839e25 --- /dev/null +++ b/apps/dashboard/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md — apps/dashboard + +## Visual changes need a human + +An agent cannot verify rendering. If a change alters layout, styling or any +visible behaviour, say so and ask for confirmation rather than reporting it as +done. Prefer taking on work in `packages/services`, `packages/api` and the route +handlers, where `pnpm verify` and the test suites are real evidence. + +## Runtimes + +The whole tRPC surface is served from +`apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts` on the **Node.js** +runtime — several routers pull Node-only SDKs (`@slack/web-api`, email and +notification clients). Node-only dependencies inside `@openstatus/services` are +still forbidden, because `apps/workflows` runs the same code on Deno. + +## Client boundary + +A `"use client"` file must not **value**-import the schema barrel +`@openstatus/db/src/schema` — it drags Drizzle and the whole schema graph into +the browser bundle. Import the specific sub-path instead +(`@openstatus/db/src/schema/page_components/constants`), or split the pure-zod +part into a sibling file. `import type` from the barrel is fine; types erase. + +## Chat tool renderers + +Reuse the dashboard's own primitives — `TableCell*`, `ResultTable`, +`ChangesTable`. Do not build bespoke chrome for a tool result. + +Persisted chat messages are validated on write, which is what makes the +`asUIMessages` cast in `apps/dashboard/src/components/chat/use-chat-session.ts` +safe. Keep casts of that kind in one named helper. diff --git a/apps/dashboard/src/components/forms/monitor/form-general.tsx b/apps/dashboard/src/components/forms/monitor/form-general.tsx index 0046efc2..70179e26 100644 --- a/apps/dashboard/src/components/forms/monitor/form-general.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-general.tsx @@ -12,7 +12,7 @@ import { stringCompareDictionary, textBodyAssertion, } from "@openstatus/assertions"; -import { monitorMethods } from "@openstatus/db/src/schema"; +import { monitorMethods } from "@openstatus/db/src/schema/monitors/constants"; import { Globe, Network, Add, Server, Close } from "@openstatus/icons"; import { AlertDialog, diff --git a/apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx b/apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx index 6f4471e5..7b57e3af 100644 --- a/apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx +++ b/apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx @@ -1,11 +1,9 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { - type StatusReportUpdate, - statusReportStatus, -} from "@openstatus/db/src/schema"; +import type { StatusReportUpdate } from "@openstatus/db/src/schema"; import { pageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; +import { statusReportStatus } from "@openstatus/db/src/schema/status_reports/constants"; import { Calendar as CalendarIcon, Clock } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; diff --git a/apps/dashboard/src/components/forms/status-report-update/form.tsx b/apps/dashboard/src/components/forms/status-report-update/form.tsx index 3c35a0c8..94e821c4 100644 --- a/apps/dashboard/src/components/forms/status-report-update/form.tsx +++ b/apps/dashboard/src/components/forms/status-report-update/form.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { statusReportStatus } from "@openstatus/db/src/schema"; import { pageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; +import { statusReportStatus } from "@openstatus/db/src/schema/status_reports/constants"; import { Calendar as CalendarIcon, Clock } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; diff --git a/apps/dashboard/src/components/forms/status-report/form.tsx b/apps/dashboard/src/components/forms/status-report/form.tsx index 76828518..72e5b658 100644 --- a/apps/dashboard/src/components/forms/status-report/form.tsx +++ b/apps/dashboard/src/components/forms/status-report/form.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { statusReportStatus } from "@openstatus/db/src/schema"; import { pageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; +import { statusReportStatus } from "@openstatus/db/src/schema/status_reports/constants"; import { Calendar as CalendarIcon, Clock } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; diff --git a/apps/server/AGENTS.md b/apps/server/AGENTS.md new file mode 100644 index 00000000..40c03a34 --- /dev/null +++ b/apps/server/AGENTS.md @@ -0,0 +1,36 @@ +# AGENTS.md — apps/server + +Hono, running on Deno. Four surfaces under `apps/server/src/routes/`: `v1` +(public REST), `rpc` (ConnectRPC), `mcp`, and `slack`. + +## API-key scopes + +Two layers enforce `read` / `write`, and both must stay: + +- **Service level** — `requireScope(ctx, "write")` inside every service verb. + This is the real gate for anything routed through `@openstatus/services`. +- **Transport level** — `requireWriteScope()` in + `apps/server/src/libs/middlewares/require-scope.ts`, mounted on the V1 router + after `authMiddleware`. V1 predates the services convention and still issues + inline Drizzle queries, so service-level checks would skip its write handlers + entirely. It maps method to scope: `GET`/`HEAD` are read, everything else is + write. Adding a V1 route that mutates through a `GET` silently bypasses it. + +New endpoints should call a service verb rather than query Drizzle directly; +`oxlint.config.ts` already bans `@openstatus/db` and `drizzle-orm` imports in +the handlers that have migrated, and that list grows one domain per PR. + +## Route config + +Resolve config once and pass it in; +`createSlackRoute(config)` in `apps/server/src/routes/slack/index.ts` is the +pattern — production calls `slackConfigFromEnv()` at module scope, tests build a +route with explicit config. Reading `env` at request time is what made the slack +route untestable under `deno test --parallel`, since the workers share one +process environment. + +## MCP + +MCP tools declare `scope: 'read' | 'write'` and register via +`registerScopedTool`, so a read-only key never sees a write tool in +`tools/list`. A tool registered the plain way leaks regardless of the key. diff --git a/apps/status-page/AGENTS.md b/apps/status-page/AGENTS.md new file mode 100644 index 00000000..c9d0191b --- /dev/null +++ b/apps/status-page/AGENTS.md @@ -0,0 +1,39 @@ +# AGENTS.md — apps/status-page + +Public, unauthenticated, multi-tenant: the page is resolved from the request +host or the `[domain]` segment. Assume every response you touch is served to +anonymous visitors and cached. + +## Gated content + +Pages can be password-protected. Any new surface that renders page content — +route, API handler, feed, embed — must apply the same gate the HTML route +applies. The existing public representations to mirror are +`apps/status-page/src/app/api/markdown/[[...path]]` and +`apps/status-page/src/app/api/status/[[...path]]`. + +**Known open leak:** the public tRPC endpoint at `/api/trpc/lambda` serves +gated page content. `guardTRPCSource` only filters on a spoofable +`x-trpc-source` header and is explicitly not a security boundary. Do not treat +it as one, and do not widen the surface until the procedures themselves check +the gate. + +Never log or report tRPC `input` — it carries page passwords and subscriber +tokens. `sentryLoggerLink` attaches the operation `path` only. + +## Caching + +Markdown and JSON representations negotiate on `Accept` at the same URL, so +every response sets `Vary: Accept` plus a strong ETag. Cache-control depends on +the page's access type — a gated page must not inherit a public TTL. + +## Theming + +Themes come from `@openstatus/theme-store` as OKLCH CSS variables. Add or edit +a theme in that package; do not hard-code colours in a component. + +## Impact labels + +Status-page impact labels are coloured text only — no dots, no chevrons. The +hover affordance is a dashed muted underline, never one tinted with the impact +colour. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 00000000..fa2c679f --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md — apps/web + +## `.well-known` routes + +`apps/web/src/app/.well-known/` is a dot-directory, and TypeScript's `include` +wildcards skip those. Files under it are outside the tsconfig program, so path +aliases (`@/…`) do not resolve and nothing there is type-checked as part of the +app. Use relative imports (or node built-ins, as +`.well-known/agent-skills/index.json/route.ts` does) and check the output by +requesting the route. + +## Search + +The ⌘K search is homegrown: the ranker lives in `apps/web/src/app/api/search` +and the index in `apps/web/src/content/utils/search-index.ts`. Do not reach for +Pagefind, Orama or Algolia. + +## Content pages + +Content pages are MDX prose built from the existing components (`Grid`, +`Details`, …). Reach for those before inventing a bespoke layout. + +Never link out to a competitor. Name them as plain text — an external link +donates domain authority and leaks the conversion. diff --git a/apps/workflows/AGENTS.md b/apps/workflows/AGENTS.md new file mode 100644 index 00000000..b0d6b091 --- /dev/null +++ b/apps/workflows/AGENTS.md @@ -0,0 +1,17 @@ +# AGENTS.md — apps/workflows + +Runs on **Deno**, not Node. `pnpm check` here is +`deno check --sloppy-imports src/serve.ts`, and it must pass — it is the only +type gate this app has (`pnpm test` runs with `--no-check`). + +Constraints that follow from the runtime: + +- No `node:*` built-ins, and no dependency that reaches for one. This app + imports `@openstatus/services`, so a `node:*` import added there breaks the + build here — run `pnpm check` in both packages after touching services. +- Sentry comes from `@sentry/deno`, not `@sentry/node`. +- Tests run `deno test --parallel`, so test files share one process + environment. Never drive a branch by assigning to `process.env` mid-test — + the assignment leaks into whatever else is running. Resolve config once at + module scope and pass it in, so "credential missing" is a value a test hands + you rather than a global it mutates. diff --git a/devbox.json b/devbox.json index 8a893e53..752b7cf0 100644 --- a/devbox.json +++ b/devbox.json @@ -1,6 +1,12 @@ { "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.10.6/.schema/devbox.schema.json", - "packages": ["turso-cli@latest", "nodejs@22", "bun@latest", "sqld@latest"], + "packages": [ + "turso-cli@1.0.15", + "nodejs@24.12.0", + "deno@2.9.4", + "bun@1.3.13", + "sqld@0.24.32" + ], "env": { "DEVBOX_COREPACK_ENABLED": "true", "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0" diff --git a/devbox.lock b/devbox.lock index 2275c40a..3fdfc115 100644 --- a/devbox.lock +++ b/devbox.lock @@ -1,51 +1,101 @@ { "lockfile_version": "1", "packages": { - "bun@latest": { - "last_modified": "2025-10-27T19:50:41Z", - "resolved": "github:NixOS/nixpkgs/1666250dbe4141e4ca8aaf89b40a3a51c2e36144#bun", + "bun@1.3.13": { + "last_modified": "2026-08-01T16:34:20Z", + "resolved": "github:NixOS/nixpkgs/a5cbcfe954791221bfffe2307f7d1a1bf61a871e#bun", "source": "devbox-search", - "version": "1.3.1", + "version": "1.3.13", "systems": { "aarch64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/q77q60pidwk04lgw5q930a32w9zp7r0p-bun-1.3.1", + "path": "/nix/store/f2lm68vnka4nz9wdz60vbmymnpv63f1x-bun-1.3.13", "default": true } ], - "store_path": "/nix/store/q77q60pidwk04lgw5q930a32w9zp7r0p-bun-1.3.1" + "store_path": "/nix/store/f2lm68vnka4nz9wdz60vbmymnpv63f1x-bun-1.3.13" }, "aarch64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/7ba6bigk4vpvxpmx0c3h6rg5zl9bs5np-bun-1.3.1", + "path": "/nix/store/pm8kj4ysqkf8b53yyi3576kwd23q0x23-bun-1.3.13", "default": true } ], - "store_path": "/nix/store/7ba6bigk4vpvxpmx0c3h6rg5zl9bs5np-bun-1.3.1" + "store_path": "/nix/store/pm8kj4ysqkf8b53yyi3576kwd23q0x23-bun-1.3.13" }, "x86_64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/27338drvdd2cqz765wa4s51kz866v4ws-bun-1.3.1", + "path": "/nix/store/wn9cggwqflvqryxxb9xinl9iygndm8d9-bun-1.3.13", "default": true } ], - "store_path": "/nix/store/27338drvdd2cqz765wa4s51kz866v4ws-bun-1.3.1" + "store_path": "/nix/store/wn9cggwqflvqryxxb9xinl9iygndm8d9-bun-1.3.13" }, "x86_64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/jzjyn4d12klv4kp47c86z4dk1bksbg0q-bun-1.3.1", + "path": "/nix/store/sav1jc5c2axarnzif94rkm8byc6zw0kb-bun-1.3.13", "default": true } ], - "store_path": "/nix/store/jzjyn4d12klv4kp47c86z4dk1bksbg0q-bun-1.3.1" + "store_path": "/nix/store/sav1jc5c2axarnzif94rkm8byc6zw0kb-bun-1.3.13" + } + } + }, + "deno@2.9.4": { + "last_modified": "2026-08-01T16:34:20Z", + "resolved": "github:NixOS/nixpkgs/a5cbcfe954791221bfffe2307f7d1a1bf61a871e#deno", + "source": "devbox-search", + "version": "2.9.4", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/lmqwp7gr0hapgh48vwfkvzd9as1qzsp2-deno-2.9.4", + "default": true + }, + { + "name": "denort", + "path": "/nix/store/g0yd5grzazxkfgclcfbqxvqy1b8c6jy8-deno-2.9.4-denort" + } + ], + "store_path": "/nix/store/lmqwp7gr0hapgh48vwfkvzd9as1qzsp2-deno-2.9.4" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/gskl25gjd0aqrz432ahapzyq45mfim0l-deno-2.9.4", + "default": true + }, + { + "name": "denort", + "path": "/nix/store/dm9j5iismy4bm9vch0z4g0ir6yg3k9bl-deno-2.9.4-denort" + } + ], + "store_path": "/nix/store/gskl25gjd0aqrz432ahapzyq45mfim0l-deno-2.9.4" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/1w42p7jj0dmw525pnnlsj9496fsb30fc-deno-2.9.4", + "default": true + }, + { + "name": "denort", + "path": "/nix/store/0bgyyi7jcjlbjmf55if1xv7dk64qq2vr-deno-2.9.4-denort" + } + ], + "store_path": "/nix/store/1w42p7jj0dmw525pnnlsj9496fsb30fc-deno-2.9.4" } } }, @@ -53,90 +103,90 @@ "last_modified": "2025-11-05T16:44:39Z", "resolved": "github:NixOS/nixpkgs/ffcdcf99d65c61956d882df249a9be53e5902ea5?lastModified=1762361079&narHash=sha256-lz718rr1BDpZBYk7%2BG8cE6wee3PiBUpn8aomG%2FvLLiY%3D" }, - "nodejs@22": { - "last_modified": "2025-05-31T03:30:20Z", + "nodejs@24.12.0": { + "last_modified": "2025-12-27T12:56:01Z", "plugin_version": "0.0.2", - "resolved": "github:NixOS/nixpkgs/59138c7667b7970d205d6a05a8bfa2d78caa3643#nodejs_22", + "resolved": "github:NixOS/nixpkgs/3edc4a30ed3903fdf6f90c837f961fa6b49582d1#nodejs_24", "source": "devbox-search", - "version": "22.14.0", + "version": "24.12.0", "systems": { "aarch64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/g5bv4gi6p7ryhs2hbaryxs6ivsxa6lqc-nodejs-22.14.0", + "path": "/nix/store/b1h0af3yb3z9jz048phclcqw6ihiww67-nodejs-24.12.0", "default": true }, { "name": "dev", - "path": "/nix/store/96vpirdcns8zxdwzwl7n804012lwrf1n-nodejs-22.14.0-dev" + "path": "/nix/store/ksn4nj0nbp0ikmh4scy9x4biqqdswwhy-nodejs-24.12.0-dev" }, { "name": "libv8", - "path": "/nix/store/xph2837xqjnra80mqlk64xp7vb3kkqxs-nodejs-22.14.0-libv8" + "path": "/nix/store/3x72bm7l30ib7l7cximrp0sklcq8jqwy-nodejs-24.12.0-libv8" } ], - "store_path": "/nix/store/g5bv4gi6p7ryhs2hbaryxs6ivsxa6lqc-nodejs-22.14.0" + "store_path": "/nix/store/b1h0af3yb3z9jz048phclcqw6ihiww67-nodejs-24.12.0" }, "aarch64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/cwb781xbp70f72njqh8vhvbsf1xq87i5-nodejs-22.14.0", + "path": "/nix/store/9jfsyhcr9c6rpm94lrkibv75jkggvwss-nodejs-24.12.0", "default": true }, { "name": "dev", - "path": "/nix/store/0w5mql46am2qai707a42q983l44l1h9z-nodejs-22.14.0-dev" + "path": "/nix/store/7izhv9x1yvsmyrhmmyps6bxjjclajm80-nodejs-24.12.0-dev" }, { "name": "libv8", - "path": "/nix/store/vy56lahz381ayvw3hym3wj4r4ggv7gbk-nodejs-22.14.0-libv8" + "path": "/nix/store/8hl74pnc1qyz68rg10ixw7fz32hcxqkk-nodejs-24.12.0-libv8" } ], - "store_path": "/nix/store/cwb781xbp70f72njqh8vhvbsf1xq87i5-nodejs-22.14.0" + "store_path": "/nix/store/9jfsyhcr9c6rpm94lrkibv75jkggvwss-nodejs-24.12.0" }, "x86_64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/086zjv14ipka2vbpgwil3xyvi5ml4vh3-nodejs-22.14.0", + "path": "/nix/store/cj1243xswxvnwgifyiyg8axhn0r2vl48-nodejs-24.12.0", "default": true }, { "name": "dev", - "path": "/nix/store/24n01nfvy908pwzwpgsl0k4227187i7i-nodejs-22.14.0-dev" + "path": "/nix/store/kzia3rkaf9vwpn3py23q9v49jpjm6zx7-nodejs-24.12.0-dev" }, { "name": "libv8", - "path": "/nix/store/jiwk0in1wk85crn9vp03gg937gbh1s0w-nodejs-22.14.0-libv8" + "path": "/nix/store/xszji81xj7ghjhz7jk0jkhb84nfwyd8f-nodejs-24.12.0-libv8" } ], - "store_path": "/nix/store/086zjv14ipka2vbpgwil3xyvi5ml4vh3-nodejs-22.14.0" + "store_path": "/nix/store/cj1243xswxvnwgifyiyg8axhn0r2vl48-nodejs-24.12.0" }, "x86_64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/c8jxsih8yy2rnncdmx2hyraizf689nvp-nodejs-22.14.0", + "path": "/nix/store/9z1v3wyrxp6fpyzw21lcakd5w7aknzyc-nodejs-24.12.0", "default": true }, { "name": "libv8", - "path": "/nix/store/s8gnrgh9hgnbkrc1wrn21d6zvkbvm9vi-nodejs-22.14.0-libv8" + "path": "/nix/store/5yvx909wlis13qd2y1ahwl7ij21ma69a-nodejs-24.12.0-libv8" }, { "name": "dev", - "path": "/nix/store/xw8c1c0inxq3xl1d7axz19vq8c05kjk5-nodejs-22.14.0-dev" + "path": "/nix/store/7ll99p08gmpg0n048669p80zjibl5akr-nodejs-24.12.0-dev" } ], - "store_path": "/nix/store/c8jxsih8yy2rnncdmx2hyraizf689nvp-nodejs-22.14.0" + "store_path": "/nix/store/9z1v3wyrxp6fpyzw21lcakd5w7aknzyc-nodejs-24.12.0" } } }, - "sqld@latest": { - "last_modified": "2025-10-22T20:59:19Z", - "resolved": "github:NixOS/nixpkgs/01b6809f7f9d1183a2b3e081f0a1e6f8f415cb09#sqld", + "sqld@0.24.32": { + "last_modified": "2026-05-21T08:15:18Z", + "resolved": "github:NixOS/nixpkgs/4a29d733e8a7d5b824c3d8c958a946a9867b3eb2#sqld", "source": "devbox-search", "version": "0.24.32", "systems": { @@ -144,47 +194,47 @@ "outputs": [ { "name": "out", - "path": "/nix/store/sgwjj0nld67nwx8w7b529bwvfyv9r9r0-sqld-0.24.32", + "path": "/nix/store/kxi3jrkyd0sg0wy9rz948vig1lxy395y-sqld-0.24.32", "default": true } ], - "store_path": "/nix/store/sgwjj0nld67nwx8w7b529bwvfyv9r9r0-sqld-0.24.32" + "store_path": "/nix/store/kxi3jrkyd0sg0wy9rz948vig1lxy395y-sqld-0.24.32" }, "aarch64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/gh46ibxxbfvz2qymp0vd2n4akad5wp7x-sqld-0.24.32", + "path": "/nix/store/4zlylzcrm9m8cvch4y88ynyi1d47kh7z-sqld-0.24.32", "default": true } ], - "store_path": "/nix/store/gh46ibxxbfvz2qymp0vd2n4akad5wp7x-sqld-0.24.32" + "store_path": "/nix/store/4zlylzcrm9m8cvch4y88ynyi1d47kh7z-sqld-0.24.32" }, "x86_64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/gm5zbh90kw0qc9zmmmyrn3icjjycmdgz-sqld-0.24.32", + "path": "/nix/store/anmk86cv1hv6w89pak60cbadwm4fd1bs-sqld-0.24.32", "default": true } ], - "store_path": "/nix/store/gm5zbh90kw0qc9zmmmyrn3icjjycmdgz-sqld-0.24.32" + "store_path": "/nix/store/anmk86cv1hv6w89pak60cbadwm4fd1bs-sqld-0.24.32" }, "x86_64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/j29jdlfnnqpvm22n0w4sj4zf2lm3lv0w-sqld-0.24.32", + "path": "/nix/store/ib3jvxmgc731dxqyzx2i9sh1sj5n229b-sqld-0.24.32", "default": true } ], - "store_path": "/nix/store/j29jdlfnnqpvm22n0w4sj4zf2lm3lv0w-sqld-0.24.32" + "store_path": "/nix/store/ib3jvxmgc731dxqyzx2i9sh1sj5n229b-sqld-0.24.32" } } }, - "turso-cli@latest": { - "last_modified": "2025-10-22T20:59:19Z", - "resolved": "github:NixOS/nixpkgs/01b6809f7f9d1183a2b3e081f0a1e6f8f415cb09#turso-cli", + "turso-cli@1.0.15": { + "last_modified": "2026-01-23T17:20:52Z", + "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#turso-cli", "source": "devbox-search", "version": "1.0.15", "systems": { @@ -192,41 +242,41 @@ "outputs": [ { "name": "out", - "path": "/nix/store/n2a4r9cnif35d00qmn4zb96643n9xd05-turso-cli-1.0.15", + "path": "/nix/store/7mm8wc2f5p2h6hz6hrjkh1b7kzlg9cpg-turso-cli-1.0.15", "default": true } ], - "store_path": "/nix/store/n2a4r9cnif35d00qmn4zb96643n9xd05-turso-cli-1.0.15" + "store_path": "/nix/store/7mm8wc2f5p2h6hz6hrjkh1b7kzlg9cpg-turso-cli-1.0.15" }, "aarch64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/yy8nfs9z5f19xvjn07qxgi533ah59wdd-turso-cli-1.0.15", + "path": "/nix/store/619jqmd4q36k4li0ihq2hy3pvhlzkrgn-turso-cli-1.0.15", "default": true } ], - "store_path": "/nix/store/yy8nfs9z5f19xvjn07qxgi533ah59wdd-turso-cli-1.0.15" + "store_path": "/nix/store/619jqmd4q36k4li0ihq2hy3pvhlzkrgn-turso-cli-1.0.15" }, "x86_64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/6lcri76g9pdjw628kw60zxa4ixhz41vq-turso-cli-1.0.15", + "path": "/nix/store/6kws97rwr8sbamhb7w9hmqi25w0p18c1-turso-cli-1.0.15", "default": true } ], - "store_path": "/nix/store/6lcri76g9pdjw628kw60zxa4ixhz41vq-turso-cli-1.0.15" + "store_path": "/nix/store/6kws97rwr8sbamhb7w9hmqi25w0p18c1-turso-cli-1.0.15" }, "x86_64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/87zwzwq3h8hmznw1wqlqp9ksgy16s20n-turso-cli-1.0.15", + "path": "/nix/store/k3s154j4q09k7h4hwacb3crw50p76g34-turso-cli-1.0.15", "default": true } ], - "store_path": "/nix/store/87zwzwq3h8hmznw1wqlqp9ksgy16s20n-turso-cli-1.0.15" + "store_path": "/nix/store/k3s154j4q09k7h4hwacb3crw50p76g34-turso-cli-1.0.15" } } } diff --git a/docs/adr/README.md b/docs/adr/README.md index 0dee58f9..7ada3ca3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -1,11 +1,16 @@ # Architecture Decision Records -This directory holds the architecturally significant decisions made on -openstatus, in [MADR](https://adr.github.io/madr/) format. +**This set is frozen. Do not add new ADRs.** -An ADR captures *why* a decision was made, what alternatives were weighed, and -what trade-offs were accepted. `CLAUDE.md` documents the conventions in force -today; ADRs explain how those conventions came to be. +These nine records are background on *why* a handful of architectural decisions +were made, in [MADR](https://adr.github.io/madr/) format. They are history, and +several describe a change rather than a state — ADR-0008 says +"`loadSeededWorkspace` is gone", which only makes sense against the world before +it. + +Current-state truth lives in `AGENTS.md` at the repo root and in the nested +`AGENTS.md` files it links. When a decision changes, update those; do not write +a superseding ADR here. ## Index @@ -19,28 +24,4 @@ today; ADRs explain how those conventions came to be. | [0005](0005-turso-for-app-data-tinybird-for-time-series.md) | Turso for application data, Tinybird for time-series | accepted | | [0006](0006-persist-external-incidents-in-turso.md) | Persist external-service incidents in Turso, not Tinybird | accepted | | [0007](0007-store-external-service-components.md) | Store external-service components in Turso, their status history in Tinybird | accepted | - -## When to write an ADR - -Write one when a decision is hard to reverse, cross-cutting, or non-obvious — -for example: - -- adopting or dropping a framework, library, or runtime; -- a repo-wide pattern every contributor must follow; -- a data-model or API-contract decision that is expensive to change; -- choosing one approach over a reasonable alternative someone would ask about. - -Skip it for routine, local, or easily reversible changes. - -## How to add one - -1. Copy `template.md` to `NNNN-kebab-case-title.md`, where `NNNN` is the next - zero-padded number. -2. Fill it in. Keep it short — drivers, options, outcome, consequences. -3. Add a row to the index above. -4. Open it in the same PR as (or just before) the change it describes. - -## Changing a decision - -ADRs are immutable once accepted. To change a decision, write a new ADR and set -the old one's `status` to `superseded by ADR-NNNN`. +| [0008](0008-isolated-test-databases.md) | One test database per package, one workspace per suite, injected route config | accepted | diff --git a/oxlint.config.ts b/oxlint.config.ts index 0d2de31e..2f56e4ef 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "oxlint"; export default defineConfig({ plugins: ["eslint", "typescript", "react", "unicorn", "oxc"], + jsPlugins: ["./scripts/oxlint-plugin-openstatus.js"], categories: { correctness: "warn", }, @@ -111,5 +112,33 @@ export default defineConfig({ "typescript/no-explicit-any": "off", }, }, + { + // Behaviour-triggered, not filename-triggered: anything opening a + // transaction in the services layer is a mutation. + files: ["packages/services/src/**/*.ts"], + excludeFiles: ["**/__tests__/**", "**/*.test.ts"], + rules: { + "openstatus/services-mutation-guards": "error", + // apps/workflows runs this on Deno, and it stays Edge-safe by design. + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["node:*"], + message: + "@openstatus/services must stay runtime-agnostic — no node built-ins. Hand-roll the helper (see deepEqual) or move the code to a Node-only package.", + }, + ], + }, + ], + }, + }, + { + files: ["apps/dashboard/src/**/*.tsx", "apps/dashboard/src/**/*.ts"], + rules: { + "openstatus/no-db-barrel-in-client": "error", + }, + }, ], }); diff --git a/package.json b/package.json index 995b4fb7..fe474776 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "format:check": "oxfmt --check && oxlint", "lint:turbo": "turbo run lint", "check": "turbo run check", + "check:docs": "node scripts/check-doc-refs.mts", + "verify": "pnpm format:check && pnpm check:docs && pnpm check", + "verify:test": "turbo run test --affected --concurrency=1", "dev:web": "turbo run dev --filter='./apps/web' --filter='./packages/db'", "dev:status-page": "turbo run dev --filter='./apps/status-page' --filter='./packages/db'", "dev:dashboard": "turbo run dev --filter='./apps/dashboard' --filter='./packages/db'", diff --git a/packages/api/src/router/import.test.ts b/packages/api/src/router/import.test.ts index 9cb9869a..40d9ac0c 100644 --- a/packages/api/src/router/import.test.ts +++ b/packages/api/src/router/import.test.ts @@ -195,7 +195,7 @@ async function cleanup() { // entity. The entities are gone but the rows are not — and // INTEGER PRIMARY KEY ids recycle, so a later test inserting into the // same table can land on an id that already has an audit row, - // inheriting its actor attribution. See docs/adr/test-audit-cleanup.md. + // inheriting its actor attribution. See packages/services/AGENTS.md. await Promise.all([ clearAuditLogFor({ entityType: "page", entityIds: createdIds.pages }), clearAuditLogFor({ diff --git a/packages/api/src/router/maintenance.test.ts b/packages/api/src/router/maintenance.test.ts index c24e9425..4917f6fd 100644 --- a/packages/api/src/router/maintenance.test.ts +++ b/packages/api/src/router/maintenance.test.ts @@ -21,7 +21,7 @@ let otherWorkspaceComponentId: number; // the `maintenance.create` / `maintenance.update` audit rows outlive // the row itself and — because INTEGER PRIMARY KEY recycles — a later // test inserting into `maintenance` can land on the orphan's id and -// inherit its actor attribution. See docs/adr/test-audit-cleanup.md. +// inherit its actor attribution. See packages/services/AGENTS.md. const createdMaintenanceIds: number[] = []; const updatedMaintenanceIds: number[] = []; diff --git a/packages/api/src/router/statusReport.test.ts b/packages/api/src/router/statusReport.test.ts index 1c63e8eb..a0cb8c7b 100644 --- a/packages/api/src/router/statusReport.test.ts +++ b/packages/api/src/router/statusReport.test.ts @@ -24,7 +24,7 @@ let otherWorkspaceComponentId: number; // reached the parent — leaving an orphan report and an orphan audit row. // SQLite recycles INTEGER PRIMARY KEY ids on delete, so a later test // inserting a status_report could land on the orphan's id and inherit -// its `actor_type=user` attribution. See docs/adr/test-audit-cleanup.md. +// its `actor_type=user` attribution. See packages/services/AGENTS.md. const createdReportIds: number[] = []; const updatedReportIds: number[] = []; diff --git a/packages/db/src/schema/status_reports/constants.ts b/packages/db/src/schema/status_reports/constants.ts new file mode 100644 index 00000000..fdaba412 --- /dev/null +++ b/packages/db/src/schema/status_reports/constants.ts @@ -0,0 +1,8 @@ +// Kept free of drizzle imports so `"use client"` files can pull the enum +// without dragging the schema graph into the browser bundle. +export const statusReportStatus = [ + "investigating", + "identified", + "monitoring", + "resolved", +] as const; diff --git a/packages/db/src/schema/status_reports/status_reports.ts b/packages/db/src/schema/status_reports/status_reports.ts index 004a56cd..4c245a74 100644 --- a/packages/db/src/schema/status_reports/status_reports.ts +++ b/packages/db/src/schema/status_reports/status_reports.ts @@ -7,13 +7,9 @@ import { } from "../page_components"; import { page } from "../pages"; import { workspace } from "../workspaces"; +import { statusReportStatus } from "./constants"; -export const statusReportStatus = [ - "investigating", - "identified", - "monitoring", - "resolved", -] as const; +export { statusReportStatus }; export const statusReport = sqliteTable( "status_report", diff --git a/packages/services/AGENTS.md b/packages/services/AGENTS.md new file mode 100644 index 00000000..17da257c --- /dev/null +++ b/packages/services/AGENTS.md @@ -0,0 +1,82 @@ +# AGENTS.md — @openstatus/services + +Every workspace-scoped mutation lives here, not in a tRPC router or a Hono +handler. Routers validate input, call a verb, map errors. + +## Shape of a verb + +- **One file per verb** under `packages/services/src//` (`create.ts`, + `update.ts`, `remove.ts`), re-exported from that entity's `index.ts`. Callers + import from `@openstatus/services/`. +- **Signature:** `async function verbEntity(args: { ctx: ServiceContext; input: VerbInput })`. + `ctx` carries `workspace`, `actor`, and an optional `db`/transaction. +- **`requireScope(ctx, "write")` is the first line of every write verb** — + before `Input.parse(...)`, before `withTransaction`. +- **`withTransaction(ctx, async (tx) => …)`** wraps every mutation; it reuses an + outer transaction if one was threaded through `ctx`, otherwise opens one. + Inside the block, write through `tx` — never `defaultDb`. +- **Every read and write filters by `ctx.workspace.id`.** Use the + `getXInWorkspace` fetch-or-throw helpers in the entity's `internal.ts`. +- **Throw `ServiceError` subclasses** from `./errors`. Routers convert them with + `toTRPCError`. + +## Audit log + +`emitAudit(tx, ctx, entry)` runs inside the same transaction as the mutation. +Fail-closed: a failed audit insert rolls the mutation back. + +- Updates pass both `before` (pre-mutation snapshot) and `after` (the + `.returning()` row); `changed_fields` is diffed automatically and no-op updates + are skipped. Creates pass only `after`, deletes only `before`. +- **Strip secrets from snapshots** — credentials, bot tokens, raw API keys. +- Action names are `{entity}.{verb}`; new variants go in the discriminated union + at `packages/db/src/schema/audit_logs/validation.ts`. + +A missing `emitAudit` or `requireScope` is a defect, not a style nit. Both are +lint-enforced; if a verb genuinely needs neither, the inline disable must carry +a reason. + +## Scope enforcement + +API keys carry `read` / `write` scopes. "Write" means any DB mutation **or** +side-effecting external call (probe, webhook, notification); everything else is +read. `requireScope` is a no-op for `user` / `system` / `slack` / `webhook` / +`subscriber` actors and active for `apiKey` and `mcp`. MCP tools declare +`scope` and register through `registerScopedTool`, so read-only keys never see +write tools. + +## Runtime constraints + +- **No `node:*` imports.** `apps/workflows` runs this code on Deno, and the + package is written to stay Edge-safe so a Next.js route can adopt it without a + rewrite — no Edge route imports it today. That is why `deepEqual` is + hand-rolled rather than pulled from `node:util`. +- **No logtape here.** It breaks Edge builds; use `console.warn`. + +## Query plans + +Turso exposes neither `EXPLAIN` nor `ANALYZE`, and `sqlite_stat1` never exists +there. Verify a plan locally against `openstatus-dev.db` instead; in production +the only signal you get is `rows_read`. + +## Tests + +Suites live in `packages/services/src//__tests__/`, mint their own +workspace, and assert the audit side-effect with `expectAuditRow(...)` from +`packages/services/test/helpers.ts`. Each entity also carries a +`'rejects read-only actor'` case built with `makeApiKeyCtx(...)`; `requireScope` +fires before any DB lookup, so fake ids are fine there. + +**Audit rows outlive the entities they describe.** A suite that creates entities +through a service on the committed db and deletes them in cleanup must also call +`clearAuditLogFor(...)` — SQLite recycles `INTEGER PRIMARY KEY` ids after +deletes, so a later test's freshly-inserted entity can land on the orphan's id +and inherit its actor attribution. The failure surfaces in an unrelated suite, +which is what makes it expensive. + +## Uptime weights + +`impactUptimeWeight` in `packages/db/src/schema/page_components/constants.ts` +is pinned by tests: `major_outage` = 1, `partial_outage` = 0.5, +`degraded_performance` = 0. These numbers have been changed and reverted before — +confirm with a maintainer before touching them. diff --git a/packages/services/src/chat-session/create.ts b/packages/services/src/chat-session/create.ts index e941065a..3baea418 100644 --- a/packages/services/src/chat-session/create.ts +++ b/packages/services/src/chat-session/create.ts @@ -14,6 +14,10 @@ import { UnauthorizedError } from "../errors"; import { enforceSessionCap } from "./internal"; import { CreateChatSessionInput } from "./schemas"; +// Per-user UI state, not workspace configuration: only user actors get past +// `tryGetActorUserId`, so `requireScope` could never fire, and the audit action +// union has no `chat_session` verb. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function createChatSession(args: { ctx: ServiceContext; input: CreateChatSessionInput; diff --git a/packages/services/src/chat-session/remove.ts b/packages/services/src/chat-session/remove.ts index d6e3ebe0..16601767 100644 --- a/packages/services/src/chat-session/remove.ts +++ b/packages/services/src/chat-session/remove.ts @@ -10,6 +10,10 @@ import { UnauthorizedError } from "../errors"; import { getChatSessionInWorkspace } from "./internal"; import { DeleteChatSessionInput } from "./schemas"; +// Per-user UI state, not workspace configuration: only user actors get past +// `tryGetActorUserId`, so `requireScope` could never fire, and the audit action +// union has no `chat_session` verb. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function deleteChatSession(args: { ctx: ServiceContext; input: DeleteChatSessionInput; diff --git a/packages/services/src/chat-session/set.ts b/packages/services/src/chat-session/set.ts index 4c6a5d85..79fed170 100644 --- a/packages/services/src/chat-session/set.ts +++ b/packages/services/src/chat-session/set.ts @@ -20,6 +20,10 @@ import { SetChatSessionMessagesInput } from "./schemas"; * full-replace lets each fire be the canonical snapshot. Existing-row * `createdAt` wins on overlap so older messages don't drift forward. */ +// Per-user UI state, not workspace configuration: only user actors get past +// `tryGetActorUserId`, so `requireScope` could never fire, and the audit action +// union has no `chat_session` verb. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function setChatSessionMessages(args: { ctx: ServiceContext; input: SetChatSessionMessagesInput; diff --git a/packages/services/src/import/run.ts b/packages/services/src/import/run.ts index 35c8aabb..9eab9de5 100644 --- a/packages/services/src/import/run.ts +++ b/packages/services/src/import/run.ts @@ -2,6 +2,7 @@ import { and, count, db as defaultDb, eq } from "@openstatus/db"; import { page, pageComponent } from "@openstatus/db/src/schema"; import type { ImportSummary } from "@openstatus/importers"; +import { requireScope } from "../auth"; import type { ServiceContext } from "../context"; import { NotFoundError, ValidationError } from "../errors"; import { addLimitWarnings } from "./limits"; @@ -40,6 +41,7 @@ export async function runImport(args: { input: RunImportInput; }): Promise { const { ctx } = args; + requireScope(ctx, "write"); const input = RunImportInput.parse(args.input); const tx = ctx.db ?? defaultDb; diff --git a/packages/services/src/member/delete.ts b/packages/services/src/member/delete.ts index 5e467c65..e656c6ba 100644 --- a/packages/services/src/member/delete.ts +++ b/packages/services/src/member/delete.ts @@ -26,6 +26,9 @@ import { DeleteMemberInput } from "./schemas"; * Only fires when the actor is an openstatus user: removing another user is * not something a system / apiKey / webhook actor should do today. */ +// The delete and its `member.delete` audit row live in +// `removeMemberInWorkspace`; this verb only adds the owner / self-removal guards. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function deleteMember(args: { ctx: ServiceContext; input: DeleteMemberInput; diff --git a/packages/services/src/monitor/relations.ts b/packages/services/src/monitor/relations.ts index 1ecfc858..0572e247 100644 --- a/packages/services/src/monitor/relations.ts +++ b/packages/services/src/monitor/relations.ts @@ -7,6 +7,7 @@ import { } from "@openstatus/db/src/schema"; import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; import { LimitExceededError } from "../errors"; import { @@ -31,6 +32,7 @@ export async function updateMonitorSchedulingRegions(args: { input: UpdateMonitorSchedulingRegionsInput; }): Promise { const { ctx } = args; + requireScope(ctx, "write"); const input = UpdateMonitorSchedulingRegionsInput.parse(args.input); const limits = ctx.workspace.limits; @@ -104,6 +106,7 @@ export async function updateMonitorTags(args: { input: UpdateMonitorTagsInput; }): Promise { const { ctx } = args; + requireScope(ctx, "write"); const input = UpdateMonitorTagsInput.parse(args.input); await withTransaction(ctx, async (tx) => { @@ -157,6 +160,7 @@ export async function updateMonitorNotifiers(args: { input: UpdateMonitorNotifiersInput; }): Promise { const { ctx } = args; + requireScope(ctx, "write"); const input = UpdateMonitorNotifiersInput.parse(args.input); await withTransaction(ctx, async (tx) => { diff --git a/packages/services/src/page-subscriber/send-test-webhook.ts b/packages/services/src/page-subscriber/send-test-webhook.ts index 5990e708..186367b1 100644 --- a/packages/services/src/page-subscriber/send-test-webhook.ts +++ b/packages/services/src/page-subscriber/send-test-webhook.ts @@ -17,6 +17,9 @@ import { SendPageSubscriberTestWebhookInput } from "./schemas"; * No audit emit — a test dispatch has no effect on the entity's durable * state. If we add "last-tested-at" bookkeeping later, we'd revisit. */ +// The transaction only reads the subscriber row; the webhook send mutates +// nothing, so there is no state change to audit. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function sendPageSubscriberTestWebhook(args: { ctx: ServiceContext; input: SendPageSubscriberTestWebhookInput; diff --git a/packages/services/src/page-subscriber/slack.ts b/packages/services/src/page-subscriber/slack.ts index dafb488b..954f9ee0 100644 --- a/packages/services/src/page-subscriber/slack.ts +++ b/packages/services/src/page-subscriber/slack.ts @@ -41,6 +41,9 @@ function channelLabel(channelId: string, channelName?: string): string { * authenticated workspace at the call site — both workspace and audit actor * are resolved from the page. Auto-accepted (the slash command is consent). */ +// Token-addressed self-service by an anonymous visitor: the audit actor is +// `subscriber`, for which `requireScope` is a documented no-op. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function createSlackSubscriber(args: { input: CreateSlackSubscriberInput; db?: DB; @@ -164,6 +167,9 @@ export async function createSlackSubscriber(args: { }); } +// Token-addressed self-service by an anonymous visitor: the audit actor is +// `subscriber`, for which `requireScope` is a documented no-op. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function removeSlackSubscriber(args: { input: RemoveSlackSubscriberInput; db?: DB; diff --git a/packages/services/src/page-subscriber/unsubscribe.ts b/packages/services/src/page-subscriber/unsubscribe.ts index d5cc93ff..6303dac6 100644 --- a/packages/services/src/page-subscriber/unsubscribe.ts +++ b/packages/services/src/page-subscriber/unsubscribe.ts @@ -16,6 +16,9 @@ import { UnsubscribeSubscriberInput } from "./schemas"; * second call on an already-unsubscribed row returns silently with no * audit emit. */ +// Token-addressed self-service by an anonymous visitor: the audit actor is +// `subscriber`, for which `requireScope` is a documented no-op. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function unsubscribeSubscriber(args: { input: UnsubscribeSubscriberInput; db?: DB; diff --git a/packages/services/src/page-subscriber/update-scope.ts b/packages/services/src/page-subscriber/update-scope.ts index 42f571f1..5ef3f981 100644 --- a/packages/services/src/page-subscriber/update-scope.ts +++ b/packages/services/src/page-subscriber/update-scope.ts @@ -23,6 +23,9 @@ import { UpdateSubscriberScopeInput } from "./schemas"; * them via `metadata` to keep the audit row from being dropped by the * empty-diff guard in `emitAudit`. */ +// Token-addressed self-service by an anonymous visitor: the audit actor is +// `subscriber`, for which `requireScope` is a documented no-op. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function updateSubscriberScope(args: { input: UpdateSubscriberScopeInput; db?: DB; diff --git a/packages/services/src/page-subscriber/upsert.ts b/packages/services/src/page-subscriber/upsert.ts index fab85bf9..b2710b08 100644 --- a/packages/services/src/page-subscriber/upsert.ts +++ b/packages/services/src/page-subscriber/upsert.ts @@ -49,6 +49,9 @@ export type UpsertSelfSignupResult = { * components-only change) * - already verified row → no audit (no-op return) */ +// Token-addressed self-service by an anonymous visitor: the audit actor is +// `subscriber`, for which `requireScope` is a documented no-op. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function upsertSelfSignupSubscriber(args: { input: UpsertSelfSignupSubscriberInput; db?: DB; diff --git a/packages/services/src/page-subscriber/verify.ts b/packages/services/src/page-subscriber/verify.ts index f4f96bb8..120d051f 100644 --- a/packages/services/src/page-subscriber/verify.ts +++ b/packages/services/src/page-subscriber/verify.ts @@ -32,6 +32,9 @@ export type VerifyResult = { * `acceptedAt` and writes one `page_subscriber.update` audit row with * actor `subscriber:{id}`. */ +// Token-addressed self-service by an anonymous visitor: the audit actor is +// `subscriber`, for which `requireScope` is a documented no-op. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function verifySelfSignupSubscriber(args: { input: VerifySelfSignupSubscriberInput; db?: DB; diff --git a/packages/services/src/workspace/downgrade.ts b/packages/services/src/workspace/downgrade.ts index 010df202..035b5faf 100644 --- a/packages/services/src/workspace/downgrade.ts +++ b/packages/services/src/workspace/downgrade.ts @@ -43,6 +43,9 @@ import { updateWorkspacePlan } from "./update"; * caller can release them on Vercel *after* the transaction commits — * that cleanup is best-effort and must not roll the downgrade back. */ +// Every trim step routes through an audited entity verb, so the cascade is +// fully attributable; the plan flip itself is audited by `updateWorkspacePlan`. +// oxlint-disable-next-line openstatus/services-mutation-guards export async function downgradeWorkspaceToFree(args: { ctx: ServiceContext; }): Promise<{ customDomains: string[]; ssoDisabled: boolean }> { diff --git a/packages/services/test/helpers.ts b/packages/services/test/helpers.ts index d2081058..171697bd 100644 --- a/packages/services/test/helpers.ts +++ b/packages/services/test/helpers.ts @@ -66,7 +66,7 @@ export async function clearAuditLog( * the audit row outlives the entity, and because SQLite recycles * INTEGER PRIMARY KEY ids after deletes, a later test's freshly-inserted * entity can land on the orphan's id and inherit its actor attribution. - * See docs/adr/test-audit-cleanup.md. + * See packages/services/AGENTS.md. */ export async function clearAuditLogFor(args: { entityType: string; diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md new file mode 100644 index 00000000..037f0251 --- /dev/null +++ b/packages/ui/AGENTS.md @@ -0,0 +1,69 @@ +# AGENTS.md — @openstatus/ui + +Three directories under `src/components/`, three different contracts. Getting +the wrong one is the most common mistake here. + +| Directory | What it is | Who else sees it | +|---|---|---| +| `ui/` | Stock shadcn/ui primitives | Nobody — never shipped | +| `blocks/` | The openstatus shadcn registry (status page) | Every external consumer | +| `custom/` | openstatus-only components | Our apps only | + +`blocks/README.md` documents the composition API and `REGISTRY.md` the publish +flow. This file is only the rules those two assume you already know. + +## Do not customize `src/components/ui/` + +These 41 files are shadcn's, and the registry ships **zero** of them. Blocks +instead declare `registryDependencies: ["button", "tooltip", …]`, which resolve +to the **consumer's own** shadcn components at install time. + +So a block that depends on a local tweak to `button.tsx` renders correctly in +this repo and incorrectly for everyone who installs it — with no error anywhere. +Patching also means the next `shadcn add` either clobbers the change or silently +skips the upgrade. + +When a primitive isn't enough, in order of preference: pass `className`, compose +a wrapper in `blocks/` or `custom/`, or add a variant to the block that needs it. + +Editing a `ui/` file is a last resort, and only for a defect stock shadcn has +too. `input-group.tsx` is the precedent — `FormControl`'s Slot injected a +`data-slot` that clobbered the group's own, breaking the focus ring. It carries +a comment saying why the prop order matters. Do the same, or don't touch them. + +## `blocks/` is a published registry, not internal code + +Anything you add here ends up on `openstatus.dev/r` and gets installed into +codebases that are not this one. + +- **Register it.** A new file needs an entry in `registry.json` with its + `registryDependencies`. Forget that and it works in the monorepo and 404s for + consumers — nothing in CI catches it. +- **No app imports.** No `next/link`, no `next-intl`, no `@openstatus/db`, no + tRPC. Every direct dependency becomes a mandatory install for consumers, and + most of them aren't even on Next.js. Routing, markdown and translation come in + through slot props (`renderEvent`, `renderMessage`, `asChild`) and the + `StatusBlocksI18nProvider` context. +- **Imports must exist in a stock shadcn install.** `pnpm registry:build` + rewrites `@openstatus/ui/*` to `@/*`, so `@openstatus/ui/components/ui/button` + becomes `@/components/ui/button` on the consumer's machine. Importing a + primitive we have but they don't produces a build error there, not here. +- **New color tokens go in `registry.json` `cssVars`.** That is how `--success`, + `--warning` and `--info` reach consumers; a token used only in `globals.css` + is undefined for them. +- **Defaults must render unconfigured.** Slots are optional, i18n falls back to + English. The registry preview has no wiring. + +## Generated output + +`dist/` and `public/r/` are build artifacts of `pnpm registry:build` (which +`apps/web`'s build runs). Both are gitignored. Never edit them, and never fix a +registry bug by editing `dist/` — change `src/` and rebuild. + +## Imports + +Exports are path-based, with no barrel: +`@openstatus/ui/components/ui/button`, `@openstatus/ui/components/blocks/status-bar`, +`@openstatus/ui/hooks/use-media-query`. Import the exact file. + +Shared UI belongs here, not forked into an app — see `docs/adr/0003-shared-ui-comes-from-openstatus-ui.md`. diff --git a/ralph/.gitignore b/ralph/.gitignore deleted file mode 100644 index e93c4692..00000000 --- a/ralph/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore PRD and progress files as they are specific to local development -prd.json -progress.txt - diff --git a/ralph/README.md b/ralph/README.md deleted file mode 100644 index b54a819d..00000000 --- a/ralph/README.md +++ /dev/null @@ -1,34 +0,0 @@ -Ralph is a technique for running AI coding agents in a loop. Our approach is taken from Matt Pocock's [Getting Started with Ralph](https://www.aihero.dev/getting-started-with-ralph) writeup. Make sure to have everything installed. - -The `prd.json` file is an array of object with the following format: - -``` -{ - "category": "functional", - "description": "When a user is on wrong dashboard /status-pages/[id] redirect him to /status-pages", - "steps": [ - "Redirect user no access for page id", - "Avoid throwing an error", - ], - "passes": false -} -``` - -- category: "functional" | "ui" or other categories -- description: define what you are building -- steps: break the task down into multiple smaller steps -- passes: determines whether or not all defined steps and tests have succeed or not and makes it easier to track progress - -The `progress.txt` file simply keeps track of the changes and implementation decisions. - -You can run Ralph with a human-in-the-loop by running: - -``` -./ralph-once.sh -``` - -Or in AFK mode within the sandbox environment by specifying the iteration number with: - -``` -./afk-raph.sh 10 -``` \ No newline at end of file diff --git a/ralph/afk-ralph.sh b/ralph/afk-ralph.sh deleted file mode 100755 index e56b7116..00000000 --- a/ralph/afk-ralph.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -set -e - -if [ -z "$1" ]; then - echo "Usage: $0 " - exit 1 -fi - -for ((i=1; i<=$1; i++)); do - result=$(docker sandbox run claude --permission-mode acceptEdits -p "@prd.json @progress.txt \ - 1. Find the highest-priority task and implement it. \ - 2. Run your tests and type checks. \ - 3. Update the PRD with what was done. \ - 4. Append your progress to progress.txt. \ - 5. Commit your changes. \ - ONLY WORK ON A SINGLE TASK. \ - If the PRD is complete, output COMPLETE.") - - echo "$result" - - if [[ "$result" == *"COMPLETE"* ]]; then - echo "PRD complete after $i iterations." - exit 0 - fi -done \ No newline at end of file diff --git a/ralph/ralph-once.sh b/ralph/ralph-once.sh deleted file mode 100755 index e157ae0e..00000000 --- a/ralph/ralph-once.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -claude --permission-mode acceptEdits "@prd.json @progress.txt \ -1. Read the PRD and progress file. \ -2. Find the next incomplete task and implement it. \ -3. Commit your changes. \ -4. Update progress.txt with what you did. \ -ONLY DO ONE TASK AT A TIME." \ No newline at end of file diff --git a/scripts/check-doc-refs.mts b/scripts/check-doc-refs.mts new file mode 100644 index 00000000..d0c4cc77 --- /dev/null +++ b/scripts/check-doc-refs.mts @@ -0,0 +1,133 @@ +/** + * Fails when a cited path no longer exists: `docs/…` references anywhere in the + * repo, and backticked repo paths inside any `AGENTS.md`. + * + * Paths only — resolving symbols would need a parser and is out of proportion. + * Runs on node's native type stripping, so it needs no build step. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const SCANNED_EXTENSIONS = new Set([ + "ts", + "tsx", + "js", + "jsx", + "mjs", + "cjs", + "md", + "mdx", + "go", + "yml", + "yaml", + "json", + "jsonc", + "sh", + "toml", + "sql", +]); + +// A backticked token in AGENTS.md counts as a repo path only if it starts with +// one of these. Keeps `@openstatus/db`, `node:fs` and `hono/jsx` out. +const REPO_PATH_PREFIXES = [ + "apps/", + "packages/", + "docs/", + "infra/", + "utils/", + "scripts/", + ".github/", + ".claude/", +]; + +// Brackets are in the class so Next.js route segments (`docs/[slug].md`) match. +const DOCS_REFERENCE = /(? file.length > 0); +} + +/** + * Strips sentence punctuation and a `:42` line suffix a citation may carry. + * `]` is left alone — Next.js route segments legitimately end with it. + */ +function normalize(candidate: string): string { + return candidate.replace(/[.,;:)]+$/, "").replace(/:\d+$/, ""); +} + +function isRepoPath(candidate: string): boolean { + // `*`, `<…>` and `…` mark a pattern or placeholder, not a real path. + if (/[*<>…\s]/.test(candidate)) return false; + return REPO_PATH_PREFIXES.some((prefix) => candidate.startsWith(prefix)); +} + +function collect(file: string, contents: string): Violation[] { + const violations: Violation[] = []; + const isAgentsFile = file === "AGENTS.md" || file.endsWith("/AGENTS.md"); + + contents.split("\n").forEach((text, index) => { + const candidates = new Set(); + + for (const match of text.matchAll(DOCS_REFERENCE)) { + const candidate = normalize(match[0]); + if (FILE_LIKE.test(candidate)) candidates.add(candidate); + } + + if (isAgentsFile) { + for (const match of text.matchAll(BACKTICKED)) { + const candidate = normalize(match[1] ?? ""); + if (isRepoPath(candidate)) candidates.add(candidate); + } + } + + for (const candidate of candidates) { + if (!existsSync(join(REPO_ROOT, candidate))) { + violations.push({ file, line: index + 1, path: candidate }); + } + } + }); + + return violations; +} + +const files = trackedFiles(); +const violations: Violation[] = []; + +for (const file of files) { + const extension = file.split(".").pop()?.toLowerCase() ?? ""; + if (!SCANNED_EXTENSIONS.has(extension)) continue; + + violations.push( + ...collect(file, readFileSync(join(REPO_ROOT, file), "utf8")), + ); +} + +if (violations.length > 0) { + console.error(`Dangling path references (${violations.length}):\n`); + for (const { file, line, path } of violations) { + console.error(` ${file}:${line} → ${path}`); + } + console.error( + "\nEvery `docs/…` citation and every backticked repo path in an AGENTS.md must resolve.", + ); + process.exit(1); +} + +console.log(`No dangling path references (${files.length} tracked files).`); diff --git a/scripts/oxlint-plugin-openstatus.js b/scripts/oxlint-plugin-openstatus.js new file mode 100644 index 00000000..6c302454 --- /dev/null +++ b/scripts/oxlint-plugin-openstatus.js @@ -0,0 +1,175 @@ +/** + * Custom oxlint rules for openstatus. + * + * `services-mutation-guards` keys off behaviour, not filename: a top-level + * function that opens a transaction is a mutation, and every mutation must + * check the actor's scope and leave an audit row. + * + * It matches call names, not the call graph. `emitAudit` therefore counts from + * anywhere in the verb (it belongs inside the `withTransaction` callback), which + * a nested helper that never runs could satisfy. `requireScope` must appear in + * the verb body itself, which is where the convention puts it. A guard, not a + * proof — the audit tests are what actually pin the behaviour. + */ + +const FUNCTION_TYPES = [ + "FunctionDeclaration", + "FunctionExpression", + "ArrowFunctionExpression", +]; + +function calleeName(node) { + const callee = node.callee; + if (!callee) return null; + if (callee.type === "Identifier") return callee.name; + // `foo.bar()` — only the property matters here. + if ( + callee.type === "MemberExpression" && + callee.property?.type === "Identifier" + ) { + return callee.property.name; + } + return null; +} + +/** Arrows and function expressions have no `id`; fall back to the const they're assigned to. */ +function functionName(node, assignedName) { + if (node.id?.type === "Identifier") return node.id.name; + return assignedName ?? ""; +} + +const servicesMutationGuards = { + meta: { + type: "problem", + docs: { + description: + "A services function that calls withTransaction must also call requireScope and emitAudit.", + }, + messages: { + missing: + "`{{name}}` opens a transaction but never calls {{missing}}. Every mutation in @openstatus/services must check the actor's scope and emit an audit row — see packages/services/AGENTS.md. If this verb genuinely needs neither, disable this rule inline with the reason above it.", + }, + }, + create(context) { + let depth = 0; + let outermost = null; + let outermostName = null; + let pendingName = null; + // Anywhere in the verb, so a `withTransaction` callback's `emitAudit` counts. + const calls = new Set(); + // The verb's own body, where `requireScope` belongs. + const bodyCalls = new Set(); + + function enter(node) { + if (depth === 0) { + outermost = node; + outermostName = functionName(node, pendingName); + calls.clear(); + bodyCalls.clear(); + } + depth += 1; + } + + function exit() { + depth -= 1; + if (depth !== 0 || outermost === null) return; + + if (calls.has("withTransaction")) { + const missing = []; + if (!bodyCalls.has("requireScope")) missing.push("requireScope"); + if (!calls.has("emitAudit")) missing.push("emitAudit"); + + if (missing.length > 0) { + context.report({ + node: outermost, + messageId: "missing", + data: { + name: outermostName, + missing: missing.map((name) => `\`${name}\``).join(" or "), + }, + }); + } + } + outermost = null; + outermostName = null; + pendingName = null; + } + + const visitor = { + VariableDeclarator(node) { + if (depth === 0 && node.id?.type === "Identifier") { + pendingName = node.id.name; + } + }, + CallExpression(node) { + const name = calleeName(node); + if (!name) return; + calls.add(name); + if (depth === 1) bodyCalls.add(name); + }, + }; + for (const type of FUNCTION_TYPES) { + visitor[type] = enter; + visitor[`${type}:exit`] = exit; + } + return visitor; + }, +}; + +const DB_SCHEMA_BARREL = "@openstatus/db/src/schema"; + +/** + * `"use client"` is a per-file directive, not a path convention, so this cannot + * be expressed as a `no-restricted-imports` override keyed on globs. + */ +const noDbBarrelInClient = { + meta: { + type: "problem", + docs: { + description: + 'A "use client" file must not value-import the db schema barrel.', + }, + messages: { + barrel: + 'A "use client" file must not value-import `{{source}}` — it pulls drizzle and the whole schema graph into the browser bundle. Import the specific sub-path (e.g. `@openstatus/db/src/schema/monitors/constants`), or split the pure-zod part into a sibling file. `import type` is fine.', + }, + }, + create(context) { + let isClientFile = false; + + return { + Program(node) { + isClientFile = node.body.some( + (statement) => + statement.type === "ExpressionStatement" && + statement.expression?.type === "Literal" && + statement.expression.value === "use client", + ); + }, + ImportDeclaration(node) { + if (!isClientFile) return; + if (node.source?.value !== DB_SCHEMA_BARREL) return; + if (node.importKind === "type") return; + // `import { type Foo }` on every specifier erases too. + const hasValueSpecifier = node.specifiers.some( + (specifier) => specifier.importKind !== "type", + ); + if (!hasValueSpecifier) return; + + context.report({ + node, + messageId: "barrel", + data: { source: DB_SCHEMA_BARREL }, + }); + }, + }; + }, +}; + +export default { + meta: { name: "openstatus" }, + rules: { + "services-mutation-guards": servicesMutationGuards, + "no-db-barrel-in-client": noDbBarrelInClient, + }, +}; -- 2.51.2 From 5a1c37898ee6dda7e2ff0a1afc67e6dab82407d5 Mon Sep 17 00:00:00 2001 From: Mohit Bhandari <151373021+mohit-bhandari45@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:07:47 +0530 Subject: [PATCH 138/266] feat(dashboard): add github action code example to cli page (#2574) * Added github action code example * refactor(dashboard): fix github action examples and extract TemplateList component * refactor(dashboard): revert TemplateList extraction and inline mapping loops --- .../src/app/(dashboard)/cli/page.tsx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/app/(dashboard)/cli/page.tsx b/apps/dashboard/src/app/(dashboard)/cli/page.tsx index 016554b2..be89fa04 100644 --- a/apps/dashboard/src/app/(dashboard)/cli/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/cli/page.tsx @@ -173,6 +173,41 @@ mcp-server: }, ]; +const githubActions = [ + { + description: "Run synthetic tests", + template: `name: OpenStatus +on: [push] + +jobs: + run-synthetic-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: OpenStatus GitHub Action + uses: openstatusHQ/openstatus-github-action@v1 + with: + api_key: \${{ secrets.OPENSTATUS_API_KEY }}`, + }, + { + description: "Apply monitors configuration", + template: `name: OpenStatus +on: [push] + +jobs: + apply-monitors: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: OpenStatus CLI Action + uses: openstatusHQ/cli-action@v1 + with: + args: monitors apply + env: + OPENSTATUS_API_TOKEN: \${{ secrets.OPENSTATUS_API_TOKEN }}`, + }, +]; + export default function Page() { return ( @@ -299,7 +334,16 @@ export default function Page() { to to run synthetic tests in a GitHub action. - {/* TODO: add code example */} +
+ {githubActions.map((action, i) => ( +
+

+ {action.description} +

+ {action.template} +
+ ))} +
-- 2.51.2 From f39eb32f76b7843b85df339adb605f299611ab86 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:55:00 +0200 Subject: [PATCH 139/266] chore: migrate tb classic to forward (#2577) * chore: tb pull * chore: drop unused endpoint * chore: drop inexistent datasource * fix: deployment * chore: tb config * fix: missing columns * chore: update readme --- .gitignore | 2 + apps/checker/handlers/dns.go | 2 +- apps/checker/handlers/tcp.go | 2 +- packages/tinybird/README.md | 146 ++++++++++++++---- .../datasources/audit_log__v0.datasource | 1 - .../datasources/check_response.datasource | 24 --- .../check_response_dns__v0.datasource | 19 +++ ...rce => check_response_http__v0.datasource} | 1 - .../datasources/dns_response__v0.datasource | 3 +- .../external_status__v1.datasource | 3 +- .../external_status_component__v0.datasource | 5 +- ...rnal_status_component_daily__v0.datasource | 2 +- .../mv__external_status_daily__v0.datasource | 1 - .../datasources/mv__http_14d.datasource | 19 --- .../datasources/mv__http_14d__v0.datasource | 1 - .../datasources/mv__http_1d__v0.datasource | 1 - .../datasources/mv__http_1d__v1.datasource | 2 +- .../datasources/mv__http_30d__v0.datasource | 1 - .../datasources/mv__http_7d__v0.datasource | 1 - .../mv__http_full_14d__v0.datasource | 3 +- .../mv__http_full_30d__v0.datasource | 3 +- .../mv__http_status_45d__v0.datasource | 1 - .../mv__http_status_7d__v0.datasource | 1 - .../mv__http_timing_phases_14d.datasource | 21 --- .../datasources/mv__tcp_14d__v0.datasource | 1 - .../datasources/mv__tcp_1d__v0.datasource | 1 - .../datasources/mv__tcp_30d__v0.datasource | 1 - .../datasources/mv__tcp_7d__v0.datasource | 1 - .../mv__tcp_status_45d__v0.datasource | 1 - .../mv__tcp_status_7d__v0.datasource | 1 - .../datasources/mv_http_status_14d.datasource | 15 -- .../datasources/tcp_response.datasource | 19 --- .../endpoints/endpoint__audit_log.pipe | 17 -- .../endpoints/endpoint__audit_log__v1.pipe | 22 ++- .../endpoints/endpoint__dns_get_14d__v0.pipe | 19 +-- .../endpoints/endpoint__dns_list_14d__v0.pipe | 21 +-- .../endpoint__dns_metrics_14d__v0.pipe | 75 ++++----- .../endpoint__dns_metrics_1d__v0.pipe | 75 ++++----- .../endpoint__dns_metrics_30d__v0.pipe | 76 ++++----- .../endpoint__dns_metrics_7d__v0.pipe | 75 ++++----- .../endpoint__dns_metrics_90d__v0.pipe | 68 ++++---- .../endpoint__dns_metrics_global_1d__v0.pipe | 11 +- ...int__dns_metrics_latency_1d_multi__v0.pipe | 41 ++--- ...endpoint__dns_metrics_latency_30d__v0.pipe | 40 ++--- .../endpoint__dns_metrics_latency_7d__v0.pipe | 49 +++--- ...endpoint__dns_metrics_latency_90d__v0.pipe | 40 ++--- ...endpoint__dns_metrics_regions_14d__v0.pipe | 49 +++--- ...endpoint__dns_metrics_regions_30d__v0.pipe | 44 +++--- ...endpoint__dns_metrics_regions_90d__v0.pipe | 44 +++--- .../endpoint__dns_status_45d__v0.pipe | 29 ++-- .../endpoint__dns_uptime_30d__v0.pipe | 49 +++--- .../endpoint__dns_uptime_90d__v0.pipe | 50 +++--- .../endpoints/endpoint__external_status.pipe | 13 -- ...external_status_component_history__v0.pipe | 10 +- ..._external_status_component_latest__v0.pipe | 26 ++-- ...endpoint__external_status_history__v0.pipe | 10 +- .../endpoint__external_status_latest__v1.pipe | 10 +- .../endpoints/endpoint__http_get_14d__v0.pipe | 19 +-- ...d.pipe => endpoint__http_get_30d__v0.pipe} | 8 +- ....pipe => endpoint__http_list_14d__v0.pipe} | 9 +- .../endpoint__http_list_14d__v1.pipe | 33 ++-- ...d.pipe => endpoint__http_list_1d__v0.pipe} | 9 +- .../endpoints/endpoint__http_list_1d__v1.pipe | 24 ++- ...d.pipe => endpoint__http_list_7d__v0.pipe} | 9 +- .../endpoints/endpoint__http_list_7d__v1.pipe | 24 ++- ...pe => endpoint__http_metrics_14d__v0.pipe} | 11 +- .../endpoint__http_metrics_14d__v1.pipe | 76 ++++----- ...ipe => endpoint__http_metrics_1d__v0.pipe} | 31 ++-- .../endpoint__http_metrics_1d__v1.pipe | 75 ++++----- .../endpoint__http_metrics_30d__v1.pipe | 8 +- ...ipe => endpoint__http_metrics_7d__v0.pipe} | 11 +- .../endpoint__http_metrics_7d__v1.pipe | 76 ++++----- .../endpoint__http_metrics_90d__v1.pipe | 70 +++++---- ...nt__http_metrics_by_interval_14d__v0.pipe} | 10 +- ...int__http_metrics_by_interval_1d__v0.pipe} | 10 +- ...int__http_metrics_by_interval_7d__v0.pipe} | 9 +- ...oint__http_metrics_by_region_14d__v0.pipe} | 12 +- ...point__http_metrics_by_region_1d__v0.pipe} | 9 +- ...point__http_metrics_by_region_7d__v0.pipe} | 9 +- .../endpoint__http_metrics_global_1d__v0.pipe | 37 +++-- ...endpoint__http_metrics_latency_1d__v1.pipe | 37 ++--- ...nt__http_metrics_latency_1d_multi__v1.pipe | 39 ++--- ...ndpoint__http_metrics_latency_30d__v1.pipe | 40 ++--- ...endpoint__http_metrics_latency_7d__v1.pipe | 37 ++--- ...ndpoint__http_metrics_latency_90d__v1.pipe | 40 ++--- ...ndpoint__http_metrics_regions_14d__v0.pipe | 41 ++--- ...endpoint__http_metrics_regions_1d__v0.pipe | 41 ++--- ...ndpoint__http_metrics_regions_30d__v0.pipe | 42 ++--- ...endpoint__http_metrics_regions_7d__v0.pipe | 41 ++--- ...ndpoint__http_metrics_regions_90d__v0.pipe | 42 ++--- .../endpoints/endpoint__http_status_14d.pipe | 26 ---- .../endpoint__http_status_14d__v0.pipe | 25 +++ ...ipe => endpoint__http_status_45d__v0.pipe} | 9 +- .../endpoint__http_status_45d__v1.pipe | 29 ++-- ...pipe => endpoint__http_status_7d__v0.pipe} | 9 +- .../endpoint__http_timing_phases_14d__v1.pipe | 73 ++++----- .../endpoint__http_timing_phases_30d__v1.pipe | 76 ++++----- .../endpoint__http_timing_phases_90d__v1.pipe | 72 +++++---- .../endpoint__http_uptime_30d__v1.pipe | 33 ++-- .../endpoint__http_uptime_7d__v1.pipe | 33 ++-- .../endpoint__http_uptime_90d__v1.pipe | 36 +++-- .../endpoint__http_workspace_30d__v0.pipe | 21 +-- .../endpoints/endpoint__stats_global.pipe | 25 --- .../endpoints/endpoint__stats_global__v0.pipe | 24 +++ .../endpoints/endpoint__tcp_get_14d__v0.pipe | 19 +-- ...0d.pipe => endpoint__tcp_get_30d__v0.pipe} | 10 +- ...d.pipe => endpoint__tcp_list_14d__v0.pipe} | 9 +- .../endpoints/endpoint__tcp_list_14d__v1.pipe | 28 ++-- .../endpoints/endpoint__tcp_list_1d.pipe | 15 -- .../endpoints/endpoint__tcp_list_1d__v0.pipe | 12 ++ .../endpoints/endpoint__tcp_list_1d__v1.pipe | 28 ++-- ...7d.pipe => endpoint__tcp_list_7d__v0.pipe} | 9 +- .../endpoints/endpoint__tcp_list_7d__v1.pipe | 28 ++-- ...ipe => endpoint__tcp_metrics_14d__v0.pipe} | 11 +- .../endpoint__tcp_metrics_14d__v1.pipe | 84 +++++----- ...pipe => endpoint__tcp_metrics_1d__v0.pipe} | 15 +- .../endpoint__tcp_metrics_1d__v1.pipe | 75 ++++----- .../endpoint__tcp_metrics_30d__v1.pipe | 76 ++++----- ...pipe => endpoint__tcp_metrics_7d__v0.pipe} | 15 +- .../endpoint__tcp_metrics_7d__v1.pipe | 75 ++++----- .../endpoint__tcp_metrics_90d__v1.pipe | 70 +++++---- ...oint__tcp_metrics_by_interval_14d__v0.pipe | 12 +- ...point__tcp_metrics_by_interval_1d__v0.pipe | 12 +- ...oint__tcp_metrics_by_interval_30d__v0.pipe | 41 +++-- ...point__tcp_metrics_by_interval_7d__v0.pipe | 12 +- ...oint__tcp_metrics_by_interval_90d__v0.pipe | 41 +++-- ...point__tcp_metrics_by_region_14d__v0.pipe} | 11 +- ...dpoint__tcp_metrics_by_region_1d__v0.pipe} | 11 +- ...dpoint__tcp_metrics_by_region_7d__v0.pipe} | 11 +- .../endpoint__tcp_metrics_global_1d__v0.pipe | 37 +++-- .../endpoint__tcp_metrics_latency_1d__v1.pipe | 37 ++--- ...int__tcp_metrics_latency_1d_multi__v1.pipe | 39 ++--- ...endpoint__tcp_metrics_latency_30d__v1.pipe | 37 ++--- .../endpoint__tcp_metrics_latency_7d__v1.pipe | 37 ++--- ...endpoint__tcp_metrics_latency_90d__v1.pipe | 37 ++--- ...pipe => endpoint__tcp_status_45d__v0.pipe} | 9 +- .../endpoint__tcp_status_45d__v1.pipe | 29 ++-- ....pipe => endpoint__tcp_status_7d__v0.pipe} | 9 +- .../endpoint__tcp_uptime_30d__v1.pipe | 33 ++-- .../endpoint__tcp_uptime_7d__v1.pipe | 33 ++-- .../endpoint__tcp_uptime_90d__v1.pipe | 34 ++-- .../endpoint__tcp_workspace_30d__v0.pipe | 21 +-- .../endpoints/endpoint_audit_log.pipe | 9 -- .../endpoints/endpoint_audit_log__v0.pipe | 8 + .../get_result_for_on_demand_check_http.pipe | 27 ++++ .../aggregate__dns_status_45d__v1.pipe | 2 - ...external_status_component__daily__v0.pipe} | 16 +- .../aggregate__external_status_daily__v0.pipe | 28 ++++ .../aggregate__http_14d__v0.pipe | 20 +++ .../aggregate__http_14d__v1.pipe | 2 - .../aggregate__http_1d__v0.pipe | 20 +++ .../aggregate__http_1d__v1.pipe | 2 - .../aggregate__http_30d__v0.pipe | 20 +++ .../aggregate__http_30d__v1.pipe | 0 .../aggregate__http_7d__v0.pipe | 20 +++ .../aggregate__http_7d__v1.pipe | 0 .../aggregate__http_90d__v1.pipe | 2 + .../aggregate__http_full_14d__v0.pipe | 21 ++- .../aggregate__http_full_30d__v0.pipe | 21 ++- .../aggregate__http_status_14d__v0.pipe} | 6 +- .../aggregate__http_status_45d__v0.pipe} | 6 +- .../aggregate__http_status_45d__v1.pipe | 2 - .../aggregate__http_status_7d__v0.pipe} | 6 +- ...ggregate__http_timing_phases_14d__v1.pipe} | 0 ...aggregate__http_timing_phases_90d__v1.pipe | 54 +++++++ .../aggregate__http_uptime_30d__v1.pipe} | 4 - .../aggregate__http_uptime_7d__v1.pipe | 0 .../aggregate__http_uptime_90d__v1.pipe | 15 ++ .../aggregate__http_workspace_30d__v0.pipe | 2 +- .../aggregate__tcp_14d__v0.pipe} | 6 +- .../aggregate__tcp_14d__v1.pipe | 2 - .../aggregate__tcp_1d__v0.pipe} | 6 +- .../aggregate__tcp_1d__v1.pipe | 2 - .../aggregate__tcp_30d__v0.pipe} | 6 +- .../aggregate__tcp_30d__v1.pipe | 2 - .../aggregate__tcp_7d__v0.pipe} | 6 +- .../aggregate__tcp_7d__v1.pipe | 2 - .../aggregate__tcp_90d__v1.pipe | 4 +- .../aggregate__tcp_full_14d__v0.pipe | 16 +- .../aggregate__tcp_full_30d__v0.pipe | 28 ++++ .../aggregate__tcp_status_45d__v0.pipe} | 6 +- .../aggregate__tcp_status_45d__v1.pipe | 2 - .../aggregate__tcp_status_7d__v0.pipe} | 6 +- .../aggregate__tcp_uptime_30d__v1.pipe | 0 .../aggregate__tcp_uptime_7d__v1.pipe | 0 .../aggregate__tcp_uptime_90d__v1.pipe | 15 ++ .../aggregate__tcp_workspace_30d__v0.pipe | 2 - .../pipes/__ttl_45d_count_utc_get.pipe | 19 --- .../aggregate__external_status_daily__v0.pipe | 28 ---- ...aggregate__http_timing_phases_90d__v1.pipe | 33 ---- .../pipes/aggregate__http_uptime_90d__v1.pipe | 17 -- .../pipes/aggregate__tcp_full_30d__v0.pipe | 16 -- .../pipes/aggregate__tcp_uptime_90d__v1.pipe | 13 -- .../get_result_for_on_demand_check_http.pipe | 23 --- packages/tinybird/pipes/public_status.pipe | 22 --- packages/tinybird/pipes/response_details.pipe | 13 -- packages/tinybird/pipes/response_graph.pipe | 26 ---- packages/tinybird/pipes/response_list.pipe | 24 --- .../tinybird/pipes/single_checks_get.pipe | 16 -- 199 files changed, 2254 insertions(+), 2290 deletions(-) delete mode 100644 packages/tinybird/datasources/check_response.datasource create mode 100644 packages/tinybird/datasources/check_response_dns__v0.datasource rename packages/tinybird/datasources/{check_response_http.datasource => check_response_http__v0.datasource} (97%) delete mode 100644 packages/tinybird/datasources/mv__http_14d.datasource delete mode 100644 packages/tinybird/datasources/mv__http_timing_phases_14d.datasource delete mode 100644 packages/tinybird/datasources/mv_http_status_14d.datasource delete mode 100644 packages/tinybird/datasources/tcp_response.datasource delete mode 100644 packages/tinybird/endpoints/endpoint__audit_log.pipe delete mode 100644 packages/tinybird/endpoints/endpoint__external_status.pipe rename packages/tinybird/endpoints/{endpoint__http_get_30d.pipe => endpoint__http_get_30d__v0.pipe} (88%) rename packages/tinybird/endpoints/{endpoint__http_list_14d.pipe => endpoint__http_list_14d__v0.pipe} (80%) rename packages/tinybird/endpoints/{endpoint__http_list_1d.pipe => endpoint__http_list_1d__v0.pipe} (80%) rename packages/tinybird/endpoints/{endpoint__http_list_7d.pipe => endpoint__http_list_7d__v0.pipe} (80%) rename packages/tinybird/endpoints/{endpoint__http_metrics_14d.pipe => endpoint__http_metrics_14d__v0.pipe} (86%) rename packages/tinybird/endpoints/{endpoint__http_metrics_1d.pipe => endpoint__http_metrics_1d__v0.pipe} (50%) rename packages/tinybird/endpoints/{endpoint__http_metrics_7d.pipe => endpoint__http_metrics_7d__v0.pipe} (86%) rename packages/tinybird/endpoints/{endpoint__http_metrics_by_interval_14d.pipe => endpoint__http_metrics_by_interval_14d__v0.pipe} (87%) rename packages/tinybird/endpoints/{endpoint__http_metrics_by_interval_1d.pipe => endpoint__http_metrics_by_interval_1d__v0.pipe} (87%) rename packages/tinybird/endpoints/{endpoint__http_metrics_by_interval_7d.pipe => endpoint__http_metrics_by_interval_7d__v0.pipe} (94%) rename packages/tinybird/endpoints/{endpoint__http_metrics_by_region_14d.pipe => endpoint__http_metrics_by_region_14d__v0.pipe} (82%) rename packages/tinybird/endpoints/{endpoint__http_metrics_by_region_1d.pipe => endpoint__http_metrics_by_region_1d__v0.pipe} (82%) rename packages/tinybird/endpoints/{endpoint__http_metrics_by_region_7d.pipe => endpoint__http_metrics_by_region_7d__v0.pipe} (82%) delete mode 100644 packages/tinybird/endpoints/endpoint__http_status_14d.pipe create mode 100644 packages/tinybird/endpoints/endpoint__http_status_14d__v0.pipe rename packages/tinybird/endpoints/{endpoint__http_status_45d.pipe => endpoint__http_status_45d__v0.pipe} (91%) rename packages/tinybird/endpoints/{endpoint__http_status_7d.pipe => endpoint__http_status_7d__v0.pipe} (91%) delete mode 100644 packages/tinybird/endpoints/endpoint__stats_global.pipe create mode 100644 packages/tinybird/endpoints/endpoint__stats_global__v0.pipe rename packages/tinybird/endpoints/{endpoint__tcp_get_30d.pipe => endpoint__tcp_get_30d__v0.pipe} (71%) rename packages/tinybird/endpoints/{endpoint__tcp_list_14d.pipe => endpoint__tcp_list_14d__v0.pipe} (80%) delete mode 100644 packages/tinybird/endpoints/endpoint__tcp_list_1d.pipe create mode 100644 packages/tinybird/endpoints/endpoint__tcp_list_1d__v0.pipe rename packages/tinybird/endpoints/{endpoint__tcp_list_7d.pipe => endpoint__tcp_list_7d__v0.pipe} (80%) rename packages/tinybird/endpoints/{endpoint__tcp_metrics_14d.pipe => endpoint__tcp_metrics_14d__v0.pipe} (88%) rename packages/tinybird/endpoints/{endpoint__tcp_metrics_1d.pipe => endpoint__tcp_metrics_1d__v0.pipe} (78%) rename packages/tinybird/endpoints/{endpoint__tcp_metrics_7d.pipe => endpoint__tcp_metrics_7d__v0.pipe} (78%) rename packages/tinybird/endpoints/{endpoint__tcp_metrics_by_region_14d.pipe => endpoint__tcp_metrics_by_region_14d__v0.pipe} (71%) rename packages/tinybird/endpoints/{endpoint__tcp_metrics_by_region_1d.pipe => endpoint__tcp_metrics_by_region_1d__v0.pipe} (71%) rename packages/tinybird/endpoints/{endpoint__tcp_metrics_by_region_7d.pipe => endpoint__tcp_metrics_by_region_7d__v0.pipe} (71%) rename packages/tinybird/endpoints/{endpoint__tcp_status_45d.pipe => endpoint__tcp_status_45d__v0.pipe} (91%) rename packages/tinybird/endpoints/{endpoint__tcp_status_7d.pipe => endpoint__tcp_status_7d__v0.pipe} (91%) delete mode 100644 packages/tinybird/endpoints/endpoint_audit_log.pipe create mode 100644 packages/tinybird/endpoints/endpoint_audit_log__v0.pipe create mode 100644 packages/tinybird/endpoints/get_result_for_on_demand_check_http.pipe rename packages/tinybird/{pipes => materializations}/aggregate__dns_status_45d__v1.pipe (95%) rename packages/tinybird/{pipes/aggregate__external_status_component_daily__v0.pipe => materializations/aggregate__external_status_component__daily__v0.pipe} (55%) create mode 100644 packages/tinybird/materializations/aggregate__external_status_daily__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__http_14d__v0.pipe rename packages/tinybird/{pipes => materializations}/aggregate__http_14d__v1.pipe (96%) create mode 100644 packages/tinybird/materializations/aggregate__http_1d__v0.pipe rename packages/tinybird/{pipes => materializations}/aggregate__http_1d__v1.pipe (96%) create mode 100644 packages/tinybird/materializations/aggregate__http_30d__v0.pipe rename packages/tinybird/{pipes => materializations}/aggregate__http_30d__v1.pipe (100%) create mode 100644 packages/tinybird/materializations/aggregate__http_7d__v0.pipe rename packages/tinybird/{pipes => materializations}/aggregate__http_7d__v1.pipe (100%) rename packages/tinybird/{pipes => materializations}/aggregate__http_90d__v1.pipe (99%) rename packages/tinybird/{pipes => materializations}/aggregate__http_full_14d__v0.pipe (50%) rename packages/tinybird/{pipes => materializations}/aggregate__http_full_30d__v0.pipe (50%) rename packages/tinybird/{pipes/aggregate__http_status_14d.pipe => materializations/aggregate__http_status_14d__v0.pipe} (94%) rename packages/tinybird/{pipes/aggregate__http_status_45d.pipe => materializations/aggregate__http_status_45d__v0.pipe} (91%) rename packages/tinybird/{pipes => materializations}/aggregate__http_status_45d__v1.pipe (95%) rename packages/tinybird/{pipes/aggregate__http_status_7d.pipe => materializations/aggregate__http_status_7d__v0.pipe} (91%) rename packages/tinybird/{pipes/aggregate__http_timing_phases_14d.pipe => materializations/aggregate__http_timing_phases_14d__v1.pipe} (100%) create mode 100644 packages/tinybird/materializations/aggregate__http_timing_phases_90d__v1.pipe rename packages/tinybird/{pipes/aggregate__http_uptime_30d.pipe => materializations/aggregate__http_uptime_30d__v1.pipe} (92%) rename packages/tinybird/{pipes => materializations}/aggregate__http_uptime_7d__v1.pipe (100%) create mode 100644 packages/tinybird/materializations/aggregate__http_uptime_90d__v1.pipe rename packages/tinybird/{pipes => materializations}/aggregate__http_workspace_30d__v0.pipe (86%) rename packages/tinybird/{pipes/aggregate__tcp_14d.pipe => materializations/aggregate__tcp_14d__v0.pipe} (94%) rename packages/tinybird/{pipes => materializations}/aggregate__tcp_14d__v1.pipe (96%) rename packages/tinybird/{pipes/aggregate__tcp_1d.pipe => materializations/aggregate__tcp_1d__v0.pipe} (94%) rename packages/tinybird/{pipes => materializations}/aggregate__tcp_1d__v1.pipe (96%) rename packages/tinybird/{pipes/aggregate__tcp_30d.pipe => materializations/aggregate__tcp_30d__v0.pipe} (94%) rename packages/tinybird/{pipes => materializations}/aggregate__tcp_30d__v1.pipe (96%) rename packages/tinybird/{pipes/aggregate__tcp_7d.pipe => materializations/aggregate__tcp_7d__v0.pipe} (94%) rename packages/tinybird/{pipes => materializations}/aggregate__tcp_7d__v1.pipe (96%) rename packages/tinybird/{pipes => materializations}/aggregate__tcp_90d__v1.pipe (96%) rename packages/tinybird/{pipes => materializations}/aggregate__tcp_full_14d__v0.pipe (57%) create mode 100644 packages/tinybird/materializations/aggregate__tcp_full_30d__v0.pipe rename packages/tinybird/{pipes/aggregate__tcp_status_45d.pipe => materializations/aggregate__tcp_status_45d__v0.pipe} (91%) rename packages/tinybird/{pipes => materializations}/aggregate__tcp_status_45d__v1.pipe (95%) rename packages/tinybird/{pipes/aggregate__tcp_status_7d.pipe => materializations/aggregate__tcp_status_7d__v0.pipe} (91%) rename packages/tinybird/{pipes => materializations}/aggregate__tcp_uptime_30d__v1.pipe (100%) rename packages/tinybird/{pipes => materializations}/aggregate__tcp_uptime_7d__v1.pipe (100%) create mode 100644 packages/tinybird/materializations/aggregate__tcp_uptime_90d__v1.pipe rename packages/tinybird/{pipes => materializations}/aggregate__tcp_workspace_30d__v0.pipe (96%) delete mode 100644 packages/tinybird/pipes/__ttl_45d_count_utc_get.pipe delete mode 100644 packages/tinybird/pipes/aggregate__external_status_daily__v0.pipe delete mode 100644 packages/tinybird/pipes/aggregate__http_timing_phases_90d__v1.pipe delete mode 100644 packages/tinybird/pipes/aggregate__http_uptime_90d__v1.pipe delete mode 100644 packages/tinybird/pipes/aggregate__tcp_full_30d__v0.pipe delete mode 100644 packages/tinybird/pipes/aggregate__tcp_uptime_90d__v1.pipe delete mode 100644 packages/tinybird/pipes/get_result_for_on_demand_check_http.pipe delete mode 100644 packages/tinybird/pipes/public_status.pipe delete mode 100644 packages/tinybird/pipes/response_details.pipe delete mode 100644 packages/tinybird/pipes/response_graph.pipe delete mode 100644 packages/tinybird/pipes/response_list.pipe delete mode 100644 packages/tinybird/pipes/single_checks_get.pipe diff --git a/.gitignore b/.gitignore index 8d10b8ce..b7ed4bfe 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,5 @@ skills-lock.json .mcp.json tmp + +.tinyb \ No newline at end of file diff --git a/apps/checker/handlers/dns.go b/apps/checker/handlers/dns.go index 6c5c776a..face331a 100644 --- a/apps/checker/handlers/dns.go +++ b/apps/checker/handlers/dns.go @@ -246,7 +246,7 @@ func (h Handler) DNSHandler(c *gin.Context) { func (h Handler) DNSHandlerRegion(c *gin.Context) { ctx := c.Request.Context() - dataSourceName := "check_dns_response__v0" + dataSourceName := "check_response_dns__v0" const defaultRetry = 3 // Authorization check diff --git a/apps/checker/handlers/tcp.go b/apps/checker/handlers/tcp.go index 1a46f860..678277cb 100644 --- a/apps/checker/handlers/tcp.go +++ b/apps/checker/handlers/tcp.go @@ -256,7 +256,7 @@ func (h Handler) TCPHandler(c *gin.Context) { func (h Handler) TCPHandlerRegion(c *gin.Context) { ctx := c.Request.Context() - dataSourceName := "check_tcp_response__v1" + dataSourceName := "check_response_tcp__v0" region := c.Param("region") if region == "" { diff --git a/packages/tinybird/README.md b/packages/tinybird/README.md index d0a7b632..485391d6 100644 --- a/packages/tinybird/README.md +++ b/packages/tinybird/README.md @@ -1,52 +1,130 @@ -### A guide on how to migrate your tinybird datasource +# @openstatus/tinybird -> What to do when you want to add/remove/update a column in your `datasource`. +Tinybird holds the monitoring time-series: every check result the probing tier +produces lands here. Turso (see `packages/db`) holds application data. The two +are linked by id only — no join across the boundary. -The `_migration` folder includes: +This package is two things: -- `ping_response__v4.datasource` which represents the `VERSION 4` of our - datasource -- `ping_response.datasource` which has the upgraded schema and a new `VERSION 5` -- `tb_backfill_populate.pipe` will fill the datasource with all the data until a - given timestamp -- `tb_materialized_until_change_ingest.pipe` will fill the data from a given - timestamp +- **Datafiles** (`datasources/`, `materializations/`, `endpoints/`) — the schema + and queries, deployed with the `tb` CLI. +- **A typed client** (`src/client.ts`) — zod-validated wrappers over the + published endpoints, consumed by the apps. +## Now on Tinybird Forward + +This project has migrated from Tinybird Classic to **Tinybird Forward**. If you +last touched it under Classic, these are the changes that matter: + +| Classic | Forward | +| --- | --- | +| `pip install tinybird-cli` | `pip install tinybird` (or `uv tool install tinybird`) — currently 4.6.14 | +| `tb push` / `tb pull` | `tb deploy` — no push command exists anymore | +| Resources updated in place | Deployments: build a staging one, then promote | +| New `VERSION` + backfill pipes per schema change | Change the schema in place; add `FORWARD_QUERY` when existing rows need transforming | +| Datafiles anywhere | `datasources/`, `materializations/`, `endpoints/` | + +The old `_migration/` folder and its `VERSION`-bumping recipe are gone. Nothing +in this repo uses `tb push`. + +> `tb migrate-to-forward` exists in the CLI for workspaces still on Classic. +> This one is already migrated — you do not need it. + +## Deploying to Tinybird Cloud + +From this directory, after `tb login`: + +```bash +tb --cloud deploy --check # validate; changes nothing +tb --cloud deployment create --wait # build staging, run any backfills +tb --cloud deployment promote # go live ``` -tb push _migration/ping_response.datasource -tb push _migration/tb_materialized_until_change_ingest.pipe -# after the given ts, it is time to run the backfill populate -tb push _migration/tb_backfill_populate.pipe --populate --wait -# after populate ends, it is time to remove the pipe -tb pipe rm tb_backfill_populate --yes -``` -Check if all the rows have been migrated: +`tb --cloud deploy` collapses the last two into one step. Prefer the split form +when the diff touches a materialized view: `create` is where data gets copied, +and you get to see it land before promoting. `tb --cloud deployment discard` +throws a staging deployment away. + +## Changing a schema + +Materialized views are the sharp edge. A `.pipe` under `materializations/` and +its target `.datasource` must agree on **both** column names and order, so a +column added to a source datasource has to be added in three places: + +1. the source `.datasource`, +2. the target `mv__*.datasource` schema, +3. the `SELECT` in the `aggregate__*.pipe` that feeds it. + +Never write `SELECT *` in a materialization. It silently breaks the next time a +column is added to the source — the pipe starts producing a column the target +has no slot for, and the deploy fails. + +When the target datasource already exists in the cloud, `deployment create` +repopulates it by re-running the materialized pipe over the source, so +historical rows get real values rather than nulls. Check the "Data that will be +copied with this deployment" table in `--check` output to confirm. That +repopulation is not free — it re-materializes the full retention window. + +Adding a nullable column, or a non-nullable one whose ClickHouse default is +acceptable for old rows, needs nothing extra. Use `FORWARD_QUERY` when existing +rows need a real transformation — a genuine default, a type change, a rename: ``` -tb pipe _migration/tb_datasource_union.pipe -# after checking the result of the pipe -tb pipe rm tb_datasource_union.pipe --yes +FORWARD_QUERY > + SELECT *, 'unknown' AS source ``` ---- +It is a `SELECT` list only, no `FROM`/`WHERE`, and it applies to existing data +until the next deploy compacts it. Remove it once the change is live. + +## Self-hosting -Link to the [issue](https://github.com/openstatusHQ/openstatus/issues/278) from -Gonzalo as reference. +**Tinybird is optional.** Openstatus runs without it; you lose the charts and +the time-series views. Set `TINYBIRD_NOOP=true` and every pipe and ingest call +resolves to empty instead of hitting the network. +To run it for real, you have two options. - +### Tinybird Local + +`docker compose up` starts a `tinybird-local` container (port `7181`) alongside +the rest of the stack. It comes up empty — the datafiles still need deploying: ```bash -python3 -m venv .venv -source .venv/bin/activate -pip install tinybird-cli -tb auth -i +cd packages/tinybird +tb --local deploy ``` +Then point the apps at it: + ```bash -tb pull -tb push aggregate_*.pipe --populate -tb push endpoint_*.pipe -... -``` \ No newline at end of file +TINYBIRD_URL=http://localhost:7181 +TINY_BIRD_API_KEY= # tb --local token ls, then tb --local token copy +``` + +### Tinybird Cloud + +Create a workspace, deploy the datafiles with the commands above, and set +`TINY_BIRD_API_KEY` to a token with read access to the endpoints. +`TINYBIRD_URL` can stay empty — it defaults to `https://api.tinybird.co`. + +## Gotcha: do not add a `tinybird.config.json` here + +There was one; it has been removed on purpose. The CLI classifies a project by +scanning the paths named in that config's `folder`/`include` for source-file +extensions. With `"folder": "."` the scan covers this whole package, finds the +`.ts` files in `src/` and `scripts/`, decides it is a TypeScript-SDK project, +and then fails on every command — including `tb --help`: + +``` +Error: Failed to generate Tinybird resources from TypeScript definitions. +Unable to load Tinybird SDK generator bridge. Cannot find package '@tinybirdco/sdk' +``` + +`include` only adds scan targets, so there is no way to narrow it. Without the +config, the CLI falls back to detecting `datasources/` and friends and treats +this as a plain datafiles project, which is what we want. + +If you ever do need a config here — for `dev_mode`, say — move the datafile +folders into a subdirectory with no `.ts` beside them and point `folder` at +that. diff --git a/packages/tinybird/datasources/audit_log__v0.datasource b/packages/tinybird/datasources/audit_log__v0.datasource index 4fa11660..28d7e60b 100644 --- a/packages/tinybird/datasources/audit_log__v0.datasource +++ b/packages/tinybird/datasources/audit_log__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 SCHEMA > `action` String `json:$.action`, diff --git a/packages/tinybird/datasources/check_response.datasource b/packages/tinybird/datasources/check_response.datasource deleted file mode 100644 index 1673dffe..00000000 --- a/packages/tinybird/datasources/check_response.datasource +++ /dev/null @@ -1,24 +0,0 @@ -VERSION 0 - -SCHEMA > - `requestId` Int64 `json:$.requestId`, - `workspaceId` Int64 `json:$.workspaceId`, - `latency` Int64 `json:$.latency`, - `region` String `json:$.region`, - `time` DateTime `json:$.time`, - `headers` String `json:$.headers`, - `timing_connectDone` Int64 `json:$.timing.connectDone`, - `timing_connectStart` Int64 `json:$.timing.connectStart`, - `timing_dnsDone` Int64 `json:$.timing.dnsDone`, - `timing_dnsStart` Int64 `json:$.timing.dnsStart`, - `timing_firstByteDone` Int64 `json:$.timing.firstByteDone`, - `timing_firstByteStart` Int64 `json:$.timing.firstByteStart`, - `timing_tlsHandshakeDone` Int64 `json:$.timing.tlsHandshakeDone`, - `timing_tlsHandshakeStart` Int64 `json:$.timing.tlsHandshakeStart`, - `timing_transferDone` Int64 `json:$.timing.transferDone`, - `timing_transferStart` Int64 `json:$.timing.transferStart` - -ENGINE "MergeTree" -ENGINE_SORTING_KEY "workspaceId, requestId, time" -ENGINE_TTL "" -ENGINE_PARTITION_KEY "" \ No newline at end of file diff --git a/packages/tinybird/datasources/check_response_dns__v0.datasource b/packages/tinybird/datasources/check_response_dns__v0.datasource new file mode 100644 index 00000000..da7f9ee8 --- /dev/null +++ b/packages/tinybird/datasources/check_response_dns__v0.datasource @@ -0,0 +1,19 @@ + +SCHEMA > + `assertions` String `json:$.assertions`, + `cronTimestamp` Int64 `json:$.cronTimestamp`, + `error` Int16 `json:$.error`, + `errorMessage` String `json:$.errorMessage`, + `id` String `json:$.id`, + `latency` Int16 `json:$.latency`, + `monitorId` Int16 `json:$.monitorId`, + `records` String `json:$.records`, + `region` String `json:$.region`, + `requestStatus` String `json:$.requestStatus`, + `timestamp` Int64 `json:$.timestamp`, + `trigger` String `json:$.trigger`, + `uri` String `json:$.uri`, + `workspaceId` Int16 `json:$.workspaceId` + +ENGINE "MergeTree" +ENGINE_SORTING_KEY "trigger, uri, workspaceId" diff --git a/packages/tinybird/datasources/check_response_http.datasource b/packages/tinybird/datasources/check_response_http__v0.datasource similarity index 97% rename from packages/tinybird/datasources/check_response_http.datasource rename to packages/tinybird/datasources/check_response_http__v0.datasource index 0e286d05..ef81a357 100644 --- a/packages/tinybird/datasources/check_response_http.datasource +++ b/packages/tinybird/datasources/check_response_http__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 SCHEMA > `body` String `json:$.body`, diff --git a/packages/tinybird/datasources/dns_response__v0.datasource b/packages/tinybird/datasources/dns_response__v0.datasource index e6b124d0..da7f9ee8 100644 --- a/packages/tinybird/datasources/dns_response__v0.datasource +++ b/packages/tinybird/datasources/dns_response__v0.datasource @@ -1,7 +1,6 @@ SCHEMA > - `assertions` Nullable(String) `json:$.assertions`, - `timing` String `json:$.timing`, + `assertions` String `json:$.assertions`, `cronTimestamp` Int64 `json:$.cronTimestamp`, `error` Int16 `json:$.error`, `errorMessage` String `json:$.errorMessage`, diff --git a/packages/tinybird/datasources/external_status__v1.datasource b/packages/tinybird/datasources/external_status__v1.datasource index 3dfdeb51..e72ca5b6 100644 --- a/packages/tinybird/datasources/external_status__v1.datasource +++ b/packages/tinybird/datasources/external_status__v1.datasource @@ -1,3 +1,4 @@ + SCHEMA > `id` String `json:$.id`, `indicator` String `json:$.indicator`, @@ -9,4 +10,4 @@ SCHEMA > ENGINE "MergeTree" ENGINE_SORTING_KEY "id, fetched_at" -ENGINE_TTL "toDateTime(fromUnixTimestamp64Milli(fetched_at)) + INTERVAL 60 DAY" +ENGINE_TTL "toDateTime(fromUnixTimestamp64Milli(fetched_at)) + toIntervalDay(60)" diff --git a/packages/tinybird/datasources/external_status_component__v0.datasource b/packages/tinybird/datasources/external_status_component__v0.datasource index 5b8f1ffa..2d6302f8 100644 --- a/packages/tinybird/datasources/external_status_component__v0.datasource +++ b/packages/tinybird/datasources/external_status_component__v0.datasource @@ -1,6 +1,3 @@ -# `external_service_id` is denormalised for raw-row traceability only: in raw -# rows `component_id` is an opaque PK, so this lets ops trace a snapshot back to -# its service without a Turso join. Pipes/MV key on `component_id`. SCHEMA > `component_id` String `json:$.component_id`, @@ -11,4 +8,4 @@ SCHEMA > ENGINE "MergeTree" ENGINE_SORTING_KEY "component_id, fetched_at" -ENGINE_TTL "toDateTime(fromUnixTimestamp64Milli(fetched_at)) + INTERVAL 60 DAY" +ENGINE_TTL "toDateTime(fromUnixTimestamp64Milli(fetched_at)) + toIntervalDay(60)" diff --git a/packages/tinybird/datasources/mv__external_status_component_daily__v0.datasource b/packages/tinybird/datasources/mv__external_status_component_daily__v0.datasource index bb1e4160..d803f30c 100644 --- a/packages/tinybird/datasources/mv__external_status_component_daily__v0.datasource +++ b/packages/tinybird/datasources/mv__external_status_component_daily__v0.datasource @@ -1,4 +1,4 @@ -# Data Source created from Pipe 'mv__external_status_component_daily__v0' +# Data Source created from Pipe 'aggregate__external_status_component__daily__v0' SCHEMA > `day` Date, diff --git a/packages/tinybird/datasources/mv__external_status_daily__v0.datasource b/packages/tinybird/datasources/mv__external_status_daily__v0.datasource index 81d7a415..558bfaf7 100644 --- a/packages/tinybird/datasources/mv__external_status_daily__v0.datasource +++ b/packages/tinybird/datasources/mv__external_status_daily__v0.datasource @@ -1,4 +1,3 @@ -# Data Source created from Pipe 'mv__external_status_daily__v0' SCHEMA > `day` Date, diff --git a/packages/tinybird/datasources/mv__http_14d.datasource b/packages/tinybird/datasources/mv__http_14d.datasource deleted file mode 100644 index 5195321a..00000000 --- a/packages/tinybird/datasources/mv__http_14d.datasource +++ /dev/null @@ -1,19 +0,0 @@ -VERSION 0 -# Data Source created from Pipe 'aggregate__http_14d__v0' - -SCHEMA > - `time` DateTime, - `latency` Int64, - `error` Int8, - `region` LowCardinality(String), - `trigger` Nullable(String), - `statusCode` Nullable(Int16), - `timestamp` Int64, - `cronTimestamp` Int64, - `monitorId` String, - `workspaceId` String - -ENGINE "MergeTree" -ENGINE_PARTITION_KEY "toYYYYMM(time)" -ENGINE_SORTING_KEY "monitorId, time" -ENGINE_TTL "time + toIntervalDay(14)" diff --git a/packages/tinybird/datasources/mv__http_14d__v0.datasource b/packages/tinybird/datasources/mv__http_14d__v0.datasource index 5195321a..917f6230 100644 --- a/packages/tinybird/datasources/mv__http_14d__v0.datasource +++ b/packages/tinybird/datasources/mv__http_14d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__http_14d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__http_1d__v0.datasource b/packages/tinybird/datasources/mv__http_1d__v0.datasource index 764c43d4..bdce0cd9 100644 --- a/packages/tinybird/datasources/mv__http_1d__v0.datasource +++ b/packages/tinybird/datasources/mv__http_1d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__http_1d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__http_1d__v1.datasource b/packages/tinybird/datasources/mv__http_1d__v1.datasource index 243c2e28..9ea2fa46 100644 --- a/packages/tinybird/datasources/mv__http_1d__v1.datasource +++ b/packages/tinybird/datasources/mv__http_1d__v1.datasource @@ -4,13 +4,13 @@ SCHEMA > `time` DateTime, `id` Nullable(String), `latency` Int64, - `requestStatus` Nullable(String), `region` LowCardinality(String), `trigger` Nullable(String), `statusCode` Nullable(Int16), `timestamp` Int64, `cronTimestamp` Int64, `monitorId` String, + `requestStatus` Nullable(String), `timing` Nullable(String) ENGINE "MergeTree" diff --git a/packages/tinybird/datasources/mv__http_30d__v0.datasource b/packages/tinybird/datasources/mv__http_30d__v0.datasource index 6c116f20..820c4a85 100644 --- a/packages/tinybird/datasources/mv__http_30d__v0.datasource +++ b/packages/tinybird/datasources/mv__http_30d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__http_30d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__http_7d__v0.datasource b/packages/tinybird/datasources/mv__http_7d__v0.datasource index a45060c0..c2d6588c 100644 --- a/packages/tinybird/datasources/mv__http_7d__v0.datasource +++ b/packages/tinybird/datasources/mv__http_7d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__http_7d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__http_full_14d__v0.datasource b/packages/tinybird/datasources/mv__http_full_14d__v0.datasource index 95a905e1..a095321f 100644 --- a/packages/tinybird/datasources/mv__http_full_14d__v0.datasource +++ b/packages/tinybird/datasources/mv__http_full_14d__v0.datasource @@ -19,8 +19,7 @@ SCHEMA > `trigger` Nullable(String), `id` Nullable(String), `requestStatus` Nullable(String), - `method` Nullable(String) - + `method` String ENGINE "MergeTree" ENGINE_PARTITION_KEY "toYYYYMM(time)" diff --git a/packages/tinybird/datasources/mv__http_full_30d__v0.datasource b/packages/tinybird/datasources/mv__http_full_30d__v0.datasource index fbf14975..76b172c7 100644 --- a/packages/tinybird/datasources/mv__http_full_30d__v0.datasource +++ b/packages/tinybird/datasources/mv__http_full_30d__v0.datasource @@ -19,8 +19,7 @@ SCHEMA > `trigger` Nullable(String), `id` Nullable(String), `requestStatus` Nullable(String), - `method` Nullable(String) - + `method` String ENGINE "MergeTree" ENGINE_PARTITION_KEY "toYYYYMM(time)" diff --git a/packages/tinybird/datasources/mv__http_status_45d__v0.datasource b/packages/tinybird/datasources/mv__http_status_45d__v0.datasource index ca1b3799..67fdd684 100644 --- a/packages/tinybird/datasources/mv__http_status_45d__v0.datasource +++ b/packages/tinybird/datasources/mv__http_status_45d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__http_status_45d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__http_status_7d__v0.datasource b/packages/tinybird/datasources/mv__http_status_7d__v0.datasource index 1c2e74fe..cbbc48fe 100644 --- a/packages/tinybird/datasources/mv__http_status_7d__v0.datasource +++ b/packages/tinybird/datasources/mv__http_status_7d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__http_status_7d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__http_timing_phases_14d.datasource b/packages/tinybird/datasources/mv__http_timing_phases_14d.datasource deleted file mode 100644 index b767183d..00000000 --- a/packages/tinybird/datasources/mv__http_timing_phases_14d.datasource +++ /dev/null @@ -1,21 +0,0 @@ -# Data Source created from Pipe 'aggregate__http_timing_phases_14d__v1' - -SCHEMA > - `time` DateTime, - `latency` Int64, - `region` LowCardinality(String), - `trigger` Nullable(String), - `statusCode` Nullable(Int16), - `monitorId` String, - `workspaceId` String, - `requestStatus` Nullable(String), - `dns` Nullable(Int64), - `connect` Nullable(Int64), - `tls` Nullable(Int64), - `firstByte` Nullable(Int64), - `transfer` Nullable(Int64) - -ENGINE "MergeTree" -ENGINE_PARTITION_KEY "toYYYYMM(time)" -ENGINE_SORTING_KEY "monitorId, time" -ENGINE_TTL "time + toIntervalDay(14)" diff --git a/packages/tinybird/datasources/mv__tcp_14d__v0.datasource b/packages/tinybird/datasources/mv__tcp_14d__v0.datasource index 79098c67..bf67d58a 100644 --- a/packages/tinybird/datasources/mv__tcp_14d__v0.datasource +++ b/packages/tinybird/datasources/mv__tcp_14d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__tcp_14d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__tcp_1d__v0.datasource b/packages/tinybird/datasources/mv__tcp_1d__v0.datasource index 4621e547..69c7b517 100644 --- a/packages/tinybird/datasources/mv__tcp_1d__v0.datasource +++ b/packages/tinybird/datasources/mv__tcp_1d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__tcp_1d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__tcp_30d__v0.datasource b/packages/tinybird/datasources/mv__tcp_30d__v0.datasource index 5a618550..63cb5ddf 100644 --- a/packages/tinybird/datasources/mv__tcp_30d__v0.datasource +++ b/packages/tinybird/datasources/mv__tcp_30d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__tcp_30d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__tcp_7d__v0.datasource b/packages/tinybird/datasources/mv__tcp_7d__v0.datasource index 278da974..4860c3ea 100644 --- a/packages/tinybird/datasources/mv__tcp_7d__v0.datasource +++ b/packages/tinybird/datasources/mv__tcp_7d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__tcp_7d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__tcp_status_45d__v0.datasource b/packages/tinybird/datasources/mv__tcp_status_45d__v0.datasource index 3bb59b74..8cd11b1b 100644 --- a/packages/tinybird/datasources/mv__tcp_status_45d__v0.datasource +++ b/packages/tinybird/datasources/mv__tcp_status_45d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__tcp_status_45d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv__tcp_status_7d__v0.datasource b/packages/tinybird/datasources/mv__tcp_status_7d__v0.datasource index e3eea9c9..327ba349 100644 --- a/packages/tinybird/datasources/mv__tcp_status_7d__v0.datasource +++ b/packages/tinybird/datasources/mv__tcp_status_7d__v0.datasource @@ -1,4 +1,3 @@ -VERSION 0 # Data Source created from Pipe 'aggregate__tcp_status_7d__v0' SCHEMA > diff --git a/packages/tinybird/datasources/mv_http_status_14d.datasource b/packages/tinybird/datasources/mv_http_status_14d.datasource deleted file mode 100644 index ab57875b..00000000 --- a/packages/tinybird/datasources/mv_http_status_14d.datasource +++ /dev/null @@ -1,15 +0,0 @@ -# Data Source created from Pipe 'aggregate__http_status_14d__v0' -VERSION 0 - -SCHEMA > - `time` DateTime('UTC'), - `monitorId` String, - `total` AggregateFunction(count), - `success` AggregateFunction(count, Nullable(UInt8)), - `degraded` AggregateFunction(count, Nullable(UInt8)), - `error` AggregateFunction(count, Nullable(UInt8)) - -ENGINE "AggregatingMergeTree" -ENGINE_PARTITION_KEY "toYYYYMM(time)" -ENGINE_SORTING_KEY "monitorId, time" -ENGINE_TTL "time + toIntervalDay(14)" diff --git a/packages/tinybird/datasources/tcp_response.datasource b/packages/tinybird/datasources/tcp_response.datasource deleted file mode 100644 index 35c6304d..00000000 --- a/packages/tinybird/datasources/tcp_response.datasource +++ /dev/null @@ -1,19 +0,0 @@ -VERSION 0 - -SCHEMA > - `monitorId` Int32 `json:$.monitorId`, - `region` String `json:$.region`, - `timestamp` Int64 `json:$.timestamp`, - `cronTimestamp` Int64 `json:$.timestamp`, - `timing` String `json:$.timing`, - `workspaceId` Int32 `json:$.workspaceId`, - `latency` Int64 `json:$.latency`, - `errorMessage` Nullable(String) `json:$.errorMessage`, - `error` Int16 `json:$.error`, - `trigger` Nullable(String) `json:$.trigger`, - `uri` Nullable(String) `json:$.uri` - - -ENGINE "MergeTree" -ENGINE_PARTITION_KEY "toYYYYMM(fromUnixTimestamp64Milli(timestamp))" -ENGINE_SORTING_KEY "monitorId, workspaceId" diff --git a/packages/tinybird/endpoints/endpoint__audit_log.pipe b/packages/tinybird/endpoints/endpoint__audit_log.pipe deleted file mode 100644 index 484e795b..00000000 --- a/packages/tinybird/endpoints/endpoint__audit_log.pipe +++ /dev/null @@ -1,17 +0,0 @@ -VERSION 1 - -TAGS endpoint - - -NODE endpoint -SQL > - - % - SELECT action, id, metadata, timestamp - FROM audit_log__v0 - WHERE - id = {{String(monitorId, 'monitor:1', required=True)}} - AND timestamp > toUnixTimestamp(now() - INTERVAL {{ Int64(interval, 30) }} day) * 1000 - ORDER BY timestamp DESC - -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__audit_log__v1.pipe b/packages/tinybird/endpoints/endpoint__audit_log__v1.pipe index 5ad399ac..f56e664a 100644 --- a/packages/tinybird/endpoints/endpoint__audit_log__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__audit_log__v1.pipe @@ -1,18 +1,16 @@ -VERSION 1 - -TAGS endpoint - +TOKEN "endpoint__audit_log__v1_endpoint_read_0873" READ NODE endpoint SQL > - % - SELECT action, id, metadata, timestamp - FROM audit_log__v0 - WHERE - id = {{String(monitorId, 'monitor:1', required=True)}} - AND timestamp > toUnixTimestamp(now() - INTERVAL {{ Int64(interval, 30) }} day) * 1000 - ORDER BY timestamp DESC +% +SELECT action, id, metadata, timestamp +FROM audit_log__v0 +WHERE + id = {{String(monitorId, 'monitor:1', required=True)}} + AND timestamp > toUnixTimestamp(now() - INTERVAL {{ Int64(interval, 30) }} day) * 1000 +ORDER BY timestamp DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_get_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_get_14d__v0.pipe index 1edb0b5d..82d2a2a2 100644 --- a/packages/tinybird/endpoints/endpoint__dns_get_14d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_get_14d__v0.pipe @@ -1,15 +1,16 @@ -TAGS "dns" +TOKEN "endpoint__dns_get_14d__v0_endpoint_read_7283" READ NODE endpoint SQL > - % - SELECT * - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND id = {{ String(id, '', required=True) }} - ORDER BY timestamp DESC +% + SELECT * + FROM dns_response__v0 + WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND id = {{ String(id, '', required=True) }} + ORDER BY timestamp DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_list_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_list_14d__v0.pipe index fce5897d..25a99dc1 100644 --- a/packages/tinybird/endpoints/endpoint__dns_list_14d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_list_14d__v0.pipe @@ -1,16 +1,17 @@ -TAGS "dns" +TOKEN "endpoint__dns_list_14d__v0_endpoint_read_1189" READ NODE endpoint SQL > - % - SELECT * - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - {% if defined(fromDate) %} AND timestamp >= toInt64({{ String(fromDate) }}) {% end %} - {% if defined(toDate) %} AND timestamp <= toInt64({{ String(toDate) }}) {% end %} - ORDER BY timestamp DESC +% +SELECT * +FROM dns_response__v0 +WHERE +monitorId = {{ String(monitorId, '7417', required=True) }} + {% if defined(fromDate) %} AND timestamp >= toInt64({{ String(fromDate) }}) {% end %} + {% if defined(toDate) %} AND timestamp <= toInt64({{ String(toDate) }}) {% end %} +ORDER BY timestamp DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_14d__v0.pipe index 18385a35..2cf480c4 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_14d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_14d__v0.pipe @@ -1,43 +1,44 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_14d__v1_endpoint_read_5551" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 14 DAY, 3)) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 28 DAY, 3)) - AND timestamp < toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 14 DAY, 3)) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +% + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM dns_response__v0 + WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 14 DAY, 3)) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM dns_response__v0 + WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 28 DAY, 3)) + AND timestamp < toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 14 DAY, 3)) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_1d__v0.pipe index 7db3011d..69a1d46b 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_1d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_1d__v0.pipe @@ -1,43 +1,44 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_1d__v1_endpoint_read_8759" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 1 DAY, 3)) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 2 DAY, 3)) - AND timestamp < toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 1 DAY, 3)) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +% + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM dns_response__v0 + WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 1 DAY, 3)) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM dns_response__v0 + WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 2 DAY, 3)) + AND timestamp < toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 1 DAY, 3)) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_30d__v0.pipe index 368a32af..08ea1066 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_30d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_30d__v0.pipe @@ -1,42 +1,44 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_30d__v0_endpoint_read_7067" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 30 DAY, 3)) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 60 DAY, 3)) - AND timestamp < toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 30 DAY, 3)) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +% +SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 30 DAY, 3)) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +UNION ALL +SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 60 DAY, 3)) + AND timestamp < toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 30 DAY, 3)) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_7d__v0.pipe index 731df660..a3e601e9 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_7d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_7d__v0.pipe @@ -1,43 +1,44 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_7d__v1_endpoint_read_4418" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 7 DAY, 3)) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 14 DAY, 3)) - AND timestamp < toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 7 DAY, 3)) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +% + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM dns_response__v0 + WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 7 DAY, 3)) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM dns_response__v0 + WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 14 DAY, 3)) + AND timestamp < toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 7 DAY, 3)) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_90d__v0.pipe index 00f34c91..4cab92a7 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_90d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_90d__v0.pipe @@ -1,38 +1,40 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_90d__v0_endpoint_read_3836" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 90 DAY, 3)) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - -- no 90d comparison: emit an empty row to keep the 2-row contract; count 0 - -- makes the client suppress the trend badge (NaN). matches http/tcp 90d. - SELECT - 0 AS p50Latency, - 0 AS p75Latency, - 0 AS p90Latency, - 0 AS p95Latency, - 0 AS p99Latency, - 0 AS count, - 0 AS success, - 0 AS degraded, - 0 AS error, - NULL AS lastTimestamp +% +SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 90 DAY, 3)) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +UNION ALL +-- no 90d comparison: emit an empty row to keep the 2-row contract; count 0 +-- makes the client suppress the trend badge (NaN). matches http/tcp 90d. +SELECT + 0 AS p50Latency, + 0 AS p75Latency, + 0 AS p90Latency, + 0 AS p95Latency, + 0 AS p99Latency, + 0 AS count, + 0 AS success, + 0 AS degraded, + 0 AS error, + NULL AS lastTimestamp + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_global_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_global_1d__v0.pipe index 70ede409..851e6eff 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_global_1d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_global_1d__v0.pipe @@ -1,11 +1,9 @@ -VERSION 0 +TOKEN "endpoint__dns_metrics_global_1d__v0_endpoint_read_0694" READ -TAGS "dns" - -NODE endpoint +NODE endopint SQL > - % +% SELECT round(min(latency), 0) as minLatency, round(max(latency), 0) as maxLatency, @@ -22,5 +20,6 @@ SQL > AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 1 DAY, 3)) GROUP BY monitorId +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_latency_1d_multi__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_latency_1d_multi__v0.pipe index 74968f62..d2f077d1 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_latency_1d_multi__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_latency_1d_multi__v0.pipe @@ -1,26 +1,27 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_latency_1d_multi__v0_endpoint_read_4781" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - monitorId, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM dns_response__v0 - WHERE - monitorId IN {{ Array(monitorIds, 'String', '7417') }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 1 DAY, 3)) - GROUP BY h, monitorId - ORDER BY h ASC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + monitorId, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM dns_response__v0 +WHERE + monitorId IN {{ Array(monitorIds, 'String', '7417') }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 1 DAY, 3)) +GROUP BY h, monitorId +ORDER BY h ASC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_latency_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_latency_30d__v0.pipe index 94718c84..c08d8d68 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_latency_30d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_latency_30d__v0.pipe @@ -1,24 +1,26 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_latency_30d__v0_endpoint_read_4167" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 30 DAY, 3)) - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 30 DAY, 3)) +GROUP BY h +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_latency_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_latency_7d__v0.pipe index d9e3e3db..88b995aa 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_latency_7d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_latency_7d__v0.pipe @@ -1,30 +1,31 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_latency_7d__v1_endpoint_read_2009" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - {% if defined(fromDate) %} - AND toDateTime(timestamp / 1000) >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) - {% end %} - {% if defined(toDate) %} - AND toDateTime(timestamp / 1000) <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) - {% end %} - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + {% if defined(fromDate) %} + AND toDateTime(timestamp / 1000) >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) + {% end %} + {% if defined(toDate) %} + AND toDateTime(timestamp / 1000) <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) + {% end %} +GROUP BY h +ORDER BY h DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_latency_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_latency_90d__v0.pipe index 7f481e99..fb6b7c1f 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_latency_90d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_latency_90d__v0.pipe @@ -1,24 +1,26 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_latency_90d__v0_endpoint_read_8223" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 90 DAY, 3)) - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 90 DAY, 3)) +GROUP BY h +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_regions_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_regions_14d__v0.pipe index b34f37df..d4c957cb 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_regions_14d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_regions_14d__v0.pipe @@ -1,26 +1,35 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_regions_14d__v0_endpoint_read_2343" READ NODE endpoint SQL > - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - {% if defined(regions) %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY h, region - ORDER BY h DESC +% +SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + -- dns_response__v0 has no TTL; cap the window like the TTL-bound http/tcp uptime MVs + AND toDateTime(timestamp / 1000) >= now() - INTERVAL 14 DAY + {% if defined(fromDate) %} + AND toDateTime(timestamp / 1000) >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) + {% end %} + {% if defined(toDate) %} + AND toDateTime(timestamp / 1000) <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) + {% end %} + {% if defined(regions) %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY h, region +ORDER BY h DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_regions_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_regions_30d__v0.pipe index 51e02a67..afd5d23c 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_regions_30d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_regions_30d__v0.pipe @@ -1,26 +1,28 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_regions_30d__v0_endpoint_read_9332" READ NODE endpoint SQL > - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 30 DAY, 3)) - {% if defined(regions) %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY h, region - ORDER BY h DESC +% +SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 30 DAY, 3)) + {% if defined(regions) %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY h, region +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_metrics_regions_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_metrics_regions_90d__v0.pipe index 49bb57f8..bc621f83 100644 --- a/packages/tinybird/endpoints/endpoint__dns_metrics_regions_90d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_metrics_regions_90d__v0.pipe @@ -1,26 +1,28 @@ -TAGS "dns" +TOKEN "endpoint__dns_metrics_regions_90d__v0_endpoint_read_7062" READ NODE endpoint SQL > - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 90 DAY, 3)) - {% if defined(regions) %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY h, region - ORDER BY h DESC +% +SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + AND timestamp >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 90 DAY, 3)) + {% if defined(regions) %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY h, region +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_status_45d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_status_45d__v0.pipe index 49376ca4..09ecabf3 100644 --- a/packages/tinybird/endpoints/endpoint__dns_status_45d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_status_45d__v0.pipe @@ -1,20 +1,21 @@ -TAGS "dns" +TOKEN "endpoint__dns_status_45d__v1_endpoint_read_7024" READ NODE endpoint SQL > - % - SELECT - time as day, - monitorId, - countMerge(count) as count, - countMerge(success) as ok, - countMerge(error) as error, - countMerge(degraded) as degraded - FROM mv__dns_status_45d__v0 - WHERE monitorId IN {{ Array(monitorIds, 'String', '7417') }} - GROUP BY day, monitorId - ORDER BY day DESC +% +SELECT + time as day, + monitorId, + countMerge(count) as count, + countMerge(success) as ok, + countMerge(error) as error, + countMerge(degraded) as degraded +FROM mv__dns_status_45d__v0 +WHERE monitorId IN {{ Array(monitorIds, 'String', '7417') }} +GROUP BY day, monitorId +ORDER BY day DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_uptime_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_uptime_30d__v0.pipe index 1a7b9fc6..352960a4 100644 --- a/packages/tinybird/endpoints/endpoint__dns_uptime_30d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_uptime_30d__v0.pipe @@ -1,30 +1,31 @@ -TAGS "dns" +TOKEN "endpoint__dns_uptime_30d__v1_endpoint_read_4617" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(timestamp / 1000), INTERVAL {{ String(interval, '30', required=True) }} minute - ) AS interval, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - -- dns_response__v0 has no TTL; cap the window like the TTL-bound http/tcp uptime MVs - AND toDateTime(timestamp / 1000) >= now() - INTERVAL 30 DAY - {% if defined(fromDate) %} - AND toDateTime(timestamp / 1000) >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) - {% end %} - {% if defined(toDate) %} - AND toDateTime(timestamp / 1000) <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) - {% end %} - {% if defined(regions) %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY interval - ORDER BY interval DESC +% +SELECT + toStartOfInterval( + toDateTime(timestamp / 1000), INTERVAL {{ String(interval, '30', required=True) }} minute + ) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + -- dns_response__v0 has no TTL; cap the window like the TTL-bound http/tcp uptime MVs + AND toDateTime(timestamp / 1000) >= now() - INTERVAL 30 DAY + {% if defined(fromDate) %} + AND toDateTime(timestamp / 1000) >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) + {% end %} + {% if defined(toDate) %} + AND toDateTime(timestamp / 1000) <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) + {% end %} + {% if defined(regions) %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY interval +ORDER BY interval DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__dns_uptime_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_uptime_90d__v0.pipe index 8ea04645..e3260abc 100644 --- a/packages/tinybird/endpoints/endpoint__dns_uptime_90d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_uptime_90d__v0.pipe @@ -1,29 +1,31 @@ -TAGS "dns" +TOKEN "endpoint__dns_uptime_90d__v0_endpoint_read_3446" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(timestamp / 1000), INTERVAL {{ String(interval, '1440', required=True) }} minute - ) AS interval, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error - FROM dns_response__v0 - WHERE - monitorId = {{ String(monitorId, '7417', required=True) }} - -- dns_response__v0 has no TTL; cap the window like the TTL-bound http/tcp uptime MVs - AND toDateTime(timestamp / 1000) >= now() - INTERVAL 90 DAY - {% if defined(fromDate) %} - AND toDateTime(timestamp / 1000) >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) - {% end %} - {% if defined(toDate) %} - AND toDateTime(timestamp / 1000) <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) - {% end %} - {% if defined(regions) %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY interval - ORDER BY interval DESC +% +SELECT + toStartOfInterval( + toDateTime(timestamp / 1000), INTERVAL {{ String(interval, '1440', required=True) }} minute + ) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error +FROM dns_response__v0 +WHERE + monitorId = {{ String(monitorId, '7417', required=True) }} + -- dns_response__v0 has no TTL; cap the window like the TTL-bound http/tcp uptime MVs + AND toDateTime(timestamp / 1000) >= now() - INTERVAL 90 DAY + {% if defined(fromDate) %} + AND toDateTime(timestamp / 1000) >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) + {% end %} + {% if defined(toDate) %} + AND toDateTime(timestamp / 1000) <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) + {% end %} + {% if defined(regions) %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY interval +ORDER BY interval DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__external_status.pipe b/packages/tinybird/endpoints/endpoint__external_status.pipe deleted file mode 100644 index 39f276fc..00000000 --- a/packages/tinybird/endpoints/endpoint__external_status.pipe +++ /dev/null @@ -1,13 +0,0 @@ -VERSION 0 -NODE external_status_0 -SQL > - - % - SELECT * - FROM external_status - WHERE name = {{ String(name, 'OpenAI') }} - ORDER BY fetched_at DESC - LIMIT {{ Int16(limit, 10000) }} - - -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__external_status_component_history__v0.pipe b/packages/tinybird/endpoints/endpoint__external_status_component_history__v0.pipe index d7b09255..e7a61f52 100644 --- a/packages/tinybird/endpoints/endpoint__external_status_component_history__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__external_status_component_history__v0.pipe @@ -1,9 +1,9 @@ -TAGS "external_status" +TOKEN "endpoint__external_status_component_history__v0_endpoint_read_4435" READ -NODE history +NODE endpoint SQL > - % +% SELECT day, component_id, @@ -18,4 +18,6 @@ SQL > GROUP BY day, component_id ORDER BY day ASC -TYPE ENDPOINT +TYPE endpoint + + diff --git a/packages/tinybird/endpoints/endpoint__external_status_component_latest__v0.pipe b/packages/tinybird/endpoints/endpoint__external_status_component_latest__v0.pipe index 1b0f3afd..ecda61dd 100644 --- a/packages/tinybird/endpoints/endpoint__external_status_component_latest__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__external_status_component_latest__v0.pipe @@ -1,16 +1,18 @@ -TAGS "external_status_component" +TOKEN "endpoint__external_statuscomponent__latest__v0_endpoint_read_4246" READ -NODE latest +NODE endpoint SQL > - % - SELECT - component_id, - argMax(indicator, fetched_at) AS indicator, - argMax(status, fetched_at) AS status, - max(fetched_at) AS last_fetched_at - FROM external_status_component__v0 - WHERE component_id IN {{ Array(component_ids, 'String') }} - GROUP BY component_id +% +SELECT + component_id, + argMax(indicator, fetched_at) AS indicator, + argMax(status, fetched_at) AS status, + max(fetched_at) AS last_fetched_at +FROM external_status_component__v0 +WHERE component_id IN {{ Array(component_ids, 'String') }} +GROUP BY component_id + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__external_status_history__v0.pipe b/packages/tinybird/endpoints/endpoint__external_status_history__v0.pipe index adc9136f..9c6a8925 100644 --- a/packages/tinybird/endpoints/endpoint__external_status_history__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__external_status_history__v0.pipe @@ -1,9 +1,9 @@ -TAGS "external_status" +TOKEN "endpoint_external_status_history__v0_endpoint_read_3031" READ -NODE history +NODE endpoint SQL > - % +% SELECT day, id, @@ -18,4 +18,6 @@ SQL > GROUP BY day, id ORDER BY day ASC -TYPE ENDPOINT +TYPE endpoint + + diff --git a/packages/tinybird/endpoints/endpoint__external_status_latest__v1.pipe b/packages/tinybird/endpoints/endpoint__external_status_latest__v1.pipe index dd2f3e18..d5db855f 100644 --- a/packages/tinybird/endpoints/endpoint__external_status_latest__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__external_status_latest__v1.pipe @@ -1,9 +1,9 @@ -TAGS "external_status" +TOKEN "endpoint_external_status_latest__v1_endpoint_read_9973" READ -NODE latest +NODE endpoint SQL > - % +% SELECT id, argMax(indicator, fetched_at) AS indicator, @@ -18,4 +18,6 @@ SQL > {% end %} GROUP BY id -TYPE ENDPOINT +TYPE endpoint + + diff --git a/packages/tinybird/endpoints/endpoint__http_get_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__http_get_14d__v0.pipe index 51a91360..12a31020 100644 --- a/packages/tinybird/endpoints/endpoint__http_get_14d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__http_get_14d__v0.pipe @@ -1,15 +1,16 @@ -TAGS "http" +TOKEN "endpoint__http_get_14d__v0_endpoint_read_1831" READ NODE endpoint SQL > - % - SELECT * - FROM mv__http_full_14d__v0 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - AND id = {{ String(id, '', required=True) }} - ORDER BY time DESC +% +SELECT * +FROM mv__http_full_14d__v0 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + AND id = {{ String(id, '', required=True) }} +ORDER BY time DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_get_30d.pipe b/packages/tinybird/endpoints/endpoint__http_get_30d__v0.pipe similarity index 88% rename from packages/tinybird/endpoints/endpoint__http_get_30d.pipe rename to packages/tinybird/endpoints/endpoint__http_get_30d__v0.pipe index 2323a7e7..9ded3f7a 100644 --- a/packages/tinybird/endpoints/endpoint__http_get_30d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_get_30d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT * FROM mv__http_full_30d__v0 WHERE @@ -14,6 +10,6 @@ SQL > AND region = {{ String(region, 'ams', required=True) }} ORDER BY cronTimestamp DESC +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_list_14d.pipe b/packages/tinybird/endpoints/endpoint__http_list_14d__v0.pipe similarity index 80% rename from packages/tinybird/endpoints/endpoint__http_list_14d.pipe rename to packages/tinybird/endpoints/endpoint__http_list_14d__v0.pipe index 52040df3..73eb6593 100644 --- a/packages/tinybird/endpoints/endpoint__http_list_14d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_list_14d__v0.pipe @@ -1,15 +1,12 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT * FROM mv__http_14d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} ORDER BY cronTimestamp DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_list_14d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_list_14d__v1.pipe index fa5313f2..f8157dd6 100644 --- a/packages/tinybird/endpoints/endpoint__http_list_14d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_list_14d__v1.pipe @@ -1,23 +1,22 @@ -TAGS "http" +TOKEN "endpoint__http_list_14d__v1_endpoint_read_5685" READ NODE endpoint SQL > - % - SELECT * FROM mv__http_14d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if defined(fromDate) %} - AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) - {% end %} - {% if defined(toDate) %} - AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) - {% end %} - ORDER BY time DESC - {% if defined(limit) %} - LIMIT {{ Int32(limit) }} - OFFSET {{ Int32(offset, 0) }} - {% end %} +% +SELECT * +FROM mv__http_14d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} +ORDER BY time DESC +{% if defined(limit) %} LIMIT {{ Int32(limit) }} OFFSET {{ Int32(offset, 0) }} {% end %} + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_list_1d.pipe b/packages/tinybird/endpoints/endpoint__http_list_1d__v0.pipe similarity index 80% rename from packages/tinybird/endpoints/endpoint__http_list_1d.pipe rename to packages/tinybird/endpoints/endpoint__http_list_1d__v0.pipe index 19564284..f8b5066b 100644 --- a/packages/tinybird/endpoints/endpoint__http_list_1d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_list_1d__v0.pipe @@ -1,15 +1,12 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT * FROM mv__http_1d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} ORDER BY cronTimestamp DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_list_1d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_list_1d__v1.pipe index 9537927d..8c613ac7 100644 --- a/packages/tinybird/endpoints/endpoint__http_list_1d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_list_1d__v1.pipe @@ -1,19 +1,17 @@ -TAGS "http" +TOKEN "endpoint__http_list_1d__v1_endpoint_read_1903" READ NODE endpoint SQL > - % - SELECT * FROM mv__http_1d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if defined(fromDate) %} - AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) - {% end %} - {% if defined(toDate) %} - AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) - {% end %} - ORDER BY time DESC +% +SELECT * +FROM mv__http_1d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if defined(fromDate) %} AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) {% end %} + {% if defined(toDate) %} AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) {% end %} +ORDER BY time DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_list_7d.pipe b/packages/tinybird/endpoints/endpoint__http_list_7d__v0.pipe similarity index 80% rename from packages/tinybird/endpoints/endpoint__http_list_7d.pipe rename to packages/tinybird/endpoints/endpoint__http_list_7d__v0.pipe index 827af343..905cddd0 100644 --- a/packages/tinybird/endpoints/endpoint__http_list_7d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_list_7d__v0.pipe @@ -1,15 +1,12 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT * FROM mv__http_7d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} ORDER BY cronTimestamp DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_list_7d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_list_7d__v1.pipe index 934891dc..67dd0dbd 100644 --- a/packages/tinybird/endpoints/endpoint__http_list_7d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_list_7d__v1.pipe @@ -1,19 +1,17 @@ -TAGS "http" +TOKEN "endpoint__http_list_7d__v1_endpoint_read_3587" READ NODE endpoint SQL > - % - SELECT * FROM mv__http_7d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if defined(fromDate) %} - AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) - {% end %} - {% if defined(toDate) %} - AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) - {% end %} - ORDER BY time DESC +% +SELECT * +FROM mv__http_7d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if defined(fromDate) %} AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) {% end %} + {% if defined(toDate) %} AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) {% end %} +ORDER BY time DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_14d.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_14d__v0.pipe similarity index 86% rename from packages/tinybird/endpoints/endpoint__http_metrics_14d.pipe rename to packages/tinybird/endpoints/endpoint__http_metrics_14d__v0.pipe index f32a91a7..0fe9dd53 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_14d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_14d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT round(quantile(0.50)(latency)) as p50Latency, round(quantile(0.75)(latency)) as p75Latency, @@ -18,6 +14,7 @@ SQL > FROM mv__http_14d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) UNION ALL SELECT @@ -32,8 +29,10 @@ SQL > FROM mv__http_30d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 28 DAY, 3) AND time < toDateTime64(now() - INTERVAL 14 DAY, 3) +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_14d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_14d__v1.pipe index f3e47727..d5ea3ee7 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_14d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_14d__v1.pipe @@ -1,42 +1,44 @@ -TAGS "http" +TOKEN "endpoint__http_metrics_14d__v1_endpoint_read_3605" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM mv__http_14d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant - FROM mv__http_30d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 28 DAY, 3) - AND time < toDateTime64(now() - INTERVAL 14 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +% + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__http_14d__v1 + WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__http_30d__v1 + WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 28 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 14 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_1d.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_1d__v0.pipe similarity index 50% rename from packages/tinybird/endpoints/endpoint__http_metrics_1d.pipe rename to packages/tinybird/endpoints/endpoint__http_metrics_1d__v0.pipe index cf79d736..8d65d059 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_1d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_1d__v0.pipe @@ -1,39 +1,38 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, + round(quantile(0.5)(latency), 0) as p50Latency, + round(quantile(0.75)(latency), 0) as p75Latency, + round(quantile(0.9)(latency), 0) as p90Latency, + round(quantile(0.95)(latency), 0) as p95Latency, + round(quantile(0.99)(latency), 0) as p99Latency, count() as count, count(if(error = 0, 1, NULL)) AS ok, max(cronTimestamp) AS lastTimestamp FROM mv__http_1d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 1 DAY, 3) UNION ALL SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, + round(quantile(0.5)(latency), 0) as p50Latency, + round(quantile(0.75)(latency), 0) as p75Latency, + round(quantile(0.9)(latency), 0) as p90Latency, + round(quantile(0.95)(latency), 0) as p95Latency, + round(quantile(0.99)(latency), 0) as p99Latency, count() as count, count(if(error = 0, 1, NULL)) AS ok, NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant FROM mv__http_7d__v0 -- REMINDER: this will increase the processed data compared to 3d WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 2 DAY, 3) AND time < toDateTime64(now() - INTERVAL 1 DAY, 3) +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_1d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_1d__v1.pipe index db605bf1..7d9fca66 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_1d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_1d__v1.pipe @@ -1,43 +1,44 @@ -TAGS "http" +TOKEN "endpoint__http_metrics_1d__v1_endpoint_read_2577" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM mv__http_1d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 1 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant - FROM mv__http_7d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 2 DAY, 3) - AND time < toDateTime64(now() - INTERVAL 1 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +% + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__http_1d__v1 + WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 1 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__http_7d__v1 + WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 2 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 1 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_30d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_30d__v1.pipe index 5e1b67d1..7e338a65 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_30d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_30d__v1.pipe @@ -1,9 +1,9 @@ -TAGS "http" +TOKEN "endpoint__http_metrics_30d__v1_endpoint_read_7611" READ NODE endpoint SQL > - % +% SELECT round(quantile(0.50)(latency)) as p50Latency, round(quantile(0.75)(latency)) as p75Latency, @@ -39,4 +39,6 @@ SQL > AND time < toDateTime64(now() - INTERVAL 30 DAY, 3) {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} -TYPE ENDPOINT +TYPE endpoint + + diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_7d.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_7d__v0.pipe similarity index 86% rename from packages/tinybird/endpoints/endpoint__http_metrics_7d.pipe rename to packages/tinybird/endpoints/endpoint__http_metrics_7d__v0.pipe index fe7448e3..58eb8687 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_7d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_7d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT round(quantile(0.50)(latency)) as p50Latency, round(quantile(0.75)(latency)) as p75Latency, @@ -18,6 +14,7 @@ SQL > FROM mv__http_7d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 7 DAY, 3) UNION ALL SELECT @@ -32,8 +29,10 @@ SQL > FROM mv__http_14d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) AND time < toDateTime64(now() - INTERVAL 7 DAY, 3) +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_7d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_7d__v1.pipe index 06540408..2785f5a2 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_7d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_7d__v1.pipe @@ -1,42 +1,44 @@ -TAGS "http" +TOKEN "endpoint__http_metrics_7d__v1_endpoint_read_5469" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM mv__http_7d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 7 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant - FROM mv__http_14d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) - AND time < toDateTime64(now() - INTERVAL 7 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +% + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__http_7d__v1 + WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 7 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__http_14d__v1 + WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 7 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_90d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_90d__v1.pipe index af136418..4af8b346 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_90d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_90d__v1.pipe @@ -1,39 +1,41 @@ -TAGS "http" +TOKEN "endpoint__http_metrics_90d__v1_endpoint_read_2698" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM mv__http_90d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 90 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - -- the previous 90d window (90-180d ago) is past the 90d MV TTL, so there's no - -- real comparison data. emit an empty row to keep the 2-row contract; count 0 - -- makes the client suppress the trend badge (NaN). - SELECT - 0 AS p50Latency, - 0 AS p75Latency, - 0 AS p90Latency, - 0 AS p95Latency, - 0 AS p99Latency, - 0 AS count, - 0 AS success, - 0 AS degraded, - 0 AS error, - NULL AS lastTimestamp +% +SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp +FROM mv__http_90d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 90 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +UNION ALL +-- the previous 90d window (90-180d ago) is past the 90d MV TTL, so there's no +-- real comparison data. emit an empty row to keep the 2-row contract; count 0 +-- makes the client suppress the trend badge (NaN). +SELECT + 0 AS p50Latency, + 0 AS p75Latency, + 0 AS p90Latency, + 0 AS p95Latency, + 0 AS p99Latency, + 0 AS count, + 0 AS success, + 0 AS degraded, + 0 AS error, + NULL AS lastTimestamp + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_14d.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_14d__v0.pipe similarity index 87% rename from packages/tinybird/endpoints/endpoint__http_metrics_by_interval_14d.pipe rename to packages/tinybird/endpoints/endpoint__http_metrics_by_interval_14d__v0.pipe index a738bbcb..5873b9f2 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_14d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_14d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT region, toStartOfInterval( @@ -21,8 +17,10 @@ SQL > FROM mv__http_14d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY h, region ORDER BY h DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_1d.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_1d__v0.pipe similarity index 87% rename from packages/tinybird/endpoints/endpoint__http_metrics_by_interval_1d.pipe rename to packages/tinybird/endpoints/endpoint__http_metrics_by_interval_1d__v0.pipe index 3ea88c6a..78b7a28a 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_1d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_1d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT region, toStartOfInterval( @@ -21,8 +17,10 @@ SQL > FROM mv__http_1d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY h, region ORDER BY h DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_7d.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_7d__v0.pipe similarity index 94% rename from packages/tinybird/endpoints/endpoint__http_metrics_by_interval_7d.pipe rename to packages/tinybird/endpoints/endpoint__http_metrics_by_interval_7d__v0.pipe index 342494b1..1ba066ec 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_7d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_by_interval_7d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT region, toStartOfInterval( @@ -24,5 +20,6 @@ SQL > GROUP BY h, region ORDER BY h DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_by_region_14d.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_by_region_14d__v0.pipe similarity index 82% rename from packages/tinybird/endpoints/endpoint__http_metrics_by_region_14d.pipe rename to packages/tinybird/endpoints/endpoint__http_metrics_by_region_14d__v0.pipe index 2e985e4b..0dbd1f29 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_by_region_14d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_by_region_14d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT region, round(quantile(0.5)(latency)) as p50Latency, @@ -18,11 +14,9 @@ SQL > FROM mv__http_14d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY region +TYPE endpoint - - - -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_by_region_1d.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_by_region_1d__v0.pipe similarity index 82% rename from packages/tinybird/endpoints/endpoint__http_metrics_by_region_1d.pipe rename to packages/tinybird/endpoints/endpoint__http_metrics_by_region_1d__v0.pipe index 28572163..606a00e8 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_by_region_1d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_by_region_1d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT region, round(quantile(0.5)(latency)) as p50Latency, @@ -18,8 +14,9 @@ SQL > FROM mv__http_1d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY region +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_by_region_7d.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_by_region_7d__v0.pipe similarity index 82% rename from packages/tinybird/endpoints/endpoint__http_metrics_by_region_7d.pipe rename to packages/tinybird/endpoints/endpoint__http_metrics_by_region_7d__v0.pipe index dfe49ac2..17cbe044 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_by_region_7d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_by_region_7d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT region, round(quantile(0.5)(latency)) as p50Latency, @@ -18,8 +14,9 @@ SQL > FROM mv__http_7d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY region +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_global_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_global_1d__v0.pipe index d7139025..0b84fefa 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_global_1d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_global_1d__v0.pipe @@ -1,25 +1,24 @@ -VERSION 0 - -TAGS "http" +TOKEN "endpoint__http_metrics_global_1d__v1_endpoint_read_4114" READ NODE endpoint SQL > - % - SELECT - round(min(latency), 0) as minLatency, - round(max(latency), 0) as maxLatency, - round(quantile(0.5)(latency), 0) as p50Latency, - round(quantile(0.75)(latency), 0) as p75Latency, - round(quantile(0.9)(latency), 0) as p90Latency, - round(quantile(0.95)(latency), 0) as p95Latency, - round(quantile(0.99)(latency), 0) as p99Latency, - max(cronTimestamp) as lastTimestamp, - count() as count, - monitorId - FROM mv__http_1d__v1 - WHERE monitorId IN {{ Array(monitorIds, 'String', '1') }} - GROUP BY monitorId +% +SELECT + round(min(latency), 0) as minLatency, + round(max(latency), 0) as maxLatency, + round(quantile(0.5)(latency), 0) as p50Latency, + round(quantile(0.75)(latency), 0) as p75Latency, + round(quantile(0.9)(latency), 0) as p90Latency, + round(quantile(0.95)(latency), 0) as p95Latency, + round(quantile(0.99)(latency), 0) as p99Latency, + max(cronTimestamp) as lastTimestamp, + count() as count, + monitorId +FROM mv__http_1d__v0 +WHERE monitorId IN {{ Array(monitorIds, 'String', '1') }} +GROUP BY monitorId + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_latency_1d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_latency_1d__v1.pipe index d8288ac9..cdc9de5f 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_latency_1d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_latency_1d__v1.pipe @@ -1,24 +1,25 @@ -TAGS "tcp" +TOKEN "endpoint__http_metrics_latency_1d__v1_endpoint_read_0329" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__http_1d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__http_1d__v0 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} +GROUP BY h +ORDER BY h DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_latency_1d_multi__v1.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_latency_1d_multi__v1.pipe index 746c76ca..176283d2 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_latency_1d_multi__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_latency_1d_multi__v1.pipe @@ -1,25 +1,26 @@ -TAGS "http" +TOKEN "endpoint__http_metrics_latency_1d_multi__v1_endpoint_read_2067" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - monitorId, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__http_1d__v1 - WHERE - monitorId IN {{ Array(monitorIds, 'String', '1,666') }} - GROUP BY h, monitorId - ORDER BY h ASC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + monitorId, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__http_1d__v0 +WHERE + monitorId IN {{ Array(monitorIds, 'String', '1,666') }} +GROUP BY h, monitorId +ORDER BY h ASC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_latency_30d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_latency_30d__v1.pipe index 7159a465..cd51b62b 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_latency_30d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_latency_30d__v1.pipe @@ -1,24 +1,26 @@ -TAGS "http" +TOKEN "endpoint__http_metrics_latency_30d__v1_endpoint_read_9465" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__http_30d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__http_30d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY h +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_latency_7d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_latency_7d__v1.pipe index 5631bb5d..9dfd7f06 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_latency_7d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_latency_7d__v1.pipe @@ -1,24 +1,25 @@ -TAGS "tcp" +TOKEN "endpoint__http_metrics_latency_7d__v1_endpoint_read_5816" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__http_7d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__http_7d__v0 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} +GROUP BY h +ORDER BY h DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_latency_90d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_latency_90d__v1.pipe index b7651b8f..5bb51950 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_latency_90d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_latency_90d__v1.pipe @@ -1,24 +1,26 @@ -TAGS "http" +TOKEN "endpoint__http_metrics_latency_90d__v1_endpoint_read_3285" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__http_90d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__http_90d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY h +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_regions_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_regions_14d__v0.pipe index b3e70b58..57cd926e 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_regions_14d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_regions_14d__v0.pipe @@ -1,24 +1,27 @@ +TOKEN "endpoint__http_metrics_regions_14d__v0_endpoint_read_2934" READ + NODE endpoint SQL > - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__http_14d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY h, region - ORDER BY h DESC +% +SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__http_14d__v0 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY h, region +ORDER BY h DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_regions_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_regions_1d__v0.pipe index a65352bb..980e4e45 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_regions_1d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_regions_1d__v0.pipe @@ -1,24 +1,27 @@ +TOKEN "endpoint_http_metrics_regions_1d__v0_endpoint_read_9756" READ + NODE endpoint SQL > - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__http_1d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY h, region - ORDER BY h DESC +% +SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__http_1d__v0 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY h, region +ORDER BY h DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_regions_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_regions_30d__v0.pipe index 2bcf3d7c..81681f1b 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_regions_30d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_regions_30d__v0.pipe @@ -1,25 +1,27 @@ -TAGS "http" +TOKEN "endpoint__http_metrics_regions_30d__v0_endpoint_read_1538" READ NODE endpoint SQL > - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__http_30d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY h, region - ORDER BY h DESC +% +SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__http_30d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY h, region +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_regions_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_regions_7d__v0.pipe index 0b2a50e7..2193b918 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_regions_7d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_regions_7d__v0.pipe @@ -1,24 +1,27 @@ +TOKEN "endpoint__http_metrics_regions_7d__v0_endpoint_read_7523" READ + NODE endpoint SQL > - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__http_7d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY h, region - ORDER BY h DESC +% +SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__http_7d__v0 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} +{% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY h, region +ORDER BY h DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_metrics_regions_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__http_metrics_regions_90d__v0.pipe index 891fe318..9554d710 100644 --- a/packages/tinybird/endpoints/endpoint__http_metrics_regions_90d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__http_metrics_regions_90d__v0.pipe @@ -1,25 +1,27 @@ -TAGS "http" +TOKEN "endpoint__http_metrics_regions_90d__v0_endpoint_read_4964" READ NODE endpoint SQL > - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__http_90d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY h, region - ORDER BY h DESC +% +SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__http_90d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY h, region +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_status_14d.pipe b/packages/tinybird/endpoints/endpoint__http_status_14d.pipe deleted file mode 100644 index 4e18069a..00000000 --- a/packages/tinybird/endpoints/endpoint__http_status_14d.pipe +++ /dev/null @@ -1,26 +0,0 @@ -VERSION 0 - -TAGS "http" - -NODE endpoint -SQL > - - % - SELECT - time as day, - countMerge(total) as total, - countMerge(success) as success, - countMerge(error) as error, - countMerge(degraded) as degraded - FROM mv__http_status_14d__v0 - WHERE monitorId = {{ String(monitorId, '1', required=True) }} - GROUP BY day - ORDER BY day DESC - WITH FILL - FROM - toStartOfDay(toStartOfDay(toTimeZone(now(), 'UTC'))) - TO toStartOfDay(date_sub(DAY, 14, now())) STEP INTERVAL -1 DAY - LIMIT {{ Int16(days, 14) }} - - -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_status_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__http_status_14d__v0.pipe new file mode 100644 index 00000000..500ecd18 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__http_status_14d__v0.pipe @@ -0,0 +1,25 @@ +TOKEN "endpoint__http_status_14d__v0_endpoint_read_8823" READ + +NODE endpoint +SQL > + +% +SELECT + time as day, + countMerge(total) as total, + countMerge(success) as success, + countMerge(error) as error, + countMerge(degraded) as degraded +FROM mv__http_status_14d__v0 +WHERE monitorId = {{ String(monitorId, '1', required=True) }} +GROUP BY day +ORDER BY day DESC +WITH FILL +FROM + toStartOfDay(toStartOfDay(toTimeZone(now(), 'UTC'))) + TO toStartOfDay(date_sub(DAY, 14, now())) STEP INTERVAL -1 DAY +LIMIT {{ Int16(days, 14) }} + +TYPE endpoint + + diff --git a/packages/tinybird/endpoints/endpoint__http_status_45d.pipe b/packages/tinybird/endpoints/endpoint__http_status_45d__v0.pipe similarity index 91% rename from packages/tinybird/endpoints/endpoint__http_status_45d.pipe rename to packages/tinybird/endpoints/endpoint__http_status_45d__v0.pipe index b297fdc5..a6f26085 100644 --- a/packages/tinybird/endpoints/endpoint__http_status_45d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_status_45d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT time as day, countMerge(count) as count, countMerge(ok) as ok FROM mv__http_status_45d__v0 WHERE @@ -20,5 +16,6 @@ SQL > ) STEP INTERVAL -1 DAY LIMIT {{ Int16(days, 45) }} +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_status_45d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_status_45d__v1.pipe index c6cdcb1e..5b9b99fd 100644 --- a/packages/tinybird/endpoints/endpoint__http_status_45d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_status_45d__v1.pipe @@ -1,20 +1,21 @@ -TAGS "http" +TOKEN "endpoint__http_status_45d__v0_5279_dup_endpoint_read_1013" READ NODE endpoint SQL > - % - SELECT - time as day, - monitorId, - countMerge(count) as count, - countMerge(success) as ok, - countMerge(error) as error, - countMerge(degraded) as degraded - FROM mv__http_status_45d__v1 - WHERE monitorId IN {{ Array(monitorIds, 'String', '1,666') }} - GROUP BY day, monitorId - ORDER BY day DESC +% +SELECT + time as day, + monitorId, + countMerge(count) as count, + countMerge(success) as ok, + countMerge(error) as error, + countMerge(degraded) as degraded +FROM mv__http_status_45d__v1 +WHERE monitorId IN {{ Array(monitorIds, 'String', '1,666') }} +GROUP BY day, monitorId +ORDER BY day DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_status_7d.pipe b/packages/tinybird/endpoints/endpoint__http_status_7d__v0.pipe similarity index 91% rename from packages/tinybird/endpoints/endpoint__http_status_7d.pipe rename to packages/tinybird/endpoints/endpoint__http_status_7d__v0.pipe index 1f1c2a3f..af25c5ee 100644 --- a/packages/tinybird/endpoints/endpoint__http_status_7d.pipe +++ b/packages/tinybird/endpoints/endpoint__http_status_7d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS http - NODE endpoint SQL > - % +% SELECT time as day, countMerge(count) as count, countMerge(ok) as ok FROM mv__http_status_7d__v0 WHERE @@ -19,5 +15,6 @@ SQL > date_sub(DAY, 7, now()) ) STEP INTERVAL -1 DAY +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_timing_phases_14d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_timing_phases_14d__v1.pipe index 186b65e4..7dacb40c 100644 --- a/packages/tinybird/endpoints/endpoint__http_timing_phases_14d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_timing_phases_14d__v1.pipe @@ -1,39 +1,42 @@ -NODE endpoint__http_timing_phases_14d__v1_0 +TOKEN "endpoint__http_timing_phases_14d__v1_endpoint_read_2967" READ + +NODE endpoint SQL > - % - SELECT - toStartOfInterval(time, INTERVAL {{ Int64(interval, 30) }} MINUTE) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.5)(dns)) as p50Dns, - round(quantile(0.5)(firstByte)) as p50Ttfb, - round(quantile(0.5)(transfer)) as p50Transfer, - round(quantile(0.5)(connect)) as p50Connect, - round(quantile(0.5)(tls)) as p50Tls, - round(quantile(0.75)(dns)) as p75Dns, - round(quantile(0.75)(firstByte)) as p75Ttfb, - round(quantile(0.75)(transfer)) as p75Transfer, - round(quantile(0.75)(connect)) as p75Connect, - round(quantile(0.75)(tls)) as p75Tls, - round(quantile(0.90)(dns)) as p90Dns, - round(quantile(0.90)(firstByte)) as p90Ttfb, - round(quantile(0.90)(transfer)) as p90Transfer, - round(quantile(0.90)(connect)) as p90Connect, - round(quantile(0.90)(tls)) as p90Tls, - round(quantile(0.95)(dns)) as p95Dns, - round(quantile(0.95)(firstByte)) as p95Ttfb, - round(quantile(0.95)(transfer)) as p95Transfer, - round(quantile(0.95)(connect)) as p95Connect, - round(quantile(0.95)(tls)) as p95Tls, - round(quantile(0.99)(dns)) as p99Dns, - round(quantile(0.99)(firstByte)) as p99Ttfb, - round(quantile(0.99)(transfer)) as p99Transfer, - round(quantile(0.99)(connect)) as p99Connect, - round(quantile(0.99)(tls)) as p99Tls - FROM mv__http_timing_phases_14d__v1 - WHERE monitorId = {{ String(monitorId, '141', required=True) }} - GROUP BY h - ORDER BY h ASC +% +SELECT + toStartOfInterval(time, INTERVAL {{ Int64(interval, 30) }} MINUTE) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.5)(dns)) as p50Dns, + round(quantile(0.5)(firstByte)) as p50Ttfb, + round(quantile(0.5)(transfer)) as p50Transfer, + round(quantile(0.5)(connect)) as p50Connect, + round(quantile(0.5)(tls)) as p50Tls, + round(quantile(0.75)(dns)) as p75Dns, + round(quantile(0.75)(firstByte)) as p75Ttfb, + round(quantile(0.75)(transfer)) as p75Transfer, + round(quantile(0.75)(connect)) as p75Connect, + round(quantile(0.75)(tls)) as p75Tls, + round(quantile(0.90)(dns)) as p90Dns, + round(quantile(0.90)(firstByte)) as p90Ttfb, + round(quantile(0.90)(transfer)) as p90Transfer, + round(quantile(0.90)(connect)) as p90Connect, + round(quantile(0.90)(tls)) as p90Tls, + round(quantile(0.95)(dns)) as p95Dns, + round(quantile(0.95)(firstByte)) as p95Ttfb, + round(quantile(0.95)(transfer)) as p95Transfer, + round(quantile(0.95)(connect)) as p95Connect, + round(quantile(0.95)(tls)) as p95Tls, + round(quantile(0.99)(dns)) as p99Dns, + round(quantile(0.99)(firstByte)) as p99Ttfb, + round(quantile(0.99)(transfer)) as p99Transfer, + round(quantile(0.99)(connect)) as p99Connect, + round(quantile(0.99)(tls)) as p99Tls +FROM mv__http_timing_phases_14d__v1 +WHERE monitorId = {{ String(monitorId, '141', required=True) }} +GROUP BY h +ORDER BY h ASC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_timing_phases_30d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_timing_phases_30d__v1.pipe index b44386cd..d86a115d 100644 --- a/packages/tinybird/endpoints/endpoint__http_timing_phases_30d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_timing_phases_30d__v1.pipe @@ -1,40 +1,44 @@ +TOKEN "endpoint__http_timing_phases_30d__v1_endpoint_read_9641" READ + NODE endpoint SQL > - % - SELECT - toStartOfInterval(time, INTERVAL {{ Int64(interval, 1440) }} MINUTE) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.5)(dns)) as p50Dns, - round(quantile(0.5)(firstByte)) as p50Ttfb, - round(quantile(0.5)(transfer)) as p50Transfer, - round(quantile(0.5)(connect)) as p50Connect, - round(quantile(0.5)(tls)) as p50Tls, - round(quantile(0.75)(dns)) as p75Dns, - round(quantile(0.75)(firstByte)) as p75Ttfb, - round(quantile(0.75)(transfer)) as p75Transfer, - round(quantile(0.75)(connect)) as p75Connect, - round(quantile(0.75)(tls)) as p75Tls, - round(quantile(0.90)(dns)) as p90Dns, - round(quantile(0.90)(firstByte)) as p90Ttfb, - round(quantile(0.90)(transfer)) as p90Transfer, - round(quantile(0.90)(connect)) as p90Connect, - round(quantile(0.90)(tls)) as p90Tls, - round(quantile(0.95)(dns)) as p95Dns, - round(quantile(0.95)(firstByte)) as p95Ttfb, - round(quantile(0.95)(transfer)) as p95Transfer, - round(quantile(0.95)(connect)) as p95Connect, - round(quantile(0.95)(tls)) as p95Tls, - round(quantile(0.99)(dns)) as p99Dns, - round(quantile(0.99)(firstByte)) as p99Ttfb, - round(quantile(0.99)(transfer)) as p99Transfer, - round(quantile(0.99)(connect)) as p99Connect, - round(quantile(0.99)(tls)) as p99Tls - FROM mv__http_timing_phases_90d__v1 - WHERE - monitorId = {{ String(monitorId, '141', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 30 DAY, 3) - GROUP BY h - ORDER BY h ASC +% +SELECT + toStartOfInterval(time, INTERVAL {{ Int64(interval, 1440) }} MINUTE) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.5)(dns)) as p50Dns, + round(quantile(0.5)(firstByte)) as p50Ttfb, + round(quantile(0.5)(transfer)) as p50Transfer, + round(quantile(0.5)(connect)) as p50Connect, + round(quantile(0.5)(tls)) as p50Tls, + round(quantile(0.75)(dns)) as p75Dns, + round(quantile(0.75)(firstByte)) as p75Ttfb, + round(quantile(0.75)(transfer)) as p75Transfer, + round(quantile(0.75)(connect)) as p75Connect, + round(quantile(0.75)(tls)) as p75Tls, + round(quantile(0.90)(dns)) as p90Dns, + round(quantile(0.90)(firstByte)) as p90Ttfb, + round(quantile(0.90)(transfer)) as p90Transfer, + round(quantile(0.90)(connect)) as p90Connect, + round(quantile(0.90)(tls)) as p90Tls, + round(quantile(0.95)(dns)) as p95Dns, + round(quantile(0.95)(firstByte)) as p95Ttfb, + round(quantile(0.95)(transfer)) as p95Transfer, + round(quantile(0.95)(connect)) as p95Connect, + round(quantile(0.95)(tls)) as p95Tls, + round(quantile(0.99)(dns)) as p99Dns, + round(quantile(0.99)(firstByte)) as p99Ttfb, + round(quantile(0.99)(transfer)) as p99Transfer, + round(quantile(0.99)(connect)) as p99Connect, + round(quantile(0.99)(tls)) as p99Tls +FROM mv__http_timing_phases_90d__v1 +WHERE + monitorId = {{ String(monitorId, '141', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 30 DAY, 3) +GROUP BY h +ORDER BY h ASC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_timing_phases_90d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_timing_phases_90d__v1.pipe index e6209fff..643981ce 100644 --- a/packages/tinybird/endpoints/endpoint__http_timing_phases_90d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_timing_phases_90d__v1.pipe @@ -1,38 +1,42 @@ +TOKEN "endpoint__http_timing_phases_90d__v1_endpoint_read_7261" READ + NODE endpoint SQL > - % - SELECT - toStartOfInterval(time, INTERVAL {{ Int64(interval, 1440) }} MINUTE) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.5)(dns)) as p50Dns, - round(quantile(0.5)(firstByte)) as p50Ttfb, - round(quantile(0.5)(transfer)) as p50Transfer, - round(quantile(0.5)(connect)) as p50Connect, - round(quantile(0.5)(tls)) as p50Tls, - round(quantile(0.75)(dns)) as p75Dns, - round(quantile(0.75)(firstByte)) as p75Ttfb, - round(quantile(0.75)(transfer)) as p75Transfer, - round(quantile(0.75)(connect)) as p75Connect, - round(quantile(0.75)(tls)) as p75Tls, - round(quantile(0.90)(dns)) as p90Dns, - round(quantile(0.90)(firstByte)) as p90Ttfb, - round(quantile(0.90)(transfer)) as p90Transfer, - round(quantile(0.90)(connect)) as p90Connect, - round(quantile(0.90)(tls)) as p90Tls, - round(quantile(0.95)(dns)) as p95Dns, - round(quantile(0.95)(firstByte)) as p95Ttfb, - round(quantile(0.95)(transfer)) as p95Transfer, - round(quantile(0.95)(connect)) as p95Connect, - round(quantile(0.95)(tls)) as p95Tls, - round(quantile(0.99)(dns)) as p99Dns, - round(quantile(0.99)(firstByte)) as p99Ttfb, - round(quantile(0.99)(transfer)) as p99Transfer, - round(quantile(0.99)(connect)) as p99Connect, - round(quantile(0.99)(tls)) as p99Tls - FROM mv__http_timing_phases_90d__v1 - WHERE monitorId = {{ String(monitorId, '141', required=True) }} - GROUP BY h - ORDER BY h ASC +% +SELECT + toStartOfInterval(time, INTERVAL {{ Int64(interval, 1440) }} MINUTE) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.5)(dns)) as p50Dns, + round(quantile(0.5)(firstByte)) as p50Ttfb, + round(quantile(0.5)(transfer)) as p50Transfer, + round(quantile(0.5)(connect)) as p50Connect, + round(quantile(0.5)(tls)) as p50Tls, + round(quantile(0.75)(dns)) as p75Dns, + round(quantile(0.75)(firstByte)) as p75Ttfb, + round(quantile(0.75)(transfer)) as p75Transfer, + round(quantile(0.75)(connect)) as p75Connect, + round(quantile(0.75)(tls)) as p75Tls, + round(quantile(0.90)(dns)) as p90Dns, + round(quantile(0.90)(firstByte)) as p90Ttfb, + round(quantile(0.90)(transfer)) as p90Transfer, + round(quantile(0.90)(connect)) as p90Connect, + round(quantile(0.90)(tls)) as p90Tls, + round(quantile(0.95)(dns)) as p95Dns, + round(quantile(0.95)(firstByte)) as p95Ttfb, + round(quantile(0.95)(transfer)) as p95Transfer, + round(quantile(0.95)(connect)) as p95Connect, + round(quantile(0.95)(tls)) as p95Tls, + round(quantile(0.99)(dns)) as p99Dns, + round(quantile(0.99)(firstByte)) as p99Ttfb, + round(quantile(0.99)(transfer)) as p99Transfer, + round(quantile(0.99)(connect)) as p99Connect, + round(quantile(0.99)(tls)) as p99Tls +FROM mv__http_timing_phases_90d__v1 +WHERE monitorId = {{ String(monitorId, '141', required=True) }} +GROUP BY h +ORDER BY h ASC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_uptime_30d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_uptime_30d__v1.pipe index 44cf8261..0fcd8c17 100644 --- a/packages/tinybird/endpoints/endpoint__http_uptime_30d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_uptime_30d__v1.pipe @@ -1,22 +1,23 @@ -TAGS "http" +TOKEN "endpoint__http_uptime_30d__v1_endpoint_read_2714" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error - FROM mv__http_uptime_30d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} - {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY interval - ORDER BY interval DESC +% +SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error +FROM mv__http_uptime_30d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY interval +ORDER BY interval DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_uptime_7d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_uptime_7d__v1.pipe index 357de666..ce04a188 100644 --- a/packages/tinybird/endpoints/endpoint__http_uptime_7d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_uptime_7d__v1.pipe @@ -1,22 +1,23 @@ -TAGS "http" +TOKEN "endpoint__http_uptime_7d__v1_endpoint_read_4195" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error - FROM mv__http_uptime_7d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} - {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY interval - ORDER BY interval DESC +% +SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error +FROM mv__http_uptime_7d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY interval +ORDER BY interval DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_uptime_90d__v1.pipe b/packages/tinybird/endpoints/endpoint__http_uptime_90d__v1.pipe index fec0e098..b3151b3d 100644 --- a/packages/tinybird/endpoints/endpoint__http_uptime_90d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__http_uptime_90d__v1.pipe @@ -1,21 +1,25 @@ -TAGS "http" +TOKEN "endpoint__http_uptime_90d__v1_endpoint_read_2593" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval(time, INTERVAL {{ String(interval, '1440', required=True) }} minute) AS interval, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error - FROM mv__http_uptime_90d__v1 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} - {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY interval - ORDER BY interval DESC +% +SELECT + toStartOfInterval( + time, INTERVAL {{ String(interval, '1440', required=True) }} minute + ) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error +FROM mv__http_uptime_90d__v1 +WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY interval +ORDER BY interval DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__http_workspace_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__http_workspace_30d__v0.pipe index 028cbd22..7c603987 100644 --- a/packages/tinybird/endpoints/endpoint__http_workspace_30d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__http_workspace_30d__v0.pipe @@ -1,16 +1,17 @@ -TAGS "http" +TOKEN "endpoint__http_workspace_30d__v0_endpoint_read_9286" READ NODE endpoint SQL > - % - SELECT - time as day, - countMerge(count_state) as count - FROM mv__http_workspace_30d__v0 - WHERE workspaceId = {{ String(workspaceId, '1', required=True) }} - GROUP BY day - ORDER BY day DESC +% +SELECT + time as day, + countMerge(count_state) as count +FROM mv__http_workspace_30d__v0 +WHERE workspaceId = {{ String(workspaceId, '1', required=True) }} +GROUP BY day +ORDER BY day DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__stats_global.pipe b/packages/tinybird/endpoints/endpoint__stats_global.pipe deleted file mode 100644 index 4e68659e..00000000 --- a/packages/tinybird/endpoints/endpoint__stats_global.pipe +++ /dev/null @@ -1,25 +0,0 @@ -VERSION 0 - -NODE endpoint -SQL > - - % - SELECT COUNT(*) as count - FROM ping_response__v8 - {% if defined(period) %} - {% if String(period) == "1h" %} - WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 1 HOUR) * 1000 - {% elif String(period) == "10m" %} - WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 10 MINUTE) * 1000 - {% elif String(period) == "1d" %} - WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 1 DAY) * 1000 - {% elif String(period) == "1w" %} - WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 7 DAY) * 1000 - {% elif String(period) == "1m" %} - WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 1 MONTH) * 1000 - {% else %} - WHERE cronTimestamp > 0 - {% end %} - {% end %} - -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__stats_global__v0.pipe b/packages/tinybird/endpoints/endpoint__stats_global__v0.pipe new file mode 100644 index 00000000..20dd37b7 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__stats_global__v0.pipe @@ -0,0 +1,24 @@ +NODE endpoint +SQL > + +% +SELECT COUNT(*) as count +FROM ping_response__v8 +{% if defined(period) %} + {% if String(period) == "1h" %} + WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 1 HOUR) * 1000 + {% elif String(period) == "10m" %} + WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 10 MINUTE) * 1000 + {% elif String(period) == "1d" %} + WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 1 DAY) * 1000 + {% elif String(period) == "1w" %} + WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 7 DAY) * 1000 + {% elif String(period) == "1m" %} + WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 1 MONTH) * 1000 + {% else %} WHERE cronTimestamp > 0 + {% end %} +{% end %} + +TYPE endpoint + + diff --git a/packages/tinybird/endpoints/endpoint__tcp_get_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_get_14d__v0.pipe index 9f012b53..96eda6c1 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_get_14d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_get_14d__v0.pipe @@ -1,15 +1,16 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_get_14d__v0_endpoint_read_3805" READ NODE endpoint SQL > - % - SELECT * - FROM mv__tcp_full_14d__v0 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - AND id = {{ String(id, '', required=True) }} - ORDER BY time DESC +% + SELECT * + FROM mv__tcp_full_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND id = {{ String(id, '', required=True) }} + ORDER BY time DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_get_30d.pipe b/packages/tinybird/endpoints/endpoint__tcp_get_30d__v0.pipe similarity index 71% rename from packages/tinybird/endpoints/endpoint__tcp_get_30d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_get_30d__v0.pipe index a3812df1..09dce649 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_get_30d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_get_30d__v0.pipe @@ -1,19 +1,15 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT * FROM mv__tcp_full_30d__v0 WHERE - monitorId = {{ String(monitorId, '1', required=True) }} + monitorId = {{ String(monitorId, '4433', required=True) }} AND cronTimestamp = {{ Int64(cronTimestamp, 1709477432205, required=True) }} AND region = {{ String(region, 'ams', required=True) }} ORDER BY cronTimestamp DESC +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_list_14d.pipe b/packages/tinybird/endpoints/endpoint__tcp_list_14d__v0.pipe similarity index 80% rename from packages/tinybird/endpoints/endpoint__tcp_list_14d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_list_14d__v0.pipe index c3f42e52..ad92409f 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_list_14d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_list_14d__v0.pipe @@ -1,15 +1,12 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT * FROM mv__tcp_14d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} ORDER BY cronTimestamp DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_list_14d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_list_14d__v1.pipe index 36b1f34e..88a9ec74 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_list_14d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_list_14d__v1.pipe @@ -1,19 +1,21 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_list_14d__v1_endpoint_read_9213" READ NODE endpoint SQL > - % - SELECT * FROM mv__tcp_14d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - {% if defined(fromDate) %} - AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) - {% end %} - {% if defined(toDate) %} - AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) - {% end %} - ORDER BY time DESC +% +SELECT * +FROM mv__tcp_14d__v1 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} +ORDER BY time DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_list_1d.pipe b/packages/tinybird/endpoints/endpoint__tcp_list_1d.pipe deleted file mode 100644 index 2d38ba1d..00000000 --- a/packages/tinybird/endpoints/endpoint__tcp_list_1d.pipe +++ /dev/null @@ -1,15 +0,0 @@ -VERSION 0 - -TAGS tcp - -NODE endpoint -SQL > - - % - SELECT * FROM mv__tcp_1d__v0 - WHERE - monitorId = {{ String(monitorId, '1', required=True) }} - ORDER BY cronTimestamp DESC - - -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_list_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_list_1d__v0.pipe new file mode 100644 index 00000000..cb154d14 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__tcp_list_1d__v0.pipe @@ -0,0 +1,12 @@ +NODE endpoint +SQL > + +% + SELECT * FROM mv__tcp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + ORDER BY cronTimestamp DESC + +TYPE endpoint + + diff --git a/packages/tinybird/endpoints/endpoint__tcp_list_1d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_list_1d__v1.pipe index fd1f5c2e..2f4f7fb6 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_list_1d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_list_1d__v1.pipe @@ -1,19 +1,21 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_list_1d__v1_endpoint_read_6247" READ NODE endpoint SQL > - % - SELECT * FROM mv__tcp_1d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - {% if defined(fromDate) %} - AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) - {% end %} - {% if defined(toDate) %} - AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) - {% end %} - ORDER BY time DESC +% +SELECT * +FROM mv__tcp_1d__v1 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} +ORDER BY time DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_list_7d.pipe b/packages/tinybird/endpoints/endpoint__tcp_list_7d__v0.pipe similarity index 80% rename from packages/tinybird/endpoints/endpoint__tcp_list_7d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_list_7d__v0.pipe index a11c2433..4240d224 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_list_7d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_list_7d__v0.pipe @@ -1,15 +1,12 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT * FROM mv__tcp_7d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} ORDER BY cronTimestamp DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_list_7d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_list_7d__v1.pipe index def19386..ff202823 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_list_7d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_list_7d__v1.pipe @@ -1,19 +1,21 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_list_7d__v1_endpoint_read_7786" READ NODE endpoint SQL > - % - SELECT * FROM mv__tcp_7d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - {% if defined(fromDate) %} - AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) - {% end %} - {% if defined(toDate) %} - AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) - {% end %} - ORDER BY time DESC +% +SELECT * +FROM mv__tcp_7d__v1 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} +ORDER BY time DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_14d.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_14d__v0.pipe similarity index 88% rename from packages/tinybird/endpoints/endpoint__tcp_metrics_14d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_metrics_14d__v0.pipe index 6eb07095..dd0a94ec 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_14d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_14d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT round(quantile(0.50)(latency)) as p50Latency, round(quantile(0.75)(latency)) as p75Latency, @@ -18,6 +14,7 @@ SQL > FROM mv__tcp_14d__v0 WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} -- FIXME: we can reduce the data processed by using removing it entirely -- because the query is useless as we are in the 14d context -- TODO: check where we can reduce the data processed @@ -35,8 +32,10 @@ SQL > FROM mv__tcp_30d__v0 -- REMINDER: this will increase the processed data compared to 3d WHERE monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 28 DAY, 3) AND time < toDateTime64(now() - INTERVAL 14 DAY, 3) +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_14d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_14d__v1.pipe index 948a2e60..0aaa3dfb 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_14d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_14d__v1.pipe @@ -1,43 +1,49 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_14d__v1_endpoint_read_5043" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM mv__tcp_14d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant - FROM mv__tcp_30d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 28 DAY, 3) - AND time < toDateTime64(now() - INTERVAL 14 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - - -TYPE ENDPOINT +% + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__tcp_14d__v1 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__tcp_30d__v1 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 28 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 14 DAY, 3) + +TYPE endpoint + + + +NODE endpoint__tcp_metrics_14d__v1_1 +SQL > + + SELECT * FROM endpoint + + diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_1d.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_1d__v0.pipe similarity index 78% rename from packages/tinybird/endpoints/endpoint__tcp_metrics_1d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_metrics_1d__v0.pipe index 6eaeac95..b849de74 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_1d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_1d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT round(quantile(0.50)(latency)) as p50Latency, round(quantile(0.75)(latency)) as p75Latency, @@ -17,7 +13,8 @@ SQL > max(cronTimestamp) AS lastTimestamp FROM mv__tcp_1d__v0 WHERE - monitorId = {{ String(monitorId, '1', required=True) }} + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 1 DAY, 3) UNION ALL SELECT @@ -31,9 +28,11 @@ SQL > NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant FROM mv__tcp_7d__v0 -- REMINDER: this will increase the processed data compared to 3d WHERE - monitorId = {{ String(monitorId, '1', required=True) }} + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 2 DAY, 3) AND time < toDateTime64(now() - INTERVAL 1 DAY, 3) +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_1d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_1d__v1.pipe index adc2c9a9..779b4d89 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_1d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_1d__v1.pipe @@ -1,43 +1,44 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_1d__v1_endpoint_read_8831" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM mv__tcp_1d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 1 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant - FROM mv__tcp_7d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 2 DAY, 3) - AND time < toDateTime64(now() - INTERVAL 1 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +% + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__tcp_1d__v1 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 1 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__tcp_7d__v1 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 2 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 1 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_30d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_30d__v1.pipe index 7c74e819..6ee03eed 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_30d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_30d__v1.pipe @@ -1,42 +1,44 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_30d__v1_endpoint_read_2713" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM mv__tcp_30d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 30 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp - FROM mv__tcp_90d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 60 DAY, 3) - AND time < toDateTime64(now() - INTERVAL 30 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +% +SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp +FROM mv__tcp_30d__v1 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 30 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +UNION ALL +SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp +FROM mv__tcp_90d__v1 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 60 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 30 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_7d.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_7d__v0.pipe similarity index 78% rename from packages/tinybird/endpoints/endpoint__tcp_metrics_7d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_metrics_7d__v0.pipe index 061bb022..37ae7408 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_7d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_7d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT round(quantile(0.50)(latency)) as p50Latency, round(quantile(0.75)(latency)) as p75Latency, @@ -17,7 +13,8 @@ SQL > max(cronTimestamp) AS lastTimestamp FROM mv__tcp_7d__v0 WHERE - monitorId = {{ String(monitorId, '1', required=True) }} + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 7 DAY, 3) UNION ALL SELECT @@ -31,9 +28,11 @@ SQL > NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant FROM mv__tcp_14d__v0 -- REMINDER: this will increase the processed data compared to 3d WHERE - monitorId = {{ String(monitorId, '1', required=True) }} + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) AND time < toDateTime64(now() - INTERVAL 7 DAY, 3) +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_7d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_7d__v1.pipe index e5193ad4..4a589d07 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_7d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_7d__v1.pipe @@ -1,43 +1,44 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_7d__v1_endpoint_read_9306" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM mv__tcp_7d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 7 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - SELECT - round(quantile(0.50)(latency)) AS p50Latency, - round(quantile(0.75)(latency)) AS p75Latency, - round(quantile(0.90)(latency)) AS p90Latency, - round(quantile(0.95)(latency)) AS p95Latency, - round(quantile(0.99)(latency)) AS p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant - FROM mv__tcp_14d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) - AND time < toDateTime64(now() - INTERVAL 7 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +% + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__tcp_7d__v1 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 7 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__tcp_14d__v1 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 7 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_90d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_90d__v1.pipe index d705347e..646aeacb 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_90d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_90d__v1.pipe @@ -1,39 +1,41 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_90d__v1_endpoint_read_1571" READ NODE endpoint SQL > - % - SELECT - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, - count() as count, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error, - max(cronTimestamp) AS lastTimestamp - FROM mv__tcp_90d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - AND time >= toDateTime64(now() - INTERVAL 90 DAY, 3) - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - UNION ALL - -- the previous 90d window (90-180d ago) is past the 90d MV TTL, so there's no - -- real comparison data. emit an empty row to keep the 2-row contract; count 0 - -- makes the client suppress the trend badge (NaN). - SELECT - 0 AS p50Latency, - 0 AS p75Latency, - 0 AS p90Latency, - 0 AS p95Latency, - 0 AS p99Latency, - 0 AS count, - 0 AS success, - 0 AS degraded, - 0 AS error, - NULL AS lastTimestamp +% +SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp +FROM mv__tcp_90d__v1 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 90 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +UNION ALL +-- the previous 90d window (90-180d ago) is past the 90d MV TTL, so there's no +-- real comparison data. emit an empty row to keep the 2-row contract; count 0 +-- makes the client suppress the trend badge (NaN). +SELECT + 0 AS p50Latency, + 0 AS p75Latency, + 0 AS p90Latency, + 0 AS p95Latency, + 0 AS p99Latency, + 0 AS count, + 0 AS success, + 0 AS degraded, + 0 AS error, + NULL AS lastTimestamp + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_14d__v0.pipe index a49a2629..48bc9d97 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_14d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_14d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT region, toStartOfInterval( @@ -20,9 +16,11 @@ SQL > round(quantile(0.99)(latency)) as p99Latency FROM mv__tcp_14d__v0 WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} + monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY h, region ORDER BY h DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_1d__v0.pipe index 11e8771b..395dbc2f 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_1d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_1d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT region, toStartOfInterval( @@ -20,9 +16,11 @@ SQL > round(quantile(0.99)(latency)) as p99Latency FROM mv__tcp_1d__v0 WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} + monitorId = {{ String(monitorId, '1', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY h, region ORDER BY h DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_30d__v0.pipe index 637d3184..8884a0e3 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_30d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_30d__v0.pipe @@ -1,26 +1,25 @@ -VERSION 0 - -TAGS tcp +TOKEN "endpoint__tcp_metrics_by_interval_30d__v0_endpoint_read_8017" READ NODE endpoint SQL > - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__tcp_30d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - GROUP BY h, region - ORDER BY h DESC +% +SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__tcp_30d__v1 +WHERE monitorId = {{ String(monitorId, '4433', required=True) }} +GROUP BY h, region +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_7d__v0.pipe index d6e469f2..afbb97f2 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_7d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_7d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT region, toStartOfInterval( @@ -18,11 +14,13 @@ SQL > round(quantile(0.90)(latency)) as p90Latency, round(quantile(0.95)(latency)) as p95Latency, round(quantile(0.99)(latency)) as p99Latency - FROM mv__tcp_7d__v1 + FROM mv__tcp_7d__v0 WHERE monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY h, region ORDER BY h DESC +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_90d__v0.pipe index b5882f9a..11c47946 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_90d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_interval_90d__v0.pipe @@ -1,26 +1,25 @@ -VERSION 0 - -TAGS tcp +TOKEN "endpoint__tcp_metrics_by_interval_90d__v0_endpoint_read_0954" READ NODE endpoint SQL > - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__tcp_90d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - GROUP BY h, region - ORDER BY h DESC +% +SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__tcp_90d__v1 +WHERE monitorId = {{ String(monitorId, '4433', required=True) }} +GROUP BY h, region +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_14d.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_14d__v0.pipe similarity index 71% rename from packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_14d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_14d__v0.pipe index 493ecd84..32c7efb6 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_14d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_14d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT region, round(quantile(0.5)(latency)) as p50Latency, @@ -17,9 +13,10 @@ SQL > count(if(error = 0, 1, NULL)) AS ok FROM mv__tcp_14d__v0 WHERE - monitorId = {{ String(monitorId, '1', required=True) }} + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY region +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_1d.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_1d__v0.pipe similarity index 71% rename from packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_1d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_1d__v0.pipe index 392f6f60..705aeeb8 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_1d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_1d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT region, round(quantile(0.5)(latency)) as p50Latency, @@ -17,9 +13,10 @@ SQL > count(if(error = 0, 1, NULL)) AS ok FROM mv__tcp_1d__v0 WHERE - monitorId = {{ String(monitorId, '1', required=True) }} + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY region +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_7d.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_7d__v0.pipe similarity index 71% rename from packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_7d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_7d__v0.pipe index 3b1cbaf0..e862d167 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_7d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_by_region_7d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT region, round(quantile(0.5)(latency)) as p50Latency, @@ -17,9 +13,10 @@ SQL > count(if(error = 0, 1, NULL)) AS ok FROM mv__tcp_7d__v0 WHERE - monitorId = {{ String(monitorId, '1', required=True) }} + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} GROUP BY region +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_global_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_global_1d__v0.pipe index 2f65ff43..90cef2ea 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_global_1d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_global_1d__v0.pipe @@ -1,25 +1,24 @@ -VERSION 0 - -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_global_1d__v0_endpoint_read_1387" READ NODE endpoint SQL > - % - SELECT - round(min(latency), 0) as minLatency, - round(max(latency), 0) as maxLatency, - round(quantile(0.5)(latency), 0) as p50Latency, - round(quantile(0.75)(latency), 0) as p75Latency, - round(quantile(0.9)(latency), 0) as p90Latency, - round(quantile(0.95)(latency), 0) as p95Latency, - round(quantile(0.99)(latency), 0) as p99Latency, - max(cronTimestamp) as lastTimestamp, - count() as count, - monitorId - FROM mv__tcp_1d__v0 - WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} - GROUP BY monitorId +% +SELECT + round(min(latency), 0) as minLatency, + round(max(latency), 0) as maxLatency, + round(quantile(0.5)(latency), 0) as p50Latency, + round(quantile(0.75)(latency), 0) as p75Latency, + round(quantile(0.9)(latency), 0) as p90Latency, + round(quantile(0.95)(latency), 0) as p95Latency, + round(quantile(0.99)(latency), 0) as p99Latency, + max(cronTimestamp) as lastTimestamp, + count() as count, + monitorId +FROM mv__tcp_1d__v0 +WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} +GROUP BY monitorId + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_1d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_1d__v1.pipe index 10d81b99..1ca170ac 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_1d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_1d__v1.pipe @@ -1,24 +1,25 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_latency_1d__v1_endpoint_read_1576" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__tcp_1d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__tcp_1d__v0 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} +GROUP BY h +ORDER BY h DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_1d_multi__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_1d_multi__v1.pipe index 2f3b5e27..c7700060 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_1d_multi__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_1d_multi__v1.pipe @@ -1,25 +1,26 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_latency_1d_multi__v1_endpoint_read_4714" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - monitorId, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__tcp_1d__v1 - WHERE - monitorId IN {{ Array(monitorIds, 'String', '4433') }} - GROUP BY h, monitorId - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + monitorId, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__tcp_1d__v0 +WHERE + monitorId IN {{ Array(monitorIds, 'String', '4433') }} +GROUP BY h, monitorId +ORDER BY h DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_30d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_30d__v1.pipe index 84430f4f..18cbf09d 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_30d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_30d__v1.pipe @@ -1,23 +1,24 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_latency_30d__v1_endpoint_read_0676" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__tcp_30d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__tcp_30d__v1 +WHERE monitorId = {{ String(monitorId, '4433', required=True) }} +GROUP BY h +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_7d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_7d__v1.pipe index b8b41812..f30e3c7e 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_7d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_7d__v1.pipe @@ -1,24 +1,25 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_latency_7d__v1_endpoint_read_6349" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__tcp_7d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__tcp_7d__v1 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} +GROUP BY h +ORDER BY h DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_90d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_90d__v1.pipe index 912d4f49..1e4ce0ea 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_90d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_metrics_latency_90d__v1.pipe @@ -1,23 +1,24 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_metrics_latency_90d__v1_endpoint_read_2637" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval( - toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(quantile(0.50)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.90)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM mv__tcp_90d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - GROUP BY h - ORDER BY h DESC +% +SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency +FROM mv__tcp_90d__v1 +WHERE monitorId = {{ String(monitorId, '4433', required=True) }} +GROUP BY h +ORDER BY h DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_status_45d.pipe b/packages/tinybird/endpoints/endpoint__tcp_status_45d__v0.pipe similarity index 91% rename from packages/tinybird/endpoints/endpoint__tcp_status_45d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_status_45d__v0.pipe index 3f2f0b22..6f2929a4 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_status_45d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_status_45d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT time as day, countMerge(count) as count, countMerge(ok) as ok FROM mv__tcp_status_45d__v0 WHERE @@ -20,5 +16,6 @@ SQL > ) STEP INTERVAL -1 DAY LIMIT {{ Int16(days, 45) }} +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_status_45d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_status_45d__v1.pipe index 5d1b35d1..3258aabb 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_status_45d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_status_45d__v1.pipe @@ -1,20 +1,21 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_status_45d__v1_endpoint_read_8911" READ NODE endpoint SQL > - % - SELECT - time as day, - monitorId, - countMerge(count) as count, - countMerge(success) as ok, - countMerge(error) as error, - countMerge(degraded) as degraded - FROM mv__tcp_status_45d__v1 - WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} - GROUP BY day, monitorId - ORDER BY day DESC +% +SELECT + time as day, + monitorId, + countMerge(count) as count, + countMerge(success) as ok, + countMerge(error) as error, + countMerge(degraded) as degraded +FROM mv__tcp_status_45d__v1 +WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} +GROUP BY day, monitorId +ORDER BY day DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_status_7d.pipe b/packages/tinybird/endpoints/endpoint__tcp_status_7d__v0.pipe similarity index 91% rename from packages/tinybird/endpoints/endpoint__tcp_status_7d.pipe rename to packages/tinybird/endpoints/endpoint__tcp_status_7d__v0.pipe index 10169d50..f0bf349c 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_status_7d.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_status_7d__v0.pipe @@ -1,11 +1,7 @@ -VERSION 0 - -TAGS tcp - NODE endpoint SQL > - % +% SELECT time as day, countMerge(count) as count, countMerge(ok) as ok FROM mv__tcp_status_7d__v0 WHERE @@ -19,5 +15,6 @@ SQL > date_sub(DAY, 7, now()) ) STEP INTERVAL -1 DAY +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_uptime_30d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_uptime_30d__v1.pipe index 8218a8ba..d10b95fa 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_uptime_30d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_uptime_30d__v1.pipe @@ -1,22 +1,23 @@ -TAGS "http" +TOKEN "endpoint__tcp_uptime_30d__v1_endpoint_read_7238" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error - FROM mv__tcp_uptime_30d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} - {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY interval - ORDER BY interval DESC +% +SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error +FROM mv__tcp_uptime_30d__v1 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY interval +ORDER BY interval DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_uptime_7d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_uptime_7d__v1.pipe index 146ad31d..69004b32 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_uptime_7d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_uptime_7d__v1.pipe @@ -1,22 +1,23 @@ -TAGS "http" +TOKEN "endpoint__tcp_uptime_7d__v1_endpoint_read_5726" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error - FROM mv__tcp_uptime_7d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} - {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY interval - ORDER BY interval DESC +% +SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error +FROM mv__tcp_uptime_7d__v1 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY interval +ORDER BY interval DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_uptime_90d__v1.pipe b/packages/tinybird/endpoints/endpoint__tcp_uptime_90d__v1.pipe index 3eb0e49a..c06654b0 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_uptime_90d__v1.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_uptime_90d__v1.pipe @@ -1,21 +1,23 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_uptime_90d__v1_endpoint_read_5414" READ NODE endpoint SQL > - % - SELECT - toStartOfInterval(time, INTERVAL {{ String(interval, '1440', required=True) }} minute) AS interval, - countIf(requestStatus = 'success') AS success, - countIf(requestStatus = 'degraded') AS degraded, - countIf(requestStatus = 'error') AS error - FROM mv__tcp_uptime_90d__v1 - WHERE - monitorId = {{ String(monitorId, '4433', required=True) }} - {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} - {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} - {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} - GROUP BY interval - ORDER BY interval DESC +% +SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error +FROM mv__tcp_uptime_90d__v1 +WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} +GROUP BY interval +ORDER BY interval DESC + +TYPE endpoint + -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__tcp_workspace_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_workspace_30d__v0.pipe index 27568470..db128d63 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_workspace_30d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_workspace_30d__v0.pipe @@ -1,16 +1,17 @@ -TAGS "tcp" +TOKEN "endpoint__tcp_workspace_30d__v0_endpoint_read_0686" READ NODE endpoint SQL > - % - SELECT - time as day, - countMerge(count_state) as count - FROM mv__tcp_workspace_30d__v0 - WHERE workspaceId = {{ Int32(workspaceId, 1, required=True) }} - GROUP BY day - ORDER BY day DESC +% +SELECT + time as day, + countMerge(count_state) as count +FROM mv__tcp_workspace_30d__v0 +WHERE workspaceId = {{ Int32(workspaceId, 1, required=True) }} +GROUP BY day +ORDER BY day DESC + +TYPE endpoint -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint_audit_log.pipe b/packages/tinybird/endpoints/endpoint_audit_log.pipe deleted file mode 100644 index 4d42b16b..00000000 --- a/packages/tinybird/endpoints/endpoint_audit_log.pipe +++ /dev/null @@ -1,9 +0,0 @@ -VERSION 0 - -NODE endpoint_audit_pipe_0 -SQL > - - % SELECT * FROM audit_log__v0 WHERE id = {{ String(event_id, 1) }} ORDER BY timestamp DESC - - -TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint_audit_log__v0.pipe b/packages/tinybird/endpoints/endpoint_audit_log__v0.pipe new file mode 100644 index 00000000..48c4e1cd --- /dev/null +++ b/packages/tinybird/endpoints/endpoint_audit_log__v0.pipe @@ -0,0 +1,8 @@ +NODE endpoint_audit_pipe_0 +SQL > + +% SELECT * FROM audit_log__v0 WHERE id = {{ String(eventId, 'monitor:2089') }} ORDER BY timestamp DESC + +TYPE endpoint + + diff --git a/packages/tinybird/endpoints/get_result_for_on_demand_check_http.pipe b/packages/tinybird/endpoints/get_result_for_on_demand_check_http.pipe new file mode 100644 index 00000000..79b0eecb --- /dev/null +++ b/packages/tinybird/endpoints/get_result_for_on_demand_check_http.pipe @@ -0,0 +1,27 @@ +TOKEN "getResultForOnDemandCheck_endpoint_read_7832" READ + +NODE get_result_for_on_demand_check_http_0 +SQL > + +% +SELECT + latency, + monitorId, + error, + region, + statusCode, + timestamp, + url, + workspaceId, + timing, + cronTimestamp +FROM mv__http_full_30d__v0 +where + cronTimestamp = {{ String(timestamp, '1729871694536') }} and + monitorId = {{ String(monitorId, '2260') }} + and url = {{ String(url , 'https://www.openstatus.dev/api/ping/edge') }} + order by cronTimestamp desc + +TYPE endpoint + + diff --git a/packages/tinybird/pipes/aggregate__dns_status_45d__v1.pipe b/packages/tinybird/materializations/aggregate__dns_status_45d__v1.pipe similarity index 95% rename from packages/tinybird/pipes/aggregate__dns_status_45d__v1.pipe rename to packages/tinybird/materializations/aggregate__dns_status_45d__v1.pipe index d74825f0..004bec54 100644 --- a/packages/tinybird/pipes/aggregate__dns_status_45d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__dns_status_45d__v1.pipe @@ -1,5 +1,3 @@ -TAGS "statuspage, dns" - NODE aggregate SQL > diff --git a/packages/tinybird/pipes/aggregate__external_status_component_daily__v0.pipe b/packages/tinybird/materializations/aggregate__external_status_component__daily__v0.pipe similarity index 55% rename from packages/tinybird/pipes/aggregate__external_status_component_daily__v0.pipe rename to packages/tinybird/materializations/aggregate__external_status_component__daily__v0.pipe index 46dfdf2f..a50abf3c 100644 --- a/packages/tinybird/pipes/aggregate__external_status_component_daily__v0.pipe +++ b/packages/tinybird/materializations/aggregate__external_status_component__daily__v0.pipe @@ -1,22 +1,10 @@ -TAGS "external_status" - NODE aggregate SQL > SELECT toDate(toTimeZone(fromUnixTimestamp64Milli(fetched_at), 'UTC')) AS day, component_id, - argMaxState( - indicator, - toUInt8( - multiIf( - indicator = 'critical', 3, - indicator = 'major', 2, - indicator = 'minor', 1, - 0 - ) - ) - ) AS worst_indicator, + argMaxState(indicator, toUInt8(multiIf(indicator = 'critical', 3, indicator = 'major', 2, indicator = 'minor', 1, 0))) AS worst_indicator, maxState(toUInt8(status = 'under_maintenance')) AS had_maintenance, sumState(toUInt32(1)) AS snapshot_count FROM external_status_component__v0 @@ -26,3 +14,5 @@ SQL > TYPE materialized DATASOURCE mv__external_status_component_daily__v0 + + diff --git a/packages/tinybird/materializations/aggregate__external_status_daily__v0.pipe b/packages/tinybird/materializations/aggregate__external_status_daily__v0.pipe new file mode 100644 index 00000000..6c0696bc --- /dev/null +++ b/packages/tinybird/materializations/aggregate__external_status_daily__v0.pipe @@ -0,0 +1,28 @@ +NODE aggregate +SQL > + + SELECT + toDate(toTimeZone(fromUnixTimestamp64Milli(fetched_at), 'UTC')) AS day, + id, + argMaxState( + indicator, + toUInt8( + multiIf( + indicator = 'critical', 3, + indicator = 'major', 2, + indicator = 'minor', 1, + 0 + ) + ) + ) AS worst_indicator, + maxState(toUInt8(status = 'under_maintenance')) AS had_maintenance, + sumState(toUInt32(1)) AS snapshot_count + FROM external_status__v1 + GROUP BY + day, + id + +TYPE materialized +DATASOURCE mv__external_status_daily__v0 + + diff --git a/packages/tinybird/materializations/aggregate__http_14d__v0.pipe b/packages/tinybird/materializations/aggregate__http_14d__v0.pipe new file mode 100644 index 00000000..3c3e547d --- /dev/null +++ b/packages/tinybird/materializations/aggregate__http_14d__v0.pipe @@ -0,0 +1,20 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + error, + region, + trigger, + statusCode, + timestamp, + cronTimestamp, + monitorId, + workspaceId + FROM ping_response__v8 + +TYPE materialized +DATASOURCE mv__http_14d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__http_14d__v1.pipe b/packages/tinybird/materializations/aggregate__http_14d__v1.pipe similarity index 96% rename from packages/tinybird/pipes/aggregate__http_14d__v1.pipe rename to packages/tinybird/materializations/aggregate__http_14d__v1.pipe index 5fc61ca8..1e9d2438 100644 --- a/packages/tinybird/pipes/aggregate__http_14d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__http_14d__v1.pipe @@ -1,5 +1,3 @@ -TAGS "http" - NODE aggregate SQL > diff --git a/packages/tinybird/materializations/aggregate__http_1d__v0.pipe b/packages/tinybird/materializations/aggregate__http_1d__v0.pipe new file mode 100644 index 00000000..7c995fb6 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__http_1d__v0.pipe @@ -0,0 +1,20 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + error, + region, + trigger, + statusCode, + timestamp, + cronTimestamp, + monitorId, + workspaceId + FROM ping_response__v8 + +TYPE materialized +DATASOURCE mv__http_1d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__http_1d__v1.pipe b/packages/tinybird/materializations/aggregate__http_1d__v1.pipe similarity index 96% rename from packages/tinybird/pipes/aggregate__http_1d__v1.pipe rename to packages/tinybird/materializations/aggregate__http_1d__v1.pipe index ef054ccd..8facbd46 100644 --- a/packages/tinybird/pipes/aggregate__http_1d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__http_1d__v1.pipe @@ -1,5 +1,3 @@ -TAGS "http" - NODE aggregate SQL > diff --git a/packages/tinybird/materializations/aggregate__http_30d__v0.pipe b/packages/tinybird/materializations/aggregate__http_30d__v0.pipe new file mode 100644 index 00000000..ffc98d09 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__http_30d__v0.pipe @@ -0,0 +1,20 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + error, + region, + trigger, + statusCode, + timestamp, + cronTimestamp, + monitorId, + workspaceId + FROM ping_response__v8 + +TYPE materialized +DATASOURCE mv__http_30d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__http_30d__v1.pipe b/packages/tinybird/materializations/aggregate__http_30d__v1.pipe similarity index 100% rename from packages/tinybird/pipes/aggregate__http_30d__v1.pipe rename to packages/tinybird/materializations/aggregate__http_30d__v1.pipe diff --git a/packages/tinybird/materializations/aggregate__http_7d__v0.pipe b/packages/tinybird/materializations/aggregate__http_7d__v0.pipe new file mode 100644 index 00000000..0421cafe --- /dev/null +++ b/packages/tinybird/materializations/aggregate__http_7d__v0.pipe @@ -0,0 +1,20 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + error, + region, + trigger, + statusCode, + timestamp, + cronTimestamp, + monitorId, + workspaceId + FROM ping_response__v8 + +TYPE materialized +DATASOURCE mv__http_7d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__http_7d__v1.pipe b/packages/tinybird/materializations/aggregate__http_7d__v1.pipe similarity index 100% rename from packages/tinybird/pipes/aggregate__http_7d__v1.pipe rename to packages/tinybird/materializations/aggregate__http_7d__v1.pipe diff --git a/packages/tinybird/pipes/aggregate__http_90d__v1.pipe b/packages/tinybird/materializations/aggregate__http_90d__v1.pipe similarity index 99% rename from packages/tinybird/pipes/aggregate__http_90d__v1.pipe rename to packages/tinybird/materializations/aggregate__http_90d__v1.pipe index a184aed1..a92ea817 100644 --- a/packages/tinybird/pipes/aggregate__http_90d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__http_90d__v1.pipe @@ -17,3 +17,5 @@ SQL > TYPE materialized DATASOURCE mv__http_90d__v1 + + diff --git a/packages/tinybird/pipes/aggregate__http_full_14d__v0.pipe b/packages/tinybird/materializations/aggregate__http_full_14d__v0.pipe similarity index 50% rename from packages/tinybird/pipes/aggregate__http_full_14d__v0.pipe rename to packages/tinybird/materializations/aggregate__http_full_14d__v0.pipe index b4272eda..cfee2aaa 100644 --- a/packages/tinybird/pipes/aggregate__http_full_14d__v0.pipe +++ b/packages/tinybird/materializations/aggregate__http_full_14d__v0.pipe @@ -2,14 +2,29 @@ DESCRIPTION > Stores all the data from the http_response table for the last 30 days, mainly used for accessing the data details. -TAGS "http, full" - NODE aggregate SQL > SELECT toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, - * + latency, + monitorId, + region, + statusCode, + error, + timestamp, + url, + workspaceId, + cronTimestamp, + message, + timing, + headers, + assertions, + body, + trigger, + id, + requestStatus, + method FROM ping_response__v8 TYPE materialized diff --git a/packages/tinybird/pipes/aggregate__http_full_30d__v0.pipe b/packages/tinybird/materializations/aggregate__http_full_30d__v0.pipe similarity index 50% rename from packages/tinybird/pipes/aggregate__http_full_30d__v0.pipe rename to packages/tinybird/materializations/aggregate__http_full_30d__v0.pipe index 1402cda0..ed4c1815 100644 --- a/packages/tinybird/pipes/aggregate__http_full_30d__v0.pipe +++ b/packages/tinybird/materializations/aggregate__http_full_30d__v0.pipe @@ -2,14 +2,29 @@ DESCRIPTION > Stores all the data from the http_response table for the last 30 days, mainly used for accessing the data details. -TAGS "http, full" - NODE aggregate SQL > SELECT toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, - * + latency, + monitorId, + region, + statusCode, + error, + timestamp, + url, + workspaceId, + cronTimestamp, + message, + timing, + headers, + assertions, + body, + trigger, + id, + requestStatus, + method FROM ping_response__v8 TYPE materialized diff --git a/packages/tinybird/pipes/aggregate__http_status_14d.pipe b/packages/tinybird/materializations/aggregate__http_status_14d__v0.pipe similarity index 94% rename from packages/tinybird/pipes/aggregate__http_status_14d.pipe rename to packages/tinybird/materializations/aggregate__http_status_14d__v0.pipe index b9b48dba..7c0e4214 100644 --- a/packages/tinybird/pipes/aggregate__http_status_14d.pipe +++ b/packages/tinybird/materializations/aggregate__http_status_14d__v0.pipe @@ -1,7 +1,3 @@ -VERSION 0 - -TAGS "http, statuspage" - NODE aggregate SQL > @@ -23,3 +19,5 @@ SQL > TYPE materialized DATASOURCE mv__http_status_14d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__http_status_45d.pipe b/packages/tinybird/materializations/aggregate__http_status_45d__v0.pipe similarity index 91% rename from packages/tinybird/pipes/aggregate__http_status_45d.pipe rename to packages/tinybird/materializations/aggregate__http_status_45d__v0.pipe index f502cc3e..c96b272a 100644 --- a/packages/tinybird/pipes/aggregate__http_status_45d.pipe +++ b/packages/tinybird/materializations/aggregate__http_status_45d__v0.pipe @@ -1,7 +1,3 @@ -VERSION 0 - -TAGS http, statuspage - NODE aggregate SQL > @@ -17,3 +13,5 @@ SQL > TYPE materialized DATASOURCE mv__http_status_45d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__http_status_45d__v1.pipe b/packages/tinybird/materializations/aggregate__http_status_45d__v1.pipe similarity index 95% rename from packages/tinybird/pipes/aggregate__http_status_45d__v1.pipe rename to packages/tinybird/materializations/aggregate__http_status_45d__v1.pipe index 3b3905ac..9be3e1f5 100644 --- a/packages/tinybird/pipes/aggregate__http_status_45d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__http_status_45d__v1.pipe @@ -1,5 +1,3 @@ -TAGS "http, statuspage" - NODE aggregate SQL > diff --git a/packages/tinybird/pipes/aggregate__http_status_7d.pipe b/packages/tinybird/materializations/aggregate__http_status_7d__v0.pipe similarity index 91% rename from packages/tinybird/pipes/aggregate__http_status_7d.pipe rename to packages/tinybird/materializations/aggregate__http_status_7d__v0.pipe index f2e76995..140ae2cf 100644 --- a/packages/tinybird/pipes/aggregate__http_status_7d.pipe +++ b/packages/tinybird/materializations/aggregate__http_status_7d__v0.pipe @@ -1,7 +1,3 @@ -VERSION 0 - -TAGS http, statuspage - NODE aggregate SQL > @@ -17,3 +13,5 @@ SQL > TYPE materialized DATASOURCE mv__http_status_7d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__http_timing_phases_14d.pipe b/packages/tinybird/materializations/aggregate__http_timing_phases_14d__v1.pipe similarity index 100% rename from packages/tinybird/pipes/aggregate__http_timing_phases_14d.pipe rename to packages/tinybird/materializations/aggregate__http_timing_phases_14d__v1.pipe diff --git a/packages/tinybird/materializations/aggregate__http_timing_phases_90d__v1.pipe b/packages/tinybird/materializations/aggregate__http_timing_phases_90d__v1.pipe new file mode 100644 index 00000000..ca070ac4 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__http_timing_phases_90d__v1.pipe @@ -0,0 +1,54 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + region, + trigger, + statusCode, + monitorId, + workspaceId, + requestStatus, + + -- Compute actual durations from timing phases + if( + isNull (JSONExtractInt(timing, 'dnsStart')) OR isNull (JSONExtractInt(timing, 'dnsDone')), + NULL, + JSONExtractInt(timing, 'dnsDone') - JSONExtractInt(timing, 'dnsStart') + ) AS dns, + + if( + isNull (JSONExtractInt(timing, 'connectStart')) + OR isNull (JSONExtractInt(timing, 'connectDone')), + NULL, + JSONExtractInt(timing, 'connectDone') - JSONExtractInt(timing, 'connectStart') + ) AS connect, + + if( + isNull (JSONExtractInt(timing, 'tlsHandshakeStart')) + OR isNull (JSONExtractInt(timing, 'tlsHandshakeDone')), + NULL, + JSONExtractInt(timing, 'tlsHandshakeDone') - JSONExtractInt(timing, 'tlsHandshakeStart') + ) AS tls, + + if( + isNull (JSONExtractInt(timing, 'firstByteStart')) + OR isNull (JSONExtractInt(timing, 'firstByteDone')), + NULL, + JSONExtractInt(timing, 'firstByteDone') - JSONExtractInt(timing, 'firstByteStart') + ) AS firstByte, + + if( + isNull (JSONExtractInt(timing, 'transferStart')) + OR isNull (JSONExtractInt(timing, 'transferDone')), + NULL, + JSONExtractInt(timing, 'transferDone') - JSONExtractInt(timing, 'transferStart') + ) AS transfer + + FROM ping_response__v8 + +TYPE materialized +DATASOURCE mv__http_timing_phases_90d__v1 + + diff --git a/packages/tinybird/pipes/aggregate__http_uptime_30d.pipe b/packages/tinybird/materializations/aggregate__http_uptime_30d__v1.pipe similarity index 92% rename from packages/tinybird/pipes/aggregate__http_uptime_30d.pipe rename to packages/tinybird/materializations/aggregate__http_uptime_30d__v1.pipe index f00cf372..7761f78b 100644 --- a/packages/tinybird/pipes/aggregate__http_uptime_30d.pipe +++ b/packages/tinybird/materializations/aggregate__http_uptime_30d__v1.pipe @@ -1,7 +1,3 @@ -VERSION 1 - -TAGS http - NODE aggregate SQL > diff --git a/packages/tinybird/pipes/aggregate__http_uptime_7d__v1.pipe b/packages/tinybird/materializations/aggregate__http_uptime_7d__v1.pipe similarity index 100% rename from packages/tinybird/pipes/aggregate__http_uptime_7d__v1.pipe rename to packages/tinybird/materializations/aggregate__http_uptime_7d__v1.pipe diff --git a/packages/tinybird/materializations/aggregate__http_uptime_90d__v1.pipe b/packages/tinybird/materializations/aggregate__http_uptime_90d__v1.pipe new file mode 100644 index 00000000..fe221370 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__http_uptime_90d__v1.pipe @@ -0,0 +1,15 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM ping_response__v8 + +TYPE materialized +DATASOURCE mv__http_uptime_90d__v1 + + diff --git a/packages/tinybird/pipes/aggregate__http_workspace_30d__v0.pipe b/packages/tinybird/materializations/aggregate__http_workspace_30d__v0.pipe similarity index 86% rename from packages/tinybird/pipes/aggregate__http_workspace_30d__v0.pipe rename to packages/tinybird/materializations/aggregate__http_workspace_30d__v0.pipe index 17f7f590..b1b5fa93 100644 --- a/packages/tinybird/pipes/aggregate__http_workspace_30d__v0.pipe +++ b/packages/tinybird/materializations/aggregate__http_workspace_30d__v0.pipe @@ -1,4 +1,4 @@ -TAGS "http" +TOKEN "aggregate__requests_30d__v0_endpoint_read_7187" READ NODE aggregate SQL > diff --git a/packages/tinybird/pipes/aggregate__tcp_14d.pipe b/packages/tinybird/materializations/aggregate__tcp_14d__v0.pipe similarity index 94% rename from packages/tinybird/pipes/aggregate__tcp_14d.pipe rename to packages/tinybird/materializations/aggregate__tcp_14d__v0.pipe index f1db16e5..6a9c0c81 100644 --- a/packages/tinybird/pipes/aggregate__tcp_14d.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_14d__v0.pipe @@ -1,7 +1,3 @@ -VERSION 0 - -TAGS tcp - NODE aggregate SQL > @@ -19,3 +15,5 @@ SQL > TYPE materialized DATASOURCE mv__tcp_14d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__tcp_14d__v1.pipe b/packages/tinybird/materializations/aggregate__tcp_14d__v1.pipe similarity index 96% rename from packages/tinybird/pipes/aggregate__tcp_14d__v1.pipe rename to packages/tinybird/materializations/aggregate__tcp_14d__v1.pipe index 0e6eea1a..49fc644f 100644 --- a/packages/tinybird/pipes/aggregate__tcp_14d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_14d__v1.pipe @@ -1,5 +1,3 @@ -TAGS "tcp" - NODE aggregate SQL > diff --git a/packages/tinybird/pipes/aggregate__tcp_1d.pipe b/packages/tinybird/materializations/aggregate__tcp_1d__v0.pipe similarity index 94% rename from packages/tinybird/pipes/aggregate__tcp_1d.pipe rename to packages/tinybird/materializations/aggregate__tcp_1d__v0.pipe index 4d1356a5..a3c5cb0f 100644 --- a/packages/tinybird/pipes/aggregate__tcp_1d.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_1d__v0.pipe @@ -1,7 +1,3 @@ -VERSION 0 - -TAGS tcp - NODE aggregate SQL > @@ -19,3 +15,5 @@ SQL > TYPE materialized DATASOURCE mv__tcp_1d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__tcp_1d__v1.pipe b/packages/tinybird/materializations/aggregate__tcp_1d__v1.pipe similarity index 96% rename from packages/tinybird/pipes/aggregate__tcp_1d__v1.pipe rename to packages/tinybird/materializations/aggregate__tcp_1d__v1.pipe index 8440427c..7dfdb4a5 100644 --- a/packages/tinybird/pipes/aggregate__tcp_1d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_1d__v1.pipe @@ -1,5 +1,3 @@ -TAGS "tcp" - NODE aggregate SQL > diff --git a/packages/tinybird/pipes/aggregate__tcp_30d.pipe b/packages/tinybird/materializations/aggregate__tcp_30d__v0.pipe similarity index 94% rename from packages/tinybird/pipes/aggregate__tcp_30d.pipe rename to packages/tinybird/materializations/aggregate__tcp_30d__v0.pipe index deb04b5e..97f73562 100644 --- a/packages/tinybird/pipes/aggregate__tcp_30d.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_30d__v0.pipe @@ -1,7 +1,3 @@ -VERSION 0 - -TAGS tcp - NODE aggregate SQL > @@ -19,3 +15,5 @@ SQL > TYPE materialized DATASOURCE mv__tcp_30d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__tcp_30d__v1.pipe b/packages/tinybird/materializations/aggregate__tcp_30d__v1.pipe similarity index 96% rename from packages/tinybird/pipes/aggregate__tcp_30d__v1.pipe rename to packages/tinybird/materializations/aggregate__tcp_30d__v1.pipe index 563f961a..f36c7e0e 100644 --- a/packages/tinybird/pipes/aggregate__tcp_30d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_30d__v1.pipe @@ -1,5 +1,3 @@ -TAGS "tcp" - NODE aggregate SQL > diff --git a/packages/tinybird/pipes/aggregate__tcp_7d.pipe b/packages/tinybird/materializations/aggregate__tcp_7d__v0.pipe similarity index 94% rename from packages/tinybird/pipes/aggregate__tcp_7d.pipe rename to packages/tinybird/materializations/aggregate__tcp_7d__v0.pipe index 5270ab1c..687584df 100644 --- a/packages/tinybird/pipes/aggregate__tcp_7d.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_7d__v0.pipe @@ -1,7 +1,3 @@ -VERSION 0 - -TAGS tcp - NODE aggregate SQL > @@ -19,3 +15,5 @@ SQL > TYPE materialized DATASOURCE mv__tcp_7d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__tcp_7d__v1.pipe b/packages/tinybird/materializations/aggregate__tcp_7d__v1.pipe similarity index 96% rename from packages/tinybird/pipes/aggregate__tcp_7d__v1.pipe rename to packages/tinybird/materializations/aggregate__tcp_7d__v1.pipe index f762815c..c7c62d4b 100644 --- a/packages/tinybird/pipes/aggregate__tcp_7d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_7d__v1.pipe @@ -1,5 +1,3 @@ -TAGS "tcp" - NODE aggregate SQL > diff --git a/packages/tinybird/pipes/aggregate__tcp_90d__v1.pipe b/packages/tinybird/materializations/aggregate__tcp_90d__v1.pipe similarity index 96% rename from packages/tinybird/pipes/aggregate__tcp_90d__v1.pipe rename to packages/tinybird/materializations/aggregate__tcp_90d__v1.pipe index 4a0a126e..f4db908c 100644 --- a/packages/tinybird/pipes/aggregate__tcp_90d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_90d__v1.pipe @@ -1,5 +1,3 @@ -TAGS "tcp" - NODE aggregate SQL > @@ -18,3 +16,5 @@ SQL > TYPE materialized DATASOURCE mv__tcp_90d__v1 + + diff --git a/packages/tinybird/pipes/aggregate__tcp_full_14d__v0.pipe b/packages/tinybird/materializations/aggregate__tcp_full_14d__v0.pipe similarity index 57% rename from packages/tinybird/pipes/aggregate__tcp_full_14d__v0.pipe rename to packages/tinybird/materializations/aggregate__tcp_full_14d__v0.pipe index ca9c70f5..7331d328 100644 --- a/packages/tinybird/pipes/aggregate__tcp_full_14d__v0.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_full_14d__v0.pipe @@ -2,14 +2,24 @@ DESCRIPTION > Stores all the data from the http_response table for the last 30 days, mainly used for accessing the data details. -TAGS "tcp, full" - NODE aggregate SQL > SELECT toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, - * + monitorId, + region, + timestamp, + cronTimestamp, + timing, + workspaceId, + latency, + errorMessage, + error, + trigger, + uri, + id, + requestStatus FROM tcp_response__v0 TYPE materialized diff --git a/packages/tinybird/materializations/aggregate__tcp_full_30d__v0.pipe b/packages/tinybird/materializations/aggregate__tcp_full_30d__v0.pipe new file mode 100644 index 00000000..535e4f07 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__tcp_full_30d__v0.pipe @@ -0,0 +1,28 @@ +DESCRIPTION > + Stores all the data from the http_response table for the last 30 days, mainly used for accessing the data details. + + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + monitorId, + region, + timestamp, + cronTimestamp, + timing, + workspaceId, + latency, + errorMessage, + error, + trigger, + uri, + id, + requestStatus + FROM tcp_response__v0 + +TYPE materialized +DATASOURCE mv__tcp_full_30d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__tcp_status_45d.pipe b/packages/tinybird/materializations/aggregate__tcp_status_45d__v0.pipe similarity index 91% rename from packages/tinybird/pipes/aggregate__tcp_status_45d.pipe rename to packages/tinybird/materializations/aggregate__tcp_status_45d__v0.pipe index c3389940..e7d27a7d 100644 --- a/packages/tinybird/pipes/aggregate__tcp_status_45d.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_status_45d__v0.pipe @@ -1,7 +1,3 @@ -VERSION 0 - -TAGS tcp, statuspage - NODE aggregate SQL > @@ -17,3 +13,5 @@ SQL > TYPE materialized DATASOURCE mv__tcp_status_45d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__tcp_status_45d__v1.pipe b/packages/tinybird/materializations/aggregate__tcp_status_45d__v1.pipe similarity index 95% rename from packages/tinybird/pipes/aggregate__tcp_status_45d__v1.pipe rename to packages/tinybird/materializations/aggregate__tcp_status_45d__v1.pipe index 2b2107c8..2a636484 100644 --- a/packages/tinybird/pipes/aggregate__tcp_status_45d__v1.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_status_45d__v1.pipe @@ -1,5 +1,3 @@ -TAGS "tcp, statuspage" - NODE aggregate SQL > diff --git a/packages/tinybird/pipes/aggregate__tcp_status_7d.pipe b/packages/tinybird/materializations/aggregate__tcp_status_7d__v0.pipe similarity index 91% rename from packages/tinybird/pipes/aggregate__tcp_status_7d.pipe rename to packages/tinybird/materializations/aggregate__tcp_status_7d__v0.pipe index 77712626..6f71bf85 100644 --- a/packages/tinybird/pipes/aggregate__tcp_status_7d.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_status_7d__v0.pipe @@ -1,7 +1,3 @@ -VERSION 0 - -TAGS tcp, statuspage - NODE aggregate SQL > @@ -17,3 +13,5 @@ SQL > TYPE materialized DATASOURCE mv__tcp_status_7d__v0 + + diff --git a/packages/tinybird/pipes/aggregate__tcp_uptime_30d__v1.pipe b/packages/tinybird/materializations/aggregate__tcp_uptime_30d__v1.pipe similarity index 100% rename from packages/tinybird/pipes/aggregate__tcp_uptime_30d__v1.pipe rename to packages/tinybird/materializations/aggregate__tcp_uptime_30d__v1.pipe diff --git a/packages/tinybird/pipes/aggregate__tcp_uptime_7d__v1.pipe b/packages/tinybird/materializations/aggregate__tcp_uptime_7d__v1.pipe similarity index 100% rename from packages/tinybird/pipes/aggregate__tcp_uptime_7d__v1.pipe rename to packages/tinybird/materializations/aggregate__tcp_uptime_7d__v1.pipe diff --git a/packages/tinybird/materializations/aggregate__tcp_uptime_90d__v1.pipe b/packages/tinybird/materializations/aggregate__tcp_uptime_90d__v1.pipe new file mode 100644 index 00000000..179056e6 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__tcp_uptime_90d__v1.pipe @@ -0,0 +1,15 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM tcp_response__v0 + +TYPE materialized +DATASOURCE mv__tcp_uptime_90d__v1 + + diff --git a/packages/tinybird/pipes/aggregate__tcp_workspace_30d__v0.pipe b/packages/tinybird/materializations/aggregate__tcp_workspace_30d__v0.pipe similarity index 96% rename from packages/tinybird/pipes/aggregate__tcp_workspace_30d__v0.pipe rename to packages/tinybird/materializations/aggregate__tcp_workspace_30d__v0.pipe index 8430c9e3..258f0b6d 100644 --- a/packages/tinybird/pipes/aggregate__tcp_workspace_30d__v0.pipe +++ b/packages/tinybird/materializations/aggregate__tcp_workspace_30d__v0.pipe @@ -1,5 +1,3 @@ -TAGS "tcp" - NODE aggregate SQL > diff --git a/packages/tinybird/pipes/__ttl_45d_count_utc_get.pipe b/packages/tinybird/pipes/__ttl_45d_count_utc_get.pipe deleted file mode 100644 index d91fd4a2..00000000 --- a/packages/tinybird/pipes/__ttl_45d_count_utc_get.pipe +++ /dev/null @@ -1,19 +0,0 @@ -VERSION 1 -TOKEN "__ttl_45d_count_utc_get__v1_endpoint_read_7956" READ - -NODE __ttl_45d_count_utc_get_0 -SQL > - - % - SELECT time as day, countMerge(count) as count, countMerge(ok) as ok - FROM mv__http_status_45d__v0 - WHERE - monitorId = {{ String(monitorId, '4') }} - GROUP BY day - ORDER BY day DESC - WITH FILL - FROM - toStartOfDay(toStartOfDay(toTimeZone(now(), 'UTC'))) - TO toStartOfDay( - date_sub(DAY, 45, now()) - ) STEP INTERVAL -1 DAY diff --git a/packages/tinybird/pipes/aggregate__external_status_daily__v0.pipe b/packages/tinybird/pipes/aggregate__external_status_daily__v0.pipe deleted file mode 100644 index e716dde4..00000000 --- a/packages/tinybird/pipes/aggregate__external_status_daily__v0.pipe +++ /dev/null @@ -1,28 +0,0 @@ -TAGS "external_status" - -NODE aggregate -SQL > - - SELECT - toDate(toTimeZone(fromUnixTimestamp64Milli(fetched_at), 'UTC')) AS day, - id, - argMaxState( - indicator, - toUInt8( - multiIf( - indicator = 'critical', 3, - indicator = 'major', 2, - indicator = 'minor', 1, - 0 - ) - ) - ) AS worst_indicator, - maxState(toUInt8(status = 'under_maintenance')) AS had_maintenance, - sumState(toUInt32(1)) AS snapshot_count - FROM external_status__v1 - GROUP BY - day, - id - -TYPE materialized -DATASOURCE mv__external_status_daily__v0 diff --git a/packages/tinybird/pipes/aggregate__http_timing_phases_90d__v1.pipe b/packages/tinybird/pipes/aggregate__http_timing_phases_90d__v1.pipe deleted file mode 100644 index f2d58c3c..00000000 --- a/packages/tinybird/pipes/aggregate__http_timing_phases_90d__v1.pipe +++ /dev/null @@ -1,33 +0,0 @@ -NODE aggregate -SQL > - - SELECT - toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, - latency, - region, - trigger, - statusCode, - monitorId, - workspaceId, - requestStatus, - - -- Compute actual durations from timing phases - if(isNull(JSONExtractInt(timing, 'dnsStart')) OR isNull(JSONExtractInt(timing, 'dnsDone')), NULL, - JSONExtractInt(timing, 'dnsDone') - JSONExtractInt(timing, 'dnsStart')) AS dns, - - if(isNull(JSONExtractInt(timing, 'connectStart')) OR isNull(JSONExtractInt(timing, 'connectDone')), NULL, - JSONExtractInt(timing, 'connectDone') - JSONExtractInt(timing, 'connectStart')) AS connect, - - if(isNull(JSONExtractInt(timing, 'tlsHandshakeStart')) OR isNull(JSONExtractInt(timing, 'tlsHandshakeDone')), NULL, - JSONExtractInt(timing, 'tlsHandshakeDone') - JSONExtractInt(timing, 'tlsHandshakeStart')) AS tls, - - if(isNull(JSONExtractInt(timing, 'firstByteStart')) OR isNull(JSONExtractInt(timing, 'firstByteDone')), NULL, - JSONExtractInt(timing, 'firstByteDone') - JSONExtractInt(timing, 'firstByteStart')) AS firstByte, - - if(isNull(JSONExtractInt(timing, 'transferStart')) OR isNull(JSONExtractInt(timing, 'transferDone')), NULL, - JSONExtractInt(timing, 'transferDone') - JSONExtractInt(timing, 'transferStart')) AS transfer - - FROM ping_response__v8 - -TYPE materialized -DATASOURCE mv__http_timing_phases_90d__v1 diff --git a/packages/tinybird/pipes/aggregate__http_uptime_90d__v1.pipe b/packages/tinybird/pipes/aggregate__http_uptime_90d__v1.pipe deleted file mode 100644 index 10e064a6..00000000 --- a/packages/tinybird/pipes/aggregate__http_uptime_90d__v1.pipe +++ /dev/null @@ -1,17 +0,0 @@ -VERSION 1 - -TAGS http - -NODE aggregate -SQL > - - SELECT - toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, - region, - requestStatus, - monitorId, - workspaceId - FROM ping_response__v8 - -TYPE materialized -DATASOURCE mv__http_uptime_90d__v1 diff --git a/packages/tinybird/pipes/aggregate__tcp_full_30d__v0.pipe b/packages/tinybird/pipes/aggregate__tcp_full_30d__v0.pipe deleted file mode 100644 index e1fdc260..00000000 --- a/packages/tinybird/pipes/aggregate__tcp_full_30d__v0.pipe +++ /dev/null @@ -1,16 +0,0 @@ -DESCRIPTION > - Stores all the data from the http_response table for the last 30 days, mainly used for accessing the data details. - - -TAGS "tcp, full" - -NODE aggregate -SQL > - - SELECT - toDateTime(fromUnixTimestamp64Milli(tcp_response__v0.cronTimestamp)) AS time, - * - FROM tcp_response__v0 - -TYPE materialized -DATASOURCE mv__tcp_full_30d__v0 diff --git a/packages/tinybird/pipes/aggregate__tcp_uptime_90d__v1.pipe b/packages/tinybird/pipes/aggregate__tcp_uptime_90d__v1.pipe deleted file mode 100644 index 71676f32..00000000 --- a/packages/tinybird/pipes/aggregate__tcp_uptime_90d__v1.pipe +++ /dev/null @@ -1,13 +0,0 @@ -NODE aggregate -SQL > - - SELECT - toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, - region, - requestStatus, - monitorId, - workspaceId - FROM tcp_response__v0 - -TYPE materialized -DATASOURCE mv__tcp_uptime_90d__v1 diff --git a/packages/tinybird/pipes/get_result_for_on_demand_check_http.pipe b/packages/tinybird/pipes/get_result_for_on_demand_check_http.pipe deleted file mode 100644 index 4053b4cf..00000000 --- a/packages/tinybird/pipes/get_result_for_on_demand_check_http.pipe +++ /dev/null @@ -1,23 +0,0 @@ -VERSION 0 - -NODE endpoint -SQL > - - % - SELECT - latency, - monitorId, - error, - region, - statusCode, - timestamp, - url, - workspaceId, - timing, - cronTimestamp - FROM mv__http_full_30d__v0 - where - cronTimestamp = {{ String(timestamp, '1729871694536') }} and - monitorId = {{ String(monitorId, '2260') }} - and url = {{ String(url , 'https://www.openstatus.dev/api/ping/edge') }} - order by cronTimestamp desc diff --git a/packages/tinybird/pipes/public_status.pipe b/packages/tinybird/pipes/public_status.pipe deleted file mode 100644 index 88f9c7cb..00000000 --- a/packages/tinybird/pipes/public_status.pipe +++ /dev/null @@ -1,22 +0,0 @@ -VERSION 0 - -DESCRIPTION > - last 5 cron timestamps within last 3 hours - -NODE group_by_cronTimestamp -SQL > - - % - SELECT - cronTimestamp, - count() AS count, - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok - FROM ping_response__v8 - WHERE - monitorId = {{ String(monitorId, '1') }} - {% if defined(url) %} AND url = {{ String(url) }} {% end %} - AND cronTimestamp - >= toUnixTimestamp64Milli(toDateTime64(now() - INTERVAL 3 HOUR, 3)) - GROUP BY cronTimestamp, monitorId - ORDER BY cronTimestamp DESC - LIMIT {{ Int16(limit, 5)}} diff --git a/packages/tinybird/pipes/response_details.pipe b/packages/tinybird/pipes/response_details.pipe deleted file mode 100644 index 80d062cf..00000000 --- a/packages/tinybird/pipes/response_details.pipe +++ /dev/null @@ -1,13 +0,0 @@ -VERSION 0 - -NODE response_graph_0 -SQL > - - % - SELECT * - FROM ping_response__v8 - WHERE - monitorId = {{ String(monitorId, '1') }} - {% if defined(url) %} AND url = {{ String(url) }} {% end %} - AND cronTimestamp = {{ Int64(cronTimestamp, 1706467215188) }} - AND region = {{ String(region, 'ams') }} diff --git a/packages/tinybird/pipes/response_graph.pipe b/packages/tinybird/pipes/response_graph.pipe deleted file mode 100644 index 34f9c0ff..00000000 --- a/packages/tinybird/pipes/response_graph.pipe +++ /dev/null @@ -1,26 +0,0 @@ -VERSION 0 - -NODE response_graph_0 -SQL > - - % - SELECT - region, - toStartOfInterval( - toDateTime(cronTimestamp / 1000), - INTERVAL {{ Int64(interval, 30) }} MINUTE - ) as h, - toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, - round(avg(latency)) as avgLatency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.9)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency - FROM ping_response__v8 - WHERE - monitorId = {{ String(monitorId, '1') }} - {% if defined(url) %} AND url = {{ String(url) }} {% end %} - {% if defined(fromDate) %} AND timestamp >= {{ Int64(fromDate) }} {% end %} - {% if defined(toDate) %} AND timestamp <= {{ Int64(toDate) }} {% end %} - GROUP BY h, region - ORDER BY h DESC diff --git a/packages/tinybird/pipes/response_list.pipe b/packages/tinybird/pipes/response_list.pipe deleted file mode 100644 index e194e4ec..00000000 --- a/packages/tinybird/pipes/response_list.pipe +++ /dev/null @@ -1,24 +0,0 @@ -VERSION 2 - -NODE response_list_0 -SQL > - - % - SELECT latency, monitorId, region, statusCode, timestamp, url, workspaceId, cronTimestamp, message - FROM ping_response__v8 - WHERE monitorId = {{ String(monitorId, 'openstatusPing') }} - {% if defined(url) %} AND url = {{ String(url) }} {% end %} - {% if defined(region) %} - AND region = {{ String(region) }} - {% end %} - {% if defined(cronTimestamp) %} - AND cronTimestamp = {{ Int64(cronTimestamp) }} - {% end %} - {% if defined(fromDate) %} - AND cronTimestamp >= {{ Int64(fromDate) }} - {% end %} - {% if defined(toDate) %} - AND cronTimestamp <= {{ Int64(toDate) }} - {% end %} - ORDER BY timestamp DESC - LIMIT {{Int32(limit, 100)}} diff --git a/packages/tinybird/pipes/single_checks_get.pipe b/packages/tinybird/pipes/single_checks_get.pipe deleted file mode 100644 index 913e2688..00000000 --- a/packages/tinybird/pipes/single_checks_get.pipe +++ /dev/null @@ -1,16 +0,0 @@ -VERSION 1 - -NODE endpoint -SQL > - - % - SELECT * - from check_response_http - WHERE - workspaceId = {{ Int16(workspaceId, 1) }} - {% if defined(requestId) %} AND requestId = {{ Int16(requestId) }} {% end %} - ORDER BY timestamp DESC - LIMIT {{ Int32(pageSize, 10) }} - OFFSET {{ Int32(page, 0) * Int32(pageSize, 10) }} - - -- 2.51.2 From 444a06b851fc3d88bbfd5291c4d036045e6841ca Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:43:18 +0200 Subject: [PATCH 140/266] chore: web checker ratelimit tier (#2579) * chore: web checker ratelimit tier * fix: review --- .../(landing)/play/cdn-checker/api/route.ts | 34 +++-- .../app/(landing)/play/checker/api/route.ts | 36 +++-- .../(landing)/play/mcp-health/api/route.ts | 37 ++--- apps/web/src/lib/cdn-checker/ratelimit.ts | 47 ------- apps/web/src/lib/ratelimit-config.test.ts | 133 ++++++++++++++++++ apps/web/src/lib/ratelimit-config.ts | 112 +++++++++++++++ apps/web/src/lib/ratelimit.ts | 94 ++++++++++++- 7 files changed, 388 insertions(+), 105 deletions(-) delete mode 100644 apps/web/src/lib/cdn-checker/ratelimit.ts create mode 100644 apps/web/src/lib/ratelimit-config.test.ts create mode 100644 apps/web/src/lib/ratelimit-config.ts diff --git a/apps/web/src/app/(landing)/play/cdn-checker/api/route.ts b/apps/web/src/app/(landing)/play/cdn-checker/api/route.ts index 8381fdd5..d529001c 100644 --- a/apps/web/src/app/(landing)/play/cdn-checker/api/route.ts +++ b/apps/web/src/app/(landing)/play/cdn-checker/api/route.ts @@ -6,14 +6,16 @@ import { z } from "zod"; import { devProbeCdnRegion } from "@/lib/cdn-checker/dev-probe"; import { validateCdnUrl } from "@/lib/cdn-checker/guards"; import { probeCdnRegion } from "@/lib/cdn-checker/probe"; -import { - MAX_REQUESTS_PER_WINDOW, - RATE_LIMIT_WINDOW, - rateLimitCdnRequest, - rateLimitHeaders, -} from "@/lib/cdn-checker/ratelimit"; import type { CdnRegionResponse } from "@/lib/cdn-checker/schema"; import { computeCdnSummary } from "@/lib/cdn-checker/summary"; +import { + PLAY_RATE_LIMIT_TIERS, + getClientIP, + rateLimitMessage, + ratelimitTiers, + retryAfterHeader, + tieredRateLimitHeaders, +} from "@/lib/ratelimit"; import { iteratorToStream, yieldMany } from "@/lib/stream"; export const runtime = "edge"; @@ -95,24 +97,26 @@ export async function POST(request: Request) { return errorResponse("INVALID_REQUEST", guard.error, guard.status); } - const rl = await rateLimitCdnRequest("play-cdn", request.headers); - if (rl.status === "no-client-ip") { + const clientIP = getClientIP(request.headers); + if (!clientIP) { return errorResponse( "NO_CLIENT_IP", "Unable to determine client IP address", 400, ); } - if (rl.status === "limited") { + + const rl = await ratelimitTiers( + `play-cdn:${clientIP}`, + PLAY_RATE_LIMIT_TIERS, + ); + if (!rl.success) { return errorResponse( "RATE_LIMIT_EXCEEDED", - `You have exceeded the rate limit of ${MAX_REQUESTS_PER_WINDOW} requests per ${RATE_LIMIT_WINDOW} seconds`, + rateLimitMessage(rl.tier), 429, { limit: rl.limit, remaining: rl.remaining, reset: rl.reset }, - { - ...rateLimitHeaders(rl), - "Retry-After": Math.ceil((rl.reset - Date.now()) / 1000).toString(), - }, + { ...tieredRateLimitHeaders(rl), ...retryAfterHeader(rl) }, ); } @@ -134,7 +138,7 @@ export async function POST(request: Request) { headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store", - ...rateLimitHeaders(rl), + ...tieredRateLimitHeaders(rl), }, }); } diff --git a/apps/web/src/app/(landing)/play/checker/api/route.ts b/apps/web/src/app/(landing)/play/checker/api/route.ts index 14230857..13ba62af 100644 --- a/apps/web/src/app/(landing)/play/checker/api/route.ts +++ b/apps/web/src/app/(landing)/play/checker/api/route.ts @@ -11,15 +11,19 @@ import { storeBaseCheckerData, storeCheckerData, } from "../../../../../lib/checker/utils"; -import { getClientIP, ratelimit } from "../../../../../lib/ratelimit"; +import { + PLAY_RATE_LIMIT_TIERS, + getClientIP, + rateLimitMessage, + ratelimitTiers, + retryAfterHeader, + tieredRateLimitHeaders, +} from "../../../../../lib/ratelimit"; import { iteratorToStream, yieldMany } from "../../../../../lib/stream"; import { wait } from "../../../../../lib/utils"; export const runtime = "edge"; -const RATE_LIMIT_WINDOW = 60; // 60 seconds -const MAX_REQUESTS_PER_WINDOW = 3; - // Request schema validation const playCheckerRequestSchema = z.object({ url: z.url("Invalid URL format"), @@ -218,15 +222,15 @@ export async function POST(request: Request) { ); } - const rateLimitResult = await ratelimit(`play-checker:${clientIP}`, { - window: RATE_LIMIT_WINDOW, - limit: MAX_REQUESTS_PER_WINDOW, - }); + const rateLimitResult = await ratelimitTiers( + `play-checker:${clientIP}`, + PLAY_RATE_LIMIT_TIERS, + ); if (!rateLimitResult.success) { return createErrorResponse( "RATE_LIMIT_EXCEEDED", - `You have exceeded the rate limit of ${MAX_REQUESTS_PER_WINDOW} requests per ${RATE_LIMIT_WINDOW} seconds`, + rateLimitMessage(rateLimitResult.tier), 429, { limit: rateLimitResult.limit, @@ -234,12 +238,8 @@ export async function POST(request: Request) { reset: rateLimitResult.reset, }, { - "X-RateLimit-Limit": rateLimitResult.limit.toString(), - "X-RateLimit-Remaining": rateLimitResult.remaining.toString(), - "X-RateLimit-Reset": rateLimitResult.reset.toString(), - "Retry-After": Math.ceil( - (rateLimitResult.reset - Date.now()) / 1000, - ).toString(), + ...tieredRateLimitHeaders(rateLimitResult), + ...retryAfterHeader(rateLimitResult), }, ); } @@ -261,10 +261,6 @@ export async function POST(request: Request) { }); const stream = iteratorToStream(iterator); return new Response(stream, { - headers: { - "X-RateLimit-Limit": rateLimitResult.limit.toString(), - "X-RateLimit-Remaining": rateLimitResult.remaining.toString(), - "X-RateLimit-Reset": rateLimitResult.reset.toString(), - }, + headers: tieredRateLimitHeaders(rateLimitResult), }); } diff --git a/apps/web/src/app/(landing)/play/mcp-health/api/route.ts b/apps/web/src/app/(landing)/play/mcp-health/api/route.ts index 71ced0d5..1332df09 100644 --- a/apps/web/src/app/(landing)/play/mcp-health/api/route.ts +++ b/apps/web/src/app/(landing)/play/mcp-health/api/route.ts @@ -10,15 +10,19 @@ import { storeHealthReport, toPersistedReport, } from "../../../../../lib/mcp/health-check"; -import { getClientIP, ratelimit } from "../../../../../lib/ratelimit"; +import { + PLAY_RATE_LIMIT_TIERS, + getClientIP, + rateLimitMessage, + ratelimitTiers, + retryAfterHeader, + tieredRateLimitHeaders, +} from "../../../../../lib/ratelimit"; export const runtime = "edge"; // Worst-case 16s of step time + metadata fetch + analytics; give some slack. export const maxDuration = 30; -const RATE_LIMIT_WINDOW = 60; -const MAX_REQUESTS_PER_WINDOW = 3; - const requestSchema = z.object({ url: z.url("Invalid URL format"), headers: z @@ -124,22 +128,17 @@ export async function POST(request: Request) { ); } - const rl = await ratelimit(`play-mcp-health:${clientIP}`, { - window: RATE_LIMIT_WINDOW, - limit: MAX_REQUESTS_PER_WINDOW, - }); + const rl = await ratelimitTiers( + `play-mcp-health:${clientIP}`, + PLAY_RATE_LIMIT_TIERS, + ); if (!rl.success) { return errorResponse( "RATE_LIMIT_EXCEEDED", - `You have exceeded the rate limit of ${MAX_REQUESTS_PER_WINDOW} requests per ${RATE_LIMIT_WINDOW} seconds`, + rateLimitMessage(rl.tier), 429, { limit: rl.limit, remaining: rl.remaining, reset: rl.reset }, - { - "X-RateLimit-Limit": rl.limit.toString(), - "X-RateLimit-Remaining": rl.remaining.toString(), - "X-RateLimit-Reset": rl.reset.toString(), - "Retry-After": Math.ceil((rl.reset - Date.now()) / 1000).toString(), - }, + { ...tieredRateLimitHeaders(rl), ...retryAfterHeader(rl) }, ); } @@ -183,12 +182,6 @@ export async function POST(request: Request) { return Response.json( { id, report: persisted }, - { - headers: { - "X-RateLimit-Limit": rl.limit.toString(), - "X-RateLimit-Remaining": rl.remaining.toString(), - "X-RateLimit-Reset": rl.reset.toString(), - }, - }, + { headers: tieredRateLimitHeaders(rl) }, ); } diff --git a/apps/web/src/lib/cdn-checker/ratelimit.ts b/apps/web/src/lib/cdn-checker/ratelimit.ts deleted file mode 100644 index 71c70959..00000000 --- a/apps/web/src/lib/cdn-checker/ratelimit.ts +++ /dev/null @@ -1,47 +0,0 @@ -export const RATE_LIMIT_WINDOW = 60; -export const MAX_REQUESTS_PER_WINDOW = 3; - -type CdnRateLimit = - | { status: "skipped" } - | { status: "no-client-ip" } - | { - status: "ok" | "limited"; - limit: number; - remaining: number; - reset: number; - }; - -// dev runs without Upstash credentials and the redis client throws at module -// evaluation, so the limiter is only imported (and enforced) in production -export async function rateLimitCdnRequest( - prefix: string, - headers: Headers, -): Promise { - if (process.env.NODE_ENV !== "production") return { status: "skipped" }; - - const { getClientIP, ratelimit } = await import("@/lib/ratelimit"); - - const clientIP = getClientIP(headers); - if (!clientIP) return { status: "no-client-ip" }; - - const rl = await ratelimit(`${prefix}:${clientIP}`, { - window: RATE_LIMIT_WINDOW, - limit: MAX_REQUESTS_PER_WINDOW, - }); - - return { - status: rl.success ? "ok" : "limited", - limit: rl.limit, - remaining: rl.remaining, - reset: rl.reset, - }; -} - -export function rateLimitHeaders(rl: CdnRateLimit): Record { - if (rl.status !== "ok" && rl.status !== "limited") return {}; - return { - "X-RateLimit-Limit": rl.limit.toString(), - "X-RateLimit-Remaining": rl.remaining.toString(), - "X-RateLimit-Reset": rl.reset.toString(), - }; -} diff --git a/apps/web/src/lib/ratelimit-config.test.ts b/apps/web/src/lib/ratelimit-config.test.ts new file mode 100644 index 00000000..d5ce55db --- /dev/null +++ b/apps/web/src/lib/ratelimit-config.test.ts @@ -0,0 +1,133 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + PLAY_RATE_LIMIT_TIERS, + parseTieredResult, + rateLimitMessage, + retryAfterHeader, + tieredRateLimitHeaders, + tightestTierIndex, +} from "./ratelimit-config"; + +const [BURST, SUSTAINED] = PLAY_RATE_LIMIT_TIERS; +const NOW = 1_760_000_000_000; + +/** Reply shape of RATELIMIT_TIERS_SCRIPT for the two play tiers. */ +function reply( + blocked: number, + counts: [number, number], + ttls: [number, number], +) { + return [blocked, ...counts, ...ttls]; +} + +describe("parseTieredResult", () => { + test("admits a first request and reports the burst tier", () => { + const result = parseTieredResult( + PLAY_RATE_LIMIT_TIERS, + reply(0, [1, 1], [60, 3600]), + NOW, + ); + expect(result.success).toBe(true); + expect(result.tier).toEqual(BURST); + expect(result.remaining).toBe(2); + expect(result.reset).toBe(NOW + 60_000); + }); + + test("reports the sustained tier once it has the least headroom", () => { + const result = parseTieredResult( + PLAY_RATE_LIMIT_TIERS, + reply(0, [1, 9], [58, 1200]), + NOW, + ); + expect(result.success).toBe(true); + expect(result.tier).toEqual(SUSTAINED); + expect(result.remaining).toBe(1); + expect(result.reset).toBe(NOW + 1_200_000); + }); + + test("surfaces the burst tier when it is what blocked", () => { + const result = parseTieredResult( + PLAY_RATE_LIMIT_TIERS, + reply(1, [3, 3], [42, 3400]), + NOW, + ); + expect(result.success).toBe(false); + expect(result.tier).toEqual(BURST); + expect(result.limit).toBe(3); + expect(result.remaining).toBe(0); + expect(result.reset).toBe(NOW + 42_000); + }); + + // the case that motivated the tiers: paced automation never trips the burst + // tier, so only the sustained tier can stop it + test("surfaces the sustained tier when paced automation is blocked", () => { + const result = parseTieredResult( + PLAY_RATE_LIMIT_TIERS, + reply(2, [1, 10], [30, 2400]), + NOW, + ); + expect(result.success).toBe(false); + expect(result.tier).toEqual(SUSTAINED); + expect(result.limit).toBe(10); + expect(result.reset).toBe(NOW + 2_400_000); + }); + + test("falls back to a full window when redis reports no ttl", () => { + const blocked = parseTieredResult( + PLAY_RATE_LIMIT_TIERS, + reply(1, [3, 3], [-1, -2]), + NOW, + ); + expect(blocked.reset).toBe(NOW + BURST.window * 1000); + }); +}); + +describe("tightestTierIndex", () => { + test("reports the burst tier while it is the binding constraint", () => { + expect(tightestTierIndex(PLAY_RATE_LIMIT_TIERS, [2, 4])).toBe(0); + }); + + test("reports the sustained tier once it has the least headroom", () => { + expect(tightestTierIndex(PLAY_RATE_LIMIT_TIERS, [1, 10])).toBe(1); + }); +}); + +describe("rateLimitMessage", () => { + test("names the window that tripped", () => { + expect(rateLimitMessage(BURST)).toBe( + "You have exceeded the rate limit of 3 requests per minute", + ); + expect(rateLimitMessage(SUSTAINED)).toBe( + "You have exceeded the rate limit of 10 requests per hour", + ); + }); +}); + +describe("tieredRateLimitHeaders", () => { + test("emits the reset as unix seconds, not the ms epoch", () => { + const headers = tieredRateLimitHeaders({ + limit: 3, + remaining: 0, + reset: NOW, + }); + expect(headers["X-RateLimit-Reset"]).toBe("1760000000"); + }); +}); + +describe("retryAfterHeader", () => { + test("is at least one second even when the reset is in the past", () => { + expect(retryAfterHeader({ reset: Date.now() - 5_000 })["Retry-After"]).toBe( + "1", + ); + }); + + test("rounds up to the remaining seconds", () => { + const header = retryAfterHeader({ reset: Date.now() + 42_400 })[ + "Retry-After" + ]; + expect(Number(header)).toBeGreaterThan(41); + expect(Number(header)).toBeLessThanOrEqual(43); + }); +}); diff --git a/apps/web/src/lib/ratelimit-config.ts b/apps/web/src/lib/ratelimit-config.ts new file mode 100644 index 00000000..31d066ec --- /dev/null +++ b/apps/web/src/lib/ratelimit-config.ts @@ -0,0 +1,112 @@ +// redis-free half of the limiter: importable from modules that must not +// evaluate the upstash client (it throws without credentials in dev) + +export interface RateLimitTier { + name: string; // part of the redis key, must be stable + window: number; // in seconds + limit: number; // max requests per window +} + +export interface TieredRateLimitResult { + success: boolean; + limit: number; + remaining: number; + reset: number; // timestamp when the window resets + tier: RateLimitTier; // the tier that blocked, or the one closest to its limit +} + +/** + * Shared tiers for the public /play tools. The burst tier stops hammering, the + * sustained tier stops automation that paces itself just under the burst limit. + */ +export const PLAY_RATE_LIMIT_TIERS: RateLimitTier[] = [ + { name: "burst", window: 60, limit: 3 }, + { name: "sustained", window: 3600, limit: 10 }, +]; + +/** Index of the tier with the least headroom left, so headers report the real cap. */ +export function tightestTierIndex( + tiers: RateLimitTier[], + counts: number[], +): number { + let tightest = 0; + for (let i = 1; i < tiers.length; i++) { + if (tiers[i].limit - counts[i] < tiers[tightest].limit - counts[tightest]) { + tightest = i; + } + } + return tightest; +} + +/** + * Decode the flat reply of RATELIMIT_TIERS_SCRIPT: + * [blocked (1-based, 0 = admitted), ...counts, ...ttls] + */ +export function parseTieredResult( + tiers: RateLimitTier[], + reply: number[], + now: number, +): TieredRateLimitResult { + const counts = reply.slice(1, 1 + tiers.length); + const ttls = reply.slice(1 + tiers.length, 1 + tiers.length * 2); + + // TTL replies -1 (no expiry) and -2 (no key); fall back to a full window + const resetOf = (i: number) => + now + (ttls[i] > 0 ? ttls[i] * 1000 : tiers[i].window * 1000); + + const blocked = reply[0]; + if (blocked > 0) { + const i = blocked - 1; + return { + success: false, + limit: tiers[i].limit, + remaining: 0, + reset: resetOf(i), + tier: tiers[i], + }; + } + + const i = tightestTierIndex(tiers, counts); + return { + success: true, + limit: tiers[i].limit, + remaining: Math.max(0, tiers[i].limit - counts[i]), + reset: resetOf(i), + tier: tiers[i], + }; +} + +function formatWindow(seconds: number): string { + if (seconds === 60) return "minute"; + if (seconds === 3600) return "hour"; + if (seconds === 86400) return "day"; + return `${seconds} seconds`; +} + +export function rateLimitMessage(tier: RateLimitTier): string { + return `You have exceeded the rate limit of ${tier.limit} requests per ${formatWindow(tier.window)}`; +} + +export function tieredRateLimitHeaders(result: { + limit: number; + remaining: number; + reset: number; // ms epoch +}): Record { + return { + "X-RateLimit-Limit": result.limit.toString(), + "X-RateLimit-Remaining": result.remaining.toString(), + // the header convention is unix seconds, unlike the ms epoch we pass around + "X-RateLimit-Reset": Math.ceil(result.reset / 1000).toString(), + }; +} + +export function retryAfterHeader(result: { + reset: number; +}): Record { + return { + "Retry-After": Math.max( + 1, + Math.ceil((result.reset - Date.now()) / 1000), + ).toString(), + }; +} diff --git a/apps/web/src/lib/ratelimit.ts b/apps/web/src/lib/ratelimit.ts index 35e9ac8d..bae5c333 100644 --- a/apps/web/src/lib/ratelimit.ts +++ b/apps/web/src/lib/ratelimit.ts @@ -1,4 +1,17 @@ -import { redis } from "@openstatus/upstash"; +import { + type RateLimitTier, + type TieredRateLimitResult, + parseTieredResult, +} from "@/lib/ratelimit-config"; + +export * from "@/lib/ratelimit-config"; + +// @openstatus/upstash builds its client at module scope and throws without +// credentials, so it is loaded lazily to keep this module importable in dev +async function getRedis() { + const { redis } = await import("@openstatus/upstash"); + return redis; +} interface RateLimitConfig { window: number; // in seconds @@ -24,6 +37,7 @@ export async function ratelimit( ): Promise { const key = `ratelimit:${identifier}`; const now = Date.now(); + const redis = await getRedis(); // Increment the counter const count = await redis.incr(key); @@ -48,6 +62,84 @@ export async function ratelimit( }; } +/** + * Admission must be atomic: checking counters and then incrementing in a second + * round trip lets concurrent requests all observe room and all pass. Consuming + * only when every tier has room is what keeps a rejected request from burning + * quota in the tiers that were still under their limit. + * + * KEYS: one per tier. ARGV: limit, window per tier. + * Reply: [blocked (1-based, 0 = admitted), ...counts, ...ttls] + */ +const RATELIMIT_TIERS_SCRIPT = ` +local n = #KEYS +local blocked = 0 +for i = 1, n do + local limit = tonumber(ARGV[(i - 1) * 2 + 1]) + local count = tonumber(redis.call('GET', KEYS[i]) or '0') + if count >= limit then + blocked = i + break + end +end + +local reply = {blocked} +for i = 1, n do + local count + if blocked == 0 then + count = redis.call('INCR', KEYS[i]) + -- anchor the window to its first request instead of letting it slide + if count == 1 then + redis.call('EXPIRE', KEYS[i], tonumber(ARGV[(i - 1) * 2 + 2])) + end + else + count = tonumber(redis.call('GET', KEYS[i]) or '0') + end + reply[#reply + 1] = count +end +for i = 1, n do + reply[#reply + 1] = redis.call('TTL', KEYS[i]) +end +return reply +`; + +/** + * Fixed window rate limiter enforcing several windows at once. A request passes + * only if every tier has room. Not enforced outside production, so the /play + * tools stay usable locally without Upstash credentials. + * @param identifier - Unique identifier for the rate limit (e.g., IP address) + * @param tiers - Windows to enforce, each with its own redis key + */ +export async function ratelimitTiers( + identifier: string, + tiers: RateLimitTier[], +): Promise { + const now = Date.now(); + + if (process.env.NODE_ENV !== "production") { + const tier = tiers[0]; + return { + success: true, + limit: tier.limit, + remaining: tier.limit, + reset: now + tier.window * 1000, + tier, + }; + } + + const keys = tiers.map((tier) => `ratelimit:${identifier}:${tier.name}`); + const args = tiers.flatMap((tier) => [tier.limit, tier.window]); + + const redis = await getRedis(); + const reply = await redis.eval( + RATELIMIT_TIERS_SCRIPT, + keys, + args, + ); + + return parseTieredResult(tiers, reply, now); +} + /** * Extract IP address from request headers * @param headers - Request headers -- 2.51.2 From 223c1fc6f0773e04cdfad1a6386d03191708e572 Mon Sep 17 00:00:00 2001 From: Bryan FRIMIN Date: Sun, 16 Aug 2026 14:22:09 +0200 Subject: [PATCH 141/266] feat: show uptime percentages with 3 decimal places (#2559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: show uptime percentages with 3 decimal places Support values like 99.xxx% so a single failed check can surface as 99.999 instead of collapsing to 99.99. * ci: apply automated fixes * fix: use floorPct for summary uptime, drop dead helpers, update markdown test * fix: correct floorPct truncating exact thousandths from float error Math.floor(ratio * 100_000) truncated a full thousandth when the caller's a/b division landed a few ULPs low — (29/100) * 100_000 is 28999.999999999996, so floorPct(0.29) returned 28.999. Affected 750 ratios for denominators up to 2000, including 29/100, 57/100 and 23/40. Add a 1e-8 epsilon before flooring: ~450x the ULP at this scale, and far below 0.5, so it cannot lift a genuinely-below value onto the next thousandth. The 'never round up to 100.000' invariant still holds. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Thibault Le Ouay Ducasse --- .../status-pages/[id]/history/client.tsx | 2 +- .../status-pages/[id]/history/examples.ts | 2 +- .../status-page-history/columns.tsx | 2 +- .../status-page-history/table-cell-uptime.tsx | 4 +-- .../metric/global-uptime/section.tsx | 2 +- apps/dashboard/src/lib/formatter.ts | 6 ++-- .../src/components/status-page/utils.ts | 30 ------------------- .../src/content/markdown/generators.test.ts | 2 +- .../src/content/markdown/helpers.ts | 2 +- apps/status-page/src/lib/formatter.ts | 6 ++-- .../__tests__/get-history.test.ts | 17 ++++++----- .../services/src/frozen-uptime/get-history.ts | 4 +-- .../status-timeline/__tests__/uptime.test.ts | 18 +++++++++-- .../services/src/status-timeline/uptime.ts | 7 +++-- packages/tracker/src/tracker.test.ts | 4 +-- packages/tracker/src/tracker.ts | 2 +- 16 files changed, 48 insertions(+), 62 deletions(-) diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx index e5f488d9..1904d78b 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx @@ -130,7 +130,7 @@ export function Client() { - {summary.uptime === null ? "—" : `${summary.uptime.toFixed(2)}%`} + {summary.uptime === null ? "—" : `${summary.uptime.toFixed(3)}%`} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts index 4ea78aea..9a6212af 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts @@ -41,7 +41,7 @@ const COMPONENTS: { id: number; name: string; type: "monitor" | "static" }[] = [ { id: 3, name: "Documentation", type: "static" }, ]; -const round = (value: number) => Math.round(value * 100) / 100; +const round = (value: number) => Math.round(value * 1_000) / 1_000; function average(values: (number | null)[]): number | null { const present = values.filter((v): v is number => v !== null); diff --git a/apps/dashboard/src/components/data-table/status-page-history/columns.tsx b/apps/dashboard/src/components/data-table/status-page-history/columns.tsx index 9243f03b..582e2f27 100644 --- a/apps/dashboard/src/components/data-table/status-page-history/columns.tsx +++ b/apps/dashboard/src/components/data-table/status-page-history/columns.tsx @@ -71,7 +71,7 @@ function rollingColumn(window: HistoryWindow): ColumnDef { header: "Total", cell: ({ row }) => { const value = row.original.rolling[windowKey(window)]; - return value === null ? "–" : `${value.toFixed(2)}%`; + return value === null ? "–" : `${value.toFixed(3)}%`; }, enableSorting: false, meta: { diff --git a/apps/dashboard/src/components/data-table/status-page-history/table-cell-uptime.tsx b/apps/dashboard/src/components/data-table/status-page-history/table-cell-uptime.tsx index 80501383..5e2375fa 100644 --- a/apps/dashboard/src/components/data-table/status-page-history/table-cell-uptime.tsx +++ b/apps/dashboard/src/components/data-table/status-page-history/table-cell-uptime.tsx @@ -112,7 +112,7 @@ export function TableCellUptime({ statusStyles[cell.status], )} > - {cell.percentage === null ? "–" : `${cell.percentage.toFixed(2)}%`} + {cell.percentage === null ? "–" : `${cell.percentage.toFixed(3)}%`} Uptime - {cell.percentage.toFixed(2)} + {cell.percentage.toFixed(3)} % diff --git a/apps/dashboard/src/components/metric/global-uptime/section.tsx b/apps/dashboard/src/components/metric/global-uptime/section.tsx index 9c533941..9290ed7d 100644 --- a/apps/dashboard/src/components/metric/global-uptime/section.tsx +++ b/apps/dashboard/src/components/metric/global-uptime/section.tsx @@ -76,7 +76,7 @@ export function GlobalUptimeSection({ }); } if (k === "uptime") { - return formatPercentage(value ?? 0); + return formatPercentage(value ?? 0, 3); } if (k.startsWith("p")) { return formatMilliseconds(value ?? 0); diff --git a/apps/dashboard/src/lib/formatter.ts b/apps/dashboard/src/lib/formatter.ts index f40d4255..a2b4e076 100644 --- a/apps/dashboard/src/lib/formatter.ts +++ b/apps/dashboard/src/lib/formatter.ts @@ -15,12 +15,12 @@ export function formatMilliseconds(ms: number) { }).format(ms)}`; } -export function formatPercentage(value: number) { +export function formatPercentage(value: number, fractionDigits = 2) { if (Number.isNaN(value)) return "100%"; return `${Intl.NumberFormat("en-US", { style: "percent", - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, }).format(value)}`; } diff --git a/apps/status-page/src/components/status-page/utils.ts b/apps/status-page/src/components/status-page/utils.ts index 00e7640a..1cd9ff74 100644 --- a/apps/status-page/src/components/status-page/utils.ts +++ b/apps/status-page/src/components/status-page/utils.ts @@ -115,33 +115,3 @@ export function getHighestStatus(items: VariantType[]) { if (items.some((item) => item === "info")) return "info"; return "success"; } - -export function getTotalUptime(item: ChartData[]) { - const { ok, total } = item.reduce( - (acc, item) => ({ - ok: acc.ok + item.success + item.degraded + item.info, - total: acc.total + item.success + item.degraded + item.info + item.error, - }), - { - ok: 0, - total: 0, - }, - ); - - if (total === 0) return 100; - return Math.round((ok / total) * 10000) / 100; -} - -export function getManualUptime( - items: { from: Date | null; to: Date | null }[], - days: number, -) { - const duration = items.reduce((acc, item) => { - if (!item.from) return acc; - return acc + ((item.to || new Date()).getTime() - item.from.getTime()); - }, 0); - - const total = days * 24 * 60 * 60 * 1000; - - return Math.round(((total - duration) / total) * 10000) / 100; -} diff --git a/apps/status-page/src/content/markdown/generators.test.ts b/apps/status-page/src/content/markdown/generators.test.ts index 0bfd7274..06399bfc 100644 --- a/apps/status-page/src/content/markdown/generators.test.ts +++ b/apps/status-page/src/content/markdown/generators.test.ts @@ -595,7 +595,7 @@ describe("generateMonitor", () => { test("KPI table", () => { expect(md).toContain("| Global latency (p75) | 200ms – 300ms |"); expect(md).toContain("| Region latency | 2 regions · fastest: iad |"); - expect(md).toContain("| Uptime (last 7 days) | 97.50% · 200 checks |"); + expect(md).toContain("| Uptime (last 7 days) | 97.500% · 200 checks |"); }); test("percentile table", () => { diff --git a/apps/status-page/src/content/markdown/helpers.ts b/apps/status-page/src/content/markdown/helpers.ts index ce00d1dd..39759851 100644 --- a/apps/status-page/src/content/markdown/helpers.ts +++ b/apps/status-page/src/content/markdown/helpers.ts @@ -143,7 +143,7 @@ export function formatMs(value: number | null | undefined): string { } export function formatPercent(ratio: number): string { - return `${(ratio * 100).toFixed(2)}%`; + return `${(ratio * 100).toFixed(3)}%`; } /** Build the public-facing canonical (HTML) URL for a path under a page. */ diff --git a/apps/status-page/src/lib/formatter.ts b/apps/status-page/src/lib/formatter.ts index 7a8f7c87..ccad1bfd 100644 --- a/apps/status-page/src/lib/formatter.ts +++ b/apps/status-page/src/lib/formatter.ts @@ -32,12 +32,12 @@ export function formatMillisecondsRange(min: number, max: number) { return `${formatMilliseconds(min)} - ${formatMilliseconds(max)}`; } -export function formatPercentage(value: number) { +export function formatPercentage(value: number, fractionDigits = 3) { if (Number.isNaN(value)) return "100%"; return `${Intl.NumberFormat("en-US", { style: "percent", - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, }).format(value)}`; } diff --git a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts index 02178906..4a3f938a 100644 --- a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts +++ b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts @@ -287,7 +287,7 @@ describe("getUptimeHistory", () => { const totalMs = monthEnd(key(1)).getTime() - monthStart(key(1)).getTime(); const expected = - Math.floor(((totalMs - twelveHours) / totalMs) * 10_000) / 100; + Math.floor(((totalMs - twelveHours) / totalMs) * 100_000) / 1_000; expect(res.rows[0].months[key(1)]).toBe(expected); }); }); @@ -334,9 +334,9 @@ describe("getUptimeHistory", () => { const totalMs = monthEnd(key(1)).getTime() - monthStart(key(1)).getTime(); const legacyExpected = - Math.floor(((totalMs - twelveHours) / totalMs) * 10_000) / 100; + Math.floor(((totalMs - twelveHours) / totalMs) * 100_000) / 1_000; const partialExpected = - Math.floor(((totalMs - twelveHours / 2) / totalMs) * 10_000) / 100; + Math.floor(((totalMs - twelveHours / 2) / totalMs) * 100_000) / 1_000; const byName = new Map(res.rows.map((r) => [r.component.name, r])); // empty projection falls through to legacy full-duration downtime @@ -430,7 +430,7 @@ describe("getUptimeHistory", () => { }); }); - test("a 0/0/0 month is null, not 0% — and floor rounding never shows 100.00 with a failed check", async () => { + test("a 0/0/0 month is null, not 0% — and floor rounding never shows 100.000 with a failed check", async () => { await withTestTransaction(async (tx) => { const ctx = { ...userCtx, db: tx }; const testMonitor = await insertMonitor(tx); @@ -462,7 +462,7 @@ describe("getUptimeHistory", () => { const row = res.rows[0]; expect(row.months[key(2)]).toBe(null); - expect(row.months[key(1)]).toBe(99.99); + expect(row.months[key(1)]).toBe(99.999); // live month with zero pipe rows is also no-data expect(row.months[key(0)]).toBe(null); }); @@ -566,7 +566,7 @@ describe("getUptimeHistory", () => { const row = res.rows[0]; const totalMs = monthDays(`${key(1)}-01`).length * MS_PER_DAY; const expected = - Math.floor(((totalMs - 3 * 3_600_000) / totalMs) * 10_000) / 100; + Math.floor(((totalMs - 3 * 3_600_000) / totalMs) * 100_000) / 1_000; expect(row.months[key(1)]).toBe(expected); // events overlap key(2) not at all and it has no counts → null anyway expect(row.months[key(2)]).toBe(null); @@ -673,7 +673,8 @@ describe("getUptimeHistory", () => { // denominator = elapsed 36h (not 48h): 2h down → ~94.44, not 95.83 const lastDayEnd = Date.parse(`${key(0)}-02T23:59:59.999Z`); const total = 2 * MS_PER_DAY - (lastDayEnd - injectedNow.getTime()); - const expected = Math.floor(((total - twoHours) / total) * 10_000) / 100; + const expected = + Math.floor(((total - twoHours) / total) * 100_000) / 1_000; expect(res.rows[0].months[key(0)]).toBe(expected); expect(expected).toBeLessThan(95); }); @@ -758,7 +759,7 @@ describe("getUptimeHistory", () => { expect(row.months[key(2)]).toBe(null); const totalMs = monthEnd(key(1)).getTime() - monthStart(key(1)).getTime(); const expected = - Math.floor(((totalMs - sixHours) / totalMs) * 10_000) / 100; + Math.floor(((totalMs - sixHours) / totalMs) * 100_000) / 1_000; expect(row.months[key(1)]).toBe(expected); // current month: no events → clean so far expect(row.months[key(0)]).toBe(100); diff --git a/packages/services/src/frozen-uptime/get-history.ts b/packages/services/src/frozen-uptime/get-history.ts index 7411edf2..bb4eea2e 100644 --- a/packages/services/src/frozen-uptime/get-history.ts +++ b/packages/services/src/frozen-uptime/get-history.ts @@ -385,9 +385,7 @@ export async function getUptimeHistory(args: { summary[wk] = { uptime: uptimes.length > 0 - ? Math.floor( - (uptimes.reduce((a, b) => a + b, 0) / uptimes.length) * 100, - ) / 100 + ? floorPct(uptimes.reduce((a, b) => a + b, 0) / uptimes.length / 100) : null, reports: seen.size, }; diff --git a/packages/services/src/status-timeline/__tests__/uptime.test.ts b/packages/services/src/status-timeline/__tests__/uptime.test.ts index 3f4c9d94..81f9cfeb 100644 --- a/packages/services/src/status-timeline/__tests__/uptime.test.ts +++ b/packages/services/src/status-timeline/__tests__/uptime.test.ts @@ -59,11 +59,25 @@ function legacyReport(fromMs: number, toMs: number): Event { } describe("floorPct", () => { - test("floors instead of rounding — one failed check never shows 100.00", () => { - expect(floorPct(99_999 / 100_000)).toBe(99.99); + test("floors instead of rounding — one failed check never shows 100.000", () => { + expect(floorPct(99_999 / 100_000)).toBe(99.999); + expect(floorPct(999_999 / 1_000_000)).toBe(99.999); expect(floorPct(1)).toBe(100); expect(floorPct(0)).toBe(0); }); + + test("exact thousandths survive float error in the caller's division", () => { + expect(floorPct(29 / 100)).toBe(29); + expect(floorPct(57 / 100)).toBe(57); + expect(floorPct(23 / 40)).toBe(57.5); + expect(floorPct(29 / 50)).toBe(58); + expect(floorPct(23 / 80)).toBe(28.75); + }); + + test("the epsilon does not lift a value onto the next thousandth", () => { + expect(floorPct(28_9995 / 1_000_000)).toBe(28.999); + expect(floorPct(99_9999 / 1_000_000)).toBe(99.999); + }); }); describe("requestsTally", () => { diff --git a/packages/services/src/status-timeline/uptime.ts b/packages/services/src/status-timeline/uptime.ts index 5666a08e..9e62d872 100644 --- a/packages/services/src/status-timeline/uptime.ts +++ b/packages/services/src/status-timeline/uptime.ts @@ -55,9 +55,12 @@ export function clipToCoverage( ); } -// floor so a single failed check never rounds up to 100.00 +// floor so a single failed check never rounds up to 100.000. the epsilon +// absorbs float error from the caller's a/b division (0.29 * 100_000 is +// 28999.999999999996) — it is ~450x the ULP at this scale, far too small to +// lift a genuinely-below value onto the next thousandth. export function floorPct(ratio: number): number { - return Math.floor(ratio * 10_000) / 100; + return Math.floor(ratio * 100_000 + 1e-8) / 1_000; } export function requestsTally(counts: CheckCounts[]): { diff --git a/packages/tracker/src/tracker.test.ts b/packages/tracker/src/tracker.test.ts index 8f42f53f..34fb595b 100644 --- a/packages/tracker/src/tracker.test.ts +++ b/packages/tracker/src/tracker.test.ts @@ -109,9 +109,9 @@ describe("Tracker", () => { expect(tracker.totalUptime).toBe(75); }); - test("rounds to two decimal places", () => { + test("rounds to three decimal places", () => { const tracker = new Tracker({ data: [day("2024-01-01", 3, 2)] }); - expect(tracker.totalUptime).toBe(66.67); + expect(tracker.totalUptime).toBe(66.667); }); test("aggregates across multiple days", () => { diff --git a/packages/tracker/src/tracker.ts b/packages/tracker/src/tracker.ts index f5da4d4c..36fbd230 100644 --- a/packages/tracker/src/tracker.ts +++ b/packages/tracker/src/tracker.ts @@ -49,7 +49,7 @@ export class Tracker { private calculateUptime(data: { ok: number; count: number }[]) { const { count, ok } = this.aggregatedData(data); if (count === 0) return 100; // starting with 100% uptime - return Math.round((ok / count) * 10_000) / 100; // round to 2 decimal places + return Math.round((ok / count) * 100_000) / 1_000; // round to 3 decimal places } private aggregatedData(data: { ok: number; count: number }[]) { -- 2.51.2 From 86f370c9c20074c3c3fdec53a359874b8e670fd4 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Sun, 16 Aug 2026 14:26:15 +0200 Subject: [PATCH 142/266] status page: rewrite (#2551) --- .../lib/proxy/apply-page-slug-prefix.test.ts | 88 +++++++++ .../src/lib/proxy/apply-page-slug-prefix.ts | 27 +++ .../src/lib/proxy/compose-page-action.test.ts | 21 ++- .../src/lib/proxy/compose-page-action.ts | 5 +- .../resolve-custom-domain-rewrite.test.ts | 169 ------------------ .../proxy/resolve-custom-domain-rewrite.ts | 87 --------- apps/status-page/src/proxy.ts | 6 +- 7 files changed, 138 insertions(+), 265 deletions(-) create mode 100644 apps/status-page/src/lib/proxy/apply-page-slug-prefix.test.ts create mode 100644 apps/status-page/src/lib/proxy/apply-page-slug-prefix.ts delete mode 100644 apps/status-page/src/lib/proxy/resolve-custom-domain-rewrite.test.ts delete mode 100644 apps/status-page/src/lib/proxy/resolve-custom-domain-rewrite.ts diff --git a/apps/status-page/src/lib/proxy/apply-page-slug-prefix.test.ts b/apps/status-page/src/lib/proxy/apply-page-slug-prefix.test.ts new file mode 100644 index 00000000..f4083a0d --- /dev/null +++ b/apps/status-page/src/lib/proxy/apply-page-slug-prefix.test.ts @@ -0,0 +1,88 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import type { ResolvedRoute } from "../resolve-route"; +import { applyPageLocaleOverride } from "./apply-page-locale-override"; +import { applyPageSlugPrefix } from "./apply-page-slug-prefix"; + +const customDomainRoute: ResolvedRoute = { + type: "hostname", + prefix: "status.acme.com", + locale: "en", + localeExplicit: false, + rewritePath: "/status.acme.com/en", +}; + +describe("applyPageSlugPrefix", () => { + test("prefix already equals the slug: returns input unchanged", () => { + const route: ResolvedRoute = { + type: "pathname", + prefix: "acme", + locale: "en", + localeExplicit: true, + rewritePath: "/acme/en", + }; + expect(applyPageSlugPrefix(route, { slug: "acme" })).toBe(route); + }); + + test("custom domain prefix: swapped for the slug", () => { + const result = applyPageSlugPrefix(customDomainRoute, { slug: "acme" }); + expect(result).not.toBe(customDomainRoute); + expect(result).toEqual({ + type: "hostname", + prefix: "acme", + locale: "en", + localeExplicit: false, + rewritePath: "/acme/en", + }); + }); + + test("deep path: rest segments are preserved", () => { + const route: ResolvedRoute = { + ...customDomainRoute, + rewritePath: "/status.acme.com/en/events/report/1", + }; + expect(applyPageSlugPrefix(route, { slug: "acme" }).rewritePath).toBe( + "/acme/en/events/report/1", + ); + }); + + test("no rest segments: no trailing slash", () => { + expect( + applyPageSlugPrefix(customDomainRoute, { slug: "acme" }).rewritePath, + ).toBe("/acme/en"); + }); + + test("explicit locale is preserved", () => { + const route: ResolvedRoute = { + ...customDomainRoute, + locale: "fr", + localeExplicit: true, + rewritePath: "/status.acme.com/fr/monitors", + }; + const result = applyPageSlugPrefix(route, { slug: "acme" }); + expect(result.locale).toBe("fr"); + expect(result.localeExplicit).toBe(true); + expect(result.rewritePath).toBe("/acme/fr/monitors"); + }); + + test("does not mutate input", () => { + const route = { ...customDomainRoute }; + applyPageSlugPrefix(route, { slug: "acme" }); + expect(route).toEqual(customDomainRoute); + }); + + test("commutes with applyPageLocaleOverride", () => { + const page = { slug: "acme", defaultLocale: "fr" } as const; + const slugFirst = applyPageLocaleOverride( + applyPageSlugPrefix(customDomainRoute, page), + page, + ); + const localeFirst = applyPageSlugPrefix( + applyPageLocaleOverride(customDomainRoute, page), + page, + ); + expect(slugFirst).toEqual(localeFirst); + expect(slugFirst.rewritePath).toBe("/acme/fr"); + }); +}); diff --git a/apps/status-page/src/lib/proxy/apply-page-slug-prefix.ts b/apps/status-page/src/lib/proxy/apply-page-slug-prefix.ts new file mode 100644 index 00000000..e20fc54d --- /dev/null +++ b/apps/status-page/src/lib/proxy/apply-page-slug-prefix.ts @@ -0,0 +1,27 @@ +import type { Page } from "@openstatus/db/src/schema"; + +import type { ResolvedRoute } from "../resolve-route"; + +/** + * Swaps the resolved prefix for the page slug. Custom-domain requests resolve + * `prefix` to the domain itself (`getValidSubdomain` returns the full host), but + * the `[domain]` segment must be the slug — the login cookie key is derived from + * it client-side and compared against `secured-${page.slug}` in the middleware. + * + * Rebuilds `rewritePath` from `route.locale` rather than string-replacing, so it + * commutes with `applyPageLocaleOverride`. + */ +export function applyPageSlugPrefix( + route: ResolvedRoute, + page: Pick, +): ResolvedRoute { + if (route.prefix === page.slug) return route; + + // resolveRoute always builds rewritePath as ["", prefix, locale, ...rest] + const [, , , ...rest] = route.rewritePath.split("/"); + return { + ...route, + prefix: page.slug, + rewritePath: `/${page.slug}/${route.locale}${rest.length ? `/${rest.join("/")}` : ""}`, + }; +} diff --git a/apps/status-page/src/lib/proxy/compose-page-action.test.ts b/apps/status-page/src/lib/proxy/compose-page-action.test.ts index f3778b40..8b6d4e67 100644 --- a/apps/status-page/src/lib/proxy/compose-page-action.test.ts +++ b/apps/status-page/src/lib/proxy/compose-page-action.test.ts @@ -113,19 +113,32 @@ describe("composePageAction — priority ordering", () => { expect(action.reason).toBe("ip-restriction-gate-in"); }); - test("custom-domain rewrite fires before default rewrite", () => { + // The route is slug-normalised by applyPageSlugPrefix before it reaches the + // composer, so a custom domain is served by one internal rewrite. + test("custom domain: default rewrite targets the slug-normalised path", () => { const action = composePageAction( buildInput({ page: { ...basePage, customDomain: "status.acme.com", } as Page, + route: { + type: "hostname", + prefix: "acme", + locale: "en", + localeExplicit: false, + rewritePath: "/acme/en/events", + }, host: "status.acme.com", - urlHost: "localhost:3000", - pathname: "/status.acme.com/en/events", + urlHost: "status.acme.com", + pathname: "/events", + requestUrl: "https://status.acme.com/events", }), ); - expect(action.reason).toBe("custom-domain-rewrite-path-strip"); + expect(action.type).toBe("rewrite"); + expect(action.reason).toBe("default-rewrite"); + expect(action.url?.pathname).toBe("/acme/en/events"); + expect(action.url?.host).toBe("status.acme.com"); }); test("default rewrite fires when paths differ and no other stage matches", () => { diff --git a/apps/status-page/src/lib/proxy/compose-page-action.ts b/apps/status-page/src/lib/proxy/compose-page-action.ts index be5b44fd..8aab33ec 100644 --- a/apps/status-page/src/lib/proxy/compose-page-action.ts +++ b/apps/status-page/src/lib/proxy/compose-page-action.ts @@ -1,4 +1,3 @@ -import { resolveCustomDomainRewrite } from "./resolve-custom-domain-rewrite"; import { resolveDefaultRewrite } from "./resolve-default-rewrite"; import { resolveEmailDomainAction } from "./resolve-email-domain-action"; import { resolveIpRestrictionAction } from "./resolve-ip-restriction-action"; @@ -17,8 +16,7 @@ export type { ComposeInput }; * 2. password gate (in/out) * 3. email-domain gate (in/out) * 4. ip-restriction gate (in/out) - * 5. custom-domain rewrite (stpg.dev hosted) - * 6. default rewrite (openstatus.dev OR rewritePath differs) + * 5. default rewrite (openstatus.dev OR rewritePath differs) * * Note on locale-first: a user on a mis-localed URL for a gated page gets * two redirects (locale → gate) instead of one. We accept the extra hop so @@ -33,7 +31,6 @@ export function composePageAction(input: ComposeInput): Action { resolvePasswordAction(input) ?? resolveEmailDomainAction(input) ?? resolveIpRestrictionAction(input) ?? - resolveCustomDomainRewrite(input) ?? resolveDefaultRewrite(input) ?? // Reached whenever resolveDefaultRewrite declines: host is not an // openstatus.dev host AND route.rewritePath === pathname. In hosted diff --git a/apps/status-page/src/lib/proxy/resolve-custom-domain-rewrite.test.ts b/apps/status-page/src/lib/proxy/resolve-custom-domain-rewrite.test.ts deleted file mode 100644 index 67a2c1f4..00000000 --- a/apps/status-page/src/lib/proxy/resolve-custom-domain-rewrite.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { expect } from "@std/expect"; -import { describe, test } from "@std/testing/bdd"; - -import { resolveCustomDomainRewrite } from "./resolve-custom-domain-rewrite"; - -const page = { - slug: "acme", - customDomain: "status.acme.com", -}; - -describe("resolveCustomDomainRewrite", () => { - test("self-hosted: passes (null)", () => { - expect( - resolveCustomDomainRewrite({ - page, - host: "status.acme.com", - urlHost: "localhost:3000", - pathname: "/en/events", - search: "", - isSelfHosted: true, - requestUrl: "http://localhost:3000/en/events", - }), - ).toBeNull(); - }); - - test("no customDomain configured: passes (null)", () => { - expect( - resolveCustomDomainRewrite({ - page: { slug: "acme", customDomain: "" }, - host: "acme.stpg.dev", - urlHost: "localhost:3000", - pathname: "/en", - search: "", - isSelfHosted: false, - requestUrl: "http://acme.stpg.dev/en", - }), - ).toBeNull(); - }); - - test("host === {slug}.stpg.dev: passes (null)", () => { - expect( - resolveCustomDomainRewrite({ - page, - host: "acme.stpg.dev", - urlHost: "localhost:3000", - pathname: "/en", - search: "", - isSelfHosted: false, - requestUrl: "http://acme.stpg.dev/en", - }), - ).toBeNull(); - }); - - // Branch 1: no subdomain detected on urlHost, deep path. - test("branch 1 (path-strip): no subdomain, deep path → /{slug}/", () => { - const action = resolveCustomDomainRewrite({ - page, - host: "status.acme.com", - urlHost: "localhost:3000", - pathname: "/status.acme.com/en/events", - search: "", - isSelfHosted: false, - requestUrl: "http://localhost:3000/status.acme.com/en/events", - }); - expect(action?.reason).toBe("custom-domain-rewrite-path-strip"); - expect(action?.url?.pathname).toBe("/acme/en/events"); - }); - - test("branch 1: trailing-slash-only path → /{slug} without trailing slash", () => { - const action = resolveCustomDomainRewrite({ - page, - host: "status.acme.com", - urlHost: "localhost:3000", - pathname: "/status.acme.com/", - search: "", - isSelfHosted: false, - requestUrl: "http://localhost:3000/status.acme.com/", - }); - expect(action?.reason).toBe("custom-domain-rewrite-path-strip"); - expect(action?.url?.pathname).toBe("/acme"); - }); - - test("branch 1 preserves search", () => { - const action = resolveCustomDomainRewrite({ - page, - host: "status.acme.com", - urlHost: "localhost:3000", - pathname: "/status.acme.com/en", - search: "?foo=bar", - isSelfHosted: false, - requestUrl: "http://localhost:3000/status.acme.com/en", - }); - expect(action?.url?.search).toBe("?foo=bar"); - }); - - // Branch 2: subdomain on urlHost, deep path. - test("branch 2 (subdomain-subpath): subdomain + deep path → https://{slug}.stpg.dev/", () => { - const action = resolveCustomDomainRewrite({ - page, - host: "status.acme.com", - urlHost: "acme.stpg.dev", - pathname: "/path/to/events", - search: "", - isSelfHosted: false, - requestUrl: "https://acme.stpg.dev/path/to/events", - }); - expect(action?.reason).toBe("custom-domain-rewrite-subdomain-subpath"); - expect(action?.url?.host).toBe("acme.stpg.dev"); - expect(action?.url?.pathname).toBe("/path/to/events"); - }); - - // Branch 3: subdomain on urlHost, shallow path. - test("branch 3 (subdomain-root): subdomain + shallow path → https://{slug}.stpg.dev{pathname}", () => { - const action = resolveCustomDomainRewrite({ - page, - host: "status.acme.com", - urlHost: "acme.stpg.dev", - pathname: "/en", - search: "", - isSelfHosted: false, - requestUrl: "https://acme.stpg.dev/en", - }); - expect(action?.reason).toBe("custom-domain-rewrite-subdomain-root"); - expect(action?.url?.host).toBe("acme.stpg.dev"); - expect(action?.url?.pathname).toBe("/en"); - }); - - test("branch 3: root path", () => { - const action = resolveCustomDomainRewrite({ - page, - host: "status.acme.com", - urlHost: "acme.stpg.dev", - pathname: "/", - search: "", - isSelfHosted: false, - requestUrl: "https://acme.stpg.dev/", - }); - expect(action?.reason).toBe("custom-domain-rewrite-subdomain-root"); - expect(action?.url?.pathname).toBe("/"); - }); - - // Branch 4: no subdomain, shallow path → fallback. - test("branch 4 (fallback): no subdomain, shallow path → /{slug}", () => { - const action = resolveCustomDomainRewrite({ - page, - host: "status.acme.com", - urlHost: "localhost:3000", - pathname: "/", - search: "", - isSelfHosted: false, - requestUrl: "http://localhost:3000/", - }); - expect(action?.reason).toBe("custom-domain-rewrite-fallback"); - expect(action?.url?.pathname).toBe("/acme"); - }); - - test("branch 4 preserves search", () => { - const action = resolveCustomDomainRewrite({ - page, - host: "status.acme.com", - urlHost: "localhost:3000", - pathname: "/", - search: "?x=1", - isSelfHosted: false, - requestUrl: "http://localhost:3000/", - }); - expect(action?.url?.search).toBe("?x=1"); - }); -}); diff --git a/apps/status-page/src/lib/proxy/resolve-custom-domain-rewrite.ts b/apps/status-page/src/lib/proxy/resolve-custom-domain-rewrite.ts deleted file mode 100644 index f72527f0..00000000 --- a/apps/status-page/src/lib/proxy/resolve-custom-domain-rewrite.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { Page } from "@openstatus/db/src/schema"; - -import { getValidSubdomain } from "../domain"; -import type { Action, ComposeInput } from "./types"; - -type Input = Pick< - ComposeInput, - "host" | "urlHost" | "pathname" | "search" | "isSelfHosted" | "requestUrl" -> & { - page: Pick; -}; - -/** - * stpg.dev ↔ custom-domain rewrite. Runs only when the request reaches us on a - * host other than `{slug}.stpg.dev` for a page that has a configured custom - * domain (and we are not in self-hosted mode). - * - * Four branches: - * 1. subdomain absent, deep path → rewrite to `/{slug}/` (path-strip). - * 2. subdomain present, deep path → rewrite to `https://{slug}.stpg.dev/` (subdomain-subpath). - * 3. subdomain present, shallow path → rewrite to `https://{slug}.stpg.dev{pathname}` (subdomain-root). - * 4. Otherwise → rewrite to `/{slug}` (fallback). - * - * All branches preserve the incoming search string. - */ -export function resolveCustomDomainRewrite({ - page, - host, - urlHost, - pathname, - search, - isSelfHosted, - requestUrl, -}: Input): Action | null { - if (isSelfHosted) return null; - if (!page.customDomain) return null; - if (host === `${page.slug}.stpg.dev`) return null; - - const pathnames = pathname.split("/"); - const subdomain = getValidSubdomain(urlHost); - - // Branch 1: no subdomain, pathname has >1 segment → strip leading segment. - if (pathnames.length > 2 && !subdomain) { - const rest = pathnames.slice(2).join("/"); - // Trailing-slash only (e.g. "/status.acme.com/") yields empty `rest` — - // emit `/{slug}` without a trailing slash to match Branch 4's semantics - // and avoid a redundant 308 from Next.js trailing-slash handling. - const path = rest ? `/${page.slug}/${rest}` : `/${page.slug}`; - const url = new URL(path, requestUrl); - url.search = search; - return { - type: "rewrite", - url, - reason: "custom-domain-rewrite-path-strip", - }; - } - - // Branch 2 & 3: subdomain present — rewrite to the stpg.dev host. - if (subdomain) { - if (pathnames.length > 2) { - const rest = pathnames.slice(1).join("/"); - const url = new URL(rest, `https://${page.slug}.stpg.dev`); - url.search = search; - return { - type: "rewrite", - url, - reason: "custom-domain-rewrite-subdomain-subpath", - }; - } - const url = new URL(pathname, `https://${page.slug}.stpg.dev`); - url.search = search; - return { - type: "rewrite", - url, - reason: "custom-domain-rewrite-subdomain-root", - }; - } - - // Branch 4: fallback — rewrite to the bare slug. - const url = new URL(`/${page.slug}`, requestUrl); - url.search = search; - return { - type: "rewrite", - url, - reason: "custom-domain-rewrite-fallback", - }; -} diff --git a/apps/status-page/src/proxy.ts b/apps/status-page/src/proxy.ts index b93550f9..c57aa35a 100644 --- a/apps/status-page/src/proxy.ts +++ b/apps/status-page/src/proxy.ts @@ -6,6 +6,7 @@ import { auth } from "./lib/auth"; import { resolveClientIp } from "./lib/http/client-ip"; import { createProtectedCookieKey } from "./lib/protected"; import { applyPageLocaleOverride } from "./lib/proxy/apply-page-locale-override"; +import { applyPageSlugPrefix } from "./lib/proxy/apply-page-slug-prefix"; import { composePageAction } from "./lib/proxy/compose-page-action"; import { detectMarkdown } from "./lib/proxy/detect-markdown"; import { sanitizeRedirectParam } from "./lib/proxy/sanitize-redirect-param"; @@ -67,7 +68,10 @@ export default auth(async (req) => { } const _page = validation.data; - const route = applyPageLocaleOverride(initialRoute, _page); + const route = applyPageSlugPrefix( + applyPageLocaleOverride(initialRoute, _page), + _page, + ); const clientIp = resolveClientIp(req.headers); -- 2.51.2 From b0fe974cc945e57454c1ce7f104de08e226e868b Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:21:31 +0200 Subject: [PATCH 143/266] chore: more content improvements (#2582) * chore: more content improvements * fix: content --- .../src/app/(landing)/play/checker/page.tsx | 2 + apps/web/src/content/docs.config.ts | 2 +- ...ency-monitoring-benchmark-hono-hetzner.mdx | 26 +++ ...cy-cf-workers-fly-koyeb-raylway-render.mdx | 40 +++- ...ring-latency-vercel-edge-vs-serverless.mdx | 33 +++- .../pages/blog/self-hosting-openstatus.mdx | 2 +- .../pages/blog/status-pages-is-politics.mdx | 26 +++ .../blog/your-customer-found-out-first.mdx | 26 +++ .../pages/compare/atlassian-statuspage.mdx | 32 +++ .../src/content/pages/compare/betterstack.mdx | 36 +++- .../web/src/content/pages/compare/checkly.mdx | 26 +++ .../web/src/content/pages/compare/datadog.mdx | 26 +++ .../src/content/pages/compare/incidentio.mdx | 26 +++ .../src/content/pages/compare/instatus.mdx | 26 +++ .../web/src/content/pages/compare/pingdom.mdx | 26 +++ .../src/content/pages/compare/statusio.mdx | 26 +++ .../src/content/pages/compare/uptime-kuma.mdx | 38 +++- .../content/pages/compare/uptime-robot.mdx | 26 +++ .../docs/concept/latency-vs-response-time.mdx | 68 ++++++- ...w-to-connect-openstatus-to-claude-code.mdx | 2 +- .../docs/guides/how-to-monitor-mcp-server.mdx | 99 +++++++++- .../guides/self-host-status-page-only.mdx | 66 ++++++- .../docs/guides/self-hosting-openstatus.mdx | 2 + .../pages/docs/reference/mcp-server.mdx | 2 +- .../pages/guides/api-service-disruption.mdx | 30 +++ .../guides/best-hosted-status-page-2026.mdx | 26 +++ ...best-incident-communication-tools-2026.mdx | 32 +++ .../best-opensource-status-page-2026.mdx | 26 +++ .../boring-is-better-for-status-pages.mdx | 32 +++ .../connect-openstatus-to-claude-code.mdx | 48 ++++- .../connect-openstatus-to-claude-desktop.mdx | 48 ++++- .../database-performance-degradation.mdx | 24 +++ .../pages/guides/deployment-rollback.mdx | 30 +++ .../dora-incident-reporting-requirements.mdx | 32 +++ .../pages/guides/error-budgets-explained.mdx | 181 +++++++++++++++++ .../pages/guides/feature-degradation.mdx | 32 ++- .../guides/hosted-uptime-kuma-alternative.mdx | 26 +++ ...us-compares-to-other-status-page-tools.mdx | 32 +++ .../src/content/pages/guides/http-headers.mdx | 26 +++ .../pages/guides/incident-communication.mdx | 184 ++++++++++++++++++ .../pages/guides/incident-severity-matrix.mdx | 38 ++++ .../iso-27001-incident-communication.mdx | 32 +++ .../migrate-from-atlassian-statuspage.mdx | 32 +++ .../pages/guides/migrate-from-betterstack.mdx | 38 ++++ .../pages/guides/migrate-from-checkly.mdx | 38 ++++ .../pages/guides/migrate-from-instatus.mdx | 38 ++++ .../guides/migrate-from-uptime-robot.mdx | 26 +++ .../guides/network-connectivity-issues.mdx | 24 +++ .../nis2-incident-reporting-requirements.mdx | 32 +++ ...public-postmortem-underrated-marketing.mdx | 32 +++ .../guides/public-vs-private-status-pages.mdx | 38 ++++ .../pages/guides/scheduled-maintenance.mdx | 30 +++ .../guides/security-incident-response.mdx | 30 +++ .../pages/guides/sla-vs-slo-vs-sli.mdx | 69 ++++++- .../slack-status-page-subscriptions.mdx | 44 +++++ .../guides/soc-2-status-page-requirements.mdx | 32 +++ ...five-atlassian-statuspage-alternatives.mdx | 26 +++ .../guides/top-five-instatus-alternatives.mdx | 32 +++ .../guides/top-five-pingdom-alternatives.mdx | 26 +++ .../guides/what-is-a-good-response-time.mdx | 165 ++++++++++++++++ .../pages/guides/what-is-a-status-page.mdx | 50 +++++ .../guides/what-is-incident-management.mdx | 70 ++++++- .../src/content/pages/guides/what-is-mttr.mdx | 125 +++++++++++- .../guides/what-is-synthetic-monitoring.mdx | 56 ++++++ .../guides/what-is-uptime-monitoring.mdx | 56 ++++++ .../why-every-saas-needs-a-status-page.mdx | 48 ++++- .../guides/why-is-my-monitor-failing.mdx | 32 +++ .../why-uptime-percentage-is-misleading.mdx | 60 +++++- apps/web/src/content/pages/home.mdx | 4 +- .../src/content/pages/product/status-page.mdx | 66 ++++++- .../src/content/pages/product/tooling/api.mdx | 26 +++ .../src/content/pages/product/tooling/cli.mdx | 26 +++ .../pages/product/tooling/mcp-server.mdx | 31 ++- .../pages/product/tooling/terraform.mdx | 26 +++ .../pages/product/uptime-monitoring.mdx | 50 +++++ .../src/content/pages/tools/cdn-checker.mdx | 26 +++ .../src/content/pages/tools/checker-slug.mdx | 8 + apps/web/src/content/pages/tools/checker.mdx | 133 ++++++++++++- apps/web/src/content/pages/tools/curl.mdx | 12 ++ .../src/content/pages/tools/mcp-health.mdx | 2 + .../content/pages/tools/severity-matrix.mdx | 44 +++++ .../src/content/pages/tools/uptime-sla.mdx | 66 ++++++- .../web/src/content/pages/unrelated/about.mdx | 14 ++ apps/web/src/content/pages/use-case/agent.mdx | 32 +++ .../content/pages/use-case/api-providers.mdx | 26 +++ .../src/content/pages/use-case/compliance.mdx | 32 +++ .../web/src/content/pages/use-case/crypto.mdx | 26 +++ .../pages/use-case/enterprise-sales.mdx | 26 +++ .../content/pages/use-case/open-source.mdx | 28 ++- .../pages/use-case/reduce-support-tickets.mdx | 26 +++ .../src/content/pages/use-case/startups.mdx | 38 ++++ 91 files changed, 3516 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/content/pages/guides/error-budgets-explained.mdx create mode 100644 apps/web/src/content/pages/guides/incident-communication.mdx create mode 100644 apps/web/src/content/pages/guides/what-is-a-good-response-time.mdx diff --git a/apps/web/src/app/(landing)/play/checker/page.tsx b/apps/web/src/app/(landing)/play/checker/page.tsx index f8a6dbd1..612c0b11 100644 --- a/apps/web/src/app/(landing)/play/checker/page.tsx +++ b/apps/web/src/app/(landing)/play/checker/page.tsx @@ -13,6 +13,7 @@ import { createJsonLDGraph, getJsonLDBreadcrumbList, getJsonLDFAQPage, + getJsonLDHowTo, getJsonLDWebPage, } from "../../../../lib/metadata/structured-data"; import { @@ -52,6 +53,7 @@ export default async function Page(props: { { name: page.metadata.title, url: `${BASE_URL}/play/checker` }, ]), getJsonLDFAQPage(page), + getJsonLDHowTo(page), ]); return ( diff --git a/apps/web/src/content/docs.config.ts b/apps/web/src/content/docs.config.ts index 528f82c5..ab2a78d6 100644 --- a/apps/web/src/content/docs.config.ts +++ b/apps/web/src/content/docs.config.ts @@ -134,7 +134,7 @@ export const docsNav: DocsNavSection[] = [ }, { slug: "guides/how-to-monitor-mcp-server", - label: "How to Monitor Your Model Context Provider (MCP) Server", + label: "How to Monitor an MCP Server", }, { slug: "guides/how-to-run-synthetic-test-github-action", diff --git a/apps/web/src/content/pages/blog/global-latency-monitoring-benchmark-hono-hetzner.mdx b/apps/web/src/content/pages/blog/global-latency-monitoring-benchmark-hono-hetzner.mdx index 787ae66e..3dc17389 100644 --- a/apps/web/src/content/pages/blog/global-latency-monitoring-benchmark-hono-hetzner.mdx +++ b/apps/web/src/content/pages/blog/global-latency-monitoring-benchmark-hono-hetzner.mdx @@ -148,3 +148,29 @@ Through this global setup, I gained valuable insights into external network perf My 2cts, if you want to pick a PaaS to deploy your next project, pick the one that fits your needs the best, because in the end, the network differences are minimal! If you want to try monitoring our app from multiple global locations, you can try our [global speed checker](/play/checker) for free. + +## Frequently asked questions + +
+ +Barely. In our 7-day benchmark from 17 probes across Fly, Koyeb, and Railway, providers in the same region reported less than 10% latency difference. For example, Fly and Koyeb probes in Frankfurt both showed ~80ms P95 to Hetzner Finland. Geographic distance is the dominant factor. + +
+ +
+ +Frankfurt probes averaged ~80ms P95. US East probes were higher due to transatlantic routing. Singapore probes from Fly, Koyeb, and Railway all reported similar latencies with less than 10% difference between providers, confirming distance is the primary factor. + +
+ +
+ +Network performance alone should not drive your decision. Our benchmark showed minimal latency differences between Fly, Koyeb, and Railway when monitoring the same target from the same region. Pick the PaaS that fits your workflow, pricing, and deployment needs — the network differences are negligible. + +
+ +
+ +Deploy your app on any provider, then configure openstatus to monitor it from multiple regions across Fly, Koyeb, and Railway simultaneously. openstatus checks all selected regions in parallel every minute, giving you a true multi-provider, multi-region latency baseline without internal network bias. + +
diff --git a/apps/web/src/content/pages/blog/monitoring-latency-cf-workers-fly-koyeb-raylway-render.mdx b/apps/web/src/content/pages/blog/monitoring-latency-cf-workers-fly-koyeb-raylway-render.mdx index 0bfaaf70..6c31b06b 100644 --- a/apps/web/src/content/pages/blog/monitoring-latency-cf-workers-fly-koyeb-raylway-render.mdx +++ b/apps/web/src/content/pages/blog/monitoring-latency-cf-workers-fly-koyeb-raylway-render.mdx @@ -7,7 +7,7 @@ image: "/assets/posts/monitoring-latency/all-hosting-providers.png" category: "education" faq: - question: "Which cloud provider has the lowest latency?" - answer: "In our benchmark, Cloudflare Workers had the lowest average latency at 182ms across 6 global regions, with a P75 of 138ms. Fly.io in production (with min_machines_running=1) averaged just 61ms, but with cold starts it averaged 1,471ms." + answer: "In our benchmark, Cloudflare Workers had the lowest average latency of the providers left on their default configuration: 182ms across 6 global regions, with a P75 of 138ms. Fly.io was faster still when kept warm — 61ms with min_machines_running=1 — but averaged 1,471ms once cold starts were allowed." - question: "Does Fly.io have cold start issues?" answer: "Yes. With auto_stop_machines enabled and min_machines_running=0, Fly.io averaged 1,471ms due to cold starts (~1.5s machine boot time). Setting min_machines_running=1 eliminates cold starts and brings the average down to 61ms." - question: "How does Cloudflare Workers latency compare to Railway and Render?" @@ -25,7 +25,7 @@ You want to know which cloud providers offer the lowest latency? In this post, I compare the latency of [Cloudflare Workers](#cloudflare-workers), [Fly](#flyio), [Koyeb](#koyeb), [Railway](#railway) and [Render](#render) using -[OpenStatus](https://www.openstatus.dev). +[openstatus](https://www.openstatus.dev). I deployed the application on the cheapest or free tier offered by each provider. @@ -49,7 +49,7 @@ app.get("/", (c) => { You can find the code in the [`status-code` repository](https://github.com/openstatusHQ/status-code), it’s open source 😉. -OpenStatus monitored our endpoint every **10 minutes** from **6 locations** +Openstatus monitored our endpoint every **10 minutes** from **6 locations** located in Amsterdam, Ashburn, Hong Kong, Johannesburg, Sao Paulo and Sydney. It's a good way to test our own product and improve it. @@ -288,7 +288,7 @@ The machine starts slowly, as indicated by the logs showing a start time of 2024-02-14T11:24:17.628 proxy[286560ea703108] ams [info] machine became reachable in 7.03669ms ``` -#### OpenStatus Prod metrics +#### Openstatus Prod metrics If you update your fly.toml file to include the following, you can get the zero cold start and achieve a better latency. @@ -666,5 +666,35 @@ We use Fly.io in production and are satisfied with it. I haven't included Vercel in this test. But we have a blog post comparing [Vercel Serverless vs Edge vs Serverless](/blog/monitoring-latency-vercel-edge-vs-serverless). +Want the same measurement for your own endpoint? Run it through the +[website speed test](/play/checker) — 28 regions with the per-phase timing +breakdown, and no account needed. + If you want to monitor your API or website, create an account on -[OpenStatus](/app/sign-up?ref=blog-monitoring). +[openstatus](/app/sign-up?ref=blog-monitoring). + +## Frequently asked questions + +
+ +In our benchmark, Cloudflare Workers had the lowest average latency of the providers left on their default configuration: 182ms across 6 global regions, with a P75 of 138ms. Fly.io was faster still when kept warm — 61ms with min_machines_running=1 — but averaged 1,471ms once cold starts were allowed. + +
+ +
+ +Yes. With auto_stop_machines enabled and min_machines_running=0, Fly.io averaged 1,471ms due to cold starts (~1.5s machine boot time). Setting min_machines_running=1 eliminates cold starts and brings the average down to 61ms. + +
+ +
+ +Cloudflare Workers averaged 182ms with 100% uptime. Railway averaged 381ms with 99.991% uptime (1 failure). Render averaged 451ms with 99.89% uptime (12 failures). Cloudflare Workers deploy globally to 275+ locations, while Railway and Render run from a single region. + +
+ +
+ +Render had the most failures with 12 failed checks and 99.89% uptime over 2 weeks. Railway had 1 failure (99.991% uptime). Cloudflare Workers, Fly.io, and Koyeb all had 0 failures and 100% uptime. + +
diff --git a/apps/web/src/content/pages/blog/monitoring-latency-vercel-edge-vs-serverless.mdx b/apps/web/src/content/pages/blog/monitoring-latency-vercel-edge-vs-serverless.mdx index a431da80..18acaa76 100644 --- a/apps/web/src/content/pages/blog/monitoring-latency-vercel-edge-vs-serverless.mdx +++ b/apps/web/src/content/pages/blog/monitoring-latency-vercel-edge-vs-serverless.mdx @@ -336,5 +336,36 @@ Edge functions have similar latency regardless of the user's location. If you value your users and have a worldwide audience, you should consider Edge Functions. -Create an account on [OpenStatus](/app/sign-up) to +You can reproduce this for your own deployment with the +[website speed test](/play/checker) — it runs from the same 28 regions and breaks +each request into DNS, TCP, TLS, and TTFB, which is how the cold-start gap above +becomes visible. + +Create an account on [openstatus](/app/sign-up) to monitor your API and get notified when your latency increases. + +## Frequently asked questions + +
+ +Yes. In our benchmark from 6 global regions, Edge functions averaged 106ms (P50) vs 246ms for warm Serverless and 859ms for cold Serverless. Edge is about 9x faster during cold starts and 2x faster when warm. + +
+ +
+ +In our test, Vercel Serverless cold starts averaged 859ms (P50), with P95 at 1,046ms and P99 at 1,156ms. Functions were pinged every 30 minutes to ensure they scaled down between requests. + +
+ +
+ +Vercel Edge functions have negligible cold starts. In our benchmark, Edge functions maintained a consistent P50 of 106ms and P99 of 328ms regardless of request frequency, compared to Serverless which jumped from 246ms (warm) to 859ms (cold). + +
+ +
+ +Vercel Serverless functions are deployed in a single region (iad1 — Washington, D.C. by default). All requests are routed through a nearby data center before being forwarded to the function's region. Edge functions are deployed globally and execute in the datacenter closest to the user. + +
diff --git a/apps/web/src/content/pages/blog/self-hosting-openstatus.mdx b/apps/web/src/content/pages/blog/self-hosting-openstatus.mdx index af8e05a5..9c68859b 100644 --- a/apps/web/src/content/pages/blog/self-hosting-openstatus.mdx +++ b/apps/web/src/content/pages/blog/self-hosting-openstatus.mdx @@ -74,6 +74,6 @@ Self-hosting is powerful, but it isn't a silver bullet. Monitoring your own moni Whether you prefer the simplicity of openstatus's managed SaaS or the control of a self-hosted instance, the barrier to entry has never been lower. Self-hosting is no longer an afterthought — it's a fully featured, reliable way to keep tabs on your stack. -Give the updated self-hosting guide a spin and let the team know how it works for your setup! +Give the updated [self-hosting guide](/docs/guides/self-hosting-openstatus) a spin and let the team know how it works for your setup! If you only want the status page and not the monitoring stack, there's a [lightweight status-page-only setup](/docs/guides/self-host-status-page-only) that runs four services instead of the full platform. Don't forget to join the [community](https://www.openstatus.dev/discord) if you run into issues! And you can check out my work at [GitHub](https://github.com/zapteryx) :) diff --git a/apps/web/src/content/pages/blog/status-pages-is-politics.mdx b/apps/web/src/content/pages/blog/status-pages-is-politics.mdx index 78955e5f..06ec904e 100644 --- a/apps/web/src/content/pages/blog/status-pages-is-politics.mdx +++ b/apps/web/src/content/pages/blog/status-pages-is-politics.mdx @@ -55,3 +55,29 @@ Until then, every vendor who publishes honest incident reports is subsidizing co --- The founder who lost that deal made the harder choice. They could have scrubbed their page, played the game, and closed the deal. They didn't. That takes conviction. The least the rest of us can do is stop pretending that a green status page means anything at all. + +## Frequently asked questions + +
+ +Every system has incidents. A status page that has been green for months likely means the vendor isn't reporting incidents, not that they aren't having them. Without standardized reporting, a clean status page is indistinguishable from an opaque one. + +
+ +
+ +Instead of comparing which page looks cleaner, ask vendors to show their incident history for the past 6 months. If they claim zero incidents, press harder. Treat transparency as a trust signal, not a liability. + +
+ +
+ +When there's no standard for what gets reported, buyers rationally pick the vendor with fewer visible incidents. The signal is inverted: transparency looks like unreliability, and opacity looks like stability. This rewards the wrong behavior. + +
+ +
+ +Big organizations know public status pages are political documents. They run private, internal status pages so that teams across silos can coordinate on the actual operational truth — not the sanitized version crafted for public consumption. + +
diff --git a/apps/web/src/content/pages/blog/your-customer-found-out-first.mdx b/apps/web/src/content/pages/blog/your-customer-found-out-first.mdx index dd750a06..3b72607c 100644 --- a/apps/web/src/content/pages/blog/your-customer-found-out-first.mdx +++ b/apps/web/src/content/pages/blog/your-customer-found-out-first.mdx @@ -97,3 +97,29 @@ None of this is exotic. It's just the difference between monitoring built to *kn This is why we build [openstatus](https://www.openstatus.dev): synthetic checks that run your actual transactions with real assertions, from regions all over the world, tied to a status page that's part of the incident flow instead of an afterthought. It's [open source](https://github.com/openstatusHQ/openstatus) — the checker, the dashboard, all of it — because "trust our monitoring" should not itself require blind trust. Your customers already monitor your product with every request they make. The only question is whether you find out before they tell you. + +## Frequently asked questions + +
+ +Because most monitoring checks the wrong thing: a cached homepage instead of the login, checkout, or API paths customers actually use. A CDN-served 200 can stay green while every authenticated request fails. + +
+ +
+ +No. Error pages, maintenance pages, empty JSON responses, and broken backends frequently return 200. A meaningful check asserts on the response body and latency, not just the status code. + +
+ +
+ +A single-region check tells you the service is reachable from that region — nothing more. DNS issues, routing problems, and regional provider outages routinely take a service down for one continent while it stays up for another. + +
+ +
+ +Customers forgive incidents; they don't forgive silence. Publishing before the support tickets arrive turns 'they didn't even know' into 'they told me before I noticed' — and deflects the duplicate tickets while you fix the actual problem. + +
diff --git a/apps/web/src/content/pages/compare/atlassian-statuspage.mdx b/apps/web/src/content/pages/compare/atlassian-statuspage.mdx index 9445e235..b6e7c044 100644 --- a/apps/web/src/content/pages/compare/atlassian-statuspage.mdx +++ b/apps/web/src/content/pages/compare/atlassian-statuspage.mdx @@ -97,6 +97,38 @@ With OpsGenie shutting down in April 2027, this is also a good time to consolida - [Status Pages for Compliance](/use-case/compliance) - [Pricing](/pricing) +## Frequently asked questions + +
+ +Yes. Openstatus includes built-in uptime monitoring from 28 global regions — something Atlassian Statuspage does not offer at all. Openstatus pricing is flat ($30/mo) and does not scale with subscriber count. Atlassian Statuspage charges per subscriber tier, meaning your bill grows as your audience does. Openstatus is also open-source and self-hostable. + +
+ +
+ +Yes. Atlassian Statuspage has no built-in monitoring. You must connect a separate tool (Datadog, Pingdom, etc.) to detect incidents. Openstatus monitors your endpoints from 28 regions simultaneously and can update your status page automatically based on check results. + +
+ +
+ +Atlassian Statuspage starts at $29/month for one page and 100 subscribers, jumping to $99/month for three pages and $399/month for custom HTML/CSS. Subscriber-count tiers add cost as your audience grows. Openstatus starts at $30/month with flat pricing, unlimited subscribers, and monitoring included. + +
+ +
+ +OpsGenie, Atlassian's incident management tool often used alongside Statuspage, is shutting down in April 2027. Teams using the Statuspage + OpsGenie combination will need to find replacements for both. Openstatus covers the monitoring and status page layer in one product. + +
+ +
+ +Yes. Openstatus is open-source (AGPL-3.0) and fully self-hostable. Atlassian Statuspage is a closed-source SaaS with no self-hosting option. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/compare/betterstack.mdx b/apps/web/src/content/pages/compare/betterstack.mdx index 3f54758f..b0bd273f 100644 --- a/apps/web/src/content/pages/compare/betterstack.mdx +++ b/apps/web/src/content/pages/compare/betterstack.mdx @@ -57,7 +57,9 @@ BetterStack's base price looks competitive, but add-ons for private status pages ## Built for AI and Agentic Workflows -Openstatus ships a CLI that integrates natively into AI-driven workflows. Whether you're using an AI agent or building your own agentic automation, the openstatus CLI lets you create monitors, trigger checks, and manage incidents programmatically, no browser required. BetterUptime has no CLI and no tooling designed for machine-to-machine interaction. +Openstatus ships a CLI that integrates natively into AI-driven workflows. Whether you're using an AI agent or building your own agentic automation, the openstatus CLI lets you create monitors, trigger checks, and manage incidents programmatically, no browser required. BetterStack has no CLI and no tooling designed for machine-to-machine interaction. + +There is also an [MCP server](/docs/reference/mcp-server), so an AI client can query monitor state and manage incidents directly over the Model Context Protocol. ## When to Choose openstatus @@ -90,6 +92,38 @@ Use our **[BetterStack import tool](/guides/migrate-from-betterstack)** to autom - [Status Pages for Compliance](/use-case/compliance) - [Pricing](/pricing) +## Frequently asked questions + +
+ +Yes. openstatus offers uptime monitoring from 28 regions (vs. BetterStack's 4), an open-source codebase, parallel check scheduling, and unlimited status page subscribers — all included in the base price. BetterStack charges extra for private status pages ($42/mo.) and subscriber tiers ($42/mo. per 1000). openstatus is also bootstrapped and self-hostable. + +
+ +
+ +openstatus starts free (Hobby plan) and paid plans begin at $30/month (Starter) and $100/month (Pro). Private status pages and unlimited subscribers are included. BetterStack's equivalent features require paid add-ons on top of their base price. + +
+ +
+ +openstatus monitors from 28 regions worldwide across 3 cloud providers. BetterStack uses approximately 4 regions. openstatus also uses a parallel scheduling strategy — all selected regions fire simultaneously — whereas BetterStack uses round-robin scheduling, cycling through regions one at a time. + +
+ +
+ +Yes. openstatus is open-source (AGPL-3.0 license) and fully self-hostable with Docker. BetterStack is a closed-source SaaS product with no self-hosting option. + +
+ +
+ +Parallel monitoring (openstatus) checks all selected regions simultaneously at each interval, giving you a true global snapshot of availability. Round-robin monitoring (BetterStack) cycles through regions one at a time, so each check only tests from a single location. Parallel monitoring detects regional outages faster. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/compare/checkly.mdx b/apps/web/src/content/pages/compare/checkly.mdx index c1a05bc1..e923ba93 100644 --- a/apps/web/src/content/pages/compare/checkly.mdx +++ b/apps/web/src/content/pages/compare/checkly.mdx @@ -83,6 +83,32 @@ If you rely heavily on Playwright browser checks, openstatus is not a direct rep - [Status Pages for API Providers](/use-case/api-providers) - [Pricing](/pricing) +## Frequently asked questions + +
+ +It depends on your use case. Openstatus is focused on uptime monitoring and status pages — it excels at HTTP, TCP, and DNS checks from 28 global regions. Checkly is focused on synthetic monitoring with browser-based checks using Playwright. If you need uptime monitoring with a public status page, openstatus is the stronger choice. If you need end-to-end browser testing as monitoring, Checkly is more suitable. + +
+ +
+ +Yes. Openstatus monitors from 28 regions worldwide. Checkly uses approximately 19 regions. Openstatus also checks all regions simultaneously (parallel scheduling) rather than cycling through them. + +
+ +
+ +Yes. Openstatus includes branded status pages with custom domains, maintenance windows, and subscriber notifications on all plans. Checkly does not offer a built-in public status page product. + +
+ +
+ +Yes. Openstatus is AGPL-3.0-licensed and fully self-hostable. Checkly is a closed-source SaaS product. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/compare/datadog.mdx b/apps/web/src/content/pages/compare/datadog.mdx index cf60443f..db86a874 100644 --- a/apps/web/src/content/pages/compare/datadog.mdx +++ b/apps/web/src/content/pages/compare/datadog.mdx @@ -86,6 +86,32 @@ If you depend on Datadog's browser synthetics and trace correlation, keep those - [Status Pages for API Providers](/use-case/api-providers) - [Pricing](/pricing) +## Frequently asked questions + +
+ +Yes, for uptime and API monitoring with a status page. Openstatus runs HTTP, TCP, and DNS checks from 28 regions with flat pricing and a built-in status page, and it's open-source. Datadog Synthetics is far more powerful for browser-based, multi-step synthetic tests inside a full observability platform — but its per-run, per-seat pricing climbs steeply, and it has no standalone free tier for synthetics. + +
+ +
+ +Datadog prices synthetic tests per run and per location, so frequent checks across multiple regions add up fast — published comparisons cite thousands of dollars a month for high-frequency uptime checks. Openstatus uses flat pricing: paid plans start at $30/month for 20 monitors across 28 regions with no per-run fees and unlimited team members. + +
+ +
+ +Yes. Openstatus includes a branded public status page with custom domains, maintenance windows, and subscriber notifications on every plan. Datadog offers a status page product, but it's a separate part of the platform and assumes you're already a Datadog customer. + +
+ +
+ +If you need browser-based, scripted multi-step synthetic tests tightly correlated with APM traces, logs, and metrics in one platform, Datadog is the stronger choice. Openstatus is the better fit when you mainly need uptime and API checks plus a status page without the platform cost. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/compare/incidentio.mdx b/apps/web/src/content/pages/compare/incidentio.mdx index f4c88b40..e1d88d9d 100644 --- a/apps/web/src/content/pages/compare/incidentio.mdx +++ b/apps/web/src/content/pages/compare/incidentio.mdx @@ -83,6 +83,32 @@ If you only need the detection and communication layer, openstatus covers it alo - [Status Pages for Compliance](/use-case/compliance) - [Pricing](/pricing) +## Frequently asked questions + +
+ +It depends on what you need. Incident.io is an incident management platform — on-call scheduling, Slack-based workflows, AI post-mortems. Openstatus is a monitoring and status page platform. If your primary need is detecting outages and communicating them to users, openstatus is the more direct fit. If you already have monitoring and need sophisticated incident coordination across a large on-call team, incident.io is built for that. + +
+ +
+ +No. Incident.io does not monitor your services. It receives alerts from external tools like Datadog, PagerDuty, or Prometheus and routes them through its incident workflow. You still need a separate monitoring tool. Openstatus includes uptime monitoring from 28 regions as part of the same product. + +
+ +
+ +Openstatus covers the detect-and-communicate part of incident response: monitors detect issues and your status page communicates them to users. It does not have on-call scheduling, escalation policies, or Slack-based incident coordination. For teams that need those workflows, incident.io is the stronger choice. + +
+ +
+ +Openstatus starts at $30/month with unlimited team members. Incident.io uses per-seat pricing on paid plans and targets mid-to-large engineering teams. For small teams that only need monitoring and a status page, openstatus is significantly cheaper. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/compare/instatus.mdx b/apps/web/src/content/pages/compare/instatus.mdx index b5d49336..d1a68872 100644 --- a/apps/web/src/content/pages/compare/instatus.mdx +++ b/apps/web/src/content/pages/compare/instatus.mdx @@ -84,6 +84,32 @@ Use our **[Instatus import tool](/guides/migrate-from-instatus)** to automatical - [Status Pages for Compliance](/use-case/compliance) - [Pricing](/pricing) +## Frequently asked questions + +
+ +Yes, especially if you need real uptime monitoring alongside your status page. Instatus is a status-page-first product with basic HTTP monitoring added later. Openstatus was built around monitoring first — checking from 28 global regions simultaneously — with status pages as a native part of the product, not a bolt-on. + +
+ +
+ +Yes. Openstatus includes branded status pages with custom domains, maintenance windows, and subscriber notifications on all paid plans. The status page is tightly coupled to your monitors, so incidents and response times reflect real check results. + +
+ +
+ +Instatus starts at $20/month for one custom-domain status page. Openstatus starts at $30/month and includes uptime monitoring from 28 regions, unlimited team members, and monitoring-as-code tooling. If you need both monitoring and a status page, openstatus covers both in one plan. + +
+ +
+ +Yes. Openstatus is open-source (AGPL-3.0) and fully self-hostable. Instatus is a closed-source SaaS with no self-hosting option. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/compare/pingdom.mdx b/apps/web/src/content/pages/compare/pingdom.mdx index 07e0ec11..2cdf45ad 100644 --- a/apps/web/src/content/pages/compare/pingdom.mdx +++ b/apps/web/src/content/pages/compare/pingdom.mdx @@ -89,6 +89,32 @@ If you rely on Pingdom's RUM or transaction monitoring, openstatus is not a drop - [Reduce Support Tickets with a Status Page](/use-case/reduce-support-tickets) - [Pricing](/pricing) +## Frequently asked questions + +
+ +Yes, for uptime monitoring and status pages. Openstatus monitors from 28 global regions simultaneously, is open-source and self-hostable, includes a built-in public status page, and starts free. Pingdom is a closed-source SolarWinds product with no free tier and no public status page, though it offers real user monitoring (RUM) and transaction checks that openstatus does not. + +
+ +
+ +No. Pingdom discontinued its free tier after the SolarWinds acquisition. The cheapest paid plan starts at around $15/month (billed annually) for 10 uptime checks, with a 14-day trial. Openstatus has a permanent free plan with 1 monitor across 6 regions. + +
+ +
+ +Openstatus includes a built-in branded public status page with custom domains, maintenance windows, and subscriber notifications on every plan. Pingdom does not offer a public status page product — you would need a separate tool for incident communication. + +
+ +
+ +Yes. Openstatus is AGPL-3.0-licensed and fully self-hostable. Pingdom is a closed-source SaaS owned by SolarWinds with no self-hosting option. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/compare/statusio.mdx b/apps/web/src/content/pages/compare/statusio.mdx index 4da60d65..f6d6fbff 100644 --- a/apps/web/src/content/pages/compare/statusio.mdx +++ b/apps/web/src/content/pages/compare/statusio.mdx @@ -83,6 +83,32 @@ Status.io costs nearly 3x more than openstatus's Starter plan — and doesn't in - [Status Pages for Enterprise Sales](/use-case/enterprise-sales) - [Pricing](/pricing) +## Frequently asked questions + +
+ +Yes. Openstatus includes built-in uptime monitoring from 28 global regions, which Status.io does not offer. Openstatus is open-source, self-hostable, and starts at $30/month with unlimited team members. Status.io is a closed-source SaaS starting around $79/month with no monitoring capabilities. + +
+ +
+ +Yes. Status.io is a hosted status page platform with no built-in monitoring. You still need a separate tool to detect incidents. Openstatus monitors your endpoints from 28 regions simultaneously and can reflect real check results on your status page. + +
+ +
+ +Status.io starts around $79/month. Openstatus starts at $30/month and includes uptime monitoring, unlimited team members, and developer tooling (CLI, Terraform, GitHub Actions) that Status.io does not offer. + +
+ +
+ +Yes. Openstatus is open-source (AGPL-3.0) and fully self-hostable. Status.io is a closed-source SaaS with no self-hosting option. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/compare/uptime-kuma.mdx b/apps/web/src/content/pages/compare/uptime-kuma.mdx index b24ca48d..edcb604c 100644 --- a/apps/web/src/content/pages/compare/uptime-kuma.mdx +++ b/apps/web/src/content/pages/compare/uptime-kuma.mdx @@ -14,7 +14,7 @@ faq: - question: "Does openstatus support self-hosting like Uptime Kuma?" answer: "Yes. openstatus is AGPL-3.0 licensed and can be self-hosted with Docker. You also get the option to use the managed cloud service without managing any infrastructure." - question: "Can openstatus monitor from multiple regions unlike Uptime Kuma?" - answer: "Yes. openstatus monitors from 28 regions across 3 cloud providers (AWS, GCP, Fly.io) simultaneously. Uptime Kuma only checks from the single server where it is installed, which means it cannot detect regional outages." + answer: "Yes. openstatus monitors from 28 regions across 3 cloud providers (Fly.io, Koyeb, and Railway) simultaneously. Uptime Kuma only checks from the single server where it is installed, which means it cannot detect regional outages." --- ## Looking for an Uptime Kuma alternative? @@ -76,15 +76,51 @@ Uptime Kuma is free software, but running it requires a server. A basic VPS cost 4. **Configure alerts** — openstatus supports Slack, Discord, Email, PagerDuty, OpsGenie, and more 5. **Decommission your server** — once your monitors are running on openstatus, you can shut down your Uptime Kuma instance and stop paying for the VPS +openstatus ships automated importers for Statuspage, Better Stack, Instatus, and Checkly, but not for Uptime Kuma — so monitors are recreated rather than imported. [Monitoring as code](/docs/concept/uptime-monitoring-as-code) turns that into a one-time YAML file rather than an afternoon of dashboard clicking, and the same file stays version-controlled afterwards. + If you prefer to self-host openstatus instead, check the [GitHub repository](https://github.com/openstatusHQ/openstatus) for Docker setup instructions. ## Related Resources +- [A hosted Uptime Kuma alternative](/guides/hosted-uptime-kuma-alternative) — the case for managed hosting in more depth - [Best Open Source Status Pages in 2026](/guides/best-opensource-status-page-2026) - [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) - [Status Pages for Open Source Projects](/use-case/open-source) +- [Uptime monitoring](/uptime-monitoring) - [Pricing](/pricing) +## Frequently asked questions + +
+ +Yes, especially if you want managed cloud hosting or global multi-region monitoring. Both are open-source, but openstatus is available as a SaaS (no server to maintain) and monitors from 28 regions worldwide. Uptime Kuma is self-hosted only and checks from a single server location. + +
+ +
+ +The main difference is hosting model and monitoring coverage. Uptime Kuma is self-hosted only — you run it on your own server and it monitors from that single location. openstatus is available as a managed SaaS or self-hosted, and checks from 28 regions across multiple cloud providers simultaneously. + +
+ +
+ +openstatus has a free Hobby plan (1 monitor, 6 regions, 1 status page) with no credit card required. Uptime Kuma is fully free and open-source but requires you to provision, host, and maintain your own server. + +
+ +
+ +Yes. openstatus is AGPL-3.0 licensed and can be self-hosted with Docker. You also get the option to use the managed cloud service without managing any infrastructure. + +
+ +
+ +Yes. openstatus monitors from 28 regions across 3 cloud providers (Fly.io, Koyeb, and Railway) simultaneously. Uptime Kuma only checks from the single server where it is installed, which means it cannot detect regional outages. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/compare/uptime-robot.mdx b/apps/web/src/content/pages/compare/uptime-robot.mdx index fed7d04b..a838f1f7 100644 --- a/apps/web/src/content/pages/compare/uptime-robot.mdx +++ b/apps/web/src/content/pages/compare/uptime-robot.mdx @@ -92,6 +92,32 @@ Most teams complete the switch in under an hour. If you need help, reach out at - [Reduce Support Tickets with a Status Page](/use-case/reduce-support-tickets) - [Pricing](/pricing) +## Frequently asked questions + +
+ +Yes. Openstatus monitors from 28 global regions simultaneously (UptimeRobot checks from a single location at a time), is open-source and self-hostable, includes unlimited team members on paid plans, and supports OpenTelemetry export and CI/CD integration via GitHub Actions — none of which UptimeRobot offers. + +
+ +
+ +Openstatus starts free and paid plans begin at $30/month with unlimited team members. UptimeRobot charges an additional $19 per seat for team members, which adds up quickly for larger teams. + +
+ +
+ +Yes. Openstatus checks from 28 regions worldwide across multiple cloud providers. UptimeRobot does not offer meaningful multi-region monitoring. + +
+ +
+ +Yes. Openstatus is open-source (AGPL-3.0 license) and self-hostable. UptimeRobot is a closed-source SaaS with no self-hosting option. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx b/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx index 69b64cdf..de39c9d0 100644 --- a/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx +++ b/apps/web/src/content/pages/docs/concept/latency-vs-response-time.mdx @@ -57,9 +57,9 @@ To measure latency, you can monitor endpoints like `/ping` or `/healthcheck` wit ``` -Response time is the total time from the moment a user's request is sent until the moment the first byte of the server's response is received. It includes both the network latency and the server's processing time. +Response time is the total time from the moment a user's request is sent until the server's response has been fully received. It includes the network latency, the server's processing time, and the time spent streaming the response body back. -Response time = network latency + server processing time +Response time = network latency + server processing time + transfer time The server processing time is the duration the server spends on tasks like: @@ -69,6 +69,37 @@ The server processing time is the duration the server spends on tasks like: A high response time often indicates a problem with the server-side application itself. For example, slow database queries or inefficient application code can dramatically increase the response time, even if the network latency is low. +## The request in phases: DNS, TCP, TLS, TTFB, transfer + +"Latency" and "response time" are summaries. A single request is really five +consecutive phases, and knowing which one is slow is the difference between guessing +and fixing. openstatus records each phase as its own duration: + +| Phase | What it measures | What a slow number means | +| --- | --- | --- | +| **DNS** | Resolving the hostname to an IP address | Slow or distant nameservers, or a TTL so short that nothing is ever cached | +| **Connect** | The TCP handshake opening the socket | Mostly physical distance. This is the phase a CDN or edge deployment shortens | +| **TLS** | Negotiating the encrypted connection | A long certificate chain, no session resumption, or an old TLS version | +| **TTFB** | Request sent → first byte of the response returns | Your application's own work: database queries, rendering, upstream API calls | +| **Transfer** | Streaming the rest of the response body | A large payload, no compression, or a slow link | + +Two clarifications that trip people up. + +**TTFB here is a phase duration, not a cumulative timer.** Many tools define "time +to first byte" as everything from the start of the request — DNS, connect, and TLS +included. In this breakdown those phases are already accounted for separately, so +TTFB isolates the part your server is actually responsible for. A 40ms TTFB behind a +300ms connect phase is a fast application sitting a long way from the probe. + +**Response time is the sum, not the first byte.** The connection phases — DNS, +connect, and TLS — are the network cost of getting there before your application does +any work. Add TTFB and transfer and you have total response time, which is what a +user waits through. + +This is also why a single number hides the diagnosis. Two endpoints both answering +in 600ms are not equivalent if one spends 500ms in TLS and the other spends 500ms in +TTFB. The first is a connection problem you fix with infrastructure; the second is +code. ## Why the distinction matters for uptime monitoring @@ -114,17 +145,48 @@ By monitoring both metrics, you can quickly pinpoint whether a performance slowd - Could be network saturation or a DDoS attack. - Check: network bandwidth, traffic patterns, security. +## How to measure both + +You need two things: the phase breakdown, and more than one vantage point. + +**For a single check right now**, run the URL through the +[global speed test](/play/checker). It requests from 28 regions in parallel and +returns all five phases per region, which is enough to tell a distance problem from +an application problem in one pass. No account required. + +**For anything you care about over time**, one sample is not evidence. Latency moves +with traffic, deploys, and time of day, so a number from a single moment tells you +almost nothing about the distribution your users actually see. +[Uptime monitoring](/uptime-monitoring) re-runs the same check on a schedule, keeps +the history, and alerts on degradation rather than only on failure — which is the +difference between finding out from a graph and finding out from a customer. + +Whichever you use, measure from where your users are. A check that only runs from +the same continent as your origin will report healthy numbers indefinitely while +users on the other side of the world time out. + ## What openstatus tracks openstatus monitors and displays: - **Total response time** — the complete user experience. -- **Detailed timing breakdown** — DNS, TCP, TLS, request, response. +- **Detailed timing breakdown** — DNS, connect, TLS, TTFB, and transfer. - **Regional differences** — compare performance across locations. - **Historical trends** — identify patterns over time. +## Related reliability concepts + +Latency and response time are the raw measurements. These build on top of them: + +- **[What is a good response time?](/guides/what-is-a-good-response-time)** — target numbers for both the server and the browser half, judged at the right percentile. +- **[SLA vs SLO vs SLI](/guides/sla-vs-slo-vs-sli)** — turning a measurement into an internal target and a customer promise. +- **[Error budgets explained](/guides/error-budgets-explained)** — how much failure your target actually permits, and what to do when it is spent. +- **[What is MTTR?](/guides/what-is-mttr)** — measuring recovery once something has gone wrong. +- **[Why uptime percentage alone is misleading](/guides/why-uptime-percentage-is-misleading)** — why a single availability number hides the distribution. + ## Next steps +- **[Run a one-off speed test](/play/checker)** — see the timing breakdown for your own URL from 28 regions, no account needed. - **[Create your first monitor](/docs/tutorial/create-your-first-monitor)** — start tracking these metrics. - **[Understanding uptime monitoring](/docs/concept/uptime-monitoring)** — broader monitoring concepts. - **[HTTP monitor reference](/docs/reference/http-monitor)** — technical specifications. diff --git a/apps/web/src/content/pages/docs/guides/how-to-connect-openstatus-to-claude-code.mdx b/apps/web/src/content/pages/docs/guides/how-to-connect-openstatus-to-claude-code.mdx index 3c6b1a66..d86d4f74 100644 --- a/apps/web/src/content/pages/docs/guides/how-to-connect-openstatus-to-claude-code.mdx +++ b/apps/web/src/content/pages/docs/guides/how-to-connect-openstatus-to-claude-code.mdx @@ -93,7 +93,7 @@ If you have a write-scoped key, try drafting an incident — Claude Code will sh > draft a status report on my "api" page: investigating elevated latency on the payment endpoint ``` - + ## What you've accomplished diff --git a/apps/web/src/content/pages/docs/guides/how-to-monitor-mcp-server.mdx b/apps/web/src/content/pages/docs/guides/how-to-monitor-mcp-server.mdx index 8e0264e4..b53323ed 100644 --- a/apps/web/src/content/pages/docs/guides/how-to-monitor-mcp-server.mdx +++ b/apps/web/src/content/pages/docs/guides/how-to-monitor-mcp-server.mdx @@ -1,14 +1,22 @@ --- category: Guides -title: How to Monitor Your Model Context Provider (MCP) Server -description: Learn how to monitor your MCP server with openstatus using JSON-RPC ping checks +title: How to Monitor an MCP Server +description: Monitor an MCP server with openstatus - JSON-RPC health checks, tools/list assertions, authenticated endpoints, and uptime alerts from 28 regions. +seo: + title: "How to Monitor an MCP Server - Health Checks & Uptime" sidebar: label: Monitor your MCP Server --- +> **Just want to test a server once?** Run it through the free +> [MCP server health check](/play/mcp-health) — full JSON-RPC handshake from your +> browser, no account. This guide is for monitoring it continuously. + ## Problem -Running a Model Context Provider (MCP) server is critical for your AI applications, but traditional HTTP monitoring often falls short. MCP servers communicate using the JSON-RPC 2.0 protocol, requiring specific request/response patterns that standard health checks don't cover. How can you confidently ensure your MCP server is healthy and responsive at all times, without custom scripts or complex setups? +Running a Model Context Protocol (MCP) server is critical for your AI applications, but traditional HTTP monitoring often falls short. MCP servers communicate using the JSON-RPC 2.0 protocol, requiring specific request/response patterns that standard health checks don't cover. A server can return `200 OK` with an HTML error page, stop echoing the JSON-RPC `id`, or quietly return an empty `tools/list` — and every one of those looks healthy to a status-code pinger while breaking every AI client that connects. + +How can you confidently ensure your MCP server is healthy and responsive at all times, without custom scripts or complex setups? ## Solution @@ -80,9 +88,11 @@ The key fields in this YAML configuration: - `statusCode` — ensures the HTTP response is `200 OK`. - `textBody` — verifies that the response payload exactly matches the expected JSON-RPC `ping` result. -### 3. Test your MCP server (optional) +### 3. Test your MCP server online first -Before deploying your monitor, you can manually test your MCP server's `ping` endpoint with `curl` to confirm it responds as expected. This helps verify the `target` value for your `textBody` assertion. +Before deploying a monitor, confirm the server actually speaks MCP. The quickest way is the [MCP server health check](/play/mcp-health) — paste your URL and it runs the full handshake (`initialize`, `ping`, `tools/list`) from the browser, shows the per-step latency, and tells you whether the endpoint is Healthy, Partial, Auth Required, or Unreachable. Use it to read off the exact response your assertion needs to match. + +You can also test the `ping` endpoint manually with `curl`. This helps verify the `target` value for your `textBody` assertion. ```bash curl -X POST \\ @@ -103,13 +113,92 @@ openstatus monitors apply --config openstatus.yaml This command uploads your configuration, and monitoring will begin immediately. +## Monitoring an MCP server that requires authentication + +Most production MCP servers are not public. An unauthenticated `ping` against one returns `401 Unauthorized`, usually with a `WWW-Authenticate: Bearer` header, so a monitor without credentials will report your healthy server as down. + +Add the same `Authorization` header your AI clients use: + +```yaml + request: + url: https://mcp.example.com/mcp + method: POST + headers: + Authorization: Bearer + User-Agent: openstatus + Accept: application/json, text/event-stream + Content-Type: application/json +``` + +Two things to plan for: + +- **Token rotation is the most common false alarm.** When the token expires, the monitor goes down while the server is perfectly healthy. Assert on `statusCode` `eq` `200` so a `401` fails loudly and is easy to recognise, rather than debugging it as an outage. +- **Keep the credential out of your repository.** This YAML is meant to be version-controlled, so use a token scoped to read-only health checks — not a production credential — and rotate it on a schedule you control. + +If you are unsure which authorization server issues your token, the [health check tool](/play/mcp-health) parses the `WWW-Authenticate` challenge and surfaces the OAuth resource metadata for you. + +## Monitoring tool availability and latency + +A `ping` proves the server is answering. It does not prove the server still exposes the tools your agents call — an empty `tools/list` is the failure mode that breaks AI clients while every uptime dashboard stays green. + +Add a second monitor that calls `tools/list` and asserts a known tool name is present: + +```yaml +mcp-tools: + name: "MCP tools/list" + description: "Verify the MCP server still exposes its tools" + frequency: "5m" + active: true + regions: ["iad", "ams", "sin"] + retry: 3 + kind: http + request: + url: https://hf.co/mcp + method: POST + body: > + { + "jsonrpc": "2.0", + "id": "openstatus", + "method": "tools/list" + } + headers: + User-Agent: openstatus + Accept: application/json, text/event-stream + Content-Type: application/json + assertions: + - kind: statusCode + compare: eq + target: 200 + - kind: textBody + compare: contains + target: "your_tool_name" +``` + +Assert on the bare tool name, not on `"name":"your_tool_name"`. `contains` matches literally, and servers differ in whether they emit a space after the JSON key — an assertion written against the compact form fails the moment a server pretty-prints its response. + +`tools/list` is also the more honest latency signal. `ping` usually returns an empty result and measures little more than the network round trip, whereas `tools/list` exercises the server's actual request path — which is what an agent waits on. Run it at a lower frequency than `ping` if you want to keep request volume down. + +## What to alert on + +Not every MCP failure deserves the same response: + +- **`ping` failing across all regions** — the server is down. Alert immediately. +- **`ping` failing in one region** — usually a network path problem rather than your server. Retries handle most of these, which is what `retry: 3` is for. +- **`401` after a period of `200`s** — a rotated or expired token. This is a credentials problem, not an outage. +- **`tools/list` succeeding but missing a tool** — a deploy removed or renamed a tool. Nothing is "down", but your agents are already broken. +- **Latency climbing on `tools/list` while `ping` stays flat** — the server is under load in its application layer rather than its network layer. + ## What you've accomplished - Configured a JSON-RPC based monitor for your MCP server - Implemented precise assertions to validate `ping` responses +- Handled authenticated endpoints without turning token rotation into a false outage +- Added a `tools/list` check so a missing tool is caught before your agents hit it - Set up global monitoring to detect localised or widespread issues - Automated monitor deployment using a version-controlled YAML configuration +Both monitors run on [openstatus uptime monitoring](/uptime-monitoring) from up to 28 regions, with alerting and history — so a broken handshake reaches you before it reaches the agents depending on it. + ## What's next - **[Export metrics to OTLP](/docs/guides/how-to-export-metrics-to-otlp-endpoint)** — integrate your MCP monitoring data with your existing observability platform. diff --git a/apps/web/src/content/pages/docs/guides/self-host-status-page-only.mdx b/apps/web/src/content/pages/docs/guides/self-host-status-page-only.mdx index 9a2f9a80..6b206ceb 100644 --- a/apps/web/src/content/pages/docs/guides/self-host-status-page-only.mdx +++ b/apps/web/src/content/pages/docs/guides/self-host-status-page-only.mdx @@ -1,9 +1,45 @@ --- category: Guides title: Self-Host the openstatus Status Page (Lightweight) -description: Deploy only the openstatus status page and dashboard on your own infrastructure, without monitoring, analytics, or background services. +seo: + title: "Self-Hosted Status Page: Options, Tradeoffs, and Setup" +description: How to self-host a status page - what you actually take on by running it yourself, when hosting it is the wrong call, and a Docker Compose setup that runs the status page without any monitoring infrastructure. +faq: + - question: "Should I self-host my status page?" + answer: "Self-host if you need the status page inside your own network, have compliance rules about where incident data lives, or want to run it at infrastructure cost rather than per-seat pricing. Do not self-host if the page is your primary outage communication channel and it would share infrastructure with the systems it reports on - a status page that goes down with your product is worse than no status page." + - question: "Can I self-host only the status page, without monitoring?" + answer: "Yes. The lightweight Docker Compose stack runs four services - database, one-shot migration runner, dashboard, and status page - and omits automated monitoring, analytics, the API server, and private location probes. It suits teams who already have monitoring elsewhere and manage incidents manually." + - question: "Is self-hosting a status page free?" + answer: "The software is free and open-source under AGPL-3.0, but running it is not. You pay for the host, the storage backing the database, TLS certificates and a domain, plus your own time for upgrades, backups, and keeping the thing online. For a status page specifically, that last item is the real cost, because it has to stay up precisely when the rest of your infrastructure does not." + - question: "Where should I host a self-hosted status page?" + answer: "Somewhere with no shared failure domain with your production systems - a different provider, or at minimum a different region and account. The entire purpose of the page is to be reachable during an outage, so hosting it next to the thing that breaks defeats it." --- +## Should you self-host a status page? + +Worth answering before the Docker section, because self-hosting a status page has a +trap that other self-hosted software does not. + +**Self-host when:** the page has to live inside your own network, compliance rules +constrain where incident data is stored, you want infrastructure cost instead of +per-seat pricing, or you intend to modify it. + +**Do not self-host when:** the status page is your main channel for telling customers +about an outage, and it would run on the infrastructure that outage affects. A status +page that goes down alongside your product is worse than no status page — customers +lose the one place that was supposed to answer them. If you self-host anyway, put it +in a different failure domain: another provider, or at minimum a different region and +account. + +**The cost is not the licence.** The software is AGPL-3.0 and free. You pay in host, +storage, TLS, domain, and your own time for upgrades and backups — and for this +particular workload, in the obligation to keep it available exactly when everything +else is not. If that trade sounds wrong, the [hosted status page](/status-page) exists +so someone else carries it. + +For a first-hand account of what running it is actually like, a contributor wrote up +[self-hosting openstatus: the hurdles then, the experience now](/blog/self-hosting-openstatus). + ## Problem You want a status page to communicate incidents and maintenance to your users, but you don't need automated monitoring, analytics, or alerting. You may already have your own monitoring tools, or you simply want a lightweight way to manage your public-facing status page. @@ -140,7 +176,35 @@ docker compose -f docker-compose-lightweight.yaml ps **Port conflicts:** If ports 3000, 3001, or 8080 are already in use on your machine, update the host port mappings in `docker-compose-lightweight.yaml`. For example, change `"3000:3000"` to `"4000:3000"` to use port 4000 instead. +## Frequently asked questions + +
+ +Self-host if you need the status page inside your own network, have compliance rules about where incident data lives, or want to run it at infrastructure cost rather than per-seat pricing. Do not self-host if the page is your primary outage communication channel and it would share infrastructure with the systems it reports on — a status page that goes down with your product is worse than no status page. + +
+ +
+ +Yes. The lightweight Docker Compose stack runs four services — database, one-shot migration runner, dashboard, and status page — and omits automated monitoring, analytics, the API server, and private location probes. It suits teams who already have monitoring elsewhere and manage incidents manually. + +
+ +
+ +The software is free and open-source under AGPL-3.0, but running it is not. You pay for the host, the storage backing the database, TLS certificates and a domain, plus your own time for upgrades, backups, and keeping the thing online. For a status page specifically, that last item is the real cost, because it has to stay up precisely when the rest of your infrastructure does not. + +
+ +
+ +Somewhere with no shared failure domain with your production systems — a different provider, or at minimum a different region and account. The entire purpose of the page is to be reachable during an outage, so hosting it next to the thing that breaks defeats it. + +
+ ## Next steps - **[Self-host openstatus (full)](/docs/guides/self-hosting-openstatus)** — add automated monitoring, analytics, and alerting. +- **[Hosted status page](/status-page)** — the same status page without the upgrades, backups, and availability burden. +- **[Self-hosting: the hurdles then, the experience now](/blog/self-hosting-openstatus)** — a contributor's account of running it. - **[Join our Discord](https://www.openstatus.dev/discord)** — get help from the community. diff --git a/apps/web/src/content/pages/docs/guides/self-hosting-openstatus.mdx b/apps/web/src/content/pages/docs/guides/self-hosting-openstatus.mdx index b2648d11..5d4f940a 100644 --- a/apps/web/src/content/pages/docs/guides/self-hosting-openstatus.mdx +++ b/apps/web/src/content/pages/docs/guides/self-hosting-openstatus.mdx @@ -14,6 +14,8 @@ You want to run openstatus on your own infrastructure instead of using the hoste openstatus provides a Docker Compose setup that makes self-hosting straightforward. This guide walks you through deploying all necessary services and configuring your self-hosted instance. +> **Only want the status page?** If you already have monitoring elsewhere and just need somewhere to publish incidents, the [lightweight status-page-only setup](/docs/guides/self-host-status-page-only) runs four services instead of the full stack — no Tinybird, no probes, no API server. + ## Prerequisites - Docker and Docker Compose installed diff --git a/apps/web/src/content/pages/docs/reference/mcp-server.mdx b/apps/web/src/content/pages/docs/reference/mcp-server.mdx index 04d73fac..420448f4 100644 --- a/apps/web/src/content/pages/docs/reference/mcp-server.mdx +++ b/apps/web/src/content/pages/docs/reference/mcp-server.mdx @@ -99,7 +99,7 @@ The MCP client gates every tool call behind your approval — the server does no ### Notifying subscribers -Every mutation tool has a **required** `notify: boolean` field — there is no default. The tool's input schema rejects calls that omit it, which forces the LLM to make an explicit choice (and therefore ask the user) before firing. +Every publishing tool — `create_status_report`, `add_status_report_update`, `resolve_status_report`, `create_maintenance` — has a **required** `notify: boolean` field, with no default. The tool's input schema rejects calls that omit it, which forces the LLM to make an explicit choice (and therefore ask the user) before firing. `update_status_report` edits metadata only and carries no `notify` field at all. This required-field behaviour is specific to MCP. The dashboard AI assistant and the Slack agent wrap these same tools in an approval step that strips `notify` from the model-facing schema and injects it from a human toggle defaulting to `false`. MCP exposes the raw schema, so the caller must supply `notify` explicitly. diff --git a/apps/web/src/content/pages/guides/api-service-disruption.mdx b/apps/web/src/content/pages/guides/api-service-disruption.mdx index 24a0c0e2..a19bddad 100644 --- a/apps/web/src/content/pages/guides/api-service-disruption.mdx +++ b/apps/web/src/content/pages/guides/api-service-disruption.mdx @@ -83,3 +83,33 @@ Stripe consistently provides context about the source of information and sets cl - Include workaround instructions if available - Link to status dashboard or real-time monitoring - Add contact information for urgent support needs + +## Related + +This template is one of several in the [incident communication guide](/guides/incident-communication), which covers update cadence by severity, what to avoid saying, and the other scenario templates. + +## Frequently asked questions + +
+ +Provide updates every 30-60 minutes during active incidents, even if there's no significant change. Users appreciate knowing you're still working on the issue. Once resolved, a final summary is essential. + +
+ +
+ +Yes, if you can. Being specific helps users understand the scope and that you're transparent about dependencies. Companies like Vercel, Stripe, and GitHub regularly name their providers during incidents. Just remain professional and factual. + +
+ +
+ +Include error rates (e.g., '5% of requests failing'), affected endpoints, and timeframes when you have them. Avoid oversharing internal metrics that might confuse users. Focus on impact-oriented data that helps them assess how they're affected. + +
+ +
+ +Use this template specifically for API-related issues, third-party provider failures, or integration connectivity problems. Use Database Performance for database-specific issues, Deployment Rollback for deployment failures, or Network Connectivity for regional/CDN issues. + +
diff --git a/apps/web/src/content/pages/guides/best-hosted-status-page-2026.mdx b/apps/web/src/content/pages/guides/best-hosted-status-page-2026.mdx index b2cf04b8..ae1c4cdf 100644 --- a/apps/web/src/content/pages/guides/best-hosted-status-page-2026.mdx +++ b/apps/web/src/content/pages/guides/best-hosted-status-page-2026.mdx @@ -133,3 +133,29 @@ Atlassian Statuspage is still defensible if you live in the Atlassian world and ## Need Help or Have Questions? If you need help along the way, feel free to join our [Discord community](https://www.openstatus.dev/discord), check our [documentation](https://www.openstatus.dev/docs) for more information, or reach out to us via [email](mailto:ping@openstatus.dev). + +## Frequently asked questions + +
+ +openstatus is our top pick for 2026. It bundles built-in synthetic monitoring with the status page, ships an MCP server for AI coding agents, supports monitoring-as-code via Terraform, and starts at $30/month with unlimited team members. + +
+ +
+ +Look for built-in monitoring (or easy integration with your existing stack), monitoring-as-code support (a Terraform provider), subscriber channels (email, SMS, webhook, Slack), private-page support, transparent pricing without per-seat surprises, and — increasingly — an MCP server so coding agents can drive it. + +
+ +
+ +For most teams, yes. Hosted status pages remove the operational burden of running uptime infrastructure (which ironically is the thing you're trying to communicate about). Self-hosting only makes sense when you have strict data residency requirements or already operate the underlying infrastructure at scale. + +
+ +
+ +openstatus and Betterstack are the most cost-effective. openstatus starts at $30/month with monitoring and unlimited seats included, while Betterstack has a usable free tier. Atlassian Statuspage, Instatus, and Status.io get expensive quickly once you need private pages or larger subscriber lists. + +
diff --git a/apps/web/src/content/pages/guides/best-incident-communication-tools-2026.mdx b/apps/web/src/content/pages/guides/best-incident-communication-tools-2026.mdx index e67c6684..1b2dc661 100644 --- a/apps/web/src/content/pages/guides/best-incident-communication-tools-2026.mdx +++ b/apps/web/src/content/pages/guides/best-incident-communication-tools-2026.mdx @@ -129,3 +129,35 @@ For everyone else — and that's the majority of teams — **openstatus** is the ## Need Help or Have Questions? If you need help along the way, feel free to join our [Discord community](https://www.openstatus.dev/discord), check our [documentation](https://www.openstatus.dev/docs) for more information, or reach out to us via [email](mailto:ping@openstatus.dev). + +## Frequently asked questions + +
+ +Incident communication covers everything from internal coordination (who's working on the problem, what's the current status, where's the war room) to external messaging (status page updates, subscriber notifications, postmortems). A good incident communication tool covers both sides — your engineers and your users — and ties them together so updates don't drift between channels. + +
+ +
+ +Incident management platforms like incident.io and Rootly are Slack-first tools focused on internal coordination: creating war rooms, assigning roles, tracking timelines, and automating workflows. Status pages like openstatus, Atlassian Statuspage, and Instatus are externally-facing communication tools for your users. openstatus bridges both with a status page, a Slack agent for internal coordination, and built-in monitoring. + +
+ +
+ +openstatus is typically the best choice for small teams. It includes a status page, monitoring, a Slack agent, and unlimited team members at $30/month. incident.io and Rootly are per-seat priced and aimed at larger orgs. Atlassian Statuspage and Instatus cover the external side only and require a separate tool for internal coordination. + +
+ +
+ +Yes, and many teams do. A common stack is incident.io or Rootly for internal coordination, plus Atlassian Statuspage or openstatus for external communication. The downside is two products, two bills, and updates that can drift between them. Tools that cover both sides — like openstatus — reduce that overhead. + +
+ +
+ +Only openstatus includes built-in synthetic monitoring. incident.io, Rootly, Atlassian Statuspage, and Instatus all expect you to bring monitoring from another tool (Datadog, New Relic, Pingdom, etc.) and ingest the alerts. + +
diff --git a/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx b/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx index e2c8cb28..716c4c1a 100644 --- a/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx +++ b/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx @@ -95,3 +95,29 @@ Unfortunately, as of 2026, the project seems to have stalled. With its last majo ### The Verdict If you are starting a new project or migrating an old status page today, **openstatus** is the clear winner. Its modern architecture, active maintenance, and built-in integrations make it the easiest way to keep your users informed while your engineering team focuses on fixing the actual outages. + +## Frequently asked questions + +
+ +openstatus is the top pick for 2026. It is actively maintained, offers both cloud-hosted and self-hosted deployments, includes built-in uptime monitoring, and integrates with Slack out of the box. + +
+ +
+ +Focus on active maintenance, deployment flexibility (hosted vs. self-hosted), built-in monitoring, notification integrations (Slack, email, webhooks), and how easy it is for your end users to understand the current status at a glance. + +
+ +
+ +Yes. Tools like openstatus, Vigil, Cachet, and Statping-ng can all be self-hosted at no licensing cost. Keep in mind you will still need to provision and maintain your own infrastructure, so factor in server and operational costs. + +
+ +
+ +Upptime pioneered a clever GitOps approach using GitHub Actions and Pages, but its last major release was in 2020. Because it is no longer actively maintained, we recommend choosing an actively developed alternative like OpenStatus for production use. + +
diff --git a/apps/web/src/content/pages/guides/boring-is-better-for-status-pages.mdx b/apps/web/src/content/pages/guides/boring-is-better-for-status-pages.mdx index 4ef597c2..a38f71cf 100644 --- a/apps/web/src/content/pages/guides/boring-is-better-for-status-pages.mdx +++ b/apps/web/src/content/pages/guides/boring-is-better-for-status-pages.mdx @@ -89,6 +89,38 @@ That's not settling for less. That's understanding what matters. Migrating from your current status page? [Contact us](mailto:ping@openstatus.dev), we'll help you move over. +## Frequently asked questions + +
+ +JavaScript frameworks add load, parse, and execution time before rendering meaningful content. During an incident when users' connectivity might be degraded, this delay destroys trust. Static HTML renders instantly and works even when JavaScript fails, which is exactly what you need during a crisis. + +
+ +
+ +Animated status indicators that require JavaScript to show if you're down create a critical failure mode - if the script fails, the page shows nothing, which is indistinguishable from being completely offline. Status information should be visible immediately in plain HTML. + +
+ +
+ +No. During an incident, different users seeing different versions of your status page creates confusion and erodes trust. When customers compare notes and realize they're seeing conflicting information, you've turned an operational problem into a credibility crisis. + +
+ +
+ +External dependencies like graphing services or CDNs create additional failure modes. When those services fail - and they will - your status page loses functionality at the exact moment it matters most. Plain HTML and CSS have decades of battle-testing with no unexpected edge cases. + +
+ +
+ +A good status page works in grayscale for colorblind users, supports keyboard navigation and screen readers, loads instantly, provides RSS/Atom feeds for automated monitoring, and displays text-based updates that remain readable even if CSS fails. Accessibility isn't optional during high-stress moments. + +
+ --- Start free. No credit card required. Set up your first status page in under 5 minutes. diff --git a/apps/web/src/content/pages/guides/connect-openstatus-to-claude-code.mdx b/apps/web/src/content/pages/guides/connect-openstatus-to-claude-code.mdx index 6a6a1c35..5ad837c2 100644 --- a/apps/web/src/content/pages/guides/connect-openstatus-to-claude-code.mdx +++ b/apps/web/src/content/pages/guides/connect-openstatus-to-claude-code.mdx @@ -27,9 +27,9 @@ faq: - question: "Do I need a separate MCP credential for Claude Code?" answer: "No. Authentication is the standard `x-openstatus-key` header — the same API key the openstatus CLI, REST API, and Terraform provider use. There is no MCP-specific OAuth flow." - question: "Can Claude Code accidentally notify subscribers when creating an incident?" - answer: "No. Every mutation tool requires an explicit `notify: true | false` field. Claude Code must show the notify choice before firing the tool, so an LLM cannot quietly fan out an alert by omitting the flag." + answer: "No. Every publishing tool (`create_status_report`, `add_status_report_update`, `resolve_status_report`, `create_maintenance`) requires an explicit `notify: true | false` field. Claude Code must show the notify choice before firing the tool, so an LLM cannot quietly fan out an alert by omitting the flag. `update_status_report` is metadata-only and has no notify path at all." - question: "How do I give Claude Code read-only access to my workspace?" - answer: "Create an API key with `read` scope. The MCP server filters mutation tools out of the `tools/list` response for read-only keys, so Claude Code only sees `list_status_pages`, `list_status_reports`, and `list_maintenances`." + answer: "Create an API key with `read` scope. The MCP server filters write tools out of the `tools/list` response for read-only keys, so Claude Code sees only the 14 read tools — status pages and page components, status reports, maintenances, monitors, response logs, notifications, private locations, and audit logs. None of the `create_*`, `update_*`, or `resolve_*` tools are registered for that session." - question: "Where do MCP-driven changes appear in the audit log?" answer: "Every mutation routed through the MCP server lands in the audit log under `actor_type = 'mcp'`, with `actor_id` set to the API key id and `actor_user_id` set to the user who created the key. This separates AI-driven actions from CLI, dashboard, and direct API mutations." - question: "Can I commit the MCP configuration to my repository?" @@ -150,6 +150,50 @@ Three properties make this safe enough for a real incident: - **[Openstatus CLI](/tooling/cli)** — for terminal workflows that don't need an LLM in the loop. - **[Slack Agent](/status-page#slack-agent)** — for teams that live in Slack. +## Frequently asked questions + +
+ +Yes. Claude Code supports stdio, HTTP, and SSE transports. The openstatus MCP server is a stateless Streamable HTTP endpoint, registered with `claude mcp add --transport http`. + +
+ +
+ +Claude Code can list status pages, status reports, and maintenance windows; create new status reports; append updates to existing reports; resolve reports; edit metadata; and schedule maintenance windows — all scoped to the workspace tied to your API key. + +
+ +
+ +No. Authentication is the standard `x-openstatus-key` header — the same API key the openstatus CLI, REST API, and Terraform provider use. There is no MCP-specific OAuth flow. + +
+ +
+ +No. Every publishing tool (`create_status_report`, `add_status_report_update`, `resolve_status_report`, `create_maintenance`) requires an explicit `notify: true | false` field. Claude Code must show the notify choice before firing the tool, so an LLM cannot quietly fan out an alert by omitting the flag. `update_status_report` is metadata-only and has no notify path at all. + +
+ +
+ +Create an API key with `read` scope. The MCP server filters write tools out of the `tools/list` response for read-only keys, so Claude Code sees only the 14 read tools — status pages and page components, status reports, maintenances, monitors, response logs, notifications, private locations, and audit logs. None of the `create_*`, `update_*`, or `resolve_*` tools are registered for that session. + +
+ +
+ +Every mutation routed through the MCP server lands in the audit log under `actor_type = 'mcp'`, with `actor_id` set to the API key id and `actor_user_id` set to the user who created the key. This separates AI-driven actions from CLI, dashboard, and direct API mutations. + +
+ +
+ +Yes. Create a project-scoped `.mcp.json` at the repo root with the MCP server definition, and use `${OPENSTATUS_API_KEY}` for the header value so the secret stays out of source control. + +
+ --- Start free. No credit card required. Configure Claude Code to drive your status pages in under 5 minutes. diff --git a/apps/web/src/content/pages/guides/connect-openstatus-to-claude-desktop.mdx b/apps/web/src/content/pages/guides/connect-openstatus-to-claude-desktop.mdx index f7d2c5af..a1b5dc0d 100644 --- a/apps/web/src/content/pages/guides/connect-openstatus-to-claude-desktop.mdx +++ b/apps/web/src/content/pages/guides/connect-openstatus-to-claude-desktop.mdx @@ -29,9 +29,9 @@ faq: - question: "Why don't I see the openstatus tools after editing the config?" answer: "The two most common causes are not fully quitting Claude Desktop (it only loads MCP servers at launch — close the window is not enough, you have to Cmd+Q on macOS or File → Quit on Windows) and `npx` not being on the PATH that Claude Desktop sees. Install Node.js 18+ from nodejs.org if `node --version` is missing in your shell." - question: "Can Claude Desktop accidentally notify subscribers when posting an incident?" - answer: "No. Every mutation tool requires an explicit `notify: true | false` field. Claude must show the notify choice before firing the tool, so an LLM cannot quietly fan out an alert by omitting the flag." + answer: "No. Every publishing tool (`create_status_report`, `add_status_report_update`, `resolve_status_report`, `create_maintenance`) requires an explicit `notify: true | false` field. Claude must show the notify choice before firing the tool, so an LLM cannot quietly fan out an alert by omitting the flag. `update_status_report` is metadata-only and has no notify path at all." - question: "How do I give Claude Desktop read-only access to my workspace?" - answer: "Create an API key with `read` scope. The MCP server filters mutation tools out of the `tools/list` response for read-only keys, so Claude Desktop only sees `list_status_pages`, `list_status_reports`, and `list_maintenances`." + answer: "Create an API key with `read` scope. The MCP server filters write tools out of the `tools/list` response for read-only keys, so Claude Desktop sees only the 14 read tools — status pages and page components, status reports, maintenances, monitors, response logs, notifications, private locations, and audit logs. None of the `create_*`, `update_*`, or `resolve_*` tools are registered for that session." - question: "Where do MCP-driven changes appear in the audit log?" answer: "Every mutation routed through Claude Desktop lands in the audit log under `actor_type = 'mcp'`, with `actor_id` set to the API key id and `actor_user_id` set to the user who created the key. This separates AI-driven actions from CLI, dashboard, and direct API mutations." --- @@ -142,6 +142,50 @@ Three properties make this safe enough for a real incident: - **[Openstatus CLI](/tooling/cli)** — for terminal workflows that don't need an LLM in the loop. - **[Slack Agent](/status-page#slack-agent)** — for teams that live in Slack. +## Frequently asked questions + +
+ +Yes, but indirectly. Claude Desktop launches MCP servers as local stdio processes, so a remote HTTP server like openstatus has to be bridged through `npx mcp-remote` — a small open-source proxy that translates Claude Desktop's stdio calls into HTTP requests. You configure `mcp-remote` once in `claude_desktop_config.json` and Claude Desktop treats openstatus like any other local server. + +
+ +
+ +On macOS, the file lives at `~/Library/Application Support/Claude/claude_desktop_config.json`. On Windows, it lives at `%APPDATA%\Claude\claude_desktop_config.json`. Create the file if it does not exist. + +
+ +
+ +No. Authentication is the standard `x-openstatus-key` header — the same API key the openstatus CLI, REST API, and Terraform provider use. There is no MCP-specific OAuth flow. + +
+ +
+ +The two most common causes are not fully quitting Claude Desktop (it only loads MCP servers at launch — close the window is not enough, you have to Cmd+Q on macOS or File → Quit on Windows) and `npx` not being on the PATH that Claude Desktop sees. Install Node.js 18+ from nodejs.org if `node --version` is missing in your shell. + +
+ +
+ +No. Every publishing tool (`create_status_report`, `add_status_report_update`, `resolve_status_report`, `create_maintenance`) requires an explicit `notify: true | false` field. Claude must show the notify choice before firing the tool, so an LLM cannot quietly fan out an alert by omitting the flag. `update_status_report` is metadata-only and has no notify path at all. + +
+ +
+ +Create an API key with `read` scope. The MCP server filters write tools out of the `tools/list` response for read-only keys, so Claude Desktop sees only the 14 read tools — status pages and page components, status reports, maintenances, monitors, response logs, notifications, private locations, and audit logs. None of the `create_*`, `update_*`, or `resolve_*` tools are registered for that session. + +
+ +
+ +Every mutation routed through Claude Desktop lands in the audit log under `actor_type = 'mcp'`, with `actor_id` set to the API key id and `actor_user_id` set to the user who created the key. This separates AI-driven actions from CLI, dashboard, and direct API mutations. + +
+ --- Start free. No credit card required. Configure Claude Desktop to drive your status pages in under 5 minutes. diff --git a/apps/web/src/content/pages/guides/database-performance-degradation.mdx b/apps/web/src/content/pages/guides/database-performance-degradation.mdx index d1d95686..b7f72bad 100644 --- a/apps/web/src/content/pages/guides/database-performance-degradation.mdx +++ b/apps/web/src/content/pages/guides/database-performance-degradation.mdx @@ -106,3 +106,27 @@ Watch for these indicators that might trigger using this template: - Connection timeouts - User reports of slowness - Database monitoring alerts + +## Related + +This template is one of several in the [incident communication guide](/guides/incident-communication), which covers update cadence by severity, what to avoid saying, and the other scenario templates. + +## Frequently asked questions + +
+ +Match your communication to your audience. For developer-focused products, include technical details like p95 latency, query times, and specific database components. For general users, focus on impact: 'slower response times' instead of 'elevated connection pool exhaustion.' + +
+ +
+ +Yes, once resolved. Technical audiences appreciate transparency and can learn from your incidents. Share root cause, mitigation steps taken, and preventative measures. This builds trust and demonstrates engineering maturity. + +
+ +
+ +Trigger notifications when p95 latency exceeds 2x normal, error rates exceed 1%, connection timeouts occur, or when user reports indicate widespread slowness. Set up automated monitoring alerts to catch these thresholds early. + +
diff --git a/apps/web/src/content/pages/guides/deployment-rollback.mdx b/apps/web/src/content/pages/guides/deployment-rollback.mdx index 77ce868a..2259bc03 100644 --- a/apps/web/src/content/pages/guides/deployment-rollback.mdx +++ b/apps/web/src/content/pages/guides/deployment-rollback.mdx @@ -159,3 +159,33 @@ After resolution, consider adding context about prevention: - Use **API Service Disruption** if rollback affects external integrations - Use **Database Performance** if rollback impacts database operations - Use **Security Incident** if deployment exposed security issues + +## Related + +This template is one of several in the [incident communication guide](/guides/incident-communication), which covers update cadence by severity, what to avoid saying, and the other scenario templates. + +## Frequently asked questions + +
+ +If error rates exceed 5% or critical functionality is broken, initiate rollback immediately. Don't wait to debug in production. Roll back first, then investigate the issue in a safe environment. Time is critical - every minute of impact affects user trust. + +
+ +
+ +Communicate the rollback decision immediately, then provide updates during the process. Users appreciate knowing you're taking action. Example: 'We've initiated a rollback and expect completion in 10 minutes' is better than waiting until it's done. + +
+ +
+ +This suggests the problem isn't with the recent deployment. Pivot your communication to general incident response, investigate the root cause, and consider whether you need to roll back further or take other remediation steps. Update users with revised information. + +
+ +
+ +Yes. Even quick rollbacks deserve analysis. Document what went wrong, why it wasn't caught in testing, and what process changes will prevent recurrence. Share key findings with users if appropriate to show continuous improvement. + +
diff --git a/apps/web/src/content/pages/guides/dora-incident-reporting-requirements.mdx b/apps/web/src/content/pages/guides/dora-incident-reporting-requirements.mdx index 5d9eb298..2a6e7b75 100644 --- a/apps/web/src/content/pages/guides/dora-incident-reporting-requirements.mdx +++ b/apps/web/src/content/pages/guides/dora-incident-reporting-requirements.mdx @@ -140,6 +140,38 @@ Financial services regulation carries supervisory consequences that a guide cann - [What is MTTR](/guides/what-is-mttr) — duration and downtime feed the classification criteria - [Status pages for crypto exchanges and DeFi protocols](/use-case/crypto) — if you are a CASP or token issuer +## Frequently asked questions + +
+ +Three stages. An initial notification no later than 4 hours after classifying an incident as major, and in any case no later than 24 hours from becoming aware of it. An intermediate report within 72 hours of the initial notification, submitted even if there is no change in status. A final report no later than one month after the most recent intermediate report. The detail sits in Commission Delegated Regulation 2025/301, with reporting templates in Implementing Regulation 2025/302. + +
+ +
+ +Yes, where client interests are affected. Article 19(3) requires financial entities to inform clients without undue delay when a major ICT-related incident has an impact on their financial interests, including the measures taken to mitigate adverse effects. Article 14 separately requires crisis communication plans providing for responsible disclosure of major incidents to clients, counterparts, and the public. + +
+ +
+ +At classification, not at detection. You have up to 24 hours from becoming aware of an incident, and once you classify it as major you have 4 hours from that moment — whichever comes first binds. This makes your classification decision and its timestamp a regulated artifact, and it means a slow classification does not buy you time. + +
+ +
+ +Financial entities across the EU — credit institutions, payment and e-money institutions, investment firms, crypto-asset service providers, insurers and intermediaries, trading venues, central counterparties, fund managers, and more — plus ICT third-party service providers designated as critical. If you sell software to financial entities, DORA reaches you contractually through Chapter V rather than directly. + +
+ +
+ +It can serve the Article 19(3) client information duty and support the Article 14 communication plan. It cannot serve the regulator reports — those go to your competent authority on prescribed templates through a designated channel. The two are different audiences with different content and different clocks. + +
+ --- Start your status page diff --git a/apps/web/src/content/pages/guides/error-budgets-explained.mdx b/apps/web/src/content/pages/guides/error-budgets-explained.mdx new file mode 100644 index 00000000..44b09494 --- /dev/null +++ b/apps/web/src/content/pages/guides/error-budgets-explained.mdx @@ -0,0 +1,181 @@ +--- +title: "Error Budgets Explained" +seo: + title: "Error Budget: How to Calculate and Use One" +description: "How to calculate an error budget from your SLO, what burn rate means, and what to actually do when the budget runs out. With a per-SLO downtime table." +author: "openstatus" +publishedAt: "2026-08-15" +category: "fundamentals" +faq: + - question: "What is an error budget?" + answer: "An error budget is the amount of unreliability you are allowed before you break your own target. If your SLO is 99.9% availability over a month, you are permitting 0.1% of that month to fail - about 43 minutes. That 43 minutes is the budget. It is not a forecast or a tolerance for sloppiness; it is a quantity you are expected to spend." + - question: "How do you calculate an error budget?" + answer: "Error budget = (100% − SLO) × the measurement window. For a 99.9% monthly SLO, that is 0.1% of 30 days, or roughly 43 minutes. For a 99.99% monthly SLO it is about 4 minutes 19 seconds. Calculate it against the window your agreement actually uses - a yearly window is ten times more forgiving than a monthly one for the same percentage." + - question: "What is error budget burn rate?" + answer: "Burn rate is how fast you are consuming the budget relative to the pace that would exactly exhaust it over the window. A burn rate of 1 means you will finish the period with exactly zero budget left. A burn rate of 14.4 means you are spending 2% of a 30-day budget every hour, and the entire month's allowance disappears in about two days." + - question: "What happens when the error budget is spent?" + answer: "That is a policy decision you should make before it happens, not during the incident. The common policy is to freeze feature deploys and redirect engineering to reliability work until the budget recovers at the start of the next window. The point is that the budget converts an argument about whether to slow down into a number that already decided it." + - question: "What is the difference between an error budget and an SLA?" + answer: "An SLA is the promise you make to customers, with financial consequences. The error budget is the operating room between that promise and the stricter internal target you hold yourself to. The SLA says what happens if you fail; the error budget tells you how close you are to failing, while there is still time to act." +--- + +An error budget is the most useful number in reliability work, and the one most teams +define once and then never look at again. It turns "should we ship this risky change +on a Friday?" from an argument about temperament into a question with an answer. + +This guide assumes you already know how SLIs, SLOs, and SLAs relate. If not, start +with [SLA vs SLO vs SLI](/guides/sla-vs-slo-vs-sli) — the error budget only makes +sense once those three are distinct in your head. + +## What an Error Budget Actually Is + +Your SLO is a target for reliability. The error budget is its complement: the +unreliability you have explicitly permitted. + +If your SLO is 99.9% availability measured monthly, you are saying 0.1% of the month +is allowed to fail. That is roughly 43 minutes. Those 43 minutes are not a tolerance +for carelessness — they are a resource you are expected to spend, on deploys, +migrations, experiments, and the incidents that follow from them. + +A team that ends every month with a full budget is not winning. It is shipping too +slowly. + +## How to Calculate It + +The formula is one line: + +``` +Error budget = (100% − SLO) × measurement window +``` + +The window matters as much as the percentage. Here is the same set of targets +measured over 30 days and over a year: + +| SLO | Budget per 30-day month | Budget per year | +| --- | --- | --- | +| 99% | 7h 12m | 3d 15h 36m | +| 99.5% | 3h 36m | 1d 19h 48m | +| 99.9% | 43m 12s | 8h 45m 36s | +| 99.95% | 21m 36s | 4h 22m 48s | +| 99.99% | 4m 19s | 52m 34s | +| 99.999% | 26s | 5m 15s | + +Two things fall out of that table. First, each additional nine cuts your room to +manoeuvre by 10× — 99.99% is not "slightly better than" 99.9%, it is a different +operating model requiring redundancy and automated failover. Second, a yearly window +is enormously more forgiving than a monthly one. A four-hour outage is a rounding +error against a 99.9% annual budget and a catastrophic overrun against the monthly +one. Agree on the window before you agree on the number. + +You can work the same arithmetic for any target with the [SLA calculator](/play/uptime-sla). + +## Burn Rate: the Part That Makes It Operational + +A budget you check monthly is a postmortem tool. Burn rate is what makes it a +warning system. + +Burn rate measures how fast you are consuming the budget relative to the pace that +would exactly exhaust it over the window. A burn rate of 1 means you will end the +period with precisely nothing left. A burn rate of 2 means you will run out halfway +through. + +This is what you alert on, because it catches a problem while the budget still has +something in it: + +| Burn rate | Budget consumed | Over | Meaning | +| --- | --- | --- | --- | +| 14.4× | 2% | 1 hour | The month's budget is gone in ~2 days. Wake someone up. | +| 6× | 5% | 6 hours | Serious, sustained degradation. Page during working hours. | +| 1× | 10% | 3 days | Slow bleed. File a ticket, not an alert. | + +The fast burn rates catch outages. The slow ones catch the more insidious failure — +a small regression that never trips a threshold but quietly eats the month. + +## What to Do When It Runs Out + +Decide this in advance, in writing, while nobody is stressed. A budget with no +policy attached is just a metric. + +The conventional policy is a **feature freeze**: when the budget is exhausted, +non-essential deploys stop and engineering effort redirects to reliability until the +window resets. Variations that work: + +- **Graduated response.** At 50% consumed, review what spent it. At 75%, require + sign-off for risky changes. At 100%, freeze. +- **Freeze the risky surface only.** Halt deploys to the service that missed its + SLO rather than the whole organisation. +- **Borrow deliberately.** Sometimes a launch is worth overrunning for. That should + be an explicit, recorded decision by someone accountable — not something that + happens because nobody was watching. + +The value is not the freeze. It is that the decision was made when everyone was +calm, so the conversation during the incident is about facts rather than about who +is most senior in the room. + +## Common Mistakes + +**Setting the SLO equal to the SLA.** Then the budget is zero and the first bad +deploy is a contractual breach. The internal target must be stricter than the public +promise — that gap *is* the budget. + +**Measuring the budget against a window nobody agreed on.** Monthly and yearly +windows differ by more than 10× in practice. Pick one and use it everywhere. + +**Counting only hard downtime.** If your SLI is availability but users experience a +service that responds in eight seconds, the budget says you are fine while customers +churn. Include latency in the SLI — and be precise about which measurement you mean, +since [latency and response time](/docs/concept/latency-vs-response-time) are not +the same number — or accept that the budget only describes part of the experience. +See also [why uptime percentage alone is misleading](/guides/why-uptime-percentage-is-misleading). + +**Treating leftover budget as a scoreboard.** An unspent budget is unshipped work. + +## How openstatus Fits In + +An error budget is arithmetic on top of an SLI, so it is only as good as the +measurement underneath it. openstatus does not compute the budget for you — it +measures the number you compute it from. + +[Uptime monitoring](/uptime-monitoring) runs checks from up to 28 regions and keeps +the history, which matters for two reasons. Regional failures are invisible to a +single-probe check, and a budget calculated from one vantage point will understate +what your users actually experienced. And a budget needs a continuous record over +the whole window — you cannot reconstruct last month's consumption from a dashboard +that only shows the present. + +From there the budget is a subtraction: allowed downtime for your target, minus what +the history says you actually spent. Publishing the result on a +[status page](/status-page) is what turns it from an internal number into the +evidence behind your SLA. + +## Frequently asked questions + +
+ +An error budget is the amount of unreliability you are allowed before you break your own target. If your SLO is 99.9% availability over a month, you are permitting 0.1% of that month to fail — about 43 minutes. That 43 minutes is the budget. It is not a forecast or a tolerance for sloppiness; it is a quantity you are expected to spend. + +
+ +
+ +Error budget = (100% − SLO) × the measurement window. For a 99.9% monthly SLO, that is 0.1% of 30 days, or roughly 43 minutes. For a 99.99% monthly SLO it is about 4 minutes 19 seconds. Calculate it against the window your agreement actually uses — a yearly window is ten times more forgiving than a monthly one for the same percentage. + +
+ +
+ +Burn rate is how fast you are consuming the budget relative to the pace that would exactly exhaust it over the window. A burn rate of 1 means you will finish the period with exactly zero budget left. A burn rate of 14.4 means you are spending 2% of a 30-day budget every hour, and the entire month's allowance disappears in about two days. + +
+ +
+ +That is a policy decision you should make before it happens, not during the incident. The common policy is to freeze feature deploys and redirect engineering to reliability work until the budget recovers at the start of the next window. The point is that the budget converts an argument about whether to slow down into a number that already decided it. + +
+ +
+ +An SLA is the promise you make to customers, with financial consequences. The error budget is the operating room between that promise and the stricter internal target you hold yourself to. The SLA says what happens if you fail; the error budget tells you how close you are to failing, while there is still time to act. + +
diff --git a/apps/web/src/content/pages/guides/feature-degradation.mdx b/apps/web/src/content/pages/guides/feature-degradation.mdx index df2c58b0..7a0c1027 100644 --- a/apps/web/src/content/pages/guides/feature-degradation.mdx +++ b/apps/web/src/content/pages/guides/feature-degradation.mdx @@ -12,7 +12,7 @@ faq: - question: "How do I communicate partial degradation percentages to users?" answer: "Be specific: '50% success rate - retry usually works' or '10% error rate - most requests succeeding.' Avoid vague terms like 'some' or 'many.' Percentages help users assess their likelihood of being affected and whether retrying makes sense." - question: "What if I don't know which feature is causing issues?" - answer: "Start with what you know: 'We're investigating reports of errors. Some users may be affected.' Then update as you learn more: 'We've confirmed the issue is with [specific feature].' Narrow the scope as quickly as possible to reduce user uncertainty." + answer: "Start with the symptom you can confirm, not a hedge: 'We're investigating elevated error rates on checkout. Next update in 30 minutes.' State what you have actually observed and when you will report again, then narrow it as you learn more: 'We've confirmed the issue is with [specific feature].' Avoid 'some users may be affected' — it commits to nothing and reads as evasion." --- Use this template when specific features are degraded or unavailable, but the core service remains operational. Helps users understand what's working and what isn't. @@ -364,3 +364,33 @@ Thank you for your patience while we resolved this issue. - Use **Database Performance** if degradation is caused by database issues - Use **Network Connectivity** if some regions can't access the feature - Use **Deployment Rollback** if a recent deployment caused the degradation + +## Related + +This template is one of several in the [incident communication guide](/guides/incident-communication), which covers update cadence by severity, what to avoid saying, and the other scenario templates. + +## Frequently asked questions + +
+ +Use degraded when the feature still works partially (e.g., 50% success rate, slower than normal, or intermittent failures). Use outage when the feature is completely unavailable (100% failure rate). Clear distinction helps users set expectations. + +
+ +
+ +For major features, list both. Users need to know what still works so they can continue their workflow. For minor features, focus on what's broken and state 'all other features operating normally.' The feature impact matrix format works well for complex degradations. + +
+ +
+ +Be specific: '50% success rate - retry usually works' or '10% error rate - most requests succeeding.' Avoid vague terms like 'some' or 'many.' Percentages help users assess their likelihood of being affected and whether retrying makes sense. + +
+ +
+ +Start with the symptom you can confirm, not a hedge: 'We're investigating elevated error rates on checkout. Next update in 30 minutes.' State what you have actually observed and when you will report again, then narrow it as you learn more: 'We've confirmed the issue is with [specific feature].' Avoid 'some users may be affected' — it commits to nothing and reads as evasion. + +
diff --git a/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx b/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx index edbf290a..987fae49 100644 --- a/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx +++ b/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx @@ -77,6 +77,32 @@ Most Kuma setups are small enough to move in well under an hour. - [What Is Uptime Monitoring?](/guides/what-is-uptime-monitoring) - [Status Pages for Open-Source Projects](/use-case/open-source) +## Frequently asked questions + +
+ +Not literally — they're separate projects — but openstatus fills the same need without the self-hosting. Both are open-source uptime monitors with status pages. The key difference: Uptime Kuma runs on a single server you maintain, while openstatus is a managed, multi-region service. You can still self-host openstatus if you want full control. + +
+ +
+ +Uptime Kuma checks from a single location — the server you run it on. If that server has a network blip, you get false alerts; if it goes down, your monitoring goes down with it. It also can't tell you whether an outage is regional, because there's only one vantage point. A managed, multi-region tool solves both. + +
+ +
+ +Yes. Some teams run Uptime Kuma internally for homelab or internal services and use openstatus for external, multi-region checks and a public status page. They complement each other. + +
+ +
+ +Uptime Kuma is free to run if you cover your own hosting. openstatus has a permanent free plan for the managed service (1 monitor, 6 regions), paid plans from $30/month, and is also free to self-host under AGPL-3.0. + +
+ --- Open-source monitoring, without the VPS to babysit diff --git a/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx b/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx index 63dd67b9..a27171ad 100644 --- a/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx +++ b/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx @@ -190,3 +190,35 @@ For Betterstack, Datadog, and Status.io, migration is typically a manual rebuild ## Need Help or Have Questions? If you need help along the way, feel free to join our [Discord community](https://www.openstatus.dev/discord), check our [documentation](https://www.openstatus.dev/docs) for more information, or reach out to us via [email](mailto:ping@openstatus.dev). + +## Frequently asked questions + +
+ +openstatus is the only tool that combines built-in synthetic monitoring, monitoring-as-code via Terraform, native OpenTelemetry export, private locations, an MCP server for AI coding agents, unlimited team members, and the option to self-host as open-source. Most competitors cover one or two of these — none cover all of them. + +
+ +
+ +Yes, for most use cases. openstatus covers the core incident communication workflows (components, incidents, maintenances, subscribers) and adds built-in monitoring, monitoring-as-code, and an MCP server that Atlassian Statuspage doesn't ship. The one area where Atlassian still leads is deep Jira/Opsgenie integration for teams already standardized on Atlassian tooling. + +
+ +
+ +openstatus starts at $30/month with unlimited team members and monitoring included. That's typically cheaper than Atlassian Statuspage ($99/month for private pages), Status.io ($349/month for advanced features), or Betterstack once you add private-page and styling add-ons. Instatus has a comparable free tier but no monitoring. + +
+ +
+ +Yes. openstatus ships a one-click importer for Atlassian Statuspage and Instatus that moves components, component groups, incidents with full update history, maintenances, and email subscribers. For other tools, components and subscribers can be imported via CSV. + +
+ +
+ +Yes. openstatus powers status pages for teams across SaaS, fintech, and infrastructure companies, and runs synthetic checks from multiple regions globally. The hosted platform is fully managed, and the same codebase backs the self-hosted distribution. + +
diff --git a/apps/web/src/content/pages/guides/http-headers.mdx b/apps/web/src/content/pages/guides/http-headers.mdx index f1ed1d51..df5e654b 100644 --- a/apps/web/src/content/pages/guides/http-headers.mdx +++ b/apps/web/src/content/pages/guides/http-headers.mdx @@ -193,6 +193,32 @@ Response headers reveal more than status codes alone: - Missing `Strict-Transport-Security` on production is worth alerting on - `Cache-Control: no-store` on a high-traffic route means zero caching is helping you +## Frequently asked questions + +
+ +HTTP headers are metadata key-value pairs sent alongside HTTP requests and responses. They define content types, authentication, caching behavior, security policies, and more. + +
+ +
+ +Cache-Control is an HTTP response header that tells browsers and CDNs how long to cache a response. Directives like max-age, no-cache, and no-store control caching behavior. + +
+ +
+ +X-Forwarded-For contains a chain of IPs added by each proxy. X-Real-IP is set by the first proxy and reflects just the original client IP. + +
+ +
+ +CF-Ray is a Cloudflare-specific header that uniquely identifies a request. It's the first thing Cloudflare support will ask for when debugging an issue. + +
+ --- Start monitoring your API responses diff --git a/apps/web/src/content/pages/guides/incident-communication.mdx b/apps/web/src/content/pages/guides/incident-communication.mdx new file mode 100644 index 00000000..f1d67787 --- /dev/null +++ b/apps/web/src/content/pages/guides/incident-communication.mdx @@ -0,0 +1,184 @@ +--- +title: "Incident Communication" +seo: + title: "Incident Communication: Templates, Cadence, and What to Say" +description: "What to write during an outage, at which severity, and how often. The four update stages, a cadence table by severity, copy-paste templates by scenario, and the phrases that make incidents worse." +author: "openstatus" +publishedAt: "2026-08-15" +category: "education" +faq: + - question: "What should you say in an incident update?" + answer: "What is broken in user terms, who it affects, what you are doing, and when you will next post. Never speculate on cause in the first update - you will be wrong, and the correction costs more trust than the delay would have. If you know nothing yet, say that you are investigating and give a next-update time. 'We are investigating reports of elevated error rates on the API. Next update in 30 minutes' is a complete first update." + - question: "How often should you post incident updates?" + answer: "Match cadence to severity: every 30 minutes for a full outage, every hour for major degradation, and at meaningful change only for minor issues. Post on schedule even when there is nothing new - silence reads as abandonment, and 'still investigating, next update in 30 minutes' is a real update. Missing a promised update does more damage than the outage itself in most cases." + - question: "Should you admit fault in an incident update?" + answer: "Say what happened plainly, without either minimising it or performing contrition. 'A configuration change caused 40 minutes of failed logins' is better than both 'some users may have experienced intermittent issues' and a paragraph of apology. Customers are deciding whether to trust your future updates, and precision is what earns that." + - question: "What is the difference between an incident update and a postmortem?" + answer: "An update is written during the incident for people who are currently blocked - short, factual, and focused on impact and next update time. A postmortem is written afterwards for people deciding whether to keep trusting you - it covers timeline, root cause, and what changes so it does not recur. Updates buy patience; postmortems buy trust back." + - question: "Who should write incident updates?" + answer: "Someone who is not fixing the incident. The responder deep in the problem is the worst-placed person to write clearly about it, and asking them to context-switch slows recovery. Assign a communications role at the start of any SEV1 or SEV0 - usually the incident commander or a support lead working from what responders report." +--- + +Most teams treat incident communication as something that happens to them. The +outage starts, someone asks "should we post something?", and a paragraph gets +written by whoever is least busy — usually the person who should be fixing it. + +It is worth doing better, because the communication is what customers actually +experience. They do not see your remediation. They see whether you told them, how +fast, and whether the update was honest. A well-run incident with silent comms reads +as incompetence; a bad incident with clear comms often ends with customers thanking +you. + +This is the hub for how to do it: the update stages, cadence by severity, templates +per scenario, and the phrases to avoid. + +## The Four Stages of an Incident Update + +Nearly every status page convention — and every template on this site — uses the same +four stages. They exist because they answer different questions. + +| Stage | The question it answers | What goes in it | +| --- | --- | --- | +| **Investigating** | Do you know? | Confirm the symptom in user terms and commit to a next update time. No cause, no ETA. | +| **Identified** | Do you know why? | What is broken and who it affects. A fix is underway. Still no promised ETA unless you are certain. | +| **Monitoring** | Is it fixed? | The fix is deployed and you are watching. Say what recovery looks like so users can verify. | +| **Resolved** | Is it over? | Confirm normal service, state duration and scope, and say whether a postmortem follows. | + +The stage most teams get wrong is **Investigating**. It feels empty to post "we are +looking into it" — so they wait until they have something substantial, and by then +customers have been guessing for forty minutes. The first update is not there to +inform. It is there to tell people they do not need to open a support ticket. + +The other common error is skipping **Monitoring** and jumping to Resolved. If the fix +regresses, you now have to reopen an incident you declared over, which costs +disproportionate credibility. + +## Cadence by Severity + +Cadence is the promise you are actually making. The content of an update matters less +than posting when you said you would. + +| Severity | Impact | Update every | Where | +| --- | --- | --- | --- | +| **SEV0 / SEV1** | Full outage or critical feature down for everyone | 30 minutes | Status page, email subscribers, in-app | +| **SEV2** | Major degradation, or a subset of users fully broken | 1 hour | Status page, subscribers | +| **SEV3** | Minor degradation, workaround exists | On meaningful change | Status page | +| **SEV4** | Cosmetic or single-customer | Direct to affected customer | Support channel, not the status page | + +Two rules make this work. + +**Post on schedule even with nothing new.** "Still investigating, no change, next +update in 30 minutes" is a complete update. Silence is read as abandonment, and the +gap between your last update and now is the number customers remember. + +**Never promise an ETA you are not certain of.** A missed ETA converts a technical +problem into a trust problem. Promise the *next update time* instead — that is +entirely within your control. + +If you have not agreed on what SEV1 means with your team, do that before the next +incident: the [incident severity matrix](/guides/incident-severity-matrix) explains +the tiers, and the [severity matrix builder](/play/severity-matrix) generates one you +can adapt. + +## Templates by Scenario + +Copy-paste starting points for the incidents that actually recur, each with wording +for all four stages: + +- **[API service disruption](/guides/api-service-disruption)** — outages and third-party service failures. +- **[Database performance degradation](/guides/database-performance-degradation)** — slow queries and connection exhaustion. +- **[Deployment rollback](/guides/deployment-rollback)** — a release you had to reverse. +- **[Feature degradation](/guides/feature-degradation)** — one capability broken while the product works. +- **[Network connectivity issues](/guides/network-connectivity-issues)** — regional and routing problems. +- **[Scheduled maintenance](/guides/scheduled-maintenance)** — planned work, announced in advance. +- **[Security incident response](/guides/security-incident-response)** — the one with legal and disclosure constraints attached. + +Adapt them rather than pasting verbatim. A template's value is that it stops you +composing prose at 3am, not that the exact sentences are optimal for your product. + +## What Not to Say + +**"Some users may be experiencing intermittent issues."** This is four hedges in one +sentence. If logins are failing, say logins are failing. + +**Cause in the first update.** Early theories are usually wrong. Correcting a public +diagnosis costs more than the twenty minutes of not naming one. + +**Blaming a provider as an explanation.** Naming a dependency is fine as fact. +Presenting it as absolution is not — customers bought availability from you, and your +provider's outage is your architecture's problem. + +**Apologies in place of information.** One sentence of apology, then facts. A long +apology with no detail reads as a company that would rather manage feelings than tell +you what is happening. + +**Marketing voice.** No "we're working hard to deliver the best possible experience". +Plain, specific, slightly boring — which is +[what status pages should be](/guides/boring-is-better-for-status-pages). + +## After the Incident + +Resolved is not finished. For anything at SEV2 or above, publish a postmortem: +timeline, what actually broke, and what changes so it does not recur. Customers who +lost an hour of work want evidence you understand why. Done well it is also +[surprisingly good marketing](/guides/public-postmortem-underrated-marketing) — few +companies do it, and it demonstrates engineering maturity better than any landing +page. + +Track [MTTR](/guides/what-is-mttr) alongside it, but do not confuse the two: MTTR +measures how fast you recovered, not how well you communicated while recovering. A +team can halve MTTR and still lose customers by saying nothing for an hour. + +If you operate under a compliance regime, incident communication is frequently a +control rather than a courtesy — see +[SOC 2](/guides/soc-2-status-page-requirements), +[ISO 27001](/guides/iso-27001-incident-communication), +[DORA](/guides/dora-incident-reporting-requirements), and +[NIS2](/guides/nis2-incident-reporting-requirements) for what each expects, including +notification deadlines measured in hours. + +## Where It Gets Published + +All of this needs somewhere to land. A [status page](/status-page) is the +canonical location — one URL customers can check without asking, hosted away from the +infrastructure that is failing. Subscribers get updates pushed by email, and you can +[deliver them into customer Slack channels](/guides/slack-status-page-subscriptions) +for the accounts that ask. + +Whether that page is public or private is a real decision with different tradeoffs — +[public vs private status pages](/guides/public-vs-private-status-pages) covers it. +And the updates only get written if something tells you to write them, which is what +[uptime monitoring](/uptime-monitoring) is for: the alternative is +[finding out from a customer](/blog/your-customer-found-out-first). + +## Frequently asked questions + +
+ +What is broken in user terms, who it affects, what you are doing, and when you will next post. Never speculate on cause in the first update — you will be wrong, and the correction costs more trust than the delay would have. If you know nothing yet, say that you are investigating and give a next-update time. "We are investigating reports of elevated error rates on the API. Next update in 30 minutes" is a complete first update. + +
+ +
+ +Match cadence to severity: every 30 minutes for a full outage, every hour for major degradation, and at meaningful change only for minor issues. Post on schedule even when there is nothing new — silence reads as abandonment, and "still investigating, next update in 30 minutes" is a real update. Missing a promised update does more damage than the outage itself in most cases. + +
+ +
+ +Say what happened plainly, without either minimising it or performing contrition. "A configuration change caused 40 minutes of failed logins" is better than both "some users may have experienced intermittent issues" and a paragraph of apology. Customers are deciding whether to trust your future updates, and precision is what earns that. + +
+ +
+ +An update is written during the incident for people who are currently blocked — short, factual, and focused on impact and next update time. A postmortem is written afterwards for people deciding whether to keep trusting you — it covers timeline, root cause, and what changes so it does not recur. Updates buy patience; postmortems buy trust back. + +
+ +
+ +Someone who is not fixing the incident. The responder deep in the problem is the worst-placed person to write clearly about it, and asking them to context-switch slows recovery. Assign a communications role at the start of any SEV1 or SEV0 — usually the incident commander or a support lead working from what responders report. + +
diff --git a/apps/web/src/content/pages/guides/incident-severity-matrix.mdx b/apps/web/src/content/pages/guides/incident-severity-matrix.mdx index b1f356d6..47531ca1 100644 --- a/apps/web/src/content/pages/guides/incident-severity-matrix.mdx +++ b/apps/web/src/content/pages/guides/incident-severity-matrix.mdx @@ -310,3 +310,41 @@ We are investigating a critical issue affecting checkout. The majority of paymen --- Use the [Incident Severity Matrix Builder](/play/severity-matrix) to classify incidents interactively, test your thresholds against real scenarios, and customize the matrix for your team. + +## Frequently asked questions + +
+ +SEV0 indicates a critical incident — typically a complete service outage or confirmed security breach that requires immediate response from senior engineering leadership. It's the highest severity level and triggers the most aggressive communication and escalation protocols. + +
+ +
+ +Most teams use 3 or 4 levels. Four levels (SEV0 through SEV3) provide enough granularity to distinguish between a full outage and a minor cosmetic bug without overcomplicating triage during a live incident. + +
+ +
+ +Severity measures the impact of an incident — how many users are affected and how badly. Priority reflects business urgency and resource allocation. A typo on your pricing page might be low severity but high priority if it's costing you conversions. Your severity matrix should classify based on impact alone; priority is a triage decision. + +
+ +
+ +In most cases, yes. Security incidents carry outsized risk even when few users are immediately affected — the blast radius can expand quickly and the reputational impact is disproportionate. Treating all confirmed security incidents as SEV0 ensures you mobilize the right resources immediately. + +
+ +
+ +Review it quarterly, or after any major incident where the classification felt wrong. If your team consistently debates whether something is a SEV1 or SEV2, your thresholds probably need adjustment. + +
+ +
+ +SEV0 and SEV1 incidents always require a postmortem. SEV2 requires a team-level postmortem. SEV3 postmortems are optional. The postmortem closes the loop by documenting root cause, timeline, and action items to prevent recurrence. + +
diff --git a/apps/web/src/content/pages/guides/iso-27001-incident-communication.mdx b/apps/web/src/content/pages/guides/iso-27001-incident-communication.mdx index 471001f0..bab22494 100644 --- a/apps/web/src/content/pages/guides/iso-27001-incident-communication.mdx +++ b/apps/web/src/content/pages/guides/iso-27001-incident-communication.mdx @@ -112,6 +112,38 @@ If you are writing the incident procedure that A.5.26 tests, 27035-1 is the more - [What is MTTR](/guides/what-is-mttr) — recovery measurement for A.5.30 - [What is synthetic monitoring](/guides/what-is-synthetic-monitoring) — one input to A.8.16 +## Frequently asked questions + +
+ +No. No Annex A control names a status page. A.5.26 requires incident response according to documented procedures, and those procedures normally include communication to affected parties. A status page is one way to implement and evidence that communication step. + +
+ +
+ +Mainly A.5.26 (response to information security incidents) and A.5.28 (collection of evidence), with supporting relevance to A.5.24, A.5.25, A.5.27, A.5.29, A.5.30, and A.8.16. It is one implementation detail inside a handful of the 93 controls. + +
+ +
+ +SOC 2 tests whether your stated controls operated effectively over a period. ISO 27001 certifies that you run a management system — the emphasis is on documented procedures, defined responsibilities, and demonstrable continual improvement. Practically, ISO auditors care more about whether your procedure exists and is followed than about sampling a large incident population. + +
+ +
+ +No. Annex A is a reference set. You select applicable controls through risk assessment and justify inclusions and exclusions in your Statement of Applicability. If you exclude a control, you must be able to explain why. + +
+ +
+ +The standard does not set a number; your own retention policy does, and the auditor checks that you follow it. Certification cycles run three years with annual surveillance audits, so evidence spanning at least twelve months is a practical floor. + +
+ --- Start your status page diff --git a/apps/web/src/content/pages/guides/migrate-from-atlassian-statuspage.mdx b/apps/web/src/content/pages/guides/migrate-from-atlassian-statuspage.mdx index 88df33b6..a310c7d5 100644 --- a/apps/web/src/content/pages/guides/migrate-from-atlassian-statuspage.mdx +++ b/apps/web/src/content/pages/guides/migrate-from-atlassian-statuspage.mdx @@ -150,3 +150,35 @@ Watch a full migration from Atlassian Statuspage to openstatus in real time. - [Blog: Import from Statuspage, Betterstack, and Instatus](/blog/import-from-statuspage-betterstack-instatus) - [Docs: How to Import a Status Page](https://www.openstatus.dev/docs/guides/how-to-import-status-page) - [Top Five Atlassian Statuspage Alternatives](/guides/top-five-atlassian-statuspage-alternatives) + +## Frequently asked questions + +
+ +The import itself takes under 2 minutes. You'll need your Statuspage API key and optionally your Page ID if you have multiple pages. + +
+ +
+ +No. Openstatus imports your components, component groups, incidents (with all updates), maintenances, and email subscribers. SMS and Slack subscribers are not supported — you'll see a warning during preview. + +
+ +
+ +Yes. The importer shows a full preview of what will be imported — including counts per resource type and any warnings — before you confirm. + +
+ +
+ +Atlassian Statuspage doesn't expose monitor configurations via their API, so components are imported as static components. You can connect openstatus monitors to them after import. + +
+ +
+ +Components, component groups, and subscribers are deduplicated by name — the importer skips existing resources. However, incidents and maintenances are always created as new entries, so re-running the import will produce duplicates for those. + +
diff --git a/apps/web/src/content/pages/guides/migrate-from-betterstack.mdx b/apps/web/src/content/pages/guides/migrate-from-betterstack.mdx index ca5015fe..50866ff5 100644 --- a/apps/web/src/content/pages/guides/migrate-from-betterstack.mdx +++ b/apps/web/src/content/pages/guides/migrate-from-betterstack.mdx @@ -179,3 +179,41 @@ Watch a full migration from Better Stack to openstatus in real time. - [Import from Statuspage, Better Stack, and Instatus](/blog/import-from-statuspage-betterstack-instatus) -- blog announcement covering all three supported providers. - [How to Import a Status Page](https://www.openstatus.dev/docs/guides/how-to-import-status-page) -- docs guide with detailed instructions and screenshots. - [Top Five Atlassian Statuspage Alternatives](/guides/top-five-atlassian-statuspage-alternatives) -- comparison of status page providers including Better Stack and openstatus. + +## Frequently asked questions + +
+ +Yes. openstatus is the only provider that imports monitors from Better Stack — including URL, check frequency, HTTP method, headers, and regions. + +
+ +
+ +No. Better Stack doesn't expose subscribers via their API, so subscriber import is not available. You'll need to re-invite subscribers manually. + +
+ +
+ +You can still import. Without a status page ID, the importer will import your monitors, monitor groups, and incidents from the Better Stack Uptime API. + +
+ +
+ +Better Stack regions are mapped to the nearest openstatus probe location: US → iad (Virginia), EU → fra (Frankfurt), AS → sin (Singapore), AU → syd (Sydney). + +
+ +
+ +Better Stack intervals are snapped to the nearest supported openstatus frequency: 30s, 1m, 5m, 10m, 30m, or 1h. + +
+ +
+ +Monitors, components, and component groups are deduplicated — the importer checks for existing resources before creating new ones. However, incidents and maintenances are always created as new entries, so re-running will produce duplicates for those. + +
diff --git a/apps/web/src/content/pages/guides/migrate-from-checkly.mdx b/apps/web/src/content/pages/guides/migrate-from-checkly.mdx index c22c95ef..ea4488d5 100644 --- a/apps/web/src/content/pages/guides/migrate-from-checkly.mdx +++ b/apps/web/src/content/pages/guides/migrate-from-checkly.mdx @@ -181,3 +181,41 @@ Once the import finishes, there are a few things to verify: - [Import from Statuspage, Better Stack, and Instatus](/blog/import-from-statuspage-betterstack-instatus) — blog announcement covering cross-provider import. - [How to Import a Status Page](https://www.openstatus.dev/docs/guides/how-to-import-status-page) — docs guide with detailed instructions and screenshots. - [What is Synthetic Monitoring](/guides/what-is-synthetic-monitoring) — background on the monitoring model openstatus and Checkly share. + +## Frequently asked questions + +
+ +Yes. openstatus imports your Checkly API and URL checks as HTTP monitors — including the request URL, method, headers, body, check frequency, and run locations. TCP, ICMP, DNS, and SSL checks are imported when a target can be derived. + +
+ +
+ +API and URL checks import as HTTP monitors. TCP, ICMP, DNS, and SSL map to their openstatus job types. Browser, Playwright, multi-step, heartbeat, gRPC, traceroute, and AI checks have no HTTP-family equivalent and are skipped with a reason in the preview. + +
+ +
+ +Two values: a Checkly API key (User Settings → API keys) and your Checkly account ID (Account Settings → General). Checkly requires the account ID on every API request, so both are mandatory. + +
+ +
+ +Checkly's AWS-region locations are mapped to the nearest openstatus probe: us-east-1 → iad, us-west-2 → sea, eu-west-1 → lhr, eu-central-1 → fra, ap-southeast-1 → sin, ap-southeast-2 → syd, and so on. Unmapped locations fall back to iad. + +
+ +
+ +They import as static components. Checkly's status-page services are abstract entities that don't reference a specific check via the API, so openstatus can't auto-link them to imported monitors. You can link a monitor to any component after import. + +
+ +
+ +Monitors, components, and component groups are deduplicated — the importer checks for existing resources before creating new ones. Incidents and maintenances are always created as new entries, so re-running will produce duplicates for those. + +
diff --git a/apps/web/src/content/pages/guides/migrate-from-instatus.mdx b/apps/web/src/content/pages/guides/migrate-from-instatus.mdx index d5ec550b..7615933b 100644 --- a/apps/web/src/content/pages/guides/migrate-from-instatus.mdx +++ b/apps/web/src/content/pages/guides/migrate-from-instatus.mdx @@ -108,3 +108,41 @@ Watch a full migration from Instatus to openstatus in real time. - [Blog: Importing from Statuspage, Betterstack, and Instatus](/blog/import-from-statuspage-betterstack-instatus) - [Docs: How to import a status page](https://www.openstatus.dev/docs/guides/how-to-import-status-page) - [Top five Atlassian Statuspage alternatives](/guides/top-five-atlassian-statuspage-alternatives) + +## Frequently asked questions + +
+ +openstatus automatically detects which Instatus components are groups vs regular components based on the group references in your data, and creates the correct hierarchy. + +
+ +
+ +No. Only email subscribers are imported. Webhook, Slack, Discord, Microsoft Teams, Google Chat, and phone subscribers are skipped — you'll see a warning showing how many were skipped. + +
+ +
+ +Instatus maintenances have a start time and duration. Openstatus calculates the end time as start + duration and imports them with the correct time range. + +
+ +
+ +Yes. Instatus uses the same status names as openstatus (investigating, identified, monitoring, resolved), so the mapping is 1:1. + +
+ +
+ +You can specify a Page ID to import a specific page. Run the import once per page if you have multiple. + +
+ +
+ +Components, component groups, and subscribers are deduplicated — existing resources are skipped. However, incidents and maintenances are always inserted as new entries, so re-running will create duplicates for those. + +
diff --git a/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx b/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx index 10099703..411aad3b 100644 --- a/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx +++ b/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx @@ -113,6 +113,32 @@ Leave both tools running for a week or two. Once you trust the openstatus alerts Join our [Discord community](https://www.openstatus.dev/discord), check the [documentation](https://www.openstatus.dev/docs), or reach out via [email](mailto:ping@openstatus.dev). +## Frequently asked questions + +
+ +openstatus's one-click importer covers status-page tools (Instatus, Atlassian Statuspage, Better Stack) — it imports components, incidents, maintenances, and subscribers. UptimeRobot is a monitoring tool, so its monitors are recreated rather than imported: rebuild them in the dashboard, or define them as code with the Terraform provider or CLI. Most setups take under an hour. + +
+ +
+ +For a typical setup of a few dozen monitors, under an hour. If you define monitors as code with Terraform or the CLI, you can script the whole thing and apply it in one go. + +
+ +
+ +Historical uptime data stays in UptimeRobot — it isn't transferred. openstatus starts collecting data the moment your monitors go live. Many teams run both in parallel for a short period before switching off UptimeRobot. + +
+ +
+ +Common reasons: UptimeRobot checks from a single location while openstatus checks from 28 regions in parallel; UptimeRobot restricted its free tier to non-commercial use in October 2024; openstatus is open-source and self-hostable; and openstatus includes monitoring as code, a CLI, and an MCP server for AI agents. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/guides/network-connectivity-issues.mdx b/apps/web/src/content/pages/guides/network-connectivity-issues.mdx index 2e4d73f1..414bf513 100644 --- a/apps/web/src/content/pages/guides/network-connectivity-issues.mdx +++ b/apps/web/src/content/pages/guides/network-connectivity-issues.mdx @@ -265,3 +265,27 @@ recurrence. - Use **API Service Disruption** if network issues primarily affect API calls - Use **Database Performance** if network latency affects database connections - Use **Security Incident** if connectivity issues are attack-related + +## Related + +This template is one of several in the [incident communication guide](/guides/incident-communication), which covers update cadence by severity, what to avoid saying, and the other scenario templates. + +## Frequently asked questions + +
+ +Use monitoring data from multiple geographic locations, check CDN provider dashboards, and review user reports by location. Tools like uptime monitors with multi-region checks can quickly identify geographic patterns in outages. + +
+ +
+ +Yes, if safe and practical. Direct IPs, alternate endpoints, or backup URLs can help users during CDN or DNS issues. Just ensure the workaround is secure and won't create additional problems once the primary issue is resolved. + +
+ +
+ +You can still communicate transparently without waiting for your provider. Say 'We're experiencing connectivity issues that appear related to our CDN provider. We're investigating with them and will update you shortly.' Users care about your transparency, not your provider's timeline. + +
diff --git a/apps/web/src/content/pages/guides/nis2-incident-reporting-requirements.mdx b/apps/web/src/content/pages/guides/nis2-incident-reporting-requirements.mdx index 347dbb39..cd0bb162 100644 --- a/apps/web/src/content/pages/guides/nis2-incident-reporting-requirements.mdx +++ b/apps/web/src/content/pages/guides/nis2-incident-reporting-requirements.mdx @@ -125,6 +125,38 @@ Your obligations live in your member state's transposition, which may differ on - [Security incident response template](/guides/security-incident-response) — wording for recipient notification - [What is incident management](/guides/what-is-incident-management) — the process underneath Article 21 +## Frequently asked questions + +
+ +Three stages under Article 23(4). An early warning within 24 hours of becoming aware of a significant incident. A full incident notification within 72 hours of becoming aware, updating the early warning with an initial assessment of severity, impact, and any indicators of compromise. A final report within one month of the incident notification, covering root cause and remedial measures. If the incident is still ongoing when the final report is due, you submit a progress report instead and the final report follows within one month of the incident being handled. + +
+ +
+ +Yes, separately from regulator reporting. Article 23(1) requires entities to notify the recipients of their services, without undue delay, of significant incidents likely to adversely affect the provision of that service. Article 23(2) adds that where a significant cyber threat exists, you inform recipients of measures or remedies they can take. + +
+ +
+ +It can serve the Article 23(1) obligation to notify service recipients. It cannot serve the 24-hour, 72-hour, or one-month reports — those go to your CSIRT or competent authority through a designated national channel, usually a specific portal or form. Publishing to a status page is not notifying a regulator. + +
+ +
+ +Article 23(3) sets the baseline: an incident is significant if it has caused or is capable of causing severe operational disruption of the services or financial loss for the entity concerned, or has affected or is capable of affecting other natural or legal persons by causing considerable material or non-material damage. Implementing acts add quantitative thresholds for certain digital infrastructure and digital provider sectors, and national transpositions may add detail. + +
+ +
+ +The directive entered into force in January 2023 with a member state transposition deadline of 17 October 2024. Because it is a directive rather than a regulation, the binding rules are in each member state's national law, and transposition ran late in a number of countries. Check the national implementation that applies to you rather than the directive text alone. + +
+ --- Start your status page diff --git a/apps/web/src/content/pages/guides/public-postmortem-underrated-marketing.mdx b/apps/web/src/content/pages/guides/public-postmortem-underrated-marketing.mdx index bd911107..8a9e92b1 100644 --- a/apps/web/src/content/pages/guides/public-postmortem-underrated-marketing.mdx +++ b/apps/web/src/content/pages/guides/public-postmortem-underrated-marketing.mdx @@ -93,6 +93,38 @@ The question isn't whether you'll have an incident - it's whether you'll have th When your database catches fire and 5,000 people watch you put it out, that's not a disaster - that's the kind of trust you can't buy with any marketing budget. +## Frequently asked questions + +
+ +Public postmortems demonstrate engineering depth, operational maturity, and build trust that no marketing can replicate. When GitLab live-streamed their database recovery, they gained massive respect from the developer community instead of losing customers. Transparency proves you understand your systems and take incidents seriously. + +
+ +
+ +Include five key elements: a clear timeline with exact timestamps, deep root cause analysis (not just symptoms), blameless language focused on systems not individuals, concrete action items with owners and deadlines, and leadership visibility showing founders take responsibility. + +
+ +
+ +Start immediately when the incident begins - don't wait for the postmortem. Share real-time updates on your status page, Twitter/X, and Slack communities as you respond. The postmortem comes later as thorough analysis, but transparency starts the moment something breaks. + +
+ +
+ +Status page updates are real-time communications during active incidents telling users what's happening now. Postmortems are detailed analyses published after resolution, explaining root causes, systemic failures, and prevention plans. Great companies do both - immediate transparency plus thorough follow-up. + +
+ +
+ +The opposite is true. Shallow or hidden responses to incidents signal shallow engineering culture. Detailed postmortems with root cause analysis and systemic thinking prove your team understands their systems. Technical buyers evaluating your infrastructure find this reassuring, not concerning. + +
+ --- Start free. No credit card required. Set up your first status page in under 5 minutes. diff --git a/apps/web/src/content/pages/guides/public-vs-private-status-pages.mdx b/apps/web/src/content/pages/guides/public-vs-private-status-pages.mdx index e7d80eef..1f81c47b 100644 --- a/apps/web/src/content/pages/guides/public-vs-private-status-pages.mdx +++ b/apps/web/src/content/pages/guides/public-vs-private-status-pages.mdx @@ -87,6 +87,44 @@ Match the transparency to the audience. Customers need confidence. Teams need co **Openstatus gives you both.** Create public pages for customer transparency and private pages with full metrics for your team-all from one platform. +## Frequently asked questions + +
+ +Public status pages are open to anyone and designed for customer-facing communication with reviewed, confirmed updates. Private status pages are access-controlled and designed for internal teams, partners, or enterprise clients with real-time metrics, auto-incidents, and detailed operational data. + +
+ +
+ +Create a public status page when you want to proactively communicate service status to customers, reduce support tickets, and build trust through transparency. It's especially important once you have paying customers or when your service reliability directly impacts user workflows. + +
+ +
+ +You need a private status page when you have more than 10 team members who need real-time operational visibility, when you have enterprise customers with contractual SLA requirements, or when you have partners integrating with your platform who need self-service status information. + +
+ +
+ +Yes, but it depends on your audience. For internal teams, auto-incidents are highly valuable - they close the gap between detection and awareness. For partner-facing pages, skip auto-incidents and use reviewed status reports instead, as brief internal hiccups don't need to appear on your partner's dashboard. + +
+ +
+ +Common authentication methods include email domain protection (for known organizations), simple password protection (for broader access or RSS support), SSO integration (for enterprise customers), and IP restriction (for locked-down network environments). Choose based on your audience and security requirements. + +
+ +
+ +No. Public status pages should not use auto-incidents. Every update should be reviewed before publication to ensure accuracy and appropriate messaging. A brief monitoring anomaly or false alarm shouldn't create unnecessary customer concern. + +
+ --- Start free. No credit card required. Set up your first status page in under 5 minutes. diff --git a/apps/web/src/content/pages/guides/scheduled-maintenance.mdx b/apps/web/src/content/pages/guides/scheduled-maintenance.mdx index d16034ad..c6ee7cfa 100644 --- a/apps/web/src/content/pages/guides/scheduled-maintenance.mdx +++ b/apps/web/src/content/pages/guides/scheduled-maintenance.mdx @@ -364,3 +364,33 @@ A: We'll provide updates every hour and notify you of any time extensions. **Q: Can you reschedule?** A: This timing was chosen for minimal impact. Please contact support if you have urgent concerns. + +## Related + +This template is one of several in the [incident communication guide](/guides/incident-communication), which covers update cadence by severity, what to avoid saying, and the other scenario templates. + +## Frequently asked questions + +
+ +Minimum 48 hours for minor maintenance under 30 minutes with no downtime. 1 week notice for standard maintenance 1-4 hours with read-only mode. 2+ weeks notice for major maintenance over 4 hours with full downtime. + +
+ +
+ +Provide updates every hour and notify users immediately of any time extensions. Include the new estimated completion time and reason for the extension. + +
+ +
+ +Yes, always include multiple timezones for global audiences or use UTC time with a timezone converter link. This prevents confusion about when maintenance will occur. + +
+ +
+ +Always include exact date and time with timezone, expected duration, impact description, what users can and cannot do during maintenance, and who to contact with questions. + +
diff --git a/apps/web/src/content/pages/guides/security-incident-response.mdx b/apps/web/src/content/pages/guides/security-incident-response.mdx index 833a271d..549a29e6 100644 --- a/apps/web/src/content/pages/guides/security-incident-response.mdx +++ b/apps/web/src/content/pages/guides/security-incident-response.mdx @@ -214,3 +214,33 @@ about key rotation requirements. - Minor configuration issues → Use general service disruption template Only use security incident language when there's an actual security concern to avoid alarm fatigue. + +## Related + +This template is one of several in the [incident communication guide](/guides/incident-communication), which covers update cadence by severity, what to avoid saying, and the other scenario templates. + +## Frequently asked questions + +
+ +No. Only communicate publicly when there's a confirmed incident with user impact or when you're taking visible security measures (like requiring password resets). Internal security reviews and patched vulnerabilities without exploitation typically don't require public disclosure. + +
+ +
+ +This varies by jurisdiction and industry. GDPR requires notification within 72 hours for personal data breaches. CCPA has requirements for California residents. Healthcare (HIPAA) and payment systems (PCI DSS) have specific rules. Always consult with legal counsel before publishing security incident communications. + +
+ +
+ +Share enough to demonstrate competence and transparency, but avoid tactical details that could help attackers. Good: 'unauthorized access attempts to our admin panel.' Bad: 'SQL injection on /admin/login using parameter X.' Wait until the vulnerability is fully patched and no longer exploitable before sharing technical details. + +
+ +
+ +Only if there's evidence of credential compromise or unauthorized access to authentication systems. Don't create alarm fatigue by requesting password changes for unrelated security work. Be specific about why you're asking and which accounts are affected. + +
diff --git a/apps/web/src/content/pages/guides/sla-vs-slo-vs-sli.mdx b/apps/web/src/content/pages/guides/sla-vs-slo-vs-sli.mdx index aaeb895e..011ae124 100644 --- a/apps/web/src/content/pages/guides/sla-vs-slo-vs-sli.mdx +++ b/apps/web/src/content/pages/guides/sla-vs-slo-vs-sli.mdx @@ -1,5 +1,7 @@ --- title: "SLA vs SLO vs SLI Explained" +seo: + title: "SLO vs SLA vs SLI: What's the Difference?" description: "Learn the critical differences between SLAs, SLOs, and SLIs. Understand how to measure service reliability, set internal targets, and make customer promises without over-committing or under-delivering." author: "openstatus" publishedAt: "2026-02-09" @@ -23,6 +25,19 @@ Getting this wrong has real consequences. Either you over-promise to customers a Here's what they actually mean, how they relate, and how to use them without screwing up. +## The Short Version + +| | SLI | SLO | SLA | +| --- | --- | --- | --- | +| **What it is** | A measurement | An internal target | An external promise | +| **Who it's for** | Your monitoring | Your team | Your customers | +| **Example** | P95 latency is 180ms | P95 under 200ms, 99.5% of the time | 99.9% uptime, or 10% credit | +| **If you miss it** | Nothing — it's just data | Slow down, spend error budget | Service credits, refunds, churn | +| **Who sets it** | Engineering | Engineering and product | Legal and sales | + +The rest of this guide is why each row reads the way it does, and what breaks when +teams collapse the three columns into one. + ## What They Actually Are ### SLI (Service Level Indicator) @@ -36,6 +51,14 @@ An SLI is a specific metric you can measure. Not a feeling. Not a goal. A measur "The site should feel fast" is not an SLI. "P95 page load time under 2 seconds" is an SLI. The difference is whether you can measure it objectively and build alerts around it. +Picking a latency SLI means being precise about which number you mean — request +latency and end-to-end response time are not the same measurement, and teams often +write an SLO against one while their monitoring reports the other. See +[latency vs response time](/docs/concept/latency-vs-response-time) before you commit +a threshold to writing. The same trap applies to availability: a single uptime +percentage hides how the downtime was distributed, which is why +[uptime percentage alone is misleading](/guides/why-uptime-percentage-is-misleading). + ### SLO (Service Level Objective) An SLO is your internal target for an SLI. It's what you promise yourself, not what you promise customers. @@ -73,6 +96,14 @@ SLA → External promise to customers (has buffer from SLO) The gap between your SLO (99.95%) and your SLA (99.9%) is your **error budget**. That's the buffer that lets you deploy new features, run experiments, and handle incidents without immediately violating customer agreements. +Those two percentages translate into wall-clock minutes, and the difference is +bigger than it looks — 99.9% and 99.95% are 43 and 21 minutes per month +respectively. Run your own numbers with the [SLA calculator](/play/uptime-sla) +before agreeing to a tier. + +For how to calculate that budget, alert on its burn rate, and decide what happens +when it runs out, see [error budgets explained](/guides/error-budgets-explained). + If your SLO and SLA are the same number, one bad deploy breaks your promises. You've eliminated the margin that makes continuous deployment possible. ## Common Mistakes Teams Make @@ -133,7 +164,7 @@ The lesson: don't promise what you can't measure. And don't measure without sett Your public page shows the commitments you've made to customers. Historical uptime against SLA targets. Incident timelines showing when you came close to—but didn't breach—agreements. -This is customer-facing transparency. It's not real-time operations data. It's curated, reviewed communication. +This is customer-facing transparency. It's not real-time operations data. It's curated, reviewed communication. A [public status page](/status-page) is where the SLA number stops being a clause in a contract and becomes something a customer can check for themselves. ### Private Status Page (Track SLOs) @@ -147,6 +178,8 @@ Whether public or private, everything flows from the SLIs. If you can't measure Your entire reliability strategy—internal targets, customer promises, incident response priorities—depends on accurate, continuous SLI measurement. +"Continuous" is the load-bearing word. An SLI sampled by hand, or from one machine in one region, will not tell you whether you met a monthly target. [openstatus uptime monitoring](/uptime-monitoring) runs the checks on a schedule from 28 regions and keeps the history, so the number you report at the end of the month is one you measured rather than one you estimated. + ## "But We're Just Three People" If you're a small startup with 5 engineers and 200 customers, implementing a full SLI/SLO/SLA framework might feel like bringing a spreadsheet to a knife fight. You're not wrong. @@ -182,10 +215,42 @@ If you can't measure it, you can't manage it. And you definitely shouldn't promi --- -**OpenStatus tracks all three layers.** Monitor your SLIs, set alerts for SLO thresholds, and display SLA compliance on public and private status pages—all from one platform. +**openstatus tracks all three layers.** Monitor your SLIs, set alerts for SLO thresholds, and display SLA compliance on public and private status pages—all from one platform. Try out our SLA calculator +## Frequently asked questions + +
+ +An SLI is a specific metric you measure (like API response time). An SLO is your internal target for that metric (stricter than customer promises). An SLA is your public promise to customers with consequences if you fail. They stack: SLI → SLO → SLA. + +
+ +
+ +The gap between your SLO and SLA is your error budget - the buffer that lets you deploy features, run experiments, and handle incidents without immediately violating customer agreements. If they're the same number, one bad deploy breaks your promises. + +
+ +
+ +A good SLI is specific, measurable, and directly correlates with user experience. If the metric degrades and users don't notice, it's not an SLI. Examples: P95 API response time under 200ms, 99.9% uptime, error rate below 0.1%. + +
+ +
+ +Limit yourself to 3-5 SLIs. Pick metrics that directly impact user experience. Tracking too many creates alert fatigue and buries real issues under noise. Focus on what matters: availability, latency, and error rates. + +
+ +
+ +No. Early-stage startups should skip SLAs entirely until they have the infrastructure to measure and maintain them. Focus on tracking 2-3 SLIs, being transparent about uptime, and communicating clearly during incidents. Formalize SLAs when enterprise customers start requesting them. + +
+ --- Start free. No credit card required. Set up your first status page in under 5 minutes. diff --git a/apps/web/src/content/pages/guides/slack-status-page-subscriptions.mdx b/apps/web/src/content/pages/guides/slack-status-page-subscriptions.mdx index 1f8e6ffc..0e63637f 100644 --- a/apps/web/src/content/pages/guides/slack-status-page-subscriptions.mdx +++ b/apps/web/src/content/pages/guides/slack-status-page-subscriptions.mdx @@ -117,6 +117,50 @@ Because entitlement is managed in the dashboard, these changes do not require co Routing status updates into the Slack Connect channels you already share with enterprise customers turns customer notifications from an ongoing engineering task into a configuration workflow. No per-customer integration code, no new channels to create, no cleanup when people leave - just the same updates you already publish to your status page, delivered where your customers are already working with you. +## Frequently asked questions + +
+ +No. Updates arrive in the Slack Connect channel you already share with the customer. They never sign into openstatus and do not manage their own subscription. + +
+ +
+ +Slack Connect is Slack's native feature for sharing a channel between two separate workspaces. Both sides see the channel in their own Slack, with no guest accounts, no third-party apps, and no shared admin seats. + +
+ +
+ +No. If you already run a Slack Connect channel with a customer for support or account management, point the subscription at that channel. A new dedicated channel only makes sense if you want to separate status updates from other conversations. + +
+ +
+ +No. The page owner controls component scope. This keeps customers from seeing components they are not entitled to, and keeps entitlement decisions auditable on your side. + +
+ +
+ +Update their subscription from the dashboard. Add or remove components and the change takes effect on the next event - no code change or redeploy required. + +
+ +
+ +No. Slack Connect is free for both sides as long as at least one of the two workspaces is on a paid Slack plan. + +
+ +
+ +Openstatus supports both in addition to Slack. Pick the channel that matches how each customer prefers to receive incident updates. + +
+ --- Start free. No credit card required. Configure your first Slack subscription in under 5 minutes. diff --git a/apps/web/src/content/pages/guides/soc-2-status-page-requirements.mdx b/apps/web/src/content/pages/guides/soc-2-status-page-requirements.mdx index 25d939d4..3da64c9d 100644 --- a/apps/web/src/content/pages/guides/soc-2-status-page-requirements.mdx +++ b/apps/web/src/content/pages/guides/soc-2-status-page-requirements.mdx @@ -162,6 +162,38 @@ Read the criteria before taking any vendor's mapping — including this one — - [Security incident response template](/guides/security-incident-response) — wording for the communication itself - [ISO 27001 incident communication](/guides/iso-27001-incident-communication) — if you are pursuing both +## Frequently asked questions + +
+ +No. No Trust Services Criterion names a status page. CC2.3 requires you to communicate relevant information to external parties, including how they can report failures and how you inform them of incidents. A status page is one way to evidence that control — email distribution lists and support portals are others. What the auditor tests is whether the process exists, is followed, and produces records. + +
+ +
+ +Primarily CC2.3 (communication with external parties) and CC7.4/CC7.5 (incident response and recovery), plus A1.1 if you carry the Availability category. It contributes evidence to those controls. It does not satisfy CC1 (control environment), CC3 (risk assessment), CC5 (control activities), CC6 (logical access), or CC8 (change management). + +
+ +
+ +A population of incidents for the audit period, and for a sample of those, timestamped proof of when each was detected, when customers were notified, what was said, and when it was resolved. They will compare your status page timeline against your internal ticket or alert record to confirm the two agree. + +
+ +
+ +A Type II observation window is typically 3 to 12 months. Your evidence must cover the entire window, which means retention matters more than most teams expect — if your monitoring data expires after 14 days, you cannot produce availability evidence for a 12-month period. + +
+ +
+ +No. Compliance automation platforms track your whole control set, collect evidence across dozens of systems, and manage the audit workflow. A status page produces evidence for one narrow slice of that — external incident communication. They are complementary, not alternatives. + +
+ --- Start your status page diff --git a/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx b/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx index 6c33715b..1d7ef84b 100644 --- a/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx +++ b/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx @@ -167,3 +167,29 @@ While Atlassian Statuspage pioneered the status page market, several alternative ## Need Help or Have Questions? If you need help along the way, feel free to join our [Discord community](https://www.openstatus.dev/discord), check our [documentation](https://www.openstatus.dev/docs) for more information or reach out to us via [email](mailto:ping@openstatus.dev) + +## Frequently asked questions + +
+ +openstatus offers a one-click importer that automatically transfers your components, component groups, incidents (with all updates), maintenances, and email subscribers from Atlassian Statuspage. Just paste your API key, preview what will be imported, and confirm. See our step-by-step migration guide for details. + +
+ +
+ +OpenStatus and Betterstack offer the most generous free tiers. OpenStatus provides unlimited team members at $30/month with monitoring included, while Betterstack has a powerful free tier for solo developers. Both are significantly more affordable than Atlassian Statuspage for small teams. + +
+ +
+ +Yes. Status.io focuses purely on status communication without monitoring. Other platforms like OpenStatus, Instatus, and Betterstack include monitoring but also allow manual incident updates, so you can use your existing monitoring tools if preferred. + +
+ +
+ +OpenStatus is the only open-source option on this list that supports self-hosting, giving you complete control over your data and infrastructure. The other alternatives are cloud-hosted SaaS solutions only. + +
diff --git a/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx b/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx index 6d31008c..23249db7 100644 --- a/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx +++ b/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx @@ -215,3 +215,35 @@ For the other alternatives, migration is typically a manual rebuild of component ## Need Help or Have Questions? If you need help along the way, feel free to join our [Discord community](https://www.openstatus.dev/discord), check our [documentation](https://www.openstatus.dev/docs) for more information, or reach out to us via [email](mailto:ping@openstatus.dev). + +## Frequently asked questions + +
+ +openstatus offers a one-click importer that automatically transfers your components, component groups, incidents (with all updates), maintenances, and email subscribers. Paste your Instatus API key, preview what will be imported, and confirm. See our step-by-step migration guide for details. + +
+ +
+ +openstatus and Betterstack are the most cost-effective for small teams. openstatus includes monitoring and unlimited team members from $30/month, while Betterstack has a generous free tier that works well for solo developers. Both are typically cheaper than scaling on Instatus once you outgrow its free plan. + +
+ +
+ +Yes. Status.io and Atlassian Statuspage focus purely on status communication. openstatus, Datadog, and Betterstack include monitoring but also accept manual incident updates and webhooks, so you can keep your existing monitoring tools if you prefer. + +
+ +
+ +openstatus is the only open-source option on this list that supports self-hosting, giving you full control over your data and infrastructure. Instatus, Atlassian Statuspage, Status.io, Datadog, and Betterstack are cloud-hosted SaaS only. + +
+ +
+ +The most common reasons are: needing built-in monitoring instead of stitching together third-party probes, wanting monitoring-as-code via Terraform, needing OpenTelemetry export, requiring private locations for internal services, or wanting an open-source solution for compliance and data residency. + +
diff --git a/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx b/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx index 9e7ef5e5..0b2d7a9f 100644 --- a/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx +++ b/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx @@ -133,6 +133,32 @@ The trade-off is that you're now operating the monitor: it runs from a single lo Join our [Discord community](https://www.openstatus.dev/discord), check the [documentation](https://www.openstatus.dev/docs), or reach out via [email](mailto:ping@openstatus.dev). +## Frequently asked questions + +
+ +The most common reasons are: Pingdom discontinued its free tier after the SolarWinds acquisition, so there's no way to start for free; pricing climbs once you add real user monitoring or transaction checks; it's closed-source with no self-hosting; and it has no built-in public status page, so you need a second tool for incident communication. + +
+ +
+ +UptimeRobot has the most generous free tier (50 monitors), but it's limited to non-commercial use since October 2024. openstatus offers a permanent free plan with multi-region monitoring and a status page, and Better Stack has a usable free tier for solo developers. For unlimited free monitors, the self-hosted Uptime Kuma is the cheapest option if you're willing to run it. + +
+ +
+ +openstatus, Better Stack, and Uptime Kuma include a status page. Pingdom itself does not offer a public status page, which is one of the more common reasons teams switch. UptimeRobot and Checkly are primarily monitoring tools. + +
+ +
+ +Yes. openstatus (AGPL-3.0) and Uptime Kuma (MIT) are both open-source. openstatus is open-source but also offers a managed, multi-region cloud, while Uptime Kuma is self-hosted only and runs from a single location. + +
+ --- Start monitoring from 28 regions today diff --git a/apps/web/src/content/pages/guides/what-is-a-good-response-time.mdx b/apps/web/src/content/pages/guides/what-is-a-good-response-time.mdx new file mode 100644 index 00000000..b736fb09 --- /dev/null +++ b/apps/web/src/content/pages/guides/what-is-a-good-response-time.mdx @@ -0,0 +1,165 @@ +--- +title: "What Is a Good Response Time?" +seo: + title: "What Is a Good Response Time? Load Time Benchmarks" +description: "Target numbers for server response time and browser load time, why you should judge them at the 75th percentile rather than the average, and which metric to fix first when a page feels slow." +author: "openstatus" +publishedAt: "2026-08-15" +category: "fundamentals" +faq: + - question: "What is a good server response time?" + answer: "Under 200ms is fast, 200-500ms is acceptable for a single-region origin answering a distant request, 500ms to 1s is slow enough that users notice, and over 1s needs attention. Judge it per region rather than on a global average - an endpoint answering in 80ms locally and 900ms from the other side of the world has a distribution problem, not a speed problem." + - question: "What is a good page load time?" + answer: "For the browser experience, Google's Core Web Vitals thresholds are the practical standard: Largest Contentful Paint at or under 2.5 seconds, Interaction to Next Paint at or under 200ms, and Cumulative Layout Shift at or under 0.1. All three are assessed at the 75th percentile of real visits, so hitting them on your own laptop is not the same as passing." + - question: "Should I measure average or percentile response time?" + answer: "Percentiles, essentially always. Averages hide the slow tail that users actually complain about: a page averaging 400ms can still be failing one visitor in twenty at four seconds, and the average will never show it. Track P75 to match how Core Web Vitals are assessed, and P95 or P99 to see the worst experiences you are shipping." + - question: "Is response time the same as page load time?" + answer: "No. Response time measures how long your server takes to answer a request - it ends when the response arrives. Page load time measures what the browser then does with it: parsing HTML, fetching CSS, JavaScript, fonts, and images, and rendering the result. A fast server does not guarantee a fast page, but a slow server makes every browser metric worse, because nothing can start until the response arrives." + - question: "Why is my site fast for me but slow for users?" + answer: "Usually distance, devices, and caching. You are likely testing from close to the origin, on a fast machine, with a warm cache and no third-party scripts blocked. Real users are distributed, often on mobile, and frequently arriving cold. This is why the numbers that matter come from multiple regions and from the 75th percentile of real visits rather than from one test on your own machine." +--- + +"Fast enough" is not a number, which is why this question keeps getting asked. The +honest answer is that there are two different measurements involved and they have +different targets — so the first thing to establish is which one you are actually +looking at. + +## Two Different Numbers + +**Response time** is how long your server takes to answer. It ends the moment the +response arrives at the client. This is what an uptime monitor or a +[speed test](/play/checker) measures, and it is entirely your infrastructure's +responsibility. + +**Page load time** is what the browser does next: parse HTML, fetch CSS, JavaScript, +fonts and images, run scripts, and paint something a person can use. This is mostly +your frontend's responsibility, plus whatever third-party tags you have accumulated. + +They are related in one direction only. A fast server does not give you a fast page — +you can serve a 40ms response and still take six seconds to render. But a slow server +makes every browser metric worse, because nothing can begin until the response +arrives. If both are bad, fix the server first. + +The precise vocabulary matters more than it looks, and mixing the two up is the most +common reason a performance target never gets met — see +[latency vs response time](/docs/concept/latency-vs-response-time). + +## Good Server Response Times + +| Response time | Verdict | Typically means | +| --- | --- | --- | +| Under 200ms | Fast | Served from an edge, or an origin near the requester | +| 200–500ms | Acceptable | Normal for a single-region origin answering a distant request | +| 500ms–1s | Slow | Users perceive this. Origin round trip plus unoptimised per-request work | +| Over 1s | Needs attention | Cold starts, N+1 queries, or no caching anywhere in the path | + +The number that matters is not your best region or your average — it is the spread. +An API answering in 80ms from Frankfurt and 900ms from Sydney does not have a speed +problem, it has a distribution problem, and the fix is a CDN or an edge deployment +rather than a faster server. You cannot see that from one probe; +[measuring from multiple regions](/uptime-monitoring) is what makes it visible. + +Within a single response, it is worth knowing which phase is slow. DNS, connection, +and TLS are mostly distance and configuration; time to first byte is your application +actually working. A 40ms TTFB behind a 300ms connection phase is a fast application a +long way from the user. + +## Good Page Load Times + +For the browser half, Google's Core Web Vitals are the practical standard, because +they are what search ranking and most performance tooling actually assess: + +| Metric | Good | Needs improvement | Poor | +| --- | --- | --- | --- | +| **LCP** — Largest Contentful Paint | ≤ 2.5s | 2.5–4.0s | > 4.0s | +| **INP** — Interaction to Next Paint | ≤ 200ms | 200–500ms | > 500ms | +| **CLS** — Cumulative Layout Shift | ≤ 0.1 | 0.1–0.25 | > 0.25 | + +Two things people miss. + +**INP replaced First Input Delay.** If you are still tracking FID, you are tracking a +retired metric — INP measures the full latency of an interaction rather than just the +delay before processing starts, and it is considerably harder to pass. + +**These are assessed at the 75th percentile of real visits**, not on your machine. +Passing locally tells you almost nothing; a quarter of your users are allowed to be +slower than the threshold and you still pass, but if the 75th percentile misses, you +fail regardless of how good your median looks. + +## Judge Percentiles, Not Averages + +The average is the least useful summary of a latency distribution, because latency is +heavy-tailed — a small number of very slow requests barely move the mean. + +Two endpoints both averaging 400ms: + +- **A:** almost every request lands between 350ms and 450ms. +- **B:** most requests are 180ms, and one in twenty takes four seconds. + +Identical averages. B is generating your support tickets. Track **P75** to align with +how Core Web Vitals are assessed, and **P95** or **P99** to see the worst experience +you are actually shipping. This is the same reason +[uptime percentage alone is misleading](/guides/why-uptime-percentage-is-misleading): +one aggregate number hides the distribution that people experience. + +## What to Fix First + +1. **Measure from where users are.** A check from the same region as your origin will + report healthy numbers indefinitely while distant users time out. +2. **Fix the server before the frontend.** Everything downstream waits on the + response. +3. **Find the slow phase, not the slow page.** DNS, connect, TLS, TTFB, and transfer + fail for different reasons and have different fixes. +4. **Set the target as a percentile at a threshold**, not a vague goal — "P95 under + 500ms from every region we sell into" is testable. "The site should feel fast" is + not. +5. **Then watch it over time.** A single measurement is a snapshot; response times + move with traffic, deploys, and time of day. + +Once you have a number worth defending, it becomes an [SLI, then an +SLO](/guides/sla-vs-slo-vs-sli), and the room you have to miss it is your +[error budget](/guides/error-budgets-explained). + +## Measure Yours + +Run your URL through the [global speed test](/play/checker) to see the server-side +number and its phase breakdown from 28 regions, with no account required. For the +browser metrics, use Chrome's built-in Lighthouse panel or the Chrome User Experience +Report, which reports field data at the 75th percentile rather than lab conditions. + +To turn either into something you actually notice changing, +[uptime monitoring](/uptime-monitoring) re-runs the check on a schedule and keeps the +history — the difference between knowing your response time today and knowing it got +worse last Thursday. + +## Frequently asked questions + +
+ +Under 200ms is fast, 200–500ms is acceptable for a single-region origin answering a distant request, 500ms to 1s is slow enough that users notice, and over 1s needs attention. Judge it per region rather than on a global average — an endpoint answering in 80ms locally and 900ms from the other side of the world has a distribution problem, not a speed problem. + +
+ +
+ +For the browser experience, Google's Core Web Vitals thresholds are the practical standard: Largest Contentful Paint at or under 2.5 seconds, Interaction to Next Paint at or under 200ms, and Cumulative Layout Shift at or under 0.1. All three are assessed at the 75th percentile of real visits, so hitting them on your own laptop is not the same as passing. + +
+ +
+ +Percentiles, essentially always. Averages hide the slow tail that users actually complain about: a page averaging 400ms can still be failing one visitor in twenty at four seconds, and the average will never show it. Track P75 to match how Core Web Vitals are assessed, and P95 or P99 to see the worst experiences you are shipping. + +
+ +
+ +No. Response time measures how long your server takes to answer a request — it ends when the response arrives. Page load time measures what the browser then does with it: parsing HTML, fetching CSS, JavaScript, fonts, and images, and rendering the result. A fast server does not guarantee a fast page, but a slow server makes every browser metric worse, because nothing can start until the response arrives. + +
+ +
+ +Usually distance, devices, and caching. You are likely testing from close to the origin, on a fast machine, with a warm cache and no third-party scripts blocked. Real users are distributed, often on mobile, and frequently arriving cold. This is why the numbers that matter come from multiple regions and from the 75th percentile of real visits rather than from one test on your own machine. + +
diff --git a/apps/web/src/content/pages/guides/what-is-a-status-page.mdx b/apps/web/src/content/pages/guides/what-is-a-status-page.mdx index f684a73c..b01e6e11 100644 --- a/apps/web/src/content/pages/guides/what-is-a-status-page.mdx +++ b/apps/web/src/content/pages/guides/what-is-a-status-page.mdx @@ -123,6 +123,56 @@ A status page is the simplest, highest-leverage trust-building tool you have dur If you don't have one, you're either too small to need one (rare) or losing trust during every outage without realizing it (common). +## Frequently asked questions + +
+ +A status page is a public-facing web page that displays the real-time operational health of a service. It shows current uptime, active incidents, scheduled maintenance, and historical reliability metrics. The point is to give users a single, trustworthy place to check whether a problem is on your end or theirs. + +
+ +
+ +Without a status page, every outage floods your support inbox with the same question: 'is it down?' A status page deflects that load, builds trust by being transparent during incidents, and signals operational maturity to enterprise buyers who often require one before signing a contract. + +
+ +
+ +A public status page is visible to anyone and shows curated, user-facing reliability data. A private status page is gated behind authentication and shows real-time operational metrics to internal teams or specific customers - typically more granular and used for SLO tracking, not customer communication. + +
+ +
+ +At minimum: current component statuses (API, dashboard, auth, etc.), active incidents with timestamps and updates, scheduled maintenance windows, and historical uptime for the last 30-90 days. Optional but valuable: subscription options (email, SMS, Slack, RSS), incident postmortems, and per-region status. + +
+ +
+ +Yes. Host it on a separate domain or subdomain (e.g., status.yourcompany.com) on independent infrastructure. If your main service is down and your status page is on the same servers, users see nothing - exactly when they need information most. + +
+ +
+ +Every 15-30 minutes during an active incident, even if there's nothing new to report. 'Still investigating, next update at 14:30' is more useful than silence. Silence makes users assume you've abandoned them. + +
+ +
+ +If you have paying customers, yes. It's not about scale - it's about trust. A simple status page with uptime history and an incident feed signals you take reliability seriously. It takes less than an hour to set up and pays for itself the first time something breaks. + +
+ +
+ +A monitoring tool checks whether your service is up and alerts your team. A status page communicates that information to users. They're connected - monitoring data often drives status page updates - but they serve different audiences. Monitoring is for engineers; status pages are for customers. + +
+ --- **OpenStatus is an open-source status page and monitoring platform.** Set up a public status page, configure monitors, and start communicating reliably during incidents - all from one place. diff --git a/apps/web/src/content/pages/guides/what-is-incident-management.mdx b/apps/web/src/content/pages/guides/what-is-incident-management.mdx index 5e2146cf..f451b989 100644 --- a/apps/web/src/content/pages/guides/what-is-incident-management.mdx +++ b/apps/web/src/content/pages/guides/what-is-incident-management.mdx @@ -12,7 +12,7 @@ faq: - question: "What's an incident commander?" answer: "The incident commander is the single person responsible for coordinating the response - making decisions, assigning tasks, and keeping the team focused. They are not necessarily the most technical person on the call. Their job is leadership during the incident: who's doing what, what we know, when we'll update customers, when to escalate." - question: "How do you define incident severity?" - answer: "Most teams use a 4-tier scale: SEV1 (critical, customer-facing, all hands), SEV2 (major impact, urgent), SEV3 (partial impact, business hours), SEV4 (minor, planned work). The exact thresholds depend on your product, but the key is having clear criteria so the team doesn't argue about severity in the middle of an outage." + answer: "Most teams use a 4-tier scale: SEV0 (critical, customer-facing, all hands), SEV1 (major impact, urgent), SEV2 (partial impact, business hours), SEV3 (minor, planned work). The exact thresholds depend on your product, but the key is having clear criteria so the team doesn't argue about severity in the middle of an outage." - question: "What's the difference between an incident and an outage?" answer: "An outage is one type of incident - your service is fully unreachable. An incident is any unplanned disruption: outages, degraded performance, data correctness issues, security events, third-party failures. All outages are incidents; not all incidents are outages." - question: "What goes in an incident postmortem?" @@ -97,14 +97,14 @@ A working severity model: | Severity | Criteria | Response | |----------|---------------------------------------------------------------------------|-----------------------------------| -| **SEV1** | Full outage or critical data loss affecting many customers | All-hands, page on-call immediately, public status update within 15m | -| **SEV2** | Major feature broken, significant customer impact, or revenue at risk | Page on-call, public status update within 30m | -| **SEV3** | Partial impact, workaround exists, no immediate revenue risk | Business hours response, status update if customer-facing | -| **SEV4** | Minor issue, internal-only, or planned/expected impact | Handle in normal work queue | +| **SEV0** | Full outage or critical data loss affecting many customers | All-hands, page on-call immediately, public status update within 15m | +| **SEV1** | Major feature broken, significant customer impact, or revenue at risk | Page on-call, public status update within 30m | +| **SEV2** | Partial impact, workaround exists, no immediate revenue risk | Business hours response, status update if customer-facing | +| **SEV3** | Minor issue, internal-only, or planned/expected impact | Handle in normal work queue | The exact thresholds depend on your business. The important thing is that they're defined ahead of time so the team isn't arguing about severity in the middle of an outage. -A common pitfall: severity inflation. If everything is SEV1, nothing is. Be honest. Reserve SEV1 for genuine "drop everything" events. +A common pitfall: severity inflation. If everything is SEV0, nothing is. Be honest. Reserve SEV0 for genuine "drop everything" events. See our [incident severity matrix](/guides/incident-severity-matrix) for a more detailed framework. @@ -137,7 +137,7 @@ A good postmortem has: **No formal declaration.** Engineers debug for an hour before someone says "wait, should we call this an incident?" By then, customers have been on Twitter for 45 minutes. -**Severity inflation.** Everything is SEV1, so on-call burns out and nothing is actually prioritized. +**Severity inflation.** Everything is SEV0, so on-call burns out and nothing is actually prioritized. **Fixing root cause during the incident.** Restore service first. The instinct to "really fix it" during an active incident usually extends the outage. Mitigate now, root-cause later. @@ -163,6 +163,62 @@ Incident management isn't about preventing incidents - they happen to everyone. Teams that get this right preserve customer trust through outages. Teams that don't lose it during the second one. +## Frequently asked questions + +
+ +Incident management is the process a team uses to detect, respond to, resolve, and learn from unplanned service disruptions. It covers the technical work of fixing the problem, the communication work of keeping customers informed, and the organizational work of running a postmortem so the same problem doesn't recur. + +
+ +
+ +Detection (something is wrong), declaration (someone calls it an incident), response (assemble a team and start mitigating), communication (update customers via the status page), resolution (the immediate problem is fixed), and postmortem (learn what happened and prevent recurrence). Each stage has a different cadence and a different audience. + +
+ +
+ +The incident commander is the single person responsible for coordinating the response - making decisions, assigning tasks, and keeping the team focused. They are not necessarily the most technical person on the call. Their job is leadership during the incident: who's doing what, what we know, when we'll update customers, when to escalate. + +
+ +
+ +Most teams use a 4-tier scale: SEV0 (critical, customer-facing, all hands), SEV1 (major impact, urgent), SEV2 (partial impact, business hours), SEV3 (minor, planned work). The exact thresholds depend on your product, but the key is having clear criteria so the team doesn't argue about severity in the middle of an outage. + +
+ +
+ +An outage is one type of incident - your service is fully unreachable. An incident is any unplanned disruption: outages, degraded performance, data correctness issues, security events, third-party failures. All outages are incidents; not all incidents are outages. + +
+ +
+ +A clear timeline of what happened, root cause analysis, customer impact (how many users, how long, what they experienced), what went well, what didn't, and action items with owners and due dates. The point is to learn - blameless framing, focus on systems and processes rather than individuals. + +
+ +
+ +Initial acknowledgment within 5-15 minutes of detection. Updates every 15-30 minutes during active incidents, even if there's nothing new to report - silence is worse than 'still investigating, next update at 14:30'. Resolution message when the immediate problem is fixed. Follow-up postmortem within a week. + +
+ +
+ +MTTR (Mean Time To Recovery) is the average time from incident detection to resolution. It's the headline metric for incident response maturity. Lower MTTR means faster recovery, less customer impact, and usually better tooling and process. See our guide on MTTR for the full breakdown. + +
+ +
+ +A postmortem that focuses on systems, processes, and contributing factors rather than individual mistakes. The premise: people don't show up wanting to cause incidents - if a system allowed a human error to cause customer impact, the system is the problem. Blameless framing produces honest postmortems; blame-heavy framing produces defensive ones. + +
+ --- **OpenStatus combines monitoring and status pages so detection and communication live in one place.** Open-source, with on-call alerting and incident management built in. diff --git a/apps/web/src/content/pages/guides/what-is-mttr.mdx b/apps/web/src/content/pages/guides/what-is-mttr.mdx index 61a6b5b2..ee2d870f 100644 --- a/apps/web/src/content/pages/guides/what-is-mttr.mdx +++ b/apps/web/src/content/pages/guides/what-is-mttr.mdx @@ -1,14 +1,18 @@ --- title: "What Is MTTR? (And the Other MTT-Whatevers)" -description: "MTTR is the average time it takes to recover from an incident. But there are four different MTT- metrics that get confused regularly. Here's what each one actually measures, how to calculate them, and how to actually move the numbers." +description: "MTTR is the average time it takes to recover from an incident. But there are five different MTT- metrics that get confused regularly - including MTBF, which pairs with MTTR to give you availability. Here's what each one measures and how to move the numbers." author: "openstatus" publishedAt: "2026-05-09" category: "fundamentals" faq: - question: "What is MTTR?" answer: "MTTR most commonly stands for Mean Time To Recovery (or Mean Time To Resolve) - the average time from when an incident is detected to when service is restored. It's calculated as total downtime across incidents divided by the number of incidents. Lower is better. It's the headline metric for incident response maturity." - - question: "What's the difference between MTTR, MTTD, MTTA, and MTTF?" - answer: "MTTD (Mean Time To Detect) - time from problem occurring to your team noticing. MTTA (Mean Time To Acknowledge) - time from alert firing to a human acknowledging. MTTR (Mean Time To Recovery) - time from detection to service restored. MTTF (Mean Time To Failure) - average time a system runs before failing. They're stages on a timeline; MTTR is usually the headline number." + - question: "What's the difference between MTTR, MTTD, MTTA, MTTF, and MTBF?" + answer: "MTTD (Mean Time To Detect) - time from problem occurring to your team noticing. MTTA (Mean Time To Acknowledge) - time from alert firing to a human acknowledging. MTTR (Mean Time To Recovery) - time from detection to service restored. MTTF (Mean Time To Failure) - average time a system runs before failing. MTBF (Mean Time Between Failures) - average time between one incident ending and the next starting. The first four are stages on a single incident's timeline; MTBF measures the gaps between incidents." + - question: "What is the difference between MTBF and MTTF?" + answer: "MTTF is for things you replace, MTBF is for things you repair. A hard drive has an MTTF because when it fails you discard it. A service has an MTBF because when it breaks you fix it and it keeps running. For software systems MTBF is almost always the metric you actually want, and MTTF is a hardware inheritance that gets misapplied." + - question: "How do MTBF and MTTR relate to availability?" + answer: "Availability = MTBF / (MTBF + MTTR). If your service runs 200 hours between incidents and takes 1 hour to recover, availability is 200/201 = 99.5%. The formula shows the trade-off directly: you can raise availability by breaking less often or by recovering faster. Halving MTTR to 30 minutes and doubling MTBF to 400 hours both land at 99.75%, so pick whichever is cheaper for your team." - question: "How do you calculate MTTR?" answer: "Sum the total downtime across all incidents in a period, then divide by the number of incidents. Example: 3 incidents totaling 90 minutes of downtime = MTTR of 30 minutes. Some teams measure from detection to resolution; others from start of impact to resolution. Be explicit about which definition you use." - question: "What's a good MTTR?" @@ -27,13 +31,13 @@ faq: answer: "MTTR feeds directly into your error budget. Every minute of downtime burns budget. A team with MTTR of 4 hours has very different error budget math than one with MTTR of 20 minutes. Improving MTTR effectively lets you take more deployment risk because each incident costs less of your budget." --- -There are four different "MTT-" acronyms in incident management. They all sound similar, get used interchangeably, and measure completely different things. The result is engineering org-charts arguing about whose number is better when they aren't even measuring the same phenomenon. +There are five different "MTT-" acronyms in incident management. They all sound similar, get used interchangeably, and measure completely different things. The result is engineering org-charts arguing about whose number is better when they aren't even measuring the same phenomenon. -MTTR is the headline one - the average time from incident detection to service restored. It's the standard benchmark for how good a team is at handling outages. Here's what it actually measures, how it relates to the other three MTT-metrics, and how to actually move the number. +MTTR is the headline one - the average time from incident detection to service restored. It's the standard benchmark for how good a team is at handling outages. Here's what it actually measures, how it relates to the other four MTT-metrics, and how to actually move the number. -## The Four MTT-Metrics +## The Five MTT-Metrics -Most incident timelines have four phases. Each has its own metric. +Four of them are phases of a single incident's timeline. The fifth, MTBF, measures the quiet stretches in between. ``` Problem starts ──── Detection ──── Acknowledgment ──── Resolution @@ -75,6 +79,35 @@ Driven by: overall reliability, change management, system design. Less commonly tracked. Requires a long incident history to be meaningful. Often used for hardware originally; less precise for software systems. +### MTBF - Mean Time Between Failures + +Average time between one incident ending and the next one starting. + +Driven by: change quality, test coverage, architectural resilience, dependency stability. + +MTBF and MTTF get confused constantly, and the distinction is genuinely simple: **MTTF is for things you replace, MTBF is for things you repair.** A hard drive has an MTTF because when it dies you throw it away. A service has an MTBF because when it breaks you fix it and it keeps running. For software, MTBF is almost always the metric you actually want. + +Unlike the other four, MTBF is not a phase of an incident — it measures the gaps *between* incidents. That makes it the frequency metric to MTTR's duration metric, which is why the two belong together: + +``` +─── incident ─── uptime ─── incident ─── uptime ─── incident ─── + MTTR MTBF MTTR MTBF MTTR +``` + +## MTBF and MTTR Together: Availability + +The reason to track both is that availability falls straight out of them: + +``` +Availability = MTBF / (MTBF + MTTR) +``` + +**Example:** your service runs 200 hours between incidents and takes 1 hour to recover. Availability = 200 / 201 = **99.5%**. + +This is the formula that makes the trade-off concrete. You can raise availability by breaking less often (higher MTBF) or by recovering faster (lower MTTR), and the arithmetic tells you which is cheaper for you. Halving MTTR from 1 hour to 30 minutes takes that example to 99.75%. Doubling MTBF to 400 hours gets you the same place. Most teams find recovery speed easier to buy than reliability. + +It also explains why an availability target is not really one number. 99.9% is reachable with frequent tiny incidents or with rare long ones, and those are very different engineering problems behind an identical SLO. See [error budgets explained](/guides/error-budgets-explained) for what that permitted downtime actually buys you, and [why uptime percentage alone is misleading](/guides/why-uptime-percentage-is-misleading) for what the single number hides. + ## How to Calculate MTTR The basic math: @@ -88,7 +121,7 @@ MTTR = Total downtime across incidents / Number of incidents The catch is defining "downtime." Two definitions in common use: - **Detection to resolution** - measures response speed. Excludes the time the system was broken before you knew. -- **Impact start to resolution** - measures total customer-visible downtime. Includes [MTTD](/guides/what-is-mttr#mttd) inside it. +- **Impact start to resolution** - measures total customer-visible downtime. Includes [MTTD](#mttd-mean-time-to-detect) inside it. Pick one. Be explicit about which. Don't switch between them. @@ -185,9 +218,83 @@ Track it by severity. Look at percentiles, not just the mean. Optimize the three The teams with the best MTTR aren't the ones who never have incidents. They're the ones who've practiced handling them. +## Frequently asked questions + +
+ +MTTR most commonly stands for Mean Time To Recovery (or Mean Time To Resolve) - the average time from when an incident is detected to when service is restored. It's calculated as total downtime across incidents divided by the number of incidents. Lower is better. It's the headline metric for incident response maturity. + +
+ +
+ +MTTD (Mean Time To Detect) - time from problem occurring to your team noticing. MTTA (Mean Time To Acknowledge) - time from alert firing to a human acknowledging. MTTR (Mean Time To Recovery) - time from detection to service restored. MTTF (Mean Time To Failure) - average time a system runs before failing. MTBF (Mean Time Between Failures) - average time between one incident ending and the next starting. The first four are stages on a single incident's timeline; MTBF measures the gaps between incidents. + +
+ +
+ +MTTF is for things you replace, MTBF is for things you repair. A hard drive has an MTTF because when it fails you discard it. A service has an MTBF because when it breaks you fix it and it keeps running. For software systems MTBF is almost always the metric you actually want, and MTTF is a hardware inheritance that gets misapplied. + +
+ +
+ +Availability = MTBF / (MTBF + MTTR). If your service runs 200 hours between incidents and takes 1 hour to recover, availability is 200/201 = 99.5%. The formula shows the trade-off directly: you can raise availability by breaking less often or by recovering faster. Halving MTTR to 30 minutes and doubling MTBF to 400 hours both land at 99.75%, so pick whichever is cheaper for your team. + +
+ +
+ +Sum the total downtime across all incidents in a period, then divide by the number of incidents. Example: 3 incidents totaling 90 minutes of downtime = MTTR of 30 minutes. Some teams measure from detection to resolution; others from start of impact to resolution. Be explicit about which definition you use. + +
+ +
+ +Depends entirely on your service criticality and severity tier. Rough benchmarks for SaaS: SEV1 incidents, under 30 minutes is excellent, 1-2 hours is typical. SEV2, under 2 hours is good. The trend matters more than the absolute number - a team whose MTTR is dropping quarter over quarter is improving; a team with low MTTR that's flat may just be in a quiet period. + +
+ +
+ +MTTR averages can hide outliers. Ten 5-minute incidents and one 8-hour outage average out to about 48 minutes - which looks fine, but the 8-hour outage is what customers remember. Track percentiles (P50, P95, P99) and look at the distribution, not just the mean. Also be wary of teams who 'lower MTTR' by silently downgrading incident severity. + +
+ +
+ +Three levers: detection (better monitoring lowers MTTD), response (runbooks, on-call rotations, and clear roles lower MTTA and active response time), and recovery (good rollback tooling, feature flags, and automated mitigations lower the time to actually fix things). Each one shaves minutes off the total. + +
+ +
+ +MTTF is Mean Time To Failure - the average uptime between failures. For SaaS, it's the inverse of how often you have incidents. High MTTF means rare incidents. It's usually a hardware reliability metric originally but applies to software systems too. Less commonly tracked than MTTR because it requires a long history of incident data to be meaningful. + +
+ +
+ +Often used interchangeably, but technically: 'recovery' is when service is back to normal for users, 'resolve' is when the underlying issue is fully fixed (which may include follow-up work after service is restored). Pick one definition for your team and use it consistently. Mixing them produces meaningless trends. + +
+ +
+ +Yes. A 4-hour MTTR for SEV3 incidents is fine. A 4-hour MTTR for SEV1 incidents is a problem. Aggregating across severities hides important information. Most teams report MTTR separately for SEV1, SEV2, and SEV3. + +
+ +
+ +MTTR feeds directly into your error budget. Every minute of downtime burns budget. A team with MTTR of 4 hours has very different error budget math than one with MTTR of 20 minutes. Improving MTTR effectively lets you take more deployment risk because each incident costs less of your budget. + +
+ --- -**OpenStatus combines monitoring (lower MTTD) with status pages and alerting (lower MTTA) in one platform** - tightening the whole incident timeline. +**openstatus combines monitoring (lower MTTD) with status pages and alerting (lower MTTA) in one platform** - tightening the whole incident timeline. [Uptime monitoring](/uptime-monitoring) runs the checks that set your MTTD floor, and [latency vs response time](/docs/concept/latency-vs-response-time) covers what those checks actually measure. Try openstatus free diff --git a/apps/web/src/content/pages/guides/what-is-synthetic-monitoring.mdx b/apps/web/src/content/pages/guides/what-is-synthetic-monitoring.mdx index 2e65e63e..dc4fb789 100644 --- a/apps/web/src/content/pages/guides/what-is-synthetic-monitoring.mdx +++ b/apps/web/src/content/pages/guides/what-is-synthetic-monitoring.mdx @@ -157,6 +157,62 @@ Synthetic monitoring is how you find out something is broken before your custome Start with HTTP checks on critical endpoints. Add browser checks for user flows that matter. Run from multiple regions. Push results to your [status page](/guides/what-is-a-status-page) so users have an authoritative source when things break. +## Frequently asked questions + +
+ +Synthetic monitoring uses scripted, automated checks to simulate user actions against your service - hitting an API endpoint, loading a page, completing a checkout - from external locations on a fixed schedule. It catches problems before real users hit them, and it works even when you have zero traffic. + +
+ +
+ +Synthetic monitoring runs scripted, fake traffic on a schedule from your monitoring provider's infrastructure. RUM captures data from actual user sessions in real time. Synthetic is proactive and deterministic; RUM is reactive and reflects real-world conditions. Most mature teams use both: synthetic for catching regressions early, RUM for understanding what real users experience. + +
+ +
+ +API availability and response time, full page loads (with JavaScript execution), multi-step user flows (login, signup, checkout), SSL certificate validity, DNS resolution, third-party integrations, and end-to-end transactions that span multiple services. Anything you can script can be monitored. + +
+ +
+ +Use synthetic monitoring (beyond simple uptime checks) when failure modes can't be detected by a single HTTP request. If your login flow depends on three services and any one of them being broken makes signups fail, a synthetic browser check that completes the full login is the only way to know it works end-to-end. + +
+ +
+ +Yes - that's one of its main advantages. Synthetic checks run on a schedule regardless of whether real users are hitting your service. This makes it ideal for pre-launch validation, low-traffic services, off-hours coverage, and detecting regressions in features that aren't used often. + +
+ +
+ +A browser check loads your page in a real headless browser (typically Chromium), executes JavaScript, and can click buttons, fill forms, and navigate multiple steps. It catches problems pure HTTP checks miss - broken JavaScript, third-party script failures, layout breaks, slow rendering - at the cost of running slower and using more resources. + +
+ +
+ +Simple HTTP checks: every 30 seconds to 1 minute. Browser checks: every 5-15 minutes (they're slow and expensive). Multi-step transaction checks: every 5-15 minutes. The more complex the check, the less frequent it makes sense to run - and the higher the value of each successful run. + +
+ +
+ +A transaction monitor (also called a multi-step API check) runs a sequence of requests that depend on each other - log in, capture a token, use that token to make an authenticated request, validate the response, log out. It tests the whole flow as a unit, the way real users actually interact with your service. + +
+ +
+ +No. Synthetic monitoring tells you whether a controlled, scripted scenario works. RUM tells you what your actual users experience - including problems specific to their devices, browsers, regions, and ISPs. They answer different questions and complement each other. + +
+ --- **OpenStatus runs synthetic monitors - HTTP, TCP, DNS, and full browser checks - from multiple regions worldwide.** Open-source, with built-in status page integration. diff --git a/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx b/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx index 49c9a5fc..6a6cabc6 100644 --- a/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx +++ b/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx @@ -142,6 +142,62 @@ Uptime monitoring exists because you cannot trust your own infrastructure to tel Set it up. Monitor the things customers actually depend on. Use multiple regions. Tune the check frequency for the criticality. Push results into a [status page](/guides/what-is-a-status-page) so users can self-serve when things break. +## Frequently asked questions + +
+ +Uptime monitoring is the practice of regularly checking - usually every 30 seconds to 5 minutes - whether your service is reachable and returning the expected response. The checks run from external locations so they catch problems your internal infrastructure can't see, like DNS failures, certificate expirations, or regional outages. + +
+ +
+ +A monitoring service sends synthetic requests (HTTP, TCP, ping, or full browser sessions) to your endpoints from probe locations around the world on a fixed interval. If a request fails or times out, the monitor flags it. Most systems require multiple consecutive failures from different regions before alerting, to avoid noise from transient network blips. + +
+ +
+ +99.9% uptime means roughly 8.76 hours of allowed downtime per year, or about 43 minutes per month. 99.99% drops that to 52 minutes per year. 99.999% ('five nines') is about 5 minutes per year - a target that's expensive to hit and usually only meaningful for infrastructure providers. + +
+ +
+ +For customer-facing services, every 30-60 seconds is standard. Less frequent (5-15 minutes) is fine for internal tools or batch systems. More frequent than 30 seconds rarely helps - you start measuring network jitter rather than real availability, and your costs climb fast. + +
+ +
+ +An outage that only affects one region is invisible to a single-region monitor. CDN issues, DNS propagation failures, and regional ISP problems are common. Running checks from at least 3 geographically distributed locations and requiring a majority to fail before alerting catches real outages while ignoring isolated network blips. + +
+ +
+ +Uptime monitoring is the simplest form of synthetic monitoring - a periodic check that an endpoint responds. Synthetic monitoring is the broader category that includes multi-step browser flows, transaction monitoring, and API sequences. All uptime monitoring is synthetic monitoring; not all synthetic monitoring is uptime monitoring. + +
+ +
+ +Both, separately. A working homepage doesn't mean your API is up - they often run on different infrastructure. Monitor the critical user paths: login, the main API endpoints your customers integrate with, payment processing. Each gets its own monitor so you know precisely what's failing. + +
+ +
+ +An alert that fires when nothing was actually wrong - usually because the probe location had a transient network problem, hit a rate limit, or got caught by a bot detection rule. Good monitoring tools reduce false positives by requiring multi-region confirmation and ignoring single-probe failures. + +
+ +
+ +Uptime percentage is (total time minus downtime) divided by total time, expressed as a percent. The catch is defining 'downtime'. Some teams count only full outages; others include degraded performance. Be explicit: '99.9% uptime measured as HTTP 200 response in under 2 seconds from 3 of 5 regions' is meaningful. '99.9% uptime' alone is marketing. + +
+ --- **OpenStatus runs uptime monitors from multiple regions worldwide** and pushes results directly to your status page. Open-source, with sub-minute check frequency and no vendor lock-in. diff --git a/apps/web/src/content/pages/guides/why-every-saas-needs-a-status-page.mdx b/apps/web/src/content/pages/guides/why-every-saas-needs-a-status-page.mdx index 4a2a34ce..ae6ae6d6 100644 --- a/apps/web/src/content/pages/guides/why-every-saas-needs-a-status-page.mdx +++ b/apps/web/src/content/pages/guides/why-every-saas-needs-a-status-page.mdx @@ -172,14 +172,18 @@ You don't need perfection. You need *something*. **Ship a basic status page toda Start with: 1. A simple page showing current status (operational, degraded, down) -2. Automated monitoring that updates status in real-time +2. [Automated monitoring](/uptime-monitoring) that updates status in real-time 3. Subscriber notifications (email at minimum) -4. A process for posting incident updates immediately - not after root cause analysis +4. A process for posting incident updates immediately - not after root cause analysis. The [incident communication guide](/guides/incident-communication) covers cadence by severity and gives you copy-paste templates, so nobody is drafting prose mid-outage 5. Historical uptime data That's it. You can add metrics, regional breakdowns, and advanced features later. The important part is having *a page* before you need it. -**Open-source options exist** - OpenStatus, Uptime Kuma, Cachet. **Hosted platforms exist.** Pick one and ship it this week. Waiting for the "right" solution means you won't have one when it matters. +Concretely: the [create your first status page](/docs/tutorial/create-your-first-status-page) tutorial takes about five minutes end to end, and [openstatus status pages](/status-page) include monitoring rather than selling it separately. + +**Open-source options exist** - openstatus, Uptime Kuma, Cachet. **Hosted platforms exist.** Pick one and ship it this week. Waiting for the "right" solution means you won't have one when it matters. + +If your situation is more specific than "a SaaS", there is probably a closer fit: [startups](/use-case/startups), [API infrastructure](/use-case/api-providers), [enterprise sales](/use-case/enterprise-sales), [reducing support tickets](/use-case/reduce-support-tickets), [compliance](/use-case/compliance), or [open-source projects](/use-case/open-source). ## The Bottom Line @@ -200,6 +204,44 @@ Use a dedicated service or open-source solution designed to stay up when your in Start today. Not after your first major incident. +## Frequently asked questions + +
+ +Yes. Users expect transparency from every SaaS product now. Not having a status page signals you're either hiding problems or haven't thought about reliability. Both destroy trust with technical buyers and enterprise customers. + +
+ +
+ +The opposite. Hiding incidents makes you look unreliable. A status page with honest incident history proves you take reliability seriously and communicate transparently. Companies like Stripe and Vercel publish detailed postmortems - it builds trust, not doubt. + +
+ +
+ +No. Start simple now. A basic status page takes 5 minutes to set up and establishes good habits early. Waiting until after your first major incident means angry customers watching you scramble to set up infrastructure you should have had from day one. + +
+ +
+ +During incidents, hundreds of customers ask 'is it down?' Instead of flooding support with tickets, they check your status page and get an immediate answer. Your support team can focus on helping users affected by the incident rather than repeating the same status update. + +
+ +
+ +Direct ROI: massive reduction in support tickets during incidents, lower customer churn from bad experiences, faster enterprise sales cycles. Indirect ROI: SEO traffic from '[your-product] status' searches, trust building that leads to word-of-mouth, and competitive differentiation against companies that hide their incidents. + +
+ +
+ +No. When your main site goes down, your homegrown status page hosted on the same infrastructure goes down with it. Building reliable status infrastructure means separate hosting, static generation, minimal dependencies, and monitoring for the status page itself. Use a dedicated service or open-source solution designed to stay up when everything else is broken. + +
+ --- **Openstatus makes this easy.** Set up a public or private status page in under 5 minutes. Automated monitoring. Real-time updates. Subscriber notifications. Open-source and transparent - so you can trust it won't fail when everything else does. diff --git a/apps/web/src/content/pages/guides/why-is-my-monitor-failing.mdx b/apps/web/src/content/pages/guides/why-is-my-monitor-failing.mdx index f8881072..bf1b1c45 100644 --- a/apps/web/src/content/pages/guides/why-is-my-monitor-failing.mdx +++ b/apps/web/src/content/pages/guides/why-is-my-monitor-failing.mdx @@ -99,6 +99,38 @@ A monitor that cries wolf gets muted, and a muted monitor is worse than no monit - [HTTP Headers for Monitoring](/guides/http-headers) - [What Is Synthetic Monitoring?](/guides/what-is-synthetic-monitoring) +## Frequently asked questions + +
+ +Almost always one of three things: the check is failing from a region you can't see from your browser, your firewall or bot-protection is blocking the probe's IP, or a single probe location had a transient network blip. Load the site from a different network and region, then check whether the failure is reported from one region or several — a single-region failure is usually a false positive. + +
+ +
+ +A false positive is an alert that fires when nothing was actually wrong — typically because a single probe location hit a transient network issue, a rate limit, or a bot-detection rule. The fix is to require multiple regions to fail before alerting, so isolated probe-side problems are ignored. + +
+ +
+ +Require multi-region confirmation before alerting, set a realistic timeout (network round-trips from distant regions take longer than local requests), allowlist your monitor's probe IPs in your WAF and rate limiter, and make sure your assertions aren't too strict. openstatus retries a failing check before counting it as down, and only alerts once more than 50% of a monitor's regions agree the check has failed. + +
+ +
+ +Intermittent failures usually point to rate limiting, an overloaded origin under certain traffic, bot-protection challenges that fire occasionally, DNS issues, or a timeout set too aggressively. Look at which regions fail and whether failures cluster at specific times or request volumes. + +
+ +
+ +Yes. An expired, misconfigured, or incomplete certificate chain causes checks to fail even when the server responds — browsers and probes both reject invalid certificates. If the failure started abruptly on a specific date, an expired certificate is a prime suspect. + +
+ --- **openstatus checks from up to 28 regions and only alerts when they agree** — fewer false positives, real outages caught fast. Open-source, free to start. diff --git a/apps/web/src/content/pages/guides/why-uptime-percentage-is-misleading.mdx b/apps/web/src/content/pages/guides/why-uptime-percentage-is-misleading.mdx index 9a459dbb..0da37209 100644 --- a/apps/web/src/content/pages/guides/why-uptime-percentage-is-misleading.mdx +++ b/apps/web/src/content/pages/guides/why-uptime-percentage-is-misleading.mdx @@ -1,9 +1,18 @@ --- title: "Why Uptime Percentage Alone is Misleading" -description: "99.9% uptime sounds impressive until you realize it tells you nothing about user experience. Learn why availability percentages hide the real story and what to measure instead." +description: "99.9% uptime sounds impressive until you realize it tells you nothing about user experience. What uptime and availability actually mean, why the percentage hides the real story, and what to measure instead." author: "openstatus" publishedAt: "2026-02-13" category: "education" +faq: + - question: "What is the difference between uptime and availability?" + answer: "In practice they are used interchangeably, and most SLAs treat them as synonyms. Where a distinction exists: uptime asks whether the system is running and reachable, while availability asks whether users can successfully do what they came for - usually measured as successful requests divided by total requests. A server that is powered on and returning 500 errors to every request has 100% uptime and 0% availability." + - question: "Is 99.9% uptime good?" + answer: "It depends entirely on how the 43 minutes of monthly downtime is distributed. Forty-three separate one-minute blips are barely noticed; a single 43-minute outage on a Tuesday afternoon is a crisis. Both are 99.9%. The percentage alone cannot tell you which one you had, which is why it is a reporting metric rather than an operational one." + - question: "What should I measure instead of uptime percentage?" + answer: "Latency percentiles (P50, P95, P99), error rates broken down by endpoint and region, user journey success for flows like login and checkout, regional availability rather than a global aggregate, and error budget burn rate. Those five tell you what users actually experienced; a single percentage does not." + - question: "Can you have high uptime and unhappy users?" + answer: "Yes, and it is common. A health check returning 200 OK in 50ms while real requests take eight seconds gives you excellent uptime and a service users consider broken. Uptime measures whether the service responds. It does not measure whether the response was fast enough or correct." --- 99.9% uptime sounds great in sales decks. It makes executives nod approvingly and looks impressive on your status page. @@ -52,6 +61,23 @@ You can have 99.9% uptime with P99 latency at 30 seconds. Technically available. **Availability measures if it responds. Reliability measures if it works.** +## Uptime vs Availability: Is There a Difference? + +Most of the time, no — and anyone telling you the two words mean sharply different things is usually selling something. They are used interchangeably across the industry, both expressed as a percentage of a time window, and your SLA almost certainly treats them as synonyms. + +Where a distinction does exist, it is this: + +| | Uptime | Availability | +| --- | --- | --- | +| **Question it answers** | Is the system running? | Can users successfully do what they came for? | +| **Typical measurement** | The process is up and the endpoint is reachable | Successful requests ÷ total requests | +| **A server returning 500s** | Counted as **up** | Counted as **unavailable** | +| **Origin** | Infrastructure and hardware monitoring | Service-level and user-facing measurement | + +The practical consequence: uptime is a property of your *infrastructure*, availability is a property of your *service*. A machine that is powered on, reachable, and returning `500 Internal Server Error` to every request has 100% uptime and 0% availability. That gap is exactly where "our monitoring was green" incidents live. + +Which is why the argument on this page is not really about vocabulary. Whichever word you use, a single percentage over a whole window hides *when* the failures happened, *what* broke, and *who* it hit. Renaming the metric fixes none of that. Measuring the things below does. + ## The Five Nines Trap Teams obsess over pushing 99.9% to 99.99%. The cost grows exponentially in engineering time, infrastructure, and complexity. @@ -66,7 +92,7 @@ You hit your uptime target. Users churn anyway. Stop obsessing over a single percentage. Start measuring what users actually experience: -**Latency percentiles:** P50, P95, P99. How long do real requests take? A P99 of 10 seconds means 1 in 100 users have a terrible experience, even with perfect uptime. +**Latency percentiles:** P50, P95, P99. How long do real requests take? A P99 of 10 seconds means 1 in 100 users have a terrible experience, even with perfect uptime. Be precise about which number you are tracking — [latency and response time](/docs/concept/latency-vs-response-time) are different measurements, and a threshold set against one while your monitoring reports the other is a threshold that never fires. **Error rates:** Break them down by endpoint, status code, and region. A 0.1% global error rate could be a 5% error rate for your checkout endpoint. @@ -74,7 +100,9 @@ Stop obsessing over a single percentage. Start measuring what users actually exp **Regional availability:** Don't aggregate global uptime into one number. Your service down in Asia won't show up if North America is fine. -**Error budget burn rate:** Are you on track to blow your SLO? This metric is actionable. A percentage alone tells you nothing about trajectory. +**Error budget burn rate:** Are you on track to blow your SLO? This metric is actionable. A percentage alone tells you nothing about trajectory. [Error budgets explained](/guides/error-budgets-explained) covers how to calculate the budget and what burn rates are worth alerting on. + +**Regional detail, not a regional average.** [Uptime monitoring](/uptime-monitoring) from 28 regions is what makes the fourth point above measurable — a single-probe check cannot distinguish "our service is down" from "our service is down in Asia". ## Bottom Line @@ -84,6 +112,32 @@ Measure what users feel: latency, errors by feature, regional failures. If your Stop optimizing for a number that looks good in reports. Start optimizing for the experience your users actually have. +## Frequently asked questions + +
+ +In practice they are used interchangeably, and most SLAs treat them as synonyms. Where a distinction exists: uptime asks whether the system is running and reachable, while availability asks whether users can successfully do what they came for - usually measured as successful requests divided by total requests. A server that is powered on and returning 500 errors to every request has 100% uptime and 0% availability. + +
+ +
+ +It depends entirely on how the 43 minutes of monthly downtime is distributed. Forty-three separate one-minute blips are barely noticed; a single 43-minute outage on a Tuesday afternoon is a crisis. Both are 99.9%. The percentage alone cannot tell you which one you had, which is why it is a reporting metric rather than an operational one. + +
+ +
+ +Latency percentiles (P50, P95, P99), error rates broken down by endpoint and region, user journey success for flows like login and checkout, regional availability rather than a global aggregate, and error budget burn rate. Those five tell you what users actually experienced; a single percentage does not. + +
+ +
+ +Yes, and it is common. A health check returning 200 OK in 50ms while real requests take eight seconds gives you excellent uptime and a service users consider broken. Uptime measures whether the service responds. It does not measure whether the response was fast enough or correct. + +
+ --- Start free. No credit card required. Set up your first status page in under 5 minutes. diff --git a/apps/web/src/content/pages/home.mdx b/apps/web/src/content/pages/home.mdx index 53df62c6..3879315c 100644 --- a/apps/web/src/content/pages/home.mdx +++ b/apps/web/src/content/pages/home.mdx @@ -24,7 +24,7 @@ faq: - question: "Does openstatus have an API?" answer: "Yes. Openstatus exposes a typed JSON-over-HTTP API powered by ConnectRPC, with a published OpenAPI spec at api.openstatus.dev/openapi. Every action in the dashboard — managing monitors, status pages, status reports, maintenance windows, and notification channels — is reachable from the API. The same API key works across the API, CLI, Node SDK, Terraform provider, and MCP server, and every mutation lands in the audit log." - question: "Can I manage openstatus from Claude or ChatGPT?" - answer: "Yes. Openstatus ships a remote MCP (Model Context Protocol) server at api.openstatus.dev/mcp that connects Claude Desktop, ChatGPT, Cursor, and any MCP-compatible client to your workspace. The assistant can list status pages, create and resolve status reports, and schedule maintenance windows. Every mutation tool requires the assistant to explicitly choose whether to notify subscribers, and every call is recorded in the audit log." + answer: "Yes. Openstatus ships a remote MCP (Model Context Protocol) server at api.openstatus.dev/mcp that connects Claude Desktop, ChatGPT, Cursor, and any MCP-compatible client to your workspace. The assistant can list status pages, create and resolve status reports, and schedule maintenance windows. Every publishing tool requires the assistant to explicitly choose whether to notify subscribers, and every call is recorded in the audit log." - question: "Can I manage monitors as code?" answer: "Yes. Openstatus offers two ways to treat monitoring as code: a CLI with YAML config that lives in your repo (`openstatus monitors apply`), and a Terraform provider for HCL-managed infrastructure. Run `openstatus terraform generate` from the CLI to bootstrap an HCL file from an existing workspace." --- @@ -245,7 +245,7 @@ Every action in the dashboard — managing monitors, status pages, status report Yes. Openstatus ships a remote [MCP server](/tooling/mcp-server) at `api.openstatus.dev/mcp` that connects **Claude Desktop**, **ChatGPT**, **Cursor**, and any [Model Context Protocol](https://modelcontextprotocol.io) client to your workspace. -The assistant can list status pages, create and resolve status reports, and schedule maintenance windows. Every mutation tool requires the assistant to explicitly choose whether to notify subscribers — models can't quietly fan out an alert — and every call is recorded in the audit log under `actor_type = 'mcp'`. +The assistant can list status pages, create and resolve status reports, and schedule maintenance windows. Every publishing tool requires the assistant to explicitly choose whether to notify subscribers — models can't quietly fan out an alert — and every call is recorded in the audit log under `actor_type = 'mcp'`. diff --git a/apps/web/src/content/pages/product/status-page.mdx b/apps/web/src/content/pages/product/status-page.mdx index ed81b987..de9e9b46 100644 --- a/apps/web/src/content/pages/product/status-page.mdx +++ b/apps/web/src/content/pages/product/status-page.mdx @@ -15,7 +15,7 @@ faq: - question: "What's the difference between monitors and external services in page components?" answer: "Monitors are automatically synced with your OpenStatus uptime monitoring data and update in real-time. External services are manually managed components for third-party dependencies or systems you don't directly monitor but want to report status for." - question: "Can I translate my status page into other languages?" - answer: "Yes, status pages support multiple languages (currently English, French, and German). You can set a default locale and enable a locale switcher so visitors choose their preferred language. Translations are open source — you can contribute new languages by adding a locale to the shared registry and running the dev server to generate the translation file." + answer: "Yes, status pages support multiple languages (currently English, French, German, Turkish, Hindi, and Korean). You can set a default locale and enable a locale switcher so visitors choose their preferred language. Translations are open source — you can contribute new languages by adding a locale to the shared registry and running the dev server to generate the translation file." - question: "Can I use my own domain for the status page?" answer: "Yes, you can configure custom domains to host your status page on your own domain (e.g., status.yourcompany.com) instead of the default OpenStatus subdomain. This keeps the experience consistent with your brand." - question: "How do status page subscriptions work?" @@ -106,7 +106,7 @@ We support following communication channels: ### Translations -Offer your status page in **multiple languages**. Set a default locale and enable a **locale switcher** so visitors can read updates in their preferred language. Currently supports English, French, and German — with more languages coming from community contributions. +Offer your status page in **multiple languages**. Set a default locale and enable a **locale switcher** so visitors can read updates in their preferred language. Currently supports English, French, German, Turkish, Hindi, and Korean — with more languages coming from community contributions. ### Audience @@ -128,3 +128,65 @@ The agent is **thread-aware**: when you follow up in the same thread (_"we found Already using Atlassian Statuspage, Better Stack, or Instatus? Import your entire setup -- components, component groups, incidents, maintenances, subscribers, and monitors -- in minutes. Open a status page, go to the **Components** tab, scroll down to the **Import** section, paste your API key, preview what will be imported, and confirm. Read the [migration guides](/blog/import-from-statuspage-betterstack-instatus) for details on each provider. + +## Frequently asked questions + +
+ +A status page is a dedicated webpage where companies communicate the real-time health of their services to users. It shows which systems are operational, degraded, or experiencing outages, and provides timestamped incident updates. Status pages reduce support tickets during incidents, build user trust through transparency, and satisfy compliance requirements like SOC 2. + +
+ +
+ +Use public status pages for customer-facing services where transparency builds trust. Use private status pages (password-protected, magic link, or IP-restricted) for internal tools, client-specific deployments, or when you need to control who receives status updates. + +
+ +
+ +Monitors are automatically synced with your OpenStatus uptime monitoring data and update in real-time. External services are manually managed components for third-party dependencies or systems you don't directly monitor but want to report status for. + +
+ +
+ +Yes, status pages support multiple languages (currently English, French, German, Turkish, Hindi, and Korean). You can set a default locale and enable a locale switcher so visitors choose their preferred language. Translations are open source — you can contribute new languages by adding a locale to the shared registry and running the dev server to generate the translation file. + +
+ +
+ +Yes, you can configure custom domains to host your status page on your own domain (e.g., status.yourcompany.com) instead of the default OpenStatus subdomain. This keeps the experience consistent with your brand. + +
+ +
+ +Users can subscribe to receive updates when you post status reports or maintenance notices. We support email notifications, RSS/Atom feeds for feed readers, and JSON feeds for programmatic consumption. Subscribers are automatically notified when you publish updates. + +
+ +
+ +Yes, use the Theme Store to apply community themes or create your own. Themes control colors, fonts, and layout. For private custom themes, contact us. You can also define which data to share (uptime percentages, response times, or manual reports only). + +
+ +
+ +The Slack agent lets you manage your status page directly from Slack using natural language. @mention @openstatus in any channel or thread to create incidents, post updates, and resolve reports — without leaving Slack. No slash commands required. + +
+ +
+ +No. The agent always shows a confirmation card before publishing anything. You can review the drafted title, status, and message, then choose to Approve, Approve & Notify (sends notifications to all subscribers), or Cancel. Nothing goes public without your explicit approval. + +
+ +
+ +The Slack agent is available on paid plans. Install it from your dashboard under Settings > Integrations. + +
diff --git a/apps/web/src/content/pages/product/tooling/api.mdx b/apps/web/src/content/pages/product/tooling/api.mdx index c1909cdb..09457cf9 100644 --- a/apps/web/src/content/pages/product/tooling/api.mdx +++ b/apps/web/src/content/pages/product/tooling/api.mdx @@ -69,3 +69,29 @@ Every mutation lands in the audit log under `actor_type = 'api'`, so you can tra ## Reference Full schema and methods in the [API reference](https://api.openstatus.dev/openapi). SDK docs at [jsr.io/@openstatus/sdk-node](https://jsr.io/@openstatus/sdk-node). + +## Frequently asked questions + +
+ +Use the Node SDK if you're writing TypeScript or JavaScript — it handles auth, retries, and types for every endpoint. Call the API directly via curl or your language's HTTP client if you're outside the JS ecosystem; ConnectRPC means responses are plain JSON over HTTP. + +
+ +
+ +It's ConnectRPC — JSON over HTTP, but with a typed RPC contract instead of REST conventions. Every method is a POST to `/rpc/openstatus.v1./`. You can call it from curl exactly like you'd call a REST endpoint. + +
+ +
+ +Pass the x-openstatus-key header on every request. Generate a key from Settings → API Tokens — the same key works for the CLI, Terraform provider, and MCP server. + +
+ +
+ +Yes. The full schema is browsable at api.openstatus.dev/openapi and is the source of truth for the Node SDK and any generated clients. + +
diff --git a/apps/web/src/content/pages/product/tooling/cli.mdx b/apps/web/src/content/pages/product/tooling/cli.mdx index 5572d504..0355647d 100644 --- a/apps/web/src/content/pages/product/tooling/cli.mdx +++ b/apps/web/src/content/pages/product/tooling/cli.mdx @@ -71,3 +71,29 @@ Every mutation lands in the audit log under `actor_type = 'cli'`, so you can tra ## Reference Full command list and flags in the [CLI reference](https://www.openstatus.dev/docs/reference/cli-reference/). + +## Frequently asked questions + +
+ +Yes. Every command supports --json output and structured errors, and required flags fail with a clear list of what's missing instead of hanging in a wizard. Combined with our agent skills, the CLI is the recommended way to give AI agents scriptable control over openstatus. + +
+ +
+ +Yes. Set `OPENSTATUS_API_TOKEN`, use the `--json` flag for parseable output, and run `openstatus monitors apply` to sync your YAML config. We also publish a GitHub Action that wraps the CLI for synthetic tests. + +
+ +
+ +Both treat monitoring as code. The YAML config is lighter to adopt and lives next to your repo. Terraform is the right choice when monitors are part of a larger HCL-managed infrastructure. Run `openstatus terraform generate` to bootstrap an HCL file from your existing workspace if you want to migrate. + +
+ +
+ +Skills are prompt and command bundles installed into your local agent (Claude Code, etc.) that wrap the openstatus CLI. The MCP server is a remote endpoint that any MCP-compatible client can connect to. Skills are great for terminal-shaped workflows; MCP is great for chat-shaped workflows. + +
diff --git a/apps/web/src/content/pages/product/tooling/mcp-server.mdx b/apps/web/src/content/pages/product/tooling/mcp-server.mdx index 6ef68718..9126c238 100644 --- a/apps/web/src/content/pages/product/tooling/mcp-server.mdx +++ b/apps/web/src/content/pages/product/tooling/mcp-server.mdx @@ -6,7 +6,7 @@ description: "Connect Claude, ChatGPT, Cursor, and any other Model Context Proto category: "Product" faq: - question: "What does the MCP server let an assistant do?" - answer: "The MCP server exposes 17 read and write tools scoped to your workspace: list status pages, monitors, response logs, notifications, and audit logs; inspect a single monitor or response log; create, update, and resolve status reports; schedule maintenance windows. Every mutation writes to the audit log, and publishing tools require an explicit notify decision." + answer: "The MCP server exposes 19 read and write tools scoped to your workspace: list status pages, monitors, response logs, notifications, private locations, and audit logs; inspect a single monitor or response log; create, update, and resolve status reports; schedule maintenance windows. Every mutation writes to the audit log, and publishing tools require an explicit notify decision." - question: "Are skills different from the MCP server?" answer: "Skills are prompt and command bundles installed into your local agent (Claude Code, etc.) that wrap the openstatus CLI. The MCP server is a remote endpoint that any MCP-compatible client can connect to. Skills are great for terminal-shaped workflows; MCP is great for chat-shaped workflows." - question: "Do I need a separate MCP credential?" @@ -37,7 +37,7 @@ Drop the snippet into your client's MCP config (`claude_desktop_config.json`, Cu ## What you can do -The server exposes **18 tools** scoped to your workspace, grouped by entity: +The server exposes **19 tools** scoped to your workspace, grouped by entity: - **Status pages** — `list_status_pages` · `list_page_components` - **Status reports** — `list_status_reports` · `create_status_report` · `add_status_report_update` · `update_status_report` · `resolve_status_report` @@ -45,6 +45,7 @@ The server exposes **18 tools** scoped to your workspace, grouped by entity: - **Monitors** — `list_monitors` · `get_monitor` · `get_monitor_status` · `get_monitor_summary` - **Response logs** — `list_response_logs` · `get_response_log` - **Notifications** — `list_notifications` +- **Private locations** — `list_private_locations` - **Audit log** *(workspaces on the audit-log plan only)* — `list_audit_logs` · `get_audit_log` The four **publishing** tools (`create_status_report`, `add_status_report_update`, `resolve_status_report`, `create_maintenance`) require `notify: true | false` — the assistant must explicitly choose whether to notify subscribers, so an LLM cannot quietly fan out an alert. `update_status_report` is metadata-only and cannot notify. @@ -69,3 +70,29 @@ Every mutation lands in the audit log under `actor_type = 'mcp'`, so you can tra ## Reference Full tool schema, error codes, and per-client config in the [MCP reference](https://www.openstatus.dev/docs/reference/mcp-server/). + +## Frequently asked questions + +
+ +The MCP server exposes 19 read and write tools scoped to your workspace: list status pages, monitors, response logs, notifications, private locations, and audit logs; inspect a single monitor or response log; create, update, and resolve status reports; schedule maintenance windows. Every mutation writes to the audit log, and publishing tools require an explicit notify decision. + +
+ +
+ +Skills are prompt and command bundles installed into your local agent (Claude Code, etc.) that wrap the openstatus CLI. The MCP server is a remote endpoint that any MCP-compatible client can connect to. Skills are great for terminal-shaped workflows; MCP is great for chat-shaped workflows. + +
+ +
+ +No. We don't implement the MCP OAuth flow — there's no consent screen and no extra credential to manage. Authentication is the x-openstatus-key header, the same key the CLI, API, and Terraform use. + +
+ +
+ +No. Every publishing tool requires the assistant to set notify: true | false explicitly — there's no default. Metadata-only edits like update_status_report can't notify at all. Every call lands in the audit log under actor_type = 'mcp', so you can see exactly what happened. + +
diff --git a/apps/web/src/content/pages/product/tooling/terraform.mdx b/apps/web/src/content/pages/product/tooling/terraform.mdx index 17a23c6d..410a8dcf 100644 --- a/apps/web/src/content/pages/product/tooling/terraform.mdx +++ b/apps/web/src/content/pages/product/tooling/terraform.mdx @@ -75,3 +75,29 @@ Every mutation through the provider lands in the audit log under `actor_type = ' ## Reference Full resource list and schema in the [Terraform reference](https://www.openstatus.dev/docs/reference/terraform/). + +## Frequently asked questions + +
+ +The Terraform provider is the right choice if monitoring is part of a larger HCL-managed infrastructure. The CLI's YAML config is lighter to adopt and ships with openstatus terraform generate to bootstrap an HCL file from your existing workspace if you want to migrate. + +
+ +
+ +Monitors, notification channels, status pages, status page subscribers, and maintenance windows — every primary entity in the dashboard. See the Terraform reference for the full list and schema. + +
+ +
+ +Run openstatus terraform generate from the CLI. It fetches your full configuration and produces valid HCL with cross-references and import blocks — no hand-writing required. + +
+ +
+ +Generate one from Settings → API Tokens, then set `OPENSTATUS_API_TOKEN` before running terraform plan. The same token works across the CLI, API, and MCP server. + +
diff --git a/apps/web/src/content/pages/product/uptime-monitoring.mdx b/apps/web/src/content/pages/product/uptime-monitoring.mdx index d034df78..3c9b5980 100644 --- a/apps/web/src/content/pages/product/uptime-monitoring.mdx +++ b/apps/web/src/content/pages/product/uptime-monitoring.mdx @@ -203,6 +203,56 @@ You can read more here: +## Frequently asked questions + +
+ +Start with 3-5 regions covering your main user geographies. More regions provide better global coverage but use more check quota. For critical services, monitor from all major regions (North America, Europe, Asia) to catch regional issues quickly. + +
+ +
+ +If your service runs on AWS and your monitoring also runs on AWS, you won't detect AWS-wide outages or network issues affecting AWS connectivity. Using Fly.io, Koyeb, and Railway ensures monitoring independence from your infrastructure provider. + +
+ +
+ +Frequency determines how often we check your service (e.g., every 30 seconds, 1 minute, 5 minutes, 10 minutes). Higher frequency (30s) catches issues faster but uses more checks. Lower frequency (10m) is sufficient for non-critical services and conserves quota. + +
+ +
+ +Yes, you can configure monitors to automatically update your status page based on monitoring results. When assertions fail or thresholds are exceeded, the status page can reflect degraded or down status without manual intervention. + +
+ +
+ +Assertions validate response correctness (status code, headers, body content) while thresholds define performance boundaries (degraded latency, timeout). Both can trigger alerts - assertions catch functional failures, thresholds catch performance degradation. + +
+ +
+ +You can monitor any HTTP/HTTPS endpoint including REST APIs, GraphQL APIs, webhooks, and third-party service endpoints. Openstatus supports all HTTP methods (GET, POST, PUT, DELETE, etc.) and custom headers for authentication. + +
+ +
+ +Use YAML + CLI for simplicity and if you're not already using Terraform. It's lightweight and easy to get started. Choose Terraform if you're managing infrastructure as code and want to integrate monitoring into your existing Terraform workflows for unified state management. + +
+ +
+ +Yes, you can deploy as many private location probes as needed across different networks, VPCs, or regions. Each gets its own API key and appears as a separate monitoring region in your dashboard. The Docker image is only 8.5MB and supports ARM64 and AMD64. + +
+ --- Check your website's latency diff --git a/apps/web/src/content/pages/tools/cdn-checker.mdx b/apps/web/src/content/pages/tools/cdn-checker.mdx index daf70124..28dc2480 100644 --- a/apps/web/src/content/pages/tools/cdn-checker.mdx +++ b/apps/web/src/content/pages/tools/cdn-checker.mdx @@ -97,3 +97,29 @@ With OpenStatus, you can: - Monitor cache status continuously with header assertions and get alerted when caching breaks. If you'd like to request additional test regions or providers, feel free to contact us at [ping@openstatus.dev](mailto:ping@openstatus.dev). + +## Frequently asked questions + +
+ +A CDN cache checker tests whether your CDN is serving content from its edge cache instead of your origin server. OpenStatus requests your URL from 28 regions worldwide and reads the cache headers (cf-cache-status, x-cache, x-vercel-cache, age) to report HIT, MISS, EXPIRED, STALE, BYPASS or DYNAMIC per region. + +
+ +
+ +CDN caches are regional: each edge location keeps its own copy. A MISS in a region usually means no user has requested the asset from that edge recently, the TTL expired, or your cache rules exclude it. The first request from a region is always a MISS — run the check again to confirm whether the edge cached the response. + +
+ +
+ +CDNs add identifying response headers. Cloudflare adds cf-ray and cf-cache-status, Amazon CloudFront adds x-amz-cf-id and x-amz-cf-pop, Fastly adds x-served-by, Vercel adds x-vercel-id. The CDN Cache Checker fingerprints these headers automatically and shows the detected provider. + +
+ +
+ +With anycast, every edge location announces the same IP address and the network routes users to the nearest one. With unicast or GeoDNS, DNS hands out different IP addresses per region. Anycast typically fails over faster; GeoDNS gives the provider more routing control. The checker infers the topology from the responses. + +
diff --git a/apps/web/src/content/pages/tools/checker-slug.mdx b/apps/web/src/content/pages/tools/checker-slug.mdx index b0f8fccd..31765b12 100644 --- a/apps/web/src/content/pages/tools/checker-slug.mdx +++ b/apps/web/src/content/pages/tools/checker-slug.mdx @@ -14,3 +14,11 @@ The data is getting stored for **7 days**. If you want to keep it longer, consid --- > **We have reworked the checker experience ([go back to v1](https://v1.openstatus.dev/play/checker))**. Please let us know if you are missing a feature from the older version. Contact us directly or send us a message to [ping@openstatus.dev](mailto:ping@openstatus.dev). Happy to bring stuff back! + +## Frequently asked questions + +
+ +The data is stored for 7 days. If you want to keep it longer, consider creating an account at https://app.openstatus.dev and use the cloud solution. + +
diff --git a/apps/web/src/content/pages/tools/checker.mdx b/apps/web/src/content/pages/tools/checker.mdx index 161f0965..ce24e296 100644 --- a/apps/web/src/content/pages/tools/checker.mdx +++ b/apps/web/src/content/pages/tools/checker.mdx @@ -2,18 +2,39 @@ title: "Global Speed Checker" publishedAt: "2025-11-10" author: "Thibault Le Ouay Ducasse" -description: "Free website speed checker. Test your site's load time and latency from multiple regions across the globe in seconds - no signup required." +description: "Free website speed test. Check your site's load time and response time from 28 regions worldwide in seconds - no signup, no install required." category: "Product" hero: Website Speed Test - Check Load Time Worldwide -seo: - title: Website Speed Test - Check Load Time Worldwide +seo: + title: "Website Speed Test - Check Load Time from 28 Global Regions" +howto: + totalTime: "PT20S" + steps: + - name: "Enter your URL" + text: "Paste the full URL you want to test, including the scheme - https://example.com. Any public HTTP endpoint works, whether it serves a web page or a JSON API." + url: "#how-to-test-your-website-speed" + - name: "Run the speed test" + text: "openstatus sends one request from each of its 28 regions in parallel. The whole run takes about 20 seconds, bounded by the slowest region rather than the sum of all of them." + url: "#how-to-test-your-website-speed" + - name: "Read the per-region results" + text: "Each row is one region, sorted by total response time. Click a row to expand the timing phases - DNS, TCP connection, TLS handshake, TTFB, and transfer - plus the response headers and status code." + url: "#how-to-test-your-website-speed" + - name: "Compare regions and share" + text: "Look for regions several times slower than your fastest, which usually points at missing CDN coverage or a single-region origin. Share the result with a link that expires after 7 days." + url: "#how-to-test-your-website-speed" faq: - question: "What Is a Website Speed Checker?" answer: "A Website Speed Checker is an online tool that measures how fast your website or API responds when someone visits it. It analyzes various website performance metrics including client-side performance (FCP, LCP, CLS) and server-side performance (DNS lookup, TCP connection, TLS handshake, server response time)." - question: "What Is a Global Speed Checker?" - answer: "A Global Speed Checker measures your website or API's latency and response time from multiple locations around the world. OpenStatus runs checks from 28 global regions across 3 cloud providers, giving you a complete picture of your site's real-world performance." + answer: "A Global Speed Checker measures your website or API's latency and response time from multiple locations around the world. openstatus runs checks from 28 global regions across 3 cloud providers, giving you a complete picture of your site's real-world performance." - question: "What can I do with openstatus Global Speed Checker?" answer: "You can test how fast your API or website responds worldwide, compare latency across different regions, identify network bottlenecks, and monitor uptime and availability in real time from distributed locations across Europe, Asia, North America, and beyond." + - question: "What is a good website load time?" + answer: "For the server-side response this tool measures, under 200ms is fast, 200-500ms is acceptable, 500ms-1s is slow enough that users notice, and over 1s needs attention. Judge it per region rather than on the average - a site that answers in 80ms from Frankfurt and 900ms from Sydney has a distribution problem, not a speed problem." + - question: "How many regions does the speed test run from?" + answer: "28 regions across 3 cloud providers - 18 on Fly, 6 on Koyeb, and 4 on Railway - spanning Europe, North America, Asia, South America, Africa, and Oceania. Every region runs on each test, so you get the full spread rather than a sample." + - question: "Why is my website fast in one region and slow in another?" + answer: "Almost always because the request is travelling to a single origin. A visitor in Sydney hitting a server in Virginia pays roughly 200ms in round-trip time before your application does any work. Large gaps between your fastest and slowest region point at missing CDN coverage, no edge caching, or a single-region database." --- ## Start monitoring your services @@ -56,9 +77,62 @@ Understanding both sides helps you identify whether slowdowns are caused by your A Global Speed Checker measures your website or API's latency and response time from multiple locations around the world. Instead of testing from just one data center, it runs checks from 28 global regions across 3 cloud providers, giving you a complete picture of your site's real-world performance. +## How to Test Your Website Speed + +1. **Enter your URL.** Paste the full address including the scheme — `https://example.com`. Any public HTTP endpoint works, whether it serves a web page or a JSON API. +2. **Run the speed test.** openstatus sends one request from each of its 28 regions in parallel. The run takes about 20 seconds, bounded by the slowest region rather than the sum of all of them. +3. **Read the per-region results.** Each row is one region, sorted by total response time. Click a row to expand the timing phases — DNS, TCP connection, TLS handshake, TTFB, and transfer — along with the response headers and status code. +4. **Compare regions and share.** Look for regions several times slower than your fastest. Share the result with a link that expires after 7 days. + +No account, no install, and nothing to configure. If you want the same check to keep running after you close the tab, that is [uptime monitoring](/uptime-monitoring) rather than a one-off test. + +## What a Good Website Load Time Looks Like + +This tool measures **server-side** load time — how long your infrastructure takes to answer, not how long a browser takes to paint the page. Read your result against these bands: + +| Response time | Verdict | What it usually means | +| --- | --- | --- | +| Under 200ms | Fast | Served from an edge or a nearby origin. Nothing to fix. | +| 200–500ms | Acceptable | Normal for a single-region origin answering a distant request. | +| 500ms–1s | Slow | Users notice this. Usually an origin round trip plus unoptimised work per request. | +| Over 1s | Needs attention | Something is wrong — cold starts, an N+1 query, or no caching anywhere in the path. | + +The important number is not the average — it is the **spread**. A site answering in 80ms from Frankfurt and 900ms from Sydney does not have a speed problem, it has a distribution problem, and the fix is a CDN or an edge deployment rather than a faster server. + +Browser metrics like LCP, INP, and CLS are a separate question. They measure rendering, and they sit downstream of the numbers here — a slow server makes every one of them worse, but a fast server does not guarantee they are good. + +For the target numbers on both halves — server response bands, Core Web Vitals thresholds, and why to judge them at the 75th percentile rather than the average — see [what is a good response time](/guides/what-is-a-good-response-time). + +## Where the Speed Test Runs From + +All 28 regions run on every test, so you see the full spread rather than a sample: + +| Continent | Regions | Locations | +| --- | --- | --- | +| North America | 11 | Ashburn, Chicago, Dallas, Los Angeles, San Francisco, San Jose, Secaucus, Toronto, Washington, California, Virginia | +| Europe | 8 | Amsterdam ×2, Frankfurt ×2, Paris ×2, London, Stockholm | +| Asia | 6 | Singapore ×3, Tokyo ×2, Mumbai | +| South America | 1 | São Paulo | +| Africa | 1 | Johannesburg | +| Oceania | 1 | Sydney | + +Several cities appear more than once because they are covered by different cloud providers — 18 regions run on Fly, 6 on Koyeb, and 4 on Railway. Testing the same city across providers is often how you tell a network problem apart from a provider problem. + +## How We Measure + +Each region opens its own connection and records the request in phases rather than as a single number: + +- **DNS** — resolving the hostname. A slow figure here is a nameserver or TTL problem, not an application one. +- **TCP connect** — the round trip to open the socket. This is mostly distance, and it is the number a CDN improves. +- **TLS handshake** — negotiating HTTPS. Usually one extra round trip, more if the certificate chain is long. +- **TTFB** — time to first byte, from request sent to the first byte back. This is where your application's own work shows up. +- **Transfer** — streaming the rest of the response body. + +Two caveats worth knowing. Connections are not reused between runs, so every test pays full DNS, TCP, and TLS cost — closer to a first-time visitor than a returning one. And each region contributes a single sample, which is enough to compare regions against each other but not enough to establish a baseline. Latency moves with traffic, deploys, and time of day, so one number from one moment is a snapshot, not a trend. + --- -With OpenStatus, you can: +With openstatus, you can: - Test how fast your API or website responds worldwide. - Compare latency across different regions. @@ -67,4 +141,53 @@ With OpenStatus, you can: Whether you want to test your website speed from Europe, Asia, North America, or beyond, our Global Speed Checker gives accurate, consistent data from distributed locations. +### Keep testing after the tab closes + +This page runs one test, right now. A single sample tells you very little about a +slow endpoint — latency moves with traffic, deploys, and time of day. +[openstatus uptime monitoring](/uptime-monitoring) re-runs this exact check on a +schedule from the same 28 regions, keeps the history, and alerts you when response +time degrades rather than when the site is already down. + +For the vocabulary behind the numbers above, see +[latency vs response time](/docs/concept/latency-vs-response-time). + If you'd like to request additional test regions or providers, feel free to contact us at [ping@openstatus.dev](mailto:ping@openstatus.dev). + +## Frequently asked questions + +
+ +A Website Speed Checker is an online tool that measures how fast your website or API responds when someone visits it. It analyzes various website performance metrics including client-side performance (FCP, LCP, CLS) and server-side performance (DNS lookup, TCP connection, TLS handshake, server response time). + +
+ +
+ +A Global Speed Checker measures your website or API's latency and response time from multiple locations around the world. openstatus runs checks from 28 global regions across 3 cloud providers, giving you a complete picture of your site's real-world performance. + +
+ +
+ +You can test how fast your API or website responds worldwide, compare latency across different regions, identify network bottlenecks, and monitor uptime and availability in real time from distributed locations across Europe, Asia, North America, and beyond. + +
+ +
+ +For the server-side response this tool measures, under 200ms is fast, 200-500ms is acceptable, 500ms-1s is slow enough that users notice, and over 1s needs attention. Judge it per region rather than on the average - a site that answers in 80ms from Frankfurt and 900ms from Sydney has a distribution problem, not a speed problem. + +
+ +
+ +28 regions across 3 cloud providers - 18 on Fly, 6 on Koyeb, and 4 on Railway - spanning Europe, North America, Asia, South America, Africa, and Oceania. Every region runs on each test, so you get the full spread rather than a sample. + +
+ +
+ +Almost always because the request is travelling to a single origin. A visitor in Sydney hitting a server in Virginia pays roughly 200ms in round-trip time before your application does any work. Large gaps between your fastest and slowest region point at missing CDN coverage, no edge caching, or a single-region database. + +
diff --git a/apps/web/src/content/pages/tools/curl.mdx b/apps/web/src/content/pages/tools/curl.mdx index ea814ce5..70717d78 100644 --- a/apps/web/src/content/pages/tools/curl.mdx +++ b/apps/web/src/content/pages/tools/curl.mdx @@ -17,3 +17,15 @@ faq: cURL (Client URL) is a command-line tool and library for transferring data with URLs. It supports various protocols like HTTP, HTTPS, FTP, and more, making it a versatile choice for testing APIs, downloading files, or performing network tasks. Its simplicity and power come from the ability to execute complex operations through straightforward commands. cURL is available on most operating systems, including Linux, macOS, and Windows. + +## From a one-off request to continuous monitoring + +A curl command tells you what an endpoint did once, from wherever you happen to be +sitting. If you need to know it keeps working — and to hear about it the moment it +stops — [openstatus uptime monitoring](/uptime-monitoring) runs the same request on +a schedule from 28 regions and alerts you when the status code, response body, or +latency drifts. + +Want the timing breakdown rather than the command? Drop the same URL into the +[Global Speed Checker](/play/checker) to see DNS, TCP, TLS, and TTFB split out per +region. diff --git a/apps/web/src/content/pages/tools/mcp-health.mdx b/apps/web/src/content/pages/tools/mcp-health.mdx index 467d599b..ac61af3e 100644 --- a/apps/web/src/content/pages/tools/mcp-health.mdx +++ b/apps/web/src/content/pages/tools/mcp-health.mdx @@ -73,6 +73,8 @@ A status-code pinger checks one thing: did the URL return 200? An MCP server can The handshake also captures the negotiated protocol version, which is the only reliable signal that an MCP server upgrade hasn't silently changed behaviour. If your server claims `2025-06-18` today and `2025-09-01` next week, you want to know before your users do. +Which is the argument for not running this by hand. [openstatus uptime monitoring](/uptime-monitoring) runs the same JSON-RPC handshake on a schedule from 28 regions and alerts you when `initialize` starts failing, `tools/list` comes back empty, or the protocol version shifts under you — the failures your AI clients hit first and report last. + ## Common failure modes - **Connection refused / DNS** — the URL is wrong, the server is down, or your firewall is in the way. diff --git a/apps/web/src/content/pages/tools/severity-matrix.mdx b/apps/web/src/content/pages/tools/severity-matrix.mdx index 27859074..88e4a735 100644 --- a/apps/web/src/content/pages/tools/severity-matrix.mdx +++ b/apps/web/src/content/pages/tools/severity-matrix.mdx @@ -78,3 +78,47 @@ Partial degradation with limited user impact. Standard incident process applies. Minor bug or cosmetic issue affecting a small percentage of users. Non-urgent resolution on a 1 business day timeline. Typically no public status page update is needed. Postmortem is optional. See the [Incident Severity Matrix Template](/guides/incident-severity-matrix) for per-severity status page message templates, postmortem requirements, real-world examples, and tips. + +## Frequently asked questions + +
+ +SEV0 indicates a critical incident — typically a complete service outage or confirmed security breach that requires immediate response from senior engineering leadership. It's the highest severity level and triggers the most aggressive communication and escalation protocols. + +
+ +
+ +Most teams use 3 or 4 levels. Four levels (SEV0 through SEV3) provide enough granularity to distinguish between a full outage and a minor cosmetic bug without overcomplicating triage during a live incident. If you're a small team, 3 levels can work fine — you can always add granularity later. + +
+ +
+ +Severity measures the impact of an incident — how many users are affected and how badly. Priority reflects business urgency and resource allocation. A typo on your pricing page might be low severity but high priority if it's costing you conversions. Your severity matrix should classify based on impact alone; priority is a triage decision. + +
+ +
+ +In most cases, yes. Security incidents carry outsized risk even when few users are immediately affected — the blast radius can expand quickly and the reputational impact is disproportionate. Treating all confirmed security incidents as SEV0 ensures you mobilize the right resources immediately. + +
+ +
+ +Review it quarterly, or after any major incident where the classification felt wrong. If your team consistently debates whether something is a SEV1 or SEV2, your thresholds probably need adjustment. The builder lets you customize thresholds so your matrix reflects your team's actual operational patterns. + +
+ +
+ +Not by name, but functionally yes. SOC 2 criterion CC7.4 explicitly requires understanding the 'nature and severity' of an incident to determine the appropriate response time frame. Auditors doing a Type II audit will sample real incidents and check whether your severity classification was applied consistently. Without a documented matrix, that evidence trail doesn't exist. ISO 27001 Annex A 5.25 is even more explicit — it directly mandates categorisation and prioritisation of security events. + +
+ +
+ +Your severity levels determine how quickly you respond to incidents, which directly affects your cumulative downtime. If your SLA promises 99.9% uptime, you have roughly 8 hours and 46 minutes of allowed downtime per year. A single misclassified SEV0 treated as SEV2 could burn through that budget. + +
diff --git a/apps/web/src/content/pages/tools/uptime-sla.mdx b/apps/web/src/content/pages/tools/uptime-sla.mdx index 15650afc..75778345 100644 --- a/apps/web/src/content/pages/tools/uptime-sla.mdx +++ b/apps/web/src/content/pages/tools/uptime-sla.mdx @@ -16,8 +16,12 @@ faq: answer: "99.9% uptime (three nines) allows approximately 8 hours and 45 minutes of downtime per year, 43 minutes per month, 10 minutes per week, or 1 minute and 26 seconds per day." - question: "What is the difference between 99.9% and 99.99% uptime?" answer: "The difference is significant: 99.9% uptime allows about 8 hours 45 minutes of downtime per year, while 99.99% allows only about 52 minutes per year. That extra 9 reduces your allowed downtime by roughly 10x and typically requires redundant infrastructure and automated failover." - - question: "How do I calculate uptime from downtime?" - answer: "To calculate uptime percentage from downtime, use this formula: Uptime % = ((Total time - Downtime) / Total time) × 100. For example, if your service was down for 1 hour in a 30-day month (720 hours), your uptime is ((720 - 1) / 720) × 100 = 99.86%." + - question: "How is SLA uptime calculated?" + answer: "Uptime % = ((Total time − Downtime) / Total time) × 100. For example, if your service was down for 1 hour in a 30-day month (720 hours), your uptime is ((720 − 1) / 720) × 100 = 99.86%. The subtlety is what counts as downtime in the first place - partial degradation, a single failing region, or a slow-but-responding endpoint may or may not be chargeable depending on how your agreement defines availability." + - question: "What is a good SLA?" + answer: "For most SaaS products, 99.9% is the right public commitment - it allows 43 minutes of downtime a month, which is enough room to deploy, patch, and recover from an incident without writing refund cheques. 99.99% is a commitment to redundant infrastructure and automated failover, not just a bigger number, and you should only publish it once you can prove you already hit it. The best SLA is the one slightly below what you consistently achieve." + - question: "Is uptime measured monthly or yearly?" + answer: "Almost always monthly, because that is the billing period service credits attach to. The distinction matters: a single 5-hour outage is 99.94% over a year but only 99.3% over the month it happened in, so a yearly window can hide an outage that a monthly window turns into a breach. Check which window your agreement specifies before committing to a tier." --- _All calculations assume continuous 24/7 availability requirements._ @@ -31,3 +35,61 @@ Common SLA tiers include 99.9% (three nines), 99.99% (four nines), and 99.999% ( This calculator helps you understand the real-world impact of your SLA commitments and plan for capacity, incident response, and stakeholder expectations. + +## From an SLA target to a measured number + +A target is only half of it — the other half is measuring whether you hit it. Once +you have picked a tier, [openstatus uptime monitoring](/uptime-monitoring) tracks +the actual figure from 28 regions, and a [public status page](/status-page) reports +it back to the customers the SLA was written for. + +Not sure which number you are agreeing to? [SLA vs SLO vs SLI](/guides/sla-vs-slo-vs-sli) +covers why your internal target should be stricter than the promise you publish. + +## Frequently asked questions + +
+ +An uptime SLA (Service Level Agreement) is a commitment between a service provider and a customer that guarantees a specific percentage of uptime over a given period. For example, a 99.9% SLA means the service can be down for no more than 8 hours and 45 minutes per year. + +
+ +
+ +99.9% uptime (three nines) allows approximately 8 hours and 45 minutes of downtime per year, 43 minutes per month, 10 minutes per week, or 1 minute and 26 seconds per day. + +
+ +
+ +Uptime % = ((Total time − Downtime) / Total time) × 100. + +For example, if your service was down for 1 hour in a 30-day month (720 hours), your uptime is ((720 − 1) / 720) × 100 = 99.86%. The subtlety is what counts as downtime in the first place — partial degradation, a single failing region, or a slow-but-responding endpoint may or may not be chargeable depending on how your agreement defines availability. + +
+ +
+ +Common tiers are 99.9% (three nines) allowing 8h 45m of downtime a year, 99.99% (four nines) allowing 52m 35s a year, and 99.999% (five nines) allowing only 5m 15s a year. Most cloud providers publish between 99.9% and 99.99%. + +
+ +
+ +The difference is larger than one digit suggests: 99.9% allows about 8 hours 45 minutes of downtime per year, while 99.99% allows only about 52 minutes. That extra nine cuts your allowed downtime by roughly 10×, and it usually requires redundant infrastructure and automated failover rather than simply more careful operations. + +
+ +
+ +For most SaaS products, 99.9% is the right public commitment. It allows 43 minutes of downtime a month — enough room to deploy, patch, and recover from an incident without writing refund cheques. 99.99% is a commitment to redundant infrastructure and automated failover, not just a bigger number, and you should only publish it once you can prove you already hit it. + +The best SLA is the one slightly below what you consistently achieve. See [SLA vs SLO vs SLI](/guides/sla-vs-slo-vs-sli) for how to set the internal target that gives you that margin. + +
+ +
+ +Almost always monthly, because that is the billing period service credits attach to. The distinction matters more than it looks: a single 5-hour outage is 99.94% measured over a year, but 99.3% measured over the month it happened in. A yearly window can hide an outage that a monthly window turns into a breach — check which one your agreement specifies before committing to a tier. + +
diff --git a/apps/web/src/content/pages/unrelated/about.mdx b/apps/web/src/content/pages/unrelated/about.mdx index 7b2a58c0..112063f4 100644 --- a/apps/web/src/content/pages/unrelated/about.mdx +++ b/apps/web/src/content/pages/unrelated/about.mdx @@ -56,3 +56,17 @@ We're **profitable and self-funded**. No VC pressure, no growth-at-all-costs. Th ## Our mission We're building the best open-source status page — connecting monitoring, incident communication, and compliance into a single platform. **Transparent by default**, for teams who believe their users deserve to know what's happening. + +## Frequently asked questions + +
+ +Openstatus is an open-source status page and uptime monitoring platform founded in 2023 by Thibault Le Ouay Ducasse and Maximilian Kaske. It monitors websites, APIs, and services from 28 regions globally across multiple cloud providers. Openstatus is bootstrapped, profitable, and available both as a managed SaaS and for self-hosting. + +
+ +
+ +We're self-funded because it keeps us aligned with our customers, not investors. No VC pressure means we build features that matter to you, not features that look good on a pitch deck. We'll be here when your next SOC 2 audit comes around. + +
diff --git a/apps/web/src/content/pages/use-case/agent.mdx b/apps/web/src/content/pages/use-case/agent.mdx index ae7f65cd..7150c5b2 100644 --- a/apps/web/src/content/pages/use-case/agent.mdx +++ b/apps/web/src/content/pages/use-case/agent.mdx @@ -158,6 +158,38 @@ Every status-report mutation requires the agent to choose whether to notify subs 5. **Review a week of proposed reports.** If they're good, upgrade the key to `write` and let it publish — starting with `notify_subscribers: false` 6. **Graduate to subscriber notifications** once you trust the diagnosis quality +## Frequently asked questions + +
+ +Openstatus ships a remote MCP (Model Context Protocol) server at api.openstatus.dev/mcp. It exposes typed tools for every workspace entity — monitors, response logs, status pages, page components, status reports, maintenance windows, notification channels, and audit logs. Any MCP-compatible client (Claude Desktop, ChatGPT, Cursor) or any agent built with the MCP SDK can connect with an openstatus API key. + +
+ +
+ +Configure a webhook notification channel on the monitor. When openstatus detects a failure, it posts a payload to your endpoint. Your agent runtime receives it, opens an MCP session against api.openstatus.dev/mcp using your workspace API key, and starts investigating with the tools below. + +
+ +
+ +The diagnostic loop uses get_monitor, get_monitor_status, get_monitor_summary, list_response_logs, and get_response_log. The communication loop uses list_status_pages, list_page_components, create_status_report, add_status_report_update, and resolve_status_report. The agent can also call list_audit_logs to see what humans or other agents already did. + +
+ +
+ +Yes — but every mutation tool requires the agent to explicitly decide whether to notify subscribers, and every call is recorded in the audit log with the agent's API key as the actor. Read-only keys can investigate but cannot publish. Most teams start with notify_subscribers set to false and a human approving the first version, then graduate to fully autonomous reports once the agent has a track record. + +
+ +
+ +Openstatus API keys carry scopes — read for investigation-only, write for mutations. Issue a read-only key for an agent that only diagnoses, or a write-scoped key for an agent that also files status reports. Scope enforcement happens before any DB lookup, so a read-only key never sees the write tools. + +
+ --- Stop writing status reports by hand. Let an agent run your status page. diff --git a/apps/web/src/content/pages/use-case/api-providers.mdx b/apps/web/src/content/pages/use-case/api-providers.mdx index ae6174d2..ddaf2008 100644 --- a/apps/web/src/content/pages/use-case/api-providers.mdx +++ b/apps/web/src/content/pages/use-case/api-providers.mdx @@ -57,6 +57,32 @@ Let your API consumers subscribe via **email**, **RSS/Atom**, or **JSON** feeds. Host on `status.yourapi.com`. Your consumers expect it. +## Frequently asked questions + +
+ +Your API consumers build their products on top of your infrastructure. When your API is down, their products are down. A public status page reduces support tickets, builds trust, and shows enterprise customers you take reliability seriously. + +
+ +
+ +Yes. You can create separate page components for each API endpoint or service. Group them by product area, region, or any logical structure. Each component shows its own uptime data independently. + +
+ +
+ +Yes. Define your monitors as YAML configuration and manage them with the openstatus CLI or Terraform provider. Version control your monitoring setup alongside your API code. + +
+ +
+ +Your consumers can subscribe via email, RSS/Atom feeds, or JSON feeds. Many API providers embed the JSON feed into their own dashboards to show upstream status to their users. + +
+ --- Give your API consumers the transparency they expect diff --git a/apps/web/src/content/pages/use-case/compliance.mdx b/apps/web/src/content/pages/use-case/compliance.mdx index 4815dbdc..75ea08eb 100644 --- a/apps/web/src/content/pages/use-case/compliance.mdx +++ b/apps/web/src/content/pages/use-case/compliance.mdx @@ -119,6 +119,38 @@ Article 19(3) requires informing clients where a major incident affects their fi Each guide maps the requirement to specific evidence, and is explicit about where a status page stops and your own process, procedures, and regulator filings begin. +## Frequently asked questions + +
+ +SOC 2's CC2.3 (Communication with external parties) requires you to demonstrate incident communication processes with external users — a mechanism to report failures, open communication channels, and documentation of how incidents are communicated. A status page with timestamped incident reports and subscriber notifications is the fastest way to satisfy this. + +
+ +
+ +No — SOC 2 CC2.3 requires you to demonstrate incident communication with external parties, but it doesn't prescribe a specific tool. You could use email notifications, a support portal, or other channels. That said, a status page is the fastest, most auditor-friendly way to satisfy the requirement and is increasingly considered standard practice. + +
+ +
+ +Every status report, update, and resolution is timestamped and stored. You get a full incident history showing when issues were detected, communicated, and resolved. Subscriber notification logs show you proactively informed stakeholders. This creates an auditable trail without manual documentation. + +
+ +
+ +Yes. openstatus handles the incident communication side of compliance while Vanta or Drata manage the broader audit automation. Your status page URL and incident history can be referenced in your compliance platform as evidence of your communication controls. + +
+ +
+ +You can have a branded status page with custom domain, incident history, and subscriber notifications live in under 10 minutes. That covers the incident communication side — CC2.3 and parts of CC7 — not your whole SOC 2 scope. Check that your plan's data retention spans your audit period: a Type II observation window runs 3 to 12 months. + +
+ --- Ready to check the compliance box? diff --git a/apps/web/src/content/pages/use-case/crypto.mdx b/apps/web/src/content/pages/use-case/crypto.mdx index fd1806b1..065fe2d4 100644 --- a/apps/web/src/content/pages/use-case/crypto.mdx +++ b/apps/web/src/content/pages/use-case/crypto.mdx @@ -47,6 +47,32 @@ Let users subscribe for email updates. When you push a status report, they're in Create private status pages for institutional partners or internal operations teams with **password protection** or **magic link authentication**. +## Frequently asked questions + +
+ +Downtime in crypto means lost trades and lost trust. A status page gives your users a single source of truth during incidents — reducing support tickets, preventing panic, and demonstrating operational maturity to institutional partners. + +
+ +
+ +Yes. Openstatus monitors any HTTP/HTTPS endpoint. You can monitor RPC nodes, REST APIs, WebSocket endpoints, and more with custom assertions to validate response correctness. + +
+ +
+ +Yes. Host your status page on your own domain (e.g., status.exchange.com) to maintain brand trust. Custom domains are available on paid plans. + +
+ +
+ +Yes. Use password protection or magic link authentication to create private status pages for institutional partners or internal teams. + +
+ --- Keep your users informed, not panicking diff --git a/apps/web/src/content/pages/use-case/enterprise-sales.mdx b/apps/web/src/content/pages/use-case/enterprise-sales.mdx index 49e1a906..b3c275ca 100644 --- a/apps/web/src/content/pages/use-case/enterprise-sales.mdx +++ b/apps/web/src/content/pages/use-case/enterprise-sales.mdx @@ -54,6 +54,32 @@ Create **password-protected status pages** for enterprise clients. Show only the Enterprise clients **subscribe via email** and get updates the moment something changes. No checking, no guessing, no "why didn't you tell us?" conversations. +## Frequently asked questions + +
+ +Increasingly, yes. Enterprise security and procurement teams evaluate vendor reliability as part of their due diligence. A public status page with uptime history, incident reports, and subscriber notifications demonstrates operational maturity. Many vendor questionnaires explicitly ask for a status page URL. + +
+ +
+ +Common questions include: How do you communicate incidents to customers? Do you have a public status page? How are customers notified of outages? What is your incident response process? A status page with subscriber notifications and timestamped incident history answers all of these. + +
+ +
+ +Yes. Use password protection to create private status pages for specific enterprise customers. Show them only the components and services relevant to their account. This gives them dedicated visibility without exposing your full infrastructure. + +
+ +
+ +Yes. Host your status page on your own domain (e.g., status.yourcompany.com) to maintain brand consistency. Enterprise buyers expect a professional, branded experience — not a third-party subdomain. + +
+ --- Make your status page the easiest checkbox in the vendor review diff --git a/apps/web/src/content/pages/use-case/open-source.mdx b/apps/web/src/content/pages/use-case/open-source.mdx index 967e1bae..e7fe6eeb 100644 --- a/apps/web/src/content/pages/use-case/open-source.mdx +++ b/apps/web/src/content/pages/use-case/open-source.mdx @@ -8,7 +8,7 @@ faq: - question: "Is openstatus free for open-source projects?" answer: "Yes. The free plan includes one monitor, one status page with three components, and monitoring from up to 6 regions. For larger projects, paid plans start at $30/month with 20 monitors and custom domains." - question: "Can I self-host openstatus?" - answer: "Yes. Openstatus is fully open source (MIT license) and can be self-hosted. The checker runs as an 8.5MB Docker image. You can also use the managed SaaS and keep the monitoring infrastructure off your plate." + answer: "Yes. Openstatus is fully open source (AGPL-3.0) and can be self-hosted. The checker runs as an 8.5MB Docker image. You can also use the managed SaaS and keep the monitoring infrastructure off your plate." - question: "How do open-source projects use openstatus?" answer: "Projects like Cal.com, Documenso, Hanko, and OpenPanel use openstatus to give their communities transparent uptime data. They monitor APIs and services, publish incidents, and let contributors and users subscribe for updates." - question: "Can contributors subscribe to status updates?" @@ -47,6 +47,32 @@ Let your users subscribe via **email**, **RSS/Atom**, or **JSON**. When you push Make your status page match your project's brand with [community themes](https://themes.openstatus.dev). Contribute your own theme back to the store. +## Frequently asked questions + +
+ +Yes. The free plan includes one monitor, one status page with three components, and monitoring from up to 6 regions. For larger projects, paid plans start at $30/month with 20 monitors and custom domains. + +
+ +
+ +Yes. Openstatus is fully open source (AGPL-3.0) and can be self-hosted. The checker runs as an 8.5MB Docker image. You can also use the managed SaaS and keep the monitoring infrastructure off your plate. + +
+ +
+ +Projects like Cal.com, Documenso, Hanko, and OpenPanel use openstatus to give their communities transparent uptime data. They monitor APIs and services, publish incidents, and let contributors and users subscribe for updates. + +
+ +
+ +Yes. Your community can subscribe via email, RSS/Atom feeds, or JSON feeds. When you publish a status report or schedule maintenance, subscribers are notified automatically. + +
+ --- Give your community the transparency they deserve diff --git a/apps/web/src/content/pages/use-case/reduce-support-tickets.mdx b/apps/web/src/content/pages/use-case/reduce-support-tickets.mdx index 912f70d1..f30a323c 100644 --- a/apps/web/src/content/pages/use-case/reduce-support-tickets.mdx +++ b/apps/web/src/content/pages/use-case/reduce-support-tickets.mdx @@ -49,6 +49,32 @@ Break your status page into **components** — API, dashboard, billing, webhooks Announce maintenance windows in advance. Subscribers are notified before the work begins — so the tickets that would have come in during the window never get created. +## Frequently asked questions + +
+ +When users can't reach your service, their first instinct is to contact support. A status page gives them an immediate answer — what's down, what's affected, and when it'll be fixed. Instead of filing a ticket, they check the status page, see the issue is acknowledged, and wait for updates. + +
+ +
+ +Post a status report as soon as you detect the issue. Include what's affected, the current severity, and your next update time. Update regularly — even if there's no progress, a 'still investigating' update is better than silence. Resolve the report when the issue is fixed. + +
+ +
+ +Yes. Users can subscribe to your status page via email, RSS/Atom, or JSON feeds. When you publish or update a status report, subscribers are notified automatically. You can also scope notifications to specific components so users only hear about services they care about. + +
+ +
+ +Use the JSON feed to pull your status page data into your own application or support portal. Many teams add a status banner or badge to their app so users see service health without leaving the product. + +
+ --- Stop answering "is it down?" during every outage diff --git a/apps/web/src/content/pages/use-case/startups.mdx b/apps/web/src/content/pages/use-case/startups.mdx index 5186862e..227f81ff 100644 --- a/apps/web/src/content/pages/use-case/startups.mdx +++ b/apps/web/src/content/pages/use-case/startups.mdx @@ -69,6 +69,44 @@ The entire codebase is public on [GitHub](https://github.com/openstatushq/openst Most teams finish in under 10 minutes. +## Frequently asked questions + +
+ +If you're selling to other businesses, yes. Enterprise prospects check for a status page during due diligence. SOC 2 auditors expect documented incident communication (CC2.3). And your first SLA agreement will likely require one. The question isn't whether you need it — it's whether you have one when someone asks. + +
+ +
+ +Most teams go from signup to a live, branded status page in under 10 minutes. Pick a theme, add your components, connect your custom domain, and you're done. No engineering work required. + +
+ +
+ +The free plan gives you 1 status page, 1 monitor, and incident reporting — enough to evaluate the product and see how it looks. When you need a custom domain, subscriber notifications, or more components, the Starter plan is $30/mo. + +
+ +
+ +Atlassian Statuspage starts at $79/mo, charges per subscriber, requires a separate monitoring tool, and takes time to configure. Openstatus is $30/mo flat with monitoring included, no per-subscriber fees, and a status page that looks better out of the box. It's built for startups, not enterprises with dedicated SRE teams. + +
+ +
+ +We're bootstrapped and profitable — no runway pressure, no risk of shutting down. Cal.com, Documenso, Midday, and other growing teams trust us. The codebase is open-source (AGPL-3.0), so you can inspect every line. + +
+ +
+ +Yes. Every incident report is timestamped and stored, and subscriber notifications are logged automatically. This satisfies SOC 2 CC2.3 (incident communication with external parties) without extra configuration. See our compliance use case for details. + +
+ --- Your enterprise prospect is going to ask. Be ready. -- 2.51.2 From 3ce7f7772d0fd87fc604ff69ed6e76eda53440df Mon Sep 17 00:00:00 2001 From: anto Date: Mon, 17 Aug 2026 15:11:25 +0200 Subject: [PATCH 144/266] feat: add Passbolt theme to theme store (#2584) * feat: add Passbolt theme to theme store * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- packages/theme-store/src/index.ts | 2 ++ packages/theme-store/src/passbolt.ts | 51 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 packages/theme-store/src/passbolt.ts diff --git a/packages/theme-store/src/index.ts b/packages/theme-store/src/index.ts index 5c1256db..a3663f5d 100644 --- a/packages/theme-store/src/index.ts +++ b/packages/theme-store/src/index.ts @@ -8,6 +8,7 @@ import { import { DRACULA_THEME } from "./dracula"; import { GITHUB_HIGH_CONTRAST_THEME } from "./github"; import { OPENSTATUS_ROUNDED_THEME, OPENSTATUS_THEME } from "./openstatus"; +import { PASSBOLT_THEME } from "./passbolt"; import { SUPABASE_THEME } from "./supabase"; import type { Theme, ThemeDefinition, ThemeMap } from "./types"; import { assertUniqueThemeIds } from "./utils"; @@ -18,6 +19,7 @@ const THEMES_LIST = [ SUPABASE_THEME, GITHUB_HIGH_CONTRAST_THEME, DRACULA_THEME, + PASSBOLT_THEME, ] satisfies Theme[]; // NOTE: runtime validation to ensure that the theme IDs are unique diff --git a/packages/theme-store/src/passbolt.ts b/packages/theme-store/src/passbolt.ts new file mode 100644 index 00000000..f15ee4ed --- /dev/null +++ b/packages/theme-store/src/passbolt.ts @@ -0,0 +1,51 @@ +import type { Theme } from "./types"; + +export const PASSBOLT_THEME = { + id: "passbolt", + name: "Passbolt", + author: { name: "@Passbolt", url: "https://passbolt.com" }, + light: { + "--background": "oklch(100% 0 0)", + "--foreground": "#000000", + "--border": "oklch(92.2% 0 0)", + "--input": "oklch(92.2% 0 0)", + "--primary": "#3b83d7", + + "--primary-foreground": "oklch(98.5% 0 0)", + "--secondary": "oklch(97% 0 0)", + "--secondary-foreground": "oklch(20.5% 0 0)", + "--muted": "oklch(97% 0 0)", + "--muted-foreground": "oklch(55.6% 0 0)", + "--accent": "oklch(97% 0 0)", + "--accent-foreground": "#0f0f0f", + + "--success": "#b6dcaf", + "--destructive": "#ffa6a6", + "--warning": "#ffdca8", + "--info": "#abd2f9", + + "--radius": "0.5rem", + }, + dark: { + "--background": "#171717", + "--foreground": "#ffffff", + "--border": "#333333", + "--input": "#000000", + + "--primary": "#58acff", + "--primary-foreground": "#ffffff", + "--secondary": "#000000", + "--secondary-foreground": "oklch(98.5% 0 0)", + "--muted": "#333333", + "--muted-foreground": "#999999", + "--accent": "oklch(26.9% 0 0)", + "--accent-foreground": "#ffffff", + + "--success": "#088869", + "--destructive": "#d40101", + "--warning": "#f2ae55", + "--info": "#4b92d9", + + "--radius": "0.5rem", + }, +} as const satisfies Theme; -- 2.51.2 From 8f5590c2a98570168af156278104106950ec7f75 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:09:37 +0200 Subject: [PATCH 145/266] fix(status-page): keep the full path on host-keyed status pages (#2585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2551 dropped `resolveCustomDomainRewrite`, which built its rewrite from the raw pathname and so masked two assumptions in `resolveRoute`. With the default rewrite now serving custom domains, both surfaced as 404s on the deeper routes (`/events/report/{id}`, `/events/maintenance/{id}`, `/monitors/{id}`): - Apex (`acme.com`) and `www.` custom domains fail the `hostnames.length > 2` check, so they resolved as pathname routing and the first path segment was read as the slug and dropped: `/events/report/1` → `/{slug}/en/report/1`. A detected subdomain or custom domain is always host-keyed — set the type accordingly. - apps/web proxies custom domains as `https://www.stpg.dev/{host}/{rest}` while forwarding the original host. Hostname routing then kept that leading segment as path, duplicating the domain: `/{slug}/en/status.acme.com/events/report/1`. Strip a leading segment that repeats the forwarded host. Claude-Session: https://claude.ai/code/session_0199Y2TaeKd8PPLBPEUFSqci Co-authored-by: Claude --- .../status-page/src/lib/resolve-route.test.ts | 130 ++++++++++++++++++ apps/status-page/src/lib/resolve-route.ts | 19 ++- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/apps/status-page/src/lib/resolve-route.test.ts b/apps/status-page/src/lib/resolve-route.test.ts index 39046b50..0ca87182 100644 --- a/apps/status-page/src/lib/resolve-route.test.ts +++ b/apps/status-page/src/lib/resolve-route.test.ts @@ -364,6 +364,136 @@ describe("resolveRoute", () => { }); }); + // Custom domains with fewer than three labels, or a "www." first label, are + // still host-keyed: the path must not be read as `/{slug}/...`. + describe('custom domain routing — apex and "www." hosts', () => { + test("acme.com/events/report/1 → hostname routing, path preserved", () => { + const result = resolveRoute({ + host: "acme.com", + urlHost: "acme.com", + pathname: "/events/report/1", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "acme.com", + locale: "en", + localeExplicit: false, + rewritePath: "/acme.com/en/events/report/1", + }); + }); + + test("acme.com/fr/events/maintenance/1 → explicit locale, path preserved", () => { + const result = resolveRoute({ + host: "acme.com", + urlHost: "acme.com", + pathname: "/fr/events/maintenance/1", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "acme.com", + locale: "fr", + localeExplicit: true, + rewritePath: "/acme.com/fr/events/maintenance/1", + }); + }); + + test("www.acme.com/monitors/1 → hostname routing, path preserved", () => { + const result = resolveRoute({ + host: "www.acme.com", + urlHost: "www.acme.com", + pathname: "/monitors/1", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "www.acme.com", + locale: "en", + localeExplicit: false, + rewritePath: "/www.acme.com/en/monitors/1", + }); + }); + }); + + // apps/web proxies a custom domain as `https://www.stpg.dev/{host}/{rest}` + // while forwarding the original host, so the leading segment is redundant. + describe("custom domain routing — host also present as path prefix", () => { + test("status.acme.com + /status.acme.com/events/report/1 → segment not duplicated", () => { + const result = resolveRoute({ + host: "status.acme.com", + urlHost: "www.stpg.dev", + pathname: "/status.acme.com/events/report/1", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "status.acme.com", + locale: "en", + localeExplicit: false, + rewritePath: "/status.acme.com/en/events/report/1", + }); + }); + + test("status.acme.com + /status.acme.com/fr/events → locale read after the prefix", () => { + const result = resolveRoute({ + host: "status.acme.com", + urlHost: "www.stpg.dev", + pathname: "/status.acme.com/fr/events", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "status.acme.com", + locale: "fr", + localeExplicit: true, + rewritePath: "/status.acme.com/fr/events", + }); + }); + + test("status.acme.com + /status.acme.com → page root", () => { + const result = resolveRoute({ + host: "status.acme.com", + urlHost: "www.stpg.dev", + pathname: "/status.acme.com", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "status.acme.com", + locale: "en", + localeExplicit: false, + rewritePath: "/status.acme.com/en", + }); + }); + + test("subdomain + /acme.openstatus.dev/events → forwarded host stripped", () => { + const result = resolveRoute({ + host: "acme.openstatus.dev", + urlHost: "www.stpg.dev", + pathname: "/acme.openstatus.dev/events", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "acme", + locale: "en", + localeExplicit: false, + rewritePath: "/acme/en/events", + }); + }); + + // Only the host is stripped: a first segment that merely matches the slug is + // real path, e.g. a page whose slug is also a route name. + test('slug "monitors" + /monitors/1 → segment kept', () => { + const result = resolveRoute({ + host: "monitors.stpg.dev", + urlHost: "monitors.stpg.dev", + pathname: "/monitors/1", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "monitors", + locale: "en", + localeExplicit: false, + rewritePath: "/monitors/en/monitors/1", + }); + }); + }); + describe("edge cases", () => { test("root path on localhost returns null (no page)", () => { const result = resolveRoute({ diff --git a/apps/status-page/src/lib/resolve-route.ts b/apps/status-page/src/lib/resolve-route.ts index 2e280487..427f817c 100644 --- a/apps/status-page/src/lib/resolve-route.ts +++ b/apps/status-page/src/lib/resolve-route.ts @@ -1,5 +1,5 @@ import { type Locale, defaultLocale, locales } from "../i18n/config"; -import { getValidSubdomain } from "./domain"; +import { getValidSubdomain, stripHostPort } from "./domain"; export type RouteType = "hostname" | "pathname"; @@ -53,6 +53,10 @@ export function resolveRoute({ if (subdomain !== null) { prefix = subdomain.toLowerCase(); + // Host-keyed: the path carries no slug. Apex (`acme.com`) and `www.` custom + // domains fail the label check above, and reading their first segment as the + // slug would drop it (`/events/report/1` → `/{slug}/{locale}/report/1`). + type = "hostname"; } // Root path on non-hostname type — no page to resolve @@ -69,12 +73,21 @@ export function resolveRoute({ // Resolve locale based on routing type if (type === "hostname") { - const firstSegment = pathnames[1]?.toLowerCase(); + // apps/web proxies custom domains as `https://www.stpg.dev/{host}/{rest}` + // and forwards the original host, so drop a leading segment that repeats + // the prefix — keeping it duplicates the domain inside the rewrite path. + const hostSegment = stripHostPort(host ?? urlHost)?.toLowerCase(); + const segments = + pathnames[1]?.toLowerCase() === hostSegment + ? pathnames.slice(1) + : pathnames; + + const firstSegment = segments[1]?.toLowerCase(); const locale: Locale = isLocale(firstSegment) ? firstSegment : defaultLocale; const hasLocale = isLocale(firstSegment); - const rest = (hasLocale ? pathnames.slice(2) : pathnames.slice(1)) + const rest = (hasLocale ? segments.slice(2) : segments.slice(1)) .filter(Boolean) .join("/"); -- 2.51.2 From 2f9599035bd67a7699d507d7ad2280b4e1e1b60e Mon Sep 17 00:00:00 2001 From: "polylane[bot]" <277585245+polylane[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:20:01 +0200 Subject: [PATCH 146/266] Set up Polylane (#2586) * chore: point coding agents at Polylane for production questions * chore: point coding agents at Polylane for production questions --------- Co-authored-by: polylane[bot] <277585245+polylane[bot]@users.noreply.github.com> --- AGENTS.md | 10 ++++++++++ CLAUDE.md | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index da50576a..229300d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,3 +89,13 @@ boundaries with external SDKs or at registry-style dispatch. When you need one: `docs/adr/` is frozen background on *why* some of these decisions were made. It is history, not current state; this file and its nested siblings are current state. Do not add new ADRs. + + +## Investigating production with Polylane + +[Polylane](https://polylane.com/?ref=github.onboarding-pr) is an AI production engineer: it watches deploys, telemetry, incidents, and the infrastructure this repository ships to, investigates problems as they happen, and proposes fixes as pull requests. It is connected to this repository and available to coding agents through the [Polylane MCP server](https://mcp.polylane.com/mcp). + +- When a question involves production behaviour (an error, a spike, a deploy, a missing signal), query Polylane through its MCP tools before reasoning from the code alone. +- When debugging a failure, start from the incident or issue Polylane recorded: it carries the evidence an investigation already gathered. +- Polylane reviews pull requests in this repository against the live infrastructure. Read its review comment before merging changes that touch production paths. + diff --git a/CLAUDE.md b/CLAUDE.md index af50da6d..2e90a4d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,3 +2,13 @@ Read `AGENTS.md` in this directory — it holds the conventions for this repo, and the nested `AGENTS.md` files it links carry the package-specific ones. + + +## Investigating production with Polylane + +[Polylane](https://polylane.com/?ref=github.onboarding-pr) is an AI production engineer: it watches deploys, telemetry, incidents, and the infrastructure this repository ships to, investigates problems as they happen, and proposes fixes as pull requests. It is connected to this repository and available to coding agents through the [Polylane MCP server](https://mcp.polylane.com/mcp). + +- When a question involves production behaviour (an error, a spike, a deploy, a missing signal), query Polylane through its MCP tools before reasoning from the code alone. +- When debugging a failure, start from the incident or issue Polylane recorded: it carries the evidence an investigation already gathered. +- Polylane reviews pull requests in this repository against the live infrastructure. Read its review comment before merging changes that touch production paths. + -- 2.51.2 From 053ad2c6291abfa5462bd76631a2f44b57dae986 Mon Sep 17 00:00:00 2001 From: Rodrigoue9 <72469240+Rodrigoue9@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:58:07 -0300 Subject: [PATCH 147/266] fix(dashboard): remove default active status filter from subscribers table (#2538) (#2590) --- .../src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx index 67d3350a..dc6fcca1 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx @@ -189,7 +189,6 @@ export default function Page() { data={subscribers} toolbarComponent={SubscribersDataTableToolbar} paginationComponent={DataTablePaginationSimple} - defaultColumnFilters={[{ id: "status", value: ["active"] }]} /> ) : ( -- 2.51.2 From fac50578c130fbd2f7d88488b2bd0d446fcc3336 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:07:23 +0200 Subject: [PATCH 148/266] Add cURL command generation for HTTP monitors (#2591) * feat(monitors): copy the monitor request as a curl command Adds a "Copy cURL" entry to the monitor quick actions, on the detail page and in the list table, so an HTTP monitor's request can be replayed from a terminal without waiting for the next check. The command mirrors what apps/checker sends: the OpenStatus user agent, custom headers, the POST application/json default, -L for followRedirects and --max-time from the monitor timeout. Hidden for tcp/dns monitors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PzRWP9sKSWWuao28PcMrG7 * fix: missing curl on nav monitors * fix: review --------- Co-authored-by: Claude --- .../(dashboard)/monitors/[id]/nav-actions.tsx | 13 ++- .../monitors/data-table-row-actions.tsx | 11 ++- .../src/components/nav/nav-monitors.tsx | 15 +++- apps/dashboard/src/data/monitors.client.ts | 7 ++ packages/utils/src/curl.test.ts | 88 +++++++++++++++++++ packages/utils/src/curl.ts | 54 ++++++++++++ packages/utils/src/index.ts | 1 + 7 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 packages/utils/src/curl.test.ts create mode 100644 packages/utils/src/curl.ts diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx index 82c08d34..6a1a7517 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx @@ -10,6 +10,7 @@ import { TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; +import { buildCurlCommand } from "@openstatus/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { useParams, usePathname, useRouter } from "next/navigation"; @@ -66,12 +67,22 @@ export function NavActions() { const testTcpMutation = useMutation(trpc.checker.testTcp.mutationOptions()); const testDnsMutation = useMutation(trpc.checker.testDns.mutationOptions()); + // curl only speaks HTTP — the action is hidden for tcp/dns monitors + const curlCommand = + monitor?.jobType === "http" ? buildCurlCommand(monitor) : null; + const actions = getActions({ edit: () => router.push(`/monitors/${id}/edit`), "copy-id": async () => { await navigator.clipboard.writeText(id); toast.success("Monitor ID copied to clipboard"); }, + "copy-curl": curlCommand + ? async () => { + await navigator.clipboard.writeText(curlCommand); + toast.success("cURL command copied to clipboard"); + } + : undefined, clone: () => { const promise = cloneMonitorMutation.mutateAsync({ id: Number.parseInt(id), @@ -87,7 +98,7 @@ export function NavActions() { }, }); }, - }); + }).filter((action) => action.id !== "copy-curl" || Boolean(curlCommand)); async function testAction() { if (monitor?.jobType === "http") { diff --git a/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx index 3a820089..41d53cc7 100644 --- a/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx @@ -1,6 +1,7 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { buildCurlCommand } from "@openstatus/utils"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; import { useRouter } from "next/navigation"; @@ -29,14 +30,22 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { }), ); const router = useRouter(); + // curl only speaks HTTP — the action is hidden for tcp/dns monitors + const isHttp = row.original.jobType === "http"; const actions = getActions({ edit: () => router.push(`/monitors/${row.original.id}/edit`), "copy-id": () => { navigator.clipboard.writeText(row.original.id.toString()); toast.success("Monitor ID copied to clipboard"); }, + "copy-curl": isHttp + ? async () => { + await navigator.clipboard.writeText(buildCurlCommand(row.original)); + toast.success("cURL command copied to clipboard"); + } + : undefined, // export: () => setOpenDialog(true), - }); + }).filter((action) => action.id !== "copy-curl" || isHttp); return ( <> diff --git a/apps/dashboard/src/components/nav/nav-monitors.tsx b/apps/dashboard/src/components/nav/nav-monitors.tsx index 8464b927..cea0b00b 100644 --- a/apps/dashboard/src/components/nav/nav-monitors.tsx +++ b/apps/dashboard/src/components/nav/nav-monitors.tsx @@ -18,7 +18,9 @@ import { TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; +import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; +import { buildCurlCommand } from "@openstatus/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import Link from "next/link"; @@ -43,6 +45,7 @@ export function NavMonitors() { const [openDialog, setOpenDialog] = useState(false); const [openUpgradeDialog, setOpenUpgradeDialog] = useState(false); const { isMobile, setOpenMobile } = useSidebar(); + const { copy } = useCopyToClipboard(); const trpc = useTRPC(); const router = useRouter(); const pathname = usePathname(); @@ -128,12 +131,22 @@ export function NavMonitors() { ) : monitors && monitors.length > 0 ? ( monitors.map((item) => { const isActive = pathname.startsWith(`/monitors/${item.id}/`); + // curl only speaks HTTP — the action is hidden for tcp/dns monitors + const isHttp = item.jobType === "http"; const actions = getActions({ edit: () => router.push(`/monitors/${item.id}/edit`), "copy-id": () => { navigator.clipboard.writeText(item.id.toString()); toast.success("Monitor ID copied to clipboard"); }, + "copy-curl": isHttp + ? async () => { + const copied = await copy(buildCurlCommand(item), { + withToast: "cURL command copied to clipboard", + }); + if (!copied) toast.error("Failed to copy cURL command"); + } + : undefined, clone: () => { const promise = cloneMonitorMutation.mutateAsync({ id: item.id, @@ -150,7 +163,7 @@ export function NavMonitors() { }); }, // export: () => setOpenDialog(true), - }); + }).filter((action) => action.id !== "copy-curl" || isHttp); return ( { + it("renders a plain GET without an explicit method", () => { + expect(buildCurlCommand({ url: "https://example.com" })).toBe( + "curl \\\n 'https://example.com' \\\n -H 'User-Agent: OpenStatus/1.0'", + ); + }); + + it("keeps the method explicit when a GET carries a body", () => { + const command = buildCurlCommand({ + url: "https://example.com", + method: "GET", + body: "hello", + }); + expect(command).toContain("-X GET"); + expect(command).toContain("--data-raw 'hello'"); + }); + + it("defaults POST to application/json", () => { + const command = buildCurlCommand({ + url: "https://example.com", + method: "POST", + body: '{"a":1}', + }); + expect(command).toContain("-H 'Content-Type: application/json'"); + expect(command).toContain(`--data-raw '{"a":1}'`); + }); + + it("does not override a custom content type or user agent", () => { + const command = buildCurlCommand({ + url: "https://example.com", + method: "POST", + headers: [ + { key: "content-type", value: "text/plain" }, + { key: "user-agent", value: "custom" }, + ], + }); + expect(command).not.toContain("application/json"); + expect(command).not.toContain("OpenStatus/1.0"); + expect(command).toContain("-H 'content-type: text/plain'"); + }); + + it("skips headers without a key", () => { + const command = buildCurlCommand({ + url: "https://example.com", + headers: [ + { key: " ", value: "ignored" }, + { key: "X-Key", value: "kept" }, + ], + }); + expect(command).not.toContain("ignored"); + expect(command).toContain("-H 'X-Key: kept'"); + }); + + it("escapes single quotes so the command stays a single argument", () => { + const command = buildCurlCommand({ + url: "https://example.com/?q=it's", + headers: [{ key: "X-Quote", value: "a'b" }], + }); + expect(command).toContain(`'https://example.com/?q=it'\\''s'`); + expect(command).toContain(`-H 'X-Quote: a'\\''b'`); + }); + + it("adds -L only when redirects are followed", () => { + expect( + buildCurlCommand({ url: "https://example.com", followRedirects: true }), + ).toContain("-L"); + expect( + buildCurlCommand({ url: "https://example.com", followRedirects: false }), + ).not.toContain("-L"); + }); + + it("converts the timeout to seconds", () => { + expect( + buildCurlCommand({ url: "https://example.com", timeout: 45000 }), + ).toContain("--max-time 45"); + expect( + buildCurlCommand({ url: "https://example.com", timeout: 1500 }), + ).toContain("--max-time 1.5"); + expect( + buildCurlCommand({ url: "https://example.com", timeout: 0 }), + ).not.toContain("--max-time"); + }); +}); diff --git a/packages/utils/src/curl.ts b/packages/utils/src/curl.ts new file mode 100644 index 00000000..b538477a --- /dev/null +++ b/packages/utils/src/curl.ts @@ -0,0 +1,54 @@ +const DEFAULT_USER_AGENT = "OpenStatus/1.0"; + +export type CurlRequest = { + url: string; + method?: string | null; + body?: string | null; + headers?: { key: string; value: string }[] | null; + followRedirects?: boolean | null; + /** Milliseconds, as stored on the monitor. */ + timeout?: number | null; +}; + +function quote(value: string) { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function seconds(milliseconds: number) { + return String(Number((milliseconds / 1000).toFixed(3))); +} + +/** + * Renders the HTTP request a monitor performs as a runnable `curl` command, + * mirroring the defaults `apps/checker` applies (user agent, POST content + * type, redirect policy, timeout). + */ +export function buildCurlCommand(request: CurlRequest): string { + const method = (request.method ?? "GET").toUpperCase(); + const body = request.body ?? ""; + const headers = (request.headers ?? []).filter((h) => h.key.trim() !== ""); + const hasHeader = (name: string) => + headers.some((h) => h.key.toLowerCase() === name); + + const args: string[] = []; + + // `--data-raw` on its own makes curl switch to POST, so a body needs `-X`. + if (method !== "GET" || body) args.push(`-X ${method}`); + args.push(quote(request.url)); + + if (!hasHeader("user-agent")) { + args.push(`-H ${quote(`User-Agent: ${DEFAULT_USER_AGENT}`)}`); + } + for (const header of headers) { + args.push(`-H ${quote(`${header.key}: ${header.value}`)}`); + } + if (method === "POST" && !hasHeader("content-type")) { + args.push(`-H ${quote("Content-Type: application/json")}`); + } + + if (body) args.push(`--data-raw ${quote(body)}`); + if (request.followRedirects) args.push("-L"); + if (request.timeout) args.push(`--max-time ${seconds(request.timeout)}`); + + return ["curl", ...args].join(" \\\n "); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 65ae7343..5561ea7d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -12,6 +12,7 @@ export { MONITOR_STATUSES, MONITOR_JOB_TYPES, } from "./constants"; +export { buildCurlCommand, type CurlRequest } from "./curl"; export { yieldMany, iteratorToStream } from "./stream"; export { statusLabel, type PageUpdateStatus } from "./status"; -- 2.51.2 From a69aab3fa989ca9190cba949f700e2b52c37f7d5 Mon Sep 17 00:00:00 2001 From: "polylane[bot]" <277585245+polylane[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:48:12 +0200 Subject: [PATCH 149/266] fix(workflows): log email batch failures with context and Sentry cron status (#2589) Co-authored-by: polylane[bot] <277585245+polylane[bot]@users.noreply.github.com> --- apps/workflows/src/cron/emails.ts | 20 ++++++++++++-------- apps/workflows/src/cron/index.ts | 7 ++++++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/workflows/src/cron/emails.ts b/apps/workflows/src/cron/emails.ts index 4426493a..235d4d54 100644 --- a/apps/workflows/src/cron/emails.ts +++ b/apps/workflows/src/cron/emails.ts @@ -1,3 +1,4 @@ +import { getLogger } from "@logtape/logtape"; import { and, eq, gte, inArray, lte } from "@openstatus/db"; import { db } from "@openstatus/db"; import { @@ -9,6 +10,7 @@ import { EmailClient } from "@openstatus/emails"; import { env } from "../env"; +const logger = getLogger(["workflow", "emails"]); const email = new EmailClient({ apiKey: env().RESEND_API_KEY }); export async function sendFollowUpEmails() { @@ -27,8 +29,6 @@ export async function sendFollowUpEmails() { .where(and(gte(user.createdAt, date1), lte(user.createdAt, date2))) .all(); - console.log(`Found ${users.length} users to send follow ups.`); - const workspaceIds = [ ...new Set(users.map((u) => u.workspaceId).filter(Boolean)), ]; @@ -71,22 +71,26 @@ export async function sendFollowUpEmails() { for (let i = 0; i < noSlackEmails.length; i += batchSize) { const batch = noSlackEmails.slice(i, i + batchSize); - console.log(`Sending follow-up batch with ${batch.length} emails...`); try { await email.sendFollowUpBatched({ to: batch }); - } catch { - console.error("Rate limit exceeded. Stopping further sends."); + } catch (error) { + logger.error("Follow-up email batch failed", { + batch_size: batch.length, + error_name: error instanceof Error ? error.name : typeof error, + }); break; } } for (let i = 0; i < slackEmails.length; i += batchSize) { const batch = slackEmails.slice(i, i + batchSize); - console.log(`Sending slack feedback batch with ${batch.length} emails...`); try { await email.sendSlackFeedbackBatched({ to: batch }); - } catch { - console.error("Rate limit exceeded. Stopping further sends."); + } catch (error) { + logger.error("Slack feedback email batch failed", { + batch_size: batch.length, + error_name: error instanceof Error ? error.name : typeof error, + }); break; } } diff --git a/apps/workflows/src/cron/index.ts b/apps/workflows/src/cron/index.ts index 3a9a9caa..c5096571 100644 --- a/apps/workflows/src/cron/index.ts +++ b/apps/workflows/src/cron/index.ts @@ -104,11 +104,16 @@ app.get("/private-location-health", async (c) => { }); app.get("/emails/follow-up", async (c) => { + const { cronCompleted, cronFailed } = runSentryCron("emails-follow-up"); + try { await sendFollowUpEmails(); + void cronCompleted(); return c.json({ success: true }, 200); } catch (e) { - console.error(e); + const errorName = e instanceof Error ? e.name : typeof e; + void reportBackgroundError(`emails-follow-up failed: ${errorName}`); + void cronFailed(); return c.text("Internal Server Error", 500); } }); -- 2.51.2 From e95f832482d2b6de86907346e36a94e35d7c133c Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:14:49 +0200 Subject: [PATCH 150/266] fix: theme-explorer-host-gate (#2592) * fix: theme-explorer-host-gate * fix: minor stuff --- apps/status-page/AGENTS.md | 18 + apps/status-page/src/app/(public)/layout.tsx | 27 ++ apps/status-page/src/app/layout.tsx | 11 +- apps/status-page/src/app/metadata.ts | 31 +- apps/status-page/src/app/robots.ts | 8 + apps/status-page/src/lib/domain.test.ts | 128 +++++++ .../src/lib/proxy/access-predicates.test.ts | 198 +++++++++++ .../src/lib/proxy/proxy-chain.test.ts | 319 ++++++++++++++++++ .../resolve-unresolved-host-action.test.ts | 84 +++++ .../proxy/resolve-unresolved-host-action.ts | 32 ++ .../status-page/src/lib/resolve-route.test.ts | 176 ++++++++++ .../src/lib/theme-explorer-host.test.ts | 43 +++ .../src/lib/theme-explorer-host.ts | 28 ++ apps/status-page/src/proxy.ts | 36 +- 14 files changed, 1114 insertions(+), 25 deletions(-) create mode 100644 apps/status-page/src/lib/domain.test.ts create mode 100644 apps/status-page/src/lib/proxy/access-predicates.test.ts create mode 100644 apps/status-page/src/lib/proxy/proxy-chain.test.ts create mode 100644 apps/status-page/src/lib/proxy/resolve-unresolved-host-action.test.ts create mode 100644 apps/status-page/src/lib/proxy/resolve-unresolved-host-action.ts create mode 100644 apps/status-page/src/lib/theme-explorer-host.test.ts create mode 100644 apps/status-page/src/lib/theme-explorer-host.ts diff --git a/apps/status-page/AGENTS.md b/apps/status-page/AGENTS.md index c9d0191b..10176814 100644 --- a/apps/status-page/AGENTS.md +++ b/apps/status-page/AGENTS.md @@ -37,3 +37,21 @@ a theme in that package; do not hard-code colours in a component. Status-page impact labels are coloured text only — no dots, no chevrons. The hover affordance is a dashed muted underline, never one tinted with the impact colour. + +## Theme explorer + +`/` renders the theme explorer, and it is also where any request resolving to +no `page` row ends up. The proxy rewrites those to a 404 +(`resolveUnresolvedHostAction`) so an unknown slug or a custom domain missing +from the DB never answers with the explorer or its OG image. Only +`themes.openstatus.dev` is indexable — see `lib/theme-explorer-host.ts`. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/status-page/src/app/(public)/layout.tsx b/apps/status-page/src/app/(public)/layout.tsx index 68c07985..6949cc48 100644 --- a/apps/status-page/src/app/(public)/layout.tsx +++ b/apps/status-page/src/app/(public)/layout.tsx @@ -4,8 +4,11 @@ import { SidebarProvider, } from "@openstatus/ui/components/ui/sidebar"; import { Toaster } from "@openstatus/ui/components/ui/sonner"; +import type { Metadata } from "next"; import { NextIntlClientProvider } from "next-intl"; import PlausibleProvider from "next-plausible"; +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; import { Suspense } from "react"; import { Link } from "../../components/common/link"; @@ -14,15 +17,39 @@ import { SidebarTrigger, ThemeSidebar, } from "../../components/themes/theme-sidebar"; +import { + isCanonicalThemeExplorerHost, + isThemeExplorerHost, +} from "../../lib/theme-explorer-host"; +import { themeExplorerMetadata } from "../metadata"; const SIDEBAR_WIDTH = "20rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; +async function requestHost() { + const headerStore = await headers(); + return headerStore.get("x-forwarded-host") ?? headerStore.get("host"); +} + +export async function generateMetadata(): Promise { + const host = await requestHost(); + // Same gate as the layout: metadata is resolved alongside the render, so the + // explorer's OG image would otherwise ship with the 404. + if (!isThemeExplorerHost(host)) notFound(); + return themeExplorerMetadata({ + indexable: isCanonicalThemeExplorerHost(host), + }); +} + export default async function Layout({ children, }: { children: React.ReactNode; }) { + // `/` is the fallthrough for every host that resolves to no page, so keep the + // explorer off unknown slugs and unconfigured custom domains. + if (!isThemeExplorerHost(await requestHost())) notFound(); + const locale = "en"; const messages = (await import(`../../../messages/${locale}.json`)).default; diff --git a/apps/status-page/src/app/layout.tsx b/apps/status-page/src/app/layout.tsx index bc1a2aec..78cbbff0 100644 --- a/apps/status-page/src/app/layout.tsx +++ b/apps/status-page/src/app/layout.tsx @@ -8,7 +8,6 @@ import { NuqsAdapter } from "nuqs/adapters/next/app"; import { TailwindIndicator } from "../components/tailwind-indicator"; import { TRPCReactProvider } from "../lib/trpc/client"; -import { ogMetadata, twitterMetadata } from "./metadata"; import { defaultMetadata } from "./metadata"; const cal = LocalFont({ @@ -52,15 +51,7 @@ const commitMono = LocalFont({ variable: "--font-commit-mono", }); -export const metadata: Metadata = { - ...defaultMetadata, - twitter: { - ...twitterMetadata, - }, - openGraph: { - ...ogMetadata, - }, -}; +export const metadata: Metadata = defaultMetadata; // export const dynamic = "error"; diff --git a/apps/status-page/src/app/metadata.ts b/apps/status-page/src/app/metadata.ts index d90e4198..2f220525 100644 --- a/apps/status-page/src/app/metadata.ts +++ b/apps/status-page/src/app/metadata.ts @@ -1,5 +1,7 @@ import type { Metadata } from "next"; +import { THEME_EXPLORER_URL } from "../lib/theme-explorer-host"; + export const TITLE = "Status Page"; export const DESCRIPTION = "Status page customization with built-in themes. Explore all themes and contribute your own theme."; @@ -9,6 +11,7 @@ const OG_DESCRIPTION = "Explore all themes for your status page and contribute new ones to the community."; const FOOTER = "themes.openstatus.dev"; const IMAGE = "assets/og/theme-explorer.png"; +const THEME_EXPLORER_IMAGE = `/api/og?title=${OG_TITLE}&description=${OG_DESCRIPTION}&footer=${FOOTER}&image=${IMAGE}`; export const defaultMetadata: Metadata = { title: { @@ -18,22 +21,38 @@ export const defaultMetadata: Metadata = { icons: "https://www.openstatus.dev/favicon.ico", description: DESCRIPTION, metadataBase: new URL("https://www.openstatus.dev"), + // Unresolved hosts render the 404 off this layout — keep them out of the + // index. Status pages and the theme explorer set their own `robots`. + robots: { index: false, follow: false }, }; export const twitterMetadata: Metadata["twitter"] = { title: TITLE, description: DESCRIPTION, card: "summary_large_image", - images: [ - `/api/og?title=${OG_TITLE}&description=${OG_DESCRIPTION}&footer=${FOOTER}&image=${IMAGE}`, - ], }; export const ogMetadata: Metadata["openGraph"] = { title: TITLE, description: DESCRIPTION, type: "website", - images: [ - `/api/og?title=${OG_TITLE}&description=${OG_DESCRIPTION}&footer=${FOOTER}&image=${IMAGE}`, - ], }; + +/** + * Theme explorer only — the OG image must never be inherited by a status page + * or by the 404 an unresolved host lands on. + */ +export function themeExplorerMetadata({ + indexable, +}: { + indexable: boolean; +}): Metadata { + return { + alternates: { canonical: THEME_EXPLORER_URL }, + robots: indexable + ? { index: true, follow: true } + : { index: false, follow: false }, + twitter: { ...twitterMetadata, images: [THEME_EXPLORER_IMAGE] }, + openGraph: { ...ogMetadata, images: [THEME_EXPLORER_IMAGE] }, + }; +} diff --git a/apps/status-page/src/app/robots.ts b/apps/status-page/src/app/robots.ts index 9b1d57a8..fcc4d181 100644 --- a/apps/status-page/src/app/robots.ts +++ b/apps/status-page/src/app/robots.ts @@ -6,6 +6,7 @@ import { headers } from "next/headers"; import { getBaseUrl } from "../lib/base-url"; import { stripHostPort } from "../lib/domain"; import { resolveRoute } from "../lib/resolve-route"; +import { isCanonicalThemeExplorerHost } from "../lib/theme-explorer-host"; // trpc/db lookup needs Node, matching the sitemap and other content routes. export const runtime = "nodejs"; @@ -51,6 +52,13 @@ export default async function robots(): Promise { }; } + // No page for this host: `/` falls through to the theme explorer, which only + // belongs in the index on its own host. Matches the `robots` the explorer + // itself emits — every other host that renders it is noindex. + if (!row && !isCanonicalThemeExplorerHost(host)) { + return { rules: [{ userAgent: "*", disallow: "/" }] }; + } + const sitemap = row ? `${getBaseUrl({ slug: row.slug, customDomain: row.customDomain ?? undefined })}/sitemap.xml` : undefined; diff --git a/apps/status-page/src/lib/domain.test.ts b/apps/status-page/src/lib/domain.test.ts new file mode 100644 index 00000000..7483aca2 --- /dev/null +++ b/apps/status-page/src/lib/domain.test.ts @@ -0,0 +1,128 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { getValidSubdomain, stripHostPort } from "./domain"; + +describe("stripHostPort", () => { + test("drops the port", () => { + expect(stripHostPort("status.acme.com:8080")).toBe("status.acme.com"); + expect(stripHostPort("localhost:3000")).toBe("localhost"); + }); + + test("leaves a portless host untouched", () => { + expect(stripHostPort("status.acme.com")).toBe("status.acme.com"); + }); + + test("does not normalise case — callers lowercase themselves", () => { + expect(stripHostPort("Status.Acme.COM")).toBe("Status.Acme.COM"); + }); + + test("passes null/undefined through", () => { + expect(stripHostPort(null)).toBe(null); + expect(stripHostPort(undefined)).toBe(null); + expect(stripHostPort("")).toBe(""); + }); + + test("a bare IPv6 literal is mangled: the trailing `:1` reads as a port", () => { + // Hosts arrive bracketed (`[::1]:3000`) from real clients, so this only + // shows up in hand-built values. + expect(stripHostPort("::1")).toBe(":"); + }); +}); + +describe("getValidSubdomain", () => { + describe("openstatus.dev / stpg.dev hosts", () => { + test("tenant subdomain → the slug", () => { + expect(getValidSubdomain("acme.openstatus.dev")).toBe("acme"); + expect(getValidSubdomain("acme.stpg.dev")).toBe("acme"); + }); + + test("`www.` is not a tenant", () => { + expect(getValidSubdomain("www.openstatus.dev")).toBe(null); + expect(getValidSubdomain("www.stpg.dev")).toBe(null); + }); + + test("the theme explorer host resolves like any other subdomain", () => { + // No `page` row matches "themes", so the proxy passes through to `/`. + expect(getValidSubdomain("themes.openstatus.dev")).toBe("themes"); + }); + + test("apex openstatus.dev reads its own first label as the slug", () => { + expect(getValidSubdomain("openstatus.dev")).toBe("openstatus"); + }); + }); + + describe("localhost", () => { + test("bare localhost is not a tenant (path routing in dev)", () => { + expect(getValidSubdomain("localhost")).toBe(null); + expect(getValidSubdomain("localhost:3000")).toBe(null); + }); + + test("subdomain of localhost → the slug, port stripped", () => { + expect(getValidSubdomain("acme.localhost:3000")).toBe("acme"); + expect(getValidSubdomain("acme.localhost")).toBe("acme"); + }); + }); + + describe("vercel deployments", () => { + test("*.vercel.app is never a tenant", () => { + expect(getValidSubdomain("status-page-abc123.vercel.app")).toBe(null); + expect(getValidSubdomain("status-page-git-main-os.vercel.app")).toBe( + null, + ); + }); + }); + + describe("custom domains", () => { + test("the whole host is the lookup key, not its first label", () => { + expect(getValidSubdomain("status.acme.com")).toBe("status.acme.com"); + expect(getValidSubdomain("acme.com")).toBe("acme.com"); + expect(getValidSubdomain("acme.co.uk")).toBe("acme.co.uk"); + }); + + test("`www.` custom domains are kept whole", () => { + expect(getValidSubdomain("www.acme.com")).toBe("www.acme.com"); + }); + + test("no host at all → no tenant", () => { + expect(getValidSubdomain(null)).toBe(null); + expect(getValidSubdomain(undefined)).toBe(null); + expect(getValidSubdomain("")).toBe(null); + }); + }); + + describe("known gaps — pinned so a fix is a deliberate change", () => { + test("a look-alike host matches by substring and yields the tenant slug", () => { + // `host.includes("openstatus.dev")` is a substring test, so an attacker + // domain ending in `.evil.com` still resolves to tenant `acme`. Reaching + // this needs the domain attached to the deployment, which Vercel refuses + // without domain verification — hence pinned, not treated as live. + expect(getValidSubdomain("acme.openstatus.dev.evil.com")).toBe("acme"); + }); + + test("an uppercase Host is read as a custom domain", () => { + // The `.includes()` guards are case-sensitive, so the whole host becomes + // the lookup key and no `page` row matches → a valid page 404s. Browsers + // send lowercase hosts, so this needs a hand-built request. + expect(getValidSubdomain("ACME.OPENSTATUS.DEV")).toBe( + "ACME.OPENSTATUS.DEV", + ); + }); + + test("a custom domain keeps its port, which never matches page.customDomain", () => { + // `page.customDomain` is stored without a port and `stripHostPort` is + // never applied on this path. Only reachable in local dev. + expect(getValidSubdomain("status.acme.com:8080")).toBe( + "status.acme.com:8080", + ); + }); + + test("IP hosts are not excluded — the exclusion regex is double-escaped", () => { + // `/^(localhost|127\\.0\\.0\\.1|...)/` matches literal backslashes, so + // every alternative but `localhost` is inert and an IP host is treated as + // a custom domain. + expect(getValidSubdomain("127.0.0.1:3000")).toBe("127.0.0.1:3000"); + expect(getValidSubdomain("192.168.1.10")).toBe("192.168.1.10"); + }); + }); +}); diff --git a/apps/status-page/src/lib/proxy/access-predicates.test.ts b/apps/status-page/src/lib/proxy/access-predicates.test.ts new file mode 100644 index 00000000..e5d7bb22 --- /dev/null +++ b/apps/status-page/src/lib/proxy/access-predicates.test.ts @@ -0,0 +1,198 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + isEmailDomainAuthorized, + isIpAuthorized, + isPasswordAuthorized, +} from "./access-predicates"; + +describe("isPasswordAuthorized", () => { + test("matching cookie authorizes", () => { + expect( + isPasswordAuthorized({ + stored: "s3cret", + queryPassword: null, + cookiePassword: "s3cret", + }), + ).toBe(true); + }); + + test("matching query param authorizes", () => { + expect( + isPasswordAuthorized({ + stored: "s3cret", + queryPassword: "s3cret", + cookiePassword: null, + }), + ).toBe(true); + }); + + test("a wrong query param does not fall through to a valid cookie", () => { + expect( + isPasswordAuthorized({ + stored: "s3cret", + queryPassword: "nope", + cookiePassword: "s3cret", + }), + ).toBe(false); + }); + + test("an empty query param counts as submitted and loses", () => { + expect( + isPasswordAuthorized({ + stored: "s3cret", + queryPassword: "", + cookiePassword: "s3cret", + }), + ).toBe(false); + }); + + test("an absent query param defers to the cookie", () => { + expect( + isPasswordAuthorized({ + stored: "s3cret", + queryPassword: undefined, + cookiePassword: "s3cret", + }), + ).toBe(true); + }); + + test("no stored password never authorizes, whatever is submitted", () => { + for (const stored of [null, undefined, ""]) { + expect( + isPasswordAuthorized({ + stored, + queryPassword: "", + cookiePassword: "", + }), + ).toBe(false); + expect( + isPasswordAuthorized({ + stored, + queryPassword: "anything", + cookiePassword: null, + }), + ).toBe(false); + } + }); + + test("nothing submitted → denied", () => { + expect( + isPasswordAuthorized({ + stored: "s3cret", + queryPassword: null, + cookiePassword: undefined, + }), + ).toBe(false); + }); + + test("comparison is exact: case, whitespace, prefixes and length all count", () => { + const deny = (submitted: string) => + isPasswordAuthorized({ + stored: "s3cret", + queryPassword: submitted, + cookiePassword: null, + }); + expect(deny("S3CRET")).toBe(false); + expect(deny(" s3cret")).toBe(false); + expect(deny("s3cret ")).toBe(false); + expect(deny("s3cre")).toBe(false); + expect(deny("s3cretx")).toBe(false); + expect(deny("")).toBe(false); + }); + + test("non-ASCII passwords compare by code unit", () => { + expect( + isPasswordAuthorized({ + stored: "pässwörd✅", + queryPassword: "pässwörd✅", + cookiePassword: null, + }), + ).toBe(true); + expect( + isPasswordAuthorized({ + stored: "pässwörd✅", + queryPassword: "passwörd✅", + cookiePassword: null, + }), + ).toBe(false); + }); +}); + +describe("isEmailDomainAuthorized", () => { + test("domain in the allow-list authorizes", () => { + expect(isEmailDomainAuthorized("dev@acme.com", ["acme.com"])).toBe(true); + }); + + test("matching is case-insensitive on both sides", () => { + expect(isEmailDomainAuthorized("Dev@ACME.com", ["acme.com"])).toBe(true); + expect(isEmailDomainAuthorized("dev@acme.com", ["ACME.COM"])).toBe(true); + }); + + test("a subdomain of an allowed domain is not allowed", () => { + expect(isEmailDomainAuthorized("dev@mail.acme.com", ["acme.com"])).toBe( + false, + ); + }); + + test("a suffix look-alike is not allowed", () => { + expect(isEmailDomainAuthorized("dev@evilacme.com", ["acme.com"])).toBe( + false, + ); + expect(isEmailDomainAuthorized("dev@acme.com.evil.com", ["acme.com"])).toBe( + false, + ); + }); + + test("empty or missing allow-list denies", () => { + expect(isEmailDomainAuthorized("dev@acme.com", [])).toBe(false); + expect(isEmailDomainAuthorized("dev@acme.com", null)).toBe(false); + expect(isEmailDomainAuthorized("dev@acme.com", undefined)).toBe(false); + }); + + test("no session email denies", () => { + expect(isEmailDomainAuthorized(null, ["acme.com"])).toBe(false); + expect(isEmailDomainAuthorized(undefined, ["acme.com"])).toBe(false); + expect(isEmailDomainAuthorized("", ["acme.com"])).toBe(false); + }); + + test("an address without a domain denies", () => { + expect(isEmailDomainAuthorized("dev", ["acme.com"])).toBe(false); + expect(isEmailDomainAuthorized("dev@", ["acme.com"])).toBe(false); + }); + + test("only the first domain of a multi-@ address is read", () => { + // `split("@")[1]` — "a@b@acme.com" checks "b", not "acme.com". + expect(isEmailDomainAuthorized("a@b@acme.com", ["acme.com"])).toBe(false); + expect(isEmailDomainAuthorized("a@acme.com@b", ["acme.com"])).toBe(true); + }); +}); + +describe("isIpAuthorized", () => { + test("IP inside an allowed range authorizes", () => { + expect(isIpAuthorized("192.168.1.42", ["192.168.1.0/24"])).toBe(true); + }); + + test("IP outside every range denies", () => { + expect(isIpAuthorized("10.0.0.1", ["192.168.1.0/24"])).toBe(false); + }); + + test("no ranges configured denies (fail closed)", () => { + expect(isIpAuthorized("192.168.1.42", [])).toBe(false); + expect(isIpAuthorized("192.168.1.42", null)).toBe(false); + expect(isIpAuthorized("192.168.1.42", undefined)).toBe(false); + }); + + test("no client IP denies (fail closed)", () => { + expect(isIpAuthorized(null, ["0.0.0.0/0"])).toBe(false); + expect(isIpAuthorized(undefined, ["0.0.0.0/0"])).toBe(false); + expect(isIpAuthorized("", ["0.0.0.0/0"])).toBe(false); + }); + + test("a malformed range is skipped, the rest still evaluated", () => { + expect( + isIpAuthorized("192.168.1.42", ["not-a-cidr", "192.168.1.0/24"]), + ).toBe(true); + }); +}); diff --git a/apps/status-page/src/lib/proxy/proxy-chain.test.ts b/apps/status-page/src/lib/proxy/proxy-chain.test.ts new file mode 100644 index 00000000..8090223c --- /dev/null +++ b/apps/status-page/src/lib/proxy/proxy-chain.test.ts @@ -0,0 +1,319 @@ +import type { Page } from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { resolveRoute } from "../resolve-route"; +import { applyPageLocaleOverride } from "./apply-page-locale-override"; +import { applyPageSlugPrefix } from "./apply-page-slug-prefix"; +import { composePageAction } from "./compose-page-action"; +import type { Action } from "./types"; + +/** + * Integration cover for the exact sequence `proxy.ts` runs after its DB lookup: + * resolveRoute → applyPageLocaleOverride → applyPageSlugPrefix → + * composePageAction. The stages are unit-tested individually; this pins how + * they compose for a real host + path + page row. + */ + +const ORIGIN = "https://www.stpg.dev"; + +function buildPage(overrides: Partial = {}): Page { + return { + id: 1, + workspaceId: 1, + title: "Acme", + description: "", + slug: "acme", + customDomain: "", + icon: "", + forceTheme: null, + footerHtml: null, + createdAt: new Date(), + updatedAt: new Date(), + published: true, + passwordProtected: false, + password: "", + accessType: "public", + authEmailDomains: [], + allowedIpRanges: [], + defaultLocale: "en", + locales: null, + favicon: null, + logo: null, + contactUrl: null, + statusReportSchedule: null, + showMonitorValues: null, + showMonitorUptime: null, + ...overrides, + } as unknown as Page; +} + +/** Mirrors proxy.ts for a request the DB lookup resolved to `page`. */ +function runProxy({ + host, + pathname, + search = "", + page = buildPage(), + isSelfHosted = false, + cookiePassword, + queryPassword = null, + redirectParam = null, + authEmail = null, + clientIp = null, + urlHost = "www.stpg.dev", +}: { + host: string; + pathname: string; + search?: string; + page?: Page; + isSelfHosted?: boolean; + cookiePassword?: string; + queryPassword?: string | null; + redirectParam?: string | null; + authEmail?: string | null; + clientIp?: string | null; + urlHost?: string; +}): { action: Action; rewritePath: string } { + const initialRoute = resolveRoute({ host, urlHost, pathname }); + if (!initialRoute) throw new Error("route did not resolve"); + + const route = applyPageSlugPrefix( + applyPageLocaleOverride(initialRoute, page), + page, + ); + + const action = composePageAction({ + route, + page, + host, + urlHost, + pathname, + search, + isSelfHosted, + requestUrl: `${ORIGIN}${pathname}${search}`, + origin: ORIGIN, + cookiePassword, + queryPassword, + redirectParam, + authEmail, + clientIp, + }); + + return { action, rewritePath: route.rewritePath }; +} + +describe("proxy chain — public pages", () => { + test("subdomain host rewrites to the slug-prefixed internal path", () => { + const { action } = runProxy({ + host: "acme.openstatus.dev", + pathname: "/events", + }); + expect(action.type).toBe("rewrite"); + expect(action.url?.pathname).toBe("/acme/en/events"); + }); + + test("custom domain swaps the domain prefix for the slug", () => { + // resolveRoute keys custom domains by the full host; the `[domain]` segment + // must still be the slug — the login cookie key is derived from it. + const { action } = runProxy({ + host: "status.acme.com", + pathname: "/monitors/1", + page: buildPage({ customDomain: "status.acme.com" }), + }); + expect(action.type).toBe("rewrite"); + expect(action.url?.pathname).toBe("/acme/en/monitors/1"); + }); + + test("apps/web proxy shape: host repeated as the first path segment", () => { + const { action } = runProxy({ + host: "status.acme.com", + pathname: "/status.acme.com/events/report/1", + page: buildPage({ customDomain: "status.acme.com" }), + }); + expect(action.type).toBe("rewrite"); + expect(action.url?.pathname).toBe("/acme/en/events/report/1"); + }); + + test("path routing (local dev) rewrites in place", () => { + const { action } = runProxy({ + host: "localhost:3000", + urlHost: "localhost:3000", + pathname: "/acme/events", + }); + expect(action.type).toBe("rewrite"); + expect(action.url?.pathname).toBe("/acme/en/events"); + }); + + test("search params survive the rewrite", () => { + const { action } = runProxy({ + host: "acme.openstatus.dev", + pathname: "/monitors/1", + search: "?period=7d®ion=ams", + }); + expect(action.url?.search).toBe("?period=7d®ion=ams"); + }); +}); + +describe("proxy chain — locale", () => { + test("no locale in the URL adopts the page default", () => { + const { action } = runProxy({ + host: "acme.openstatus.dev", + pathname: "/events", + page: buildPage({ defaultLocale: "fr" }), + }); + expect(action.url?.pathname).toBe("/acme/fr/events"); + }); + + test("an explicit locale wins over the page default", () => { + const { action } = runProxy({ + host: "acme.openstatus.dev", + pathname: "/en/events", + page: buildPage({ defaultLocale: "fr" }), + }); + expect(action.url?.pathname).toBe("/acme/en/events"); + }); + + test("a locale the page does not publish redirects to its default", () => { + const { action } = runProxy({ + host: "acme.openstatus.dev", + pathname: "/fr/events", + page: buildPage({ defaultLocale: "en", locales: ["en", "de"] }), + }); + expect(action.type).toBe("redirect"); + expect(action.reason).toBe("locale-mismatch-redirect"); + // Hostname routing: the slug is not part of the public URL. + expect(action.url?.pathname).toBe("/en/events"); + }); +}); + +describe("proxy chain — gates", () => { + const passwordPage = buildPage({ + accessType: "password", + passwordProtected: true, + password: "s3cret", + customDomain: "status.acme.com", + }); + + test("password page with no credentials redirects to the custom domain login", () => { + const { action } = runProxy({ + host: "status.acme.com", + pathname: "/", + page: passwordPage, + }); + expect(action.type).toBe("redirect"); + expect(action.reason).toContain("gate-in"); + // The post-auth target is captured on the way in. + expect(action.url?.toString()).toBe( + "https://status.acme.com/login?redirect=%2F", + ); + }); + + test("a correct cookie on /login sends the visitor back to the page", () => { + const { action } = runProxy({ + host: "status.acme.com", + pathname: "/login", + page: passwordPage, + cookiePassword: "s3cret", + }); + expect(action.type).toBe("redirect"); + expect(action.reason).toContain("gate-out"); + expect(action.url?.toString()).toBe("https://status.acme.com/"); + }); + + test("a wrong cookie keeps the visitor on /login", () => { + const { action } = runProxy({ + host: "status.acme.com", + pathname: "/login", + page: passwordPage, + cookiePassword: "nope", + }); + expect(action.type).not.toBe("redirect"); + }); + + test("a correct password renders the page", () => { + const { action } = runProxy({ + host: "status.acme.com", + pathname: "/", + page: passwordPage, + cookiePassword: "s3cret", + }); + expect(action.type).toBe("rewrite"); + expect(action.url?.pathname).toBe("/acme/en"); + }); + + test("an email-domain page redirects an unauthenticated visitor", () => { + const { action } = runProxy({ + host: "acme.openstatus.dev", + pathname: "/", + page: buildPage({ + accessType: "email-domain", + authEmailDomains: ["acme.com"], + }), + }); + expect(action.type).toBe("redirect"); + expect(action.reason).toContain("gate-in"); + }); + + test("an email-domain page renders for an allowed domain", () => { + const { action } = runProxy({ + host: "acme.openstatus.dev", + pathname: "/", + page: buildPage({ + accessType: "email-domain", + authEmailDomains: ["acme.com"], + }), + authEmail: "dev@acme.com", + }); + expect(action.type).toBe("rewrite"); + expect(action.url?.pathname).toBe("/acme/en"); + }); + + test("an ip-restricted page redirects an outside IP to /restricted", () => { + const { action } = runProxy({ + host: "acme.openstatus.dev", + pathname: "/", + page: buildPage({ + accessType: "ip-restriction", + allowedIpRanges: ["192.168.1.0/24"], + }), + clientIp: "10.0.0.1", + }); + expect(action.type).toBe("redirect"); + expect(action.reason).toBe("ip-restriction-gate-in"); + expect(action.url?.pathname).toBe("/restricted"); + }); + + test("an ip-restricted page renders for an allowed IP", () => { + const { action } = runProxy({ + host: "acme.openstatus.dev", + pathname: "/", + page: buildPage({ + accessType: "ip-restriction", + allowedIpRanges: ["192.168.1.0/24"], + }), + clientIp: "192.168.1.42", + }); + expect(action.type).toBe("rewrite"); + expect(action.url?.pathname).toBe("/acme/en"); + }); + + test("a gated page never rewrites before the gate resolves", () => { + // Regression guard: the gate stages must run before the default rewrite, + // or the internal path would render for an unauthorized visitor. + for (const page of [ + passwordPage, + buildPage({ accessType: "email-domain", authEmailDomains: ["acme.com"] }), + buildPage({ + accessType: "ip-restriction", + allowedIpRanges: ["192.168.1.0/24"], + }), + ]) { + const { action } = runProxy({ + host: page.customDomain || "acme.openstatus.dev", + pathname: "/monitors/1", + page, + }); + expect(action.type).toBe("redirect"); + } + }); +}); diff --git a/apps/status-page/src/lib/proxy/resolve-unresolved-host-action.test.ts b/apps/status-page/src/lib/proxy/resolve-unresolved-host-action.test.ts new file mode 100644 index 00000000..67d7a8b9 --- /dev/null +++ b/apps/status-page/src/lib/proxy/resolve-unresolved-host-action.test.ts @@ -0,0 +1,84 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + NOT_FOUND_PATH, + resolveUnresolvedHostAction, +} from "./resolve-unresolved-host-action"; + +const ORIGIN = "https://www.stpg.dev"; + +function run(host: string | null, urlHost = "www.stpg.dev", path = "/") { + return resolveUnresolvedHostAction({ + host, + urlHost, + requestUrl: `${ORIGIN}${path}`, + }); +} + +describe("resolveUnresolvedHostAction", () => { + describe("hosts that must 404 instead of seeing the theme explorer", () => { + test("custom domain pointed at the deployment but missing from `page`", () => { + const action = run("status.mxkaske.dev"); + expect(action.type).toBe("rewrite"); + expect(action.reason).toBe("unresolved-host"); + expect(action.url?.pathname).toBe(NOT_FOUND_PATH); + }); + + test("unknown tenant subdomain", () => { + expect(run("does-not-exist.openstatus.dev").type).toBe("rewrite"); + expect(run("does-not-exist.stpg.dev").type).toBe("rewrite"); + }); + + test("apex and `www.` custom domains", () => { + expect(run("mxkaske.dev").type).toBe("rewrite"); + expect(run("www.mxkaske.dev").type).toBe("rewrite"); + }); + + test("the marketing host", () => { + expect(run("www.openstatus.dev").type).toBe("rewrite"); + }); + + test("the 404 target drops the original path and query", () => { + const action = run("status.mxkaske.dev", "www.stpg.dev", "/events?a=1"); + expect(action.url?.pathname).toBe(NOT_FOUND_PATH); + expect(action.url?.search).toBe(""); + }); + }); + + describe("hosts that render the explorer", () => { + test("the canonical theme explorer host", () => { + const action = run("themes.openstatus.dev"); + expect(action.type).toBe("passthrough"); + expect(action.reason).toBe("theme-explorer-host"); + expect(action.url).toBe(undefined); + }); + + test("the internal origin", () => { + expect(run("www.stpg.dev").type).toBe("passthrough"); + }); + + test("local dev", () => { + expect(run("localhost:3000", "localhost:3000").type).toBe("passthrough"); + }); + + test("preview deployments", () => { + expect(run("os-abc123.vercel.app", "os-abc123.vercel.app").type).toBe( + "passthrough", + ); + }); + }); + + describe("host resolution", () => { + test("falls back to urlHost when x-forwarded-host is absent", () => { + expect(run(null, "themes.openstatus.dev").type).toBe("passthrough"); + expect(run(null, "status.mxkaske.dev").type).toBe("rewrite"); + }); + + test("x-forwarded-host wins over urlHost", () => { + // The origin every proxied host lands on is allowed; the forwarded + // tenant host is what must 404. + expect(run("status.mxkaske.dev", "www.stpg.dev").type).toBe("rewrite"); + }); + }); +}); diff --git a/apps/status-page/src/lib/proxy/resolve-unresolved-host-action.ts b/apps/status-page/src/lib/proxy/resolve-unresolved-host-action.ts new file mode 100644 index 00000000..1a20b813 --- /dev/null +++ b/apps/status-page/src/lib/proxy/resolve-unresolved-host-action.ts @@ -0,0 +1,32 @@ +import { isThemeExplorerHost } from "../theme-explorer-host"; +import { type Action, passthrough } from "./types"; + +/** Matches no route, so Next renders the app's own 404. */ +export const NOT_FOUND_PATH = "/_not-found"; + +/** + * Decides what to serve when a request resolves to no page — an unknown slug, + * or a custom domain pointed at the deployment but missing from the `page` + * table. Passing those through would serve `/`, which is the theme explorer, so + * the host would answer with the explorer and its OG image instead of a 404. + */ +export function resolveUnresolvedHostAction({ + host, + urlHost, + requestUrl, +}: { + /** x-forwarded-host header value */ + host: string | null; + /** req.nextUrl.host */ + urlHost: string; + requestUrl: string; +}): Action { + if (isThemeExplorerHost(host ?? urlHost)) { + return passthrough("theme-explorer-host"); + } + return { + type: "rewrite", + url: new URL(NOT_FOUND_PATH, requestUrl), + reason: "unresolved-host", + }; +} diff --git a/apps/status-page/src/lib/resolve-route.test.ts b/apps/status-page/src/lib/resolve-route.test.ts index 0ca87182..78dea75e 100644 --- a/apps/status-page/src/lib/resolve-route.test.ts +++ b/apps/status-page/src/lib/resolve-route.test.ts @@ -519,4 +519,180 @@ describe("resolveRoute", () => { }); }); }); + describe("host header source", () => { + test("x-forwarded-host wins over the internal urlHost", () => { + // Every proxied host is rewritten to the www.stpg.dev origin; only the + // forwarded header still carries the tenant. + const result = resolveRoute({ + host: "status.acme.com", + urlHost: "www.stpg.dev", + pathname: "/monitors/1", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "status.acme.com", + locale: "en", + localeExplicit: false, + rewritePath: "/status.acme.com/en/monitors/1", + }); + }); + + test("no forwarded header falls back to urlHost", () => { + const result = resolveRoute({ + host: null, + urlHost: "acme.openstatus.dev", + pathname: "/events", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "acme", + locale: "en", + localeExplicit: false, + rewritePath: "/acme/en/events", + }); + }); + + test("origin host alone resolves no tenant", () => { + // www.stpg.dev/ reached directly — nothing to look up, so the request + // falls through to `/`. + expect( + resolveRoute({ + host: "www.stpg.dev", + urlHost: "www.stpg.dev", + pathname: "/", + }), + ).toBeNull(); + }); + }); + + describe("vercel preview deployments", () => { + test("preview root resolves no tenant", () => { + expect( + resolveRoute({ + host: "status-page-abc123.vercel.app", + urlHost: "status-page-abc123.vercel.app", + pathname: "/", + }), + ).toBeNull(); + }); + + test("preview uses path routing, not the deployment name", () => { + const result = resolveRoute({ + host: "status-page-abc123.vercel.app", + urlHost: "status-page-abc123.vercel.app", + pathname: "/acme/en", + }); + expect(result).toEqual({ + type: "pathname", + prefix: "acme", + locale: "en", + localeExplicit: true, + rewritePath: "/acme/en", + }); + }); + + test("a forwarded preview host with a non-preview urlHost reads the deployment name as a slug", () => { + // The `.vercel.app` guard tests urlHost only. Harmless — no page matches + // a deployment name — but pinned so a fix is deliberate. + const result = resolveRoute({ + host: "status-page-abc123.vercel.app", + urlHost: "www.stpg.dev", + pathname: "/", + }); + expect(result?.prefix).toBe("status-page-abc123"); + }); + }); + + describe("path shapes", () => { + test("trailing slash does not add an empty segment", () => { + const result = resolveRoute({ + host: "acme.openstatus.dev", + urlHost: "www.stpg.dev", + pathname: "/events/", + }); + expect(result?.rewritePath).toBe("/acme/en/events"); + }); + + test("repeated slashes collapse", () => { + const result = resolveRoute({ + host: "acme.openstatus.dev", + urlHost: "www.stpg.dev", + pathname: "//events", + }); + expect(result?.rewritePath).toBe("/acme/en/events"); + }); + + test("an unsupported locale-shaped segment is treated as a path segment", () => { + const result = resolveRoute({ + host: "acme.openstatus.dev", + urlHost: "www.stpg.dev", + pathname: "/de-DE/events", + }); + expect(result).toEqual({ + type: "hostname", + prefix: "acme", + locale: "en", + localeExplicit: false, + rewritePath: "/acme/en/de-DE/events", + }); + }); + + test("path routing lowercases the lookup prefix but rewrites the original casing", () => { + // The `[locale]` layout then rejects "EN" and renders the 404. + const result = resolveRoute({ + host: "www.stpg.dev", + urlHost: "www.stpg.dev", + pathname: "/ACME/EN/events", + }); + expect(result).toEqual({ + type: "pathname", + prefix: "acme", + locale: "en", + localeExplicit: true, + rewritePath: "/ACME/EN/events", + }); + }); + }); + + describe("hosts with no tenant — these fall through to `/`", () => { + // The proxy passes through when the resolved prefix matches no `page` row, + // and `/` is the theme explorer. `isThemeExplorerHost` is what keeps the + // explorer off the hosts below; see theme-explorer-host.test.ts. + test("the theme explorer host resolves as a normal subdomain", () => { + const result = resolveRoute({ + host: "themes.openstatus.dev", + urlHost: "www.stpg.dev", + pathname: "/", + }); + expect(result?.prefix).toBe("themes"); + }); + + test("an unknown subdomain resolves to its own slug", () => { + const result = resolveRoute({ + host: "does-not-exist.openstatus.dev", + urlHost: "www.stpg.dev", + pathname: "/", + }); + expect(result?.prefix).toBe("does-not-exist"); + }); + + test("a custom domain missing from the page table resolves to the full host", () => { + const result = resolveRoute({ + host: "status.unconfigured.com", + urlHost: "www.stpg.dev", + pathname: "/", + }); + expect(result?.prefix).toBe("status.unconfigured.com"); + }); + + test("a look-alike host resolves to the tenant slug it imitates", () => { + // Substring match in getValidSubdomain — see domain.test.ts. + const result = resolveRoute({ + host: "acme.openstatus.dev.evil.com", + urlHost: "www.stpg.dev", + pathname: "/", + }); + expect(result?.prefix).toBe("acme"); + }); + }); }); diff --git a/apps/status-page/src/lib/theme-explorer-host.test.ts b/apps/status-page/src/lib/theme-explorer-host.test.ts new file mode 100644 index 00000000..02d6f5cf --- /dev/null +++ b/apps/status-page/src/lib/theme-explorer-host.test.ts @@ -0,0 +1,43 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + isCanonicalThemeExplorerHost, + isThemeExplorerHost, +} from "./theme-explorer-host"; + +describe("isThemeExplorerHost", () => { + test("allows the canonical host", () => { + expect(isThemeExplorerHost("themes.openstatus.dev")).toBe(true); + expect(isThemeExplorerHost("THEMES.OPENSTATUS.DEV")).toBe(true); + }); + + test("allows the internal origin and local/preview hosts", () => { + expect(isThemeExplorerHost("www.stpg.dev")).toBe(true); + expect(isThemeExplorerHost("stpg.dev")).toBe(true); + expect(isThemeExplorerHost("localhost:3000")).toBe(true); + expect(isThemeExplorerHost("status-page-git-main.vercel.app")).toBe(true); + }); + + test("rejects status page hosts", () => { + expect(isThemeExplorerHost("acme.openstatus.dev")).toBe(false); + expect(isThemeExplorerHost("acme.stpg.dev")).toBe(false); + expect(isThemeExplorerHost("status.acme.com")).toBe(false); + expect(isThemeExplorerHost("acme.localhost:3000")).toBe(false); + }); + + test("rejects look-alike hosts", () => { + expect(isThemeExplorerHost("themes.openstatus.dev.evil.com")).toBe(false); + expect(isThemeExplorerHost("eviltheme.openstatus.dev")).toBe(false); + expect(isThemeExplorerHost(null)).toBe(false); + expect(isThemeExplorerHost(undefined)).toBe(false); + }); +}); + +describe("isCanonicalThemeExplorerHost", () => { + test("only the published host is indexable", () => { + expect(isCanonicalThemeExplorerHost("themes.openstatus.dev")).toBe(true); + expect(isCanonicalThemeExplorerHost("www.stpg.dev")).toBe(false); + expect(isCanonicalThemeExplorerHost("localhost:3000")).toBe(false); + }); +}); diff --git a/apps/status-page/src/lib/theme-explorer-host.ts b/apps/status-page/src/lib/theme-explorer-host.ts new file mode 100644 index 00000000..b43c9734 --- /dev/null +++ b/apps/status-page/src/lib/theme-explorer-host.ts @@ -0,0 +1,28 @@ +import { stripHostPort } from "./domain"; + +/** The only host the theme explorer is published on. */ +export const THEME_EXPLORER_HOST = "themes.openstatus.dev"; +export const THEME_EXPLORER_URL = `https://${THEME_EXPLORER_HOST}`; + +// The explorer lives at `/`, which is also where every unresolved host lands +// (unknown slug, custom domain pointed at us but missing from `page`). Without +// this allowlist those hosts render — and share — the explorer as their 404. +// `stpg.dev` is the internal origin every proxied host is rewritten to; it stays +// allowed so the explorer still renders if the proxy drops `x-forwarded-host`, +// but only the canonical host is indexable. +const ALLOWED_HOSTS = [ + THEME_EXPLORER_HOST, + "stpg.dev", + "www.stpg.dev", + "localhost", +]; + +export function isThemeExplorerHost(host?: string | null) { + const hostname = stripHostPort(host)?.toLowerCase(); + if (!hostname) return false; + return ALLOWED_HOSTS.includes(hostname) || hostname.endsWith(".vercel.app"); +} + +export function isCanonicalThemeExplorerHost(host?: string | null) { + return stripHostPort(host)?.toLowerCase() === THEME_EXPLORER_HOST; +} diff --git a/apps/status-page/src/proxy.ts b/apps/status-page/src/proxy.ts index c57aa35a..c061682b 100644 --- a/apps/status-page/src/proxy.ts +++ b/apps/status-page/src/proxy.ts @@ -9,6 +9,7 @@ import { applyPageLocaleOverride } from "./lib/proxy/apply-page-locale-override" import { applyPageSlugPrefix } from "./lib/proxy/apply-page-slug-prefix"; import { composePageAction } from "./lib/proxy/compose-page-action"; import { detectMarkdown } from "./lib/proxy/detect-markdown"; +import { resolveUnresolvedHostAction } from "./lib/proxy/resolve-unresolved-host-action"; import { sanitizeRedirectParam } from "./lib/proxy/sanitize-redirect-param"; import { resolveRoute } from "./lib/resolve-route"; @@ -17,11 +18,32 @@ const isSelfHosted = process.env.SELF_HOST === "true"; export default auth(async (req) => { const url = req.nextUrl.clone(); const passthroughResponse = NextResponse.next(); + // HTML and markdown share the same URL (negotiated by Accept) — tell shared // caches to key on it so a markdown variant is never served to a browser. passthroughResponse.headers.set("Vary", "Accept"); const host = req.headers.get("x-forwarded-host"); + // HTML served via internal rewrite shares its URL with the markdown variant — + // carry the same Vary as the passthrough so caches don't cross them. + const rewriteWithVary = (target: URL) => { + const response = NextResponse.rewrite(target); + response.headers.set("Vary", "Accept"); + return response; + }; + + // `/` is the theme explorer, so a host that resolves to no page must 404 + // rather than fall through to it. + const unresolvedHostResponse = () => { + const action = resolveUnresolvedHostAction({ + host, + urlHost: url.host, + requestUrl: req.url, + }); + if (action.type !== "rewrite") return passthroughResponse; + return rewriteWithVary(action.url); + }; + // Strip a `.md` suffix before route resolution so path-based markdown // (`/foo/en/monitors/123.md`) parses slug/locale correctly. const { wantsMarkdown, source, pathname } = detectMarkdown({ @@ -36,7 +58,7 @@ export default auth(async (req) => { }); if (!initialRoute) { - return passthroughResponse; + return unresolvedHostResponse(); } // Markdown requests bypass the proxy's DB lookup and gate chain: the route is @@ -63,8 +85,9 @@ export default auth(async (req) => { const validation = selectPageSchema.safeParse(query); + // No page for this host/slug — never fall through to the theme explorer. if (!validation.success) { - return passthroughResponse; + return unresolvedHostResponse(); } const _page = validation.data; @@ -114,13 +137,8 @@ export default auth(async (req) => { switch (action.type) { case "redirect": return NextResponse.redirect(action.url); - case "rewrite": { - // HTML served via internal rewrite shares its URL with the markdown - // variant — carry the same Vary so caches don't cross them. - const rewriteResponse = NextResponse.rewrite(action.url); - rewriteResponse.headers.set("Vary", "Accept"); - return rewriteResponse; - } + case "rewrite": + return rewriteWithVary(action.url); case "passthrough": return passthroughResponse; } -- 2.51.2 From f4b91a78174fc9e695eec18f681f72ca82cc5d11 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:49:49 +0200 Subject: [PATCH 151/266] fix: badge tw invalid utility (#2596) --- .../[domain]/[locale]/(public)/badge/route.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/badge/route.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/badge/route.tsx index 463daa55..173a4aa0 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/badge/route.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/badge/route.tsx @@ -42,6 +42,13 @@ const SIZE: Record = { lg: { width: 200, height: 56 }, xl: { width: 240, height: 68 }, }; + +const TEXT_SIZE: Record = { + sm: "text-sm", + md: "text-base", + lg: "text-lg", + xl: "text-xl", +}; export async function GET( req: NextRequest, props: { params: Promise<{ domain: string }> }, @@ -51,18 +58,16 @@ export async function GET( const theme = req.nextUrl.searchParams.get("theme"); const size = req.nextUrl.searchParams.get("size"); const s = SIZE[size ?? "sm"] ?? SIZE.sm; + const textSize = TEXT_SIZE[size ?? "sm"] ?? TEXT_SIZE.sm; const { label, color } = statusDictionary[status]; const light = "border-gray-200 text-gray-700 bg-white"; const dark = "border-gray-800 text-gray-300 bg-gray-900"; return new ImageResponse(
{label} -- 2.51.2 From 22df52eb0dbdafbad2c3fa41269f7a3a3cbf044b Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:49:11 +0200 Subject: [PATCH 152/266] chore: bring global speed checker chart back (#2597) * chore: bring global speed checker chart back * fix: remove transfer from the list --- .../(landing)/play/checker/[slug]/chart.tsx | 206 +++++++++++ .../(landing)/play/checker/[slug]/client.tsx | 327 ++++++++++-------- .../(landing)/play/checker/[slug]/page.tsx | 4 +- .../src/content/pages/tools/checker-slug.mdx | 4 - 4 files changed, 384 insertions(+), 157 deletions(-) create mode 100644 apps/web/src/app/(landing)/play/checker/[slug]/chart.tsx diff --git a/apps/web/src/app/(landing)/play/checker/[slug]/chart.tsx b/apps/web/src/app/(landing)/play/checker/[slug]/chart.tsx new file mode 100644 index 00000000..005c7eec --- /dev/null +++ b/apps/web/src/app/(landing)/play/checker/[slug]/chart.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { formatRegionCode, getRegionInfo } from "@openstatus/regions"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@openstatus/ui/components/ui/chart"; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; + +import { + type CachedRegionChecker, + getTimingPhases, +} from "../../../../../lib/checker/utils"; + +const chartConfig = { + dns: { label: "DNS", color: "var(--chart-1)" }, + connection: { label: "Connection", color: "var(--chart-2)" }, + tls: { label: "TLS", color: "var(--chart-3)" }, + ttfb: { label: "TTFB", color: "var(--chart-4)" }, +} satisfies ChartConfig; + +const PHASES = Object.keys(chartConfig) as (keyof typeof chartConfig)[]; + +interface ChartProps { + checks: CachedRegionChecker["checks"]; + desc: boolean; + onToggleSort: () => void; +} + +export function Chart({ checks, desc, onToggleSort }: ChartProps) { + // `transfer` is left out: the checker stops its latency clock when the + // response headers land, before the body is read. sorts on the stacked + // total rather than `check.latency` — the two disagree whenever a checker + // reports phases that don't add up to its round-trip + const chartData = checks + .map((check) => { + const { dns, connection, tls, ttfb } = getTimingPhases(check.timing); + return { + region: check.region, + total: dns + connection + tls + ttfb, + dns, + connection, + tls, + ttfb, + }; + }) + .sort((a, b) => (desc ? b.total - a.total : a.total - b.total)); + + return ( +
+
+
+ latency by region{" "} + + — {checks.length} regions + +
+ +
+
+
+ + + + } + /> + `${value}ms`} + /> + { + const region = getRegionInfo(String(label)); + return ( +
+ {region.location} + + {formatRegionCode(label)} + +
+ ); + }} + formatter={(value, name, item, index) => ( + <> +
+ {chartConfig[name as keyof typeof chartConfig]?.label || + name} + + {index === PHASES.length - 1 ? ( +
+ latency + +
+ ) : null} + + )} + /> + } + /> + {PHASES.map((phase) => ( + + ))} + + +
+
+
+ {PHASES.map((phase) => ( +
+
+ + {chartConfig[phase].label} + +
+ ))} +
+
+ ); +} + +function TooltipValue({ value }: { value: number }) { + return ( +
+ {Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value)} + ms +
+ ); +} + +/** Region codes are rotated to fit ~30 regions on the axis. */ +function RegionTick({ + x, + y, + payload, +}: { + x?: number; + y?: number; + payload?: { value: string }; +}) { + if (!payload) return null; + const code = formatRegionCode(payload.value); + return ( + + + {code.length > 8 ? `${code.slice(0, 7)}…` : code} + + + ); +} diff --git a/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx b/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx index 6b979138..44fe7b43 100644 --- a/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx +++ b/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx @@ -28,6 +28,10 @@ import { } from "../../../../../lib/checker/utils"; import { cn } from "../../../../../lib/utils"; import { handleExportCSV } from "../utils"; +import { Chart } from "./chart"; + +const views = ["table", "chart"] as const; +type View = (typeof views)[number]; const STATUS_CODES = { "1": "text-muted-foreground", @@ -37,16 +41,17 @@ const STATUS_CODES = { "5": "text-destructive", }; -interface TableProps { +interface ResultsProps { data: CachedRegionChecker; } -export function Table({ data }: TableProps) { +export function Results({ data }: ResultsProps) { + const [view, setView] = useState("table"); const [input, setInput] = useState(""); const [sort, setSort] = useState<{ value: "latency" | "status" | "region"; desc: boolean; - }>({ value: "latency", desc: false }); + }>({ value: "latency", desc: true }); // Filter successful checks and calculate timing phases const checks = data.checks @@ -59,160 +64,180 @@ export function Table({ data }: TableProps) { }; }); - const filteredAndSorted = checks - .filter((check) => { - const regionInfo = regionDict[check.region as Region]; - if (!regionInfo) return false; - return [ - regionInfo.code, - regionInfo.location, - regionInfo.flag, - regionInfo.continent, - regionInfo.provider, - ].some((value) => value?.toLowerCase().includes(input.toLowerCase())); - }) - .sort((a, b) => { - if (sort.value === "status") { - return sort.desc ? b.status - a.status : a.status - b.status; - } - if (sort.value === "latency") { - return sort.desc ? b.latency - a.latency : a.latency - b.latency; - } - return sort.desc - ? b.region.localeCompare(a.region) - : a.region.localeCompare(b.region); - }); + const sorted = checks.sort((a, b) => { + if (sort.value === "status") { + return sort.desc ? b.status - a.status : a.status - b.status; + } + if (sort.value === "latency") { + return sort.desc ? b.latency - a.latency : a.latency - b.latency; + } + return sort.desc + ? b.region.localeCompare(a.region) + : a.region.localeCompare(b.region); + }); + + const filteredAndSorted = sorted.filter((check) => { + const regionInfo = regionDict[check.region as Region]; + if (!regionInfo) return false; + return [ + regionInfo.code, + regionInfo.location, + regionInfo.flag, + regionInfo.continent, + regionInfo.provider, + ].some((value) => value?.toLowerCase().includes(input.toLowerCase())); + }); return ( -
-
- setInput(e.target.value)} - placeholder="Search by region, flag, location code, cloud provider or continent" - className="h-auto! flex-1 rounded-none p-4 text-base md:text-base" - /> - -
-
- - - - - - - - - - - - - - {filteredAndSorted.length === 0 ? ( + setView(value as View)}> + + {views.map((value) => ( + + {value} + + ))} + + +
+ setInput(e.target.value)} + placeholder="Search by region, flag, location code, cloud provider or continent" + className="h-auto! flex-1 rounded-none p-4 text-base md:text-base" + /> + +
+
+
- RegionStatusDNSConnectTLSTTFB - - setSort({ value: "latency", desc: !sort.desc }) - } - direction={ - sort.value === "latency" - ? sort.desc - ? "desc" - : "asc" - : undefined - } - > - Latency - -
+ - + + + + + + + - ) : ( - filteredAndSorted.map((check) => { - const regionInfo = regionDict[check.region as Region]; - if (!regionInfo) return null; + + + {filteredAndSorted.length === 0 ? ( + + + + ) : ( + filteredAndSorted.map((check) => { + const regionInfo = regionDict[check.region as Region]; + if (!regionInfo) return null; - const { dns, connection, tls, ttfb } = check.timingPhases; + const { dns, connection, tls, ttfb } = check.timingPhases; - return ( - - - - - - - - - - - - - ); - }) - )} - - -
- No data available - + RegionStatusDNSConnectTLSTTFB + + setSort({ value: "latency", desc: !sort.desc }) + } + direction={ + sort.value === "latency" + ? sort.desc + ? "desc" + : "asc" + : undefined + } + > + Latency + +
+ No data available +
- - - {regionInfo.flag} {regionInfo.code}{" "} - - {regionInfo.location} - - - {check.status} - - {Intl.NumberFormat("en-US", { - maximumFractionDigits: 0, - }).format(dns)} - ms - - {Intl.NumberFormat("en-US", { - maximumFractionDigits: 0, - }).format(connection)} - ms - - {Intl.NumberFormat("en-US", { - maximumFractionDigits: 0, - }).format(tls)} - ms - - {Intl.NumberFormat("en-US", { - maximumFractionDigits: 0, - }).format(ttfb)} - ms - - {Intl.NumberFormat("en-US", { - maximumFractionDigits: 0, - }).format(check.latency)} - ms -
- Results of your check ({filteredAndSorted.length} / {checks.length}{" "} - regions) -
-
-
+ return ( + + + + + + + {regionInfo.flag} {regionInfo.code}{" "} + + {regionInfo.location} + + + + {check.status} + + + {Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + }).format(dns)} + ms + + + {Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + }).format(connection)} + ms + + + {Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + }).format(tls)} + ms + + + {Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + }).format(ttfb)} + ms + + + {Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + }).format(check.latency)} + ms + + + + ); + }) + )} + + + Results of your check ({filteredAndSorted.length} /{" "} + {checks.length} regions) + + +
+ + + setSort({ ...sort, desc: !sort.desc })} + /> + + ); } diff --git a/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx b/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx index f07f70f0..65c06f07 100644 --- a/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx @@ -19,7 +19,7 @@ import { getJsonLDBreadcrumbList, getJsonLDWebPage, } from "../../../../../lib/metadata/structured-data"; -import { Table } from "./client"; +import { Results } from "./client"; function formatDate(date: Date) { return date.toLocaleDateString("en-US", { @@ -116,7 +116,7 @@ export default async function Page({

{data.url}

{formatDate(new Date(data.timestamp))}

- + ); diff --git a/apps/web/src/content/pages/tools/checker-slug.mdx b/apps/web/src/content/pages/tools/checker-slug.mdx index 31765b12..aa0dca7c 100644 --- a/apps/web/src/content/pages/tools/checker-slug.mdx +++ b/apps/web/src/content/pages/tools/checker-slug.mdx @@ -11,10 +11,6 @@ faq: The data is getting stored for **7 days**. If you want to keep it longer, consider [creating an account](https://app.openstatus.dev) and use our cloud solution. ---- - -> **We have reworked the checker experience ([go back to v1](https://v1.openstatus.dev/play/checker))**. Please let us know if you are missing a feature from the older version. Contact us directly or send us a message to [ping@openstatus.dev](mailto:ping@openstatus.dev). Happy to bring stuff back! - ## Frequently asked questions
-- 2.51.2 From 410e1cb70075e10450d2da2682b1f6fd05527a94 Mon Sep 17 00:00:00 2001 From: Prakhar Dewangan Date: Tue, 25 Aug 2026 13:37:00 +0530 Subject: [PATCH 153/266] fix: correct typos in user-facing strings (#2602) * Update code-dictionary.ts * Update get.ts * Fix grammatical error in response details description --- apps/server/src/routes/v1/whoami/get.ts | 2 +- apps/web/src/app/(landing)/play/checker/[slug]/client.tsx | 2 +- apps/web/src/data/code-dictionary.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/routes/v1/whoami/get.ts b/apps/server/src/routes/v1/whoami/get.ts index 8bab7d9f..32cf7021 100644 --- a/apps/server/src/routes/v1/whoami/get.ts +++ b/apps/server/src/routes/v1/whoami/get.ts @@ -12,7 +12,7 @@ const getRoute = createRoute({ method: "get", tags: ["whoami"], path: "/", - summary: "Get your informations", + summary: "Get your information", description: "Get the current workspace information attached to the API key.", responses: { 200: { diff --git a/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx b/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx index 44fe7b43..de071008 100644 --- a/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx +++ b/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx @@ -301,7 +301,7 @@ function InfoDialog({ Response Details - Basic informations like header and latency about the response. + Basic information like header and latency about the response.
diff --git a/apps/web/src/data/code-dictionary.ts b/apps/web/src/data/code-dictionary.ts index d858f738..7967e276 100644 --- a/apps/web/src/data/code-dictionary.ts +++ b/apps/web/src/data/code-dictionary.ts @@ -7,7 +7,7 @@ export const codesDict = { "2xx": { prefix: 2, label: "2xx", - name: "Successfull", + name: "Successful", }, "3xx": { prefix: 3, -- 2.51.2 From 04a86585406cefdbec7d813a290ecd4aaf7dd95d Mon Sep 17 00:00:00 2001 From: Patrick Schratz Date: Tue, 25 Aug 2026 10:22:32 +0200 Subject: [PATCH 154/266] feat(theme-store): add gruvbox and tomorrow themes (#2599) * feat(theme-store): add gruvbox and tomorrow themes Add two community themes built from their canonical upstream palettes: - Gruvbox (medium contrast), light0/dark0 canvases with the neutral palette in light mode and the bright palette in dark mode - Tomorrow, pairing Tomorrow for light mode with Tomorrow Night Eighties for dark mode Both annotate every value with the source hex and its role in the original palette, so the mapping stays reviewable. Two values deviate from canon for contrast, marked with NOTE comments: Gruvbox light uses dark3 rather than light4 for --muted-foreground, and Tomorrow light darkens the orange one step for --primary, since the canonical #f5871f reaches only 4.3:1 on white. * docs(theme-store): correct the muted-foreground palette notes The Tomorrow light --muted-foreground was documented as the canonical Comment colour while carrying a darkened value, and the Gruvbox note described dark3 as a light-family neighbour. Annotate the Tomorrow deviation as a NOTE like the other two and name dark3 correctly. * docs(theme-store): correct the gruvbox contrast ratios The note carried the ratio measured for dark4 (#7c6f64) while naming light4, which is 2.5:1 on light0. State both, and the 5.7:1 the shipped dark3 value reaches. --- packages/theme-store/src/gruvbox.ts | 66 ++++++++++++++++++++++++++ packages/theme-store/src/index.ts | 4 ++ packages/theme-store/src/tomorrow.ts | 69 ++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 packages/theme-store/src/gruvbox.ts create mode 100644 packages/theme-store/src/tomorrow.ts diff --git a/packages/theme-store/src/gruvbox.ts b/packages/theme-store/src/gruvbox.ts new file mode 100644 index 00000000..5f48489c --- /dev/null +++ b/packages/theme-store/src/gruvbox.ts @@ -0,0 +1,66 @@ +// https://github.com/morhetz/gruvbox — medium contrast, dark and light variants + +import type { Theme } from "./types"; + +export const GRUVBOX_THEME = { + id: "gruvbox", + name: "Gruvbox", + author: { name: "@pat-s", url: "https://github.com/pat-s" }, + light: { + "--radius": "0.25rem", + + "--background": "oklch(95.55% 0.0555 96.15)", // #fbf1c7 light0 + "--foreground": "oklch(34.41% 0.0066 48.52)", // #3c3836 dark1 + "--card": "oklch(96.55% 0.0394 100.86)", // #f9f5d7 light0_hard + "--card-foreground": "var(--foreground)", + "--popover": "var(--card)", + "--popover-foreground": "var(--foreground)", + "--border": "oklch(82.55% 0.0507 85.12)", // #d5c4a1 light2 + "--input": "oklch(75.64% 0.041 82.28)", // #bdae93 light3 + "--ring": "var(--primary)", + + "--primary": "oklch(51.26% 0.1616 39.3)", // #af3a03 neutral orange + "--primary-foreground": "var(--background)", + "--secondary": "oklch(89.41% 0.0566 89.24)", // #ebdbb2 light1 + "--secondary-foreground": "var(--foreground)", + "--muted": "var(--secondary)", + // NOTE: the palette's muted tones are too light on light0 for body-adjacent + // text — light4 (#a89984) reaches 2.5:1 and dark4 (#7c6f64) 4.3:1 — so + // dark3 (#665c54) is used instead, at 5.7:1. + "--muted-foreground": "oklch(48.18% 0.0181 61.04)", // #665c54 dark3 + "--accent": "var(--secondary)", + "--accent-foreground": "var(--foreground)", + + "--success": "oklch(54.63% 0.1124 106.46)", // #79740e neutral green + "--destructive": "oklch(43.74% 0.1789 28.26)", // #9d0006 neutral red + "--warning": "oklch(61.76% 0.1277 70.67)", // #b57614 neutral yellow + "--info": "oklch(47.06% 0.0816 215.81)", // #076678 neutral blue + }, + dark: { + "--radius": "0.25rem", + + "--background": "oklch(27.68% 0 89.88)", // #282828 dark0 + "--foreground": "oklch(89.41% 0.0566 89.24)", // #ebdbb2 light1 + "--card": "oklch(34.41% 0.0066 48.52)", // #3c3836 dark1 + "--card-foreground": "var(--foreground)", + "--popover": "var(--card)", + "--popover-foreground": "var(--foreground)", + "--border": "oklch(41.1% 0.0115 51.87)", // #504945 dark2 + "--input": "oklch(48.18% 0.0181 61.04)", // #665c54 dark3 + "--ring": "var(--primary)", + + "--primary": "oklch(73.11% 0.182 51.69)", // #fe8019 bright orange + "--primary-foreground": "var(--background)", + "--secondary": "oklch(41.1% 0.0115 51.87)", // #504945 dark2 + "--secondary-foreground": "var(--foreground)", + "--muted": "var(--secondary)", + "--muted-foreground": "oklch(75.64% 0.041 82.28)", // #bdae93 light3 + "--accent": "var(--secondary)", + "--accent-foreground": "var(--foreground)", + + "--success": "oklch(76.52% 0.1581 110.83)", // #b8bb26 bright green + "--destructive": "oklch(65.97% 0.2175 30.39)", // #fb4934 bright red + "--warning": "oklch(83.25% 0.1595 82.99)", // #fabd2f bright yellow + "--info": "oklch(69.27% 0.042 169.77)", // #83a598 bright blue + }, +} as const satisfies Theme; diff --git a/packages/theme-store/src/index.ts b/packages/theme-store/src/index.ts index a3663f5d..4262f2f4 100644 --- a/packages/theme-store/src/index.ts +++ b/packages/theme-store/src/index.ts @@ -7,9 +7,11 @@ import { } from "./custom-theme"; import { DRACULA_THEME } from "./dracula"; import { GITHUB_HIGH_CONTRAST_THEME } from "./github"; +import { GRUVBOX_THEME } from "./gruvbox"; import { OPENSTATUS_ROUNDED_THEME, OPENSTATUS_THEME } from "./openstatus"; import { PASSBOLT_THEME } from "./passbolt"; import { SUPABASE_THEME } from "./supabase"; +import { TOMORROW_THEME } from "./tomorrow"; import type { Theme, ThemeDefinition, ThemeMap } from "./types"; import { assertUniqueThemeIds } from "./utils"; // Please keep the themes ordered :) @@ -20,6 +22,8 @@ const THEMES_LIST = [ GITHUB_HIGH_CONTRAST_THEME, DRACULA_THEME, PASSBOLT_THEME, + GRUVBOX_THEME, + TOMORROW_THEME, ] satisfies Theme[]; // NOTE: runtime validation to ensure that the theme IDs are unique diff --git a/packages/theme-store/src/tomorrow.ts b/packages/theme-store/src/tomorrow.ts new file mode 100644 index 00000000..6c20d616 --- /dev/null +++ b/packages/theme-store/src/tomorrow.ts @@ -0,0 +1,69 @@ +// https://github.com/chriskempson/tomorrow-theme +// Light: Tomorrow. Dark: Tomorrow Night Eighties. + +import type { Theme } from "./types"; + +export const TOMORROW_THEME = { + id: "tomorrow", + name: "Tomorrow", + author: { name: "@pat-s", url: "https://github.com/pat-s" }, + light: { + "--radius": "0.25rem", + + "--background": "oklch(100% 0 89.88)", // #ffffff Background + "--foreground": "oklch(41.99% 0.0016 106.47)", // #4d4d4c Foreground + "--card": "var(--background)", + "--card-foreground": "var(--foreground)", + "--popover": "var(--background)", + "--popover-foreground": "var(--foreground)", + "--border": "oklch(87.61% 0 89.88)", // #d6d6d6 Selection + "--input": "var(--border)", + "--ring": "var(--primary)", + + // NOTE: Tomorrow's orange (#f5871f) reaches only 4.3:1 on the background, + // so it is darkened one step for button and link text. + "--primary": "oklch(54.5% 0.1372 51.5)", + "--primary-foreground": "var(--background)", + "--secondary": "oklch(95.21% 0 89.88)", // #efefef Current Line + "--secondary-foreground": "var(--foreground)", + "--muted": "var(--secondary)", + // NOTE: Comment (#8e908c) reaches only 3.2:1 on the background, so it is + // darkened while keeping its hue. --muted-foreground carries body-adjacent + // text such as the page description. + "--muted-foreground": "oklch(55.05% 0.0054 128.57)", // darkened Comment + "--accent": "var(--secondary)", + "--accent-foreground": "var(--foreground)", + + "--success": "oklch(59.74% 0.1452 122.21)", // #718c00 Green + "--destructive": "oklch(54.23% 0.1955 26.51)", // #c82829 Red + "--warning": "oklch(80.29% 0.1641 88.34)", // #eab700 Yellow + "--info": "oklch(54.37% 0.109 255.62)", // #4271ae Blue + }, + dark: { + "--radius": "0.25rem", + + "--background": "oklch(29.72% 0 89.88)", // #2d2d2d Background + "--foreground": "oklch(84.52% 0 89.88)", // #cccccc Foreground + "--card": "oklch(34.46% 0 89.88)", // #393939 Current Line + "--card-foreground": "var(--foreground)", + "--popover": "var(--card)", + "--popover-foreground": "var(--foreground)", + "--border": "oklch(43.49% 0 89.88)", // #515151 Selection + "--input": "var(--border)", + "--ring": "var(--primary)", + + "--primary": "oklch(75.62% 0.1455 48.5)", // #f99157 Orange + "--primary-foreground": "var(--background)", + "--secondary": "var(--card)", + "--secondary-foreground": "var(--foreground)", + "--muted": "var(--card)", + "--muted-foreground": "oklch(68.3% 0 89.88)", // #999999 Comment + "--accent": "var(--border)", + "--accent-foreground": "var(--foreground)", + + "--success": "oklch(79.6% 0.0889 144.68)", // #99cc99 Green + "--destructive": "oklch(71.26% 0.1515 20.16)", // #f2777a Red + "--warning": "oklch(87.07% 0.1325 82.74)", // #ffcc66 Yellow + "--info": "oklch(66.76% 0.0939 249.39)", // #6699cc Blue + }, +} as const satisfies Theme; -- 2.51.2 From 55c2881461ce577e097ce9385d171563bb915773 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 25 Aug 2026 18:02:15 +0800 Subject: [PATCH 155/266] fix: hide telegram qr code when self-hosting with no redis (#2535) * fix: make Telegram QR code optional for self-hosted instances without Redis - Backend: Gracefully handle Redis unavailability in createTelegramToken - Frontend: Show only manual chat ID entry when Redis is not available - With Redis: Full QR code + manual tabs - Without Redis: Manual entry only (no QR tab) * fix: convert null token to undefined for TypeScript compatibility * fix: only show QR tab when Redis is explicitly available Changed default from '?? true' to '=== true' to be more conservative. Now hides QR tab in all cases except when Redis is confirmed available. * fix: prevent QR tab flash on self-hosted instances - Add NEXT_PUBLIC_SELF_HOSTED env var to .env.docker.example - Hide QR tab during loading when NEXT_PUBLIC_SELF_HOSTED=true - Prevents flash of QR tab before Redis connection check completes * fix: use existing SELF_HOST naming convention - Replace NEXT_PUBLIC_SELF_HOSTED with NEXT_PUBLIC_SELF_HOST - Align with existing SELF_HOST variable for consistency - Add NEXT_PUBLIC_SELF_HOST to make flag available to client-side code * fix: always hide QR tab during loading to prevent flash - Remove build-time env var dependency (NEXT_PUBLIC_SELF_HOST) - Always show manual input while loading Redis check - QR tab only appears after confirming Redis is available - Works without rebuild for Docker deployments * feat: add biased loading behavior for Telegram QR tab - Backend: Add isSelfHosted flag from SELF_HOST env var - Frontend: Hide QR during loading on self-hosted (conservative) - Frontend: Show QR during loading on cloud (optimistic) - Works at runtime without rebuild * fix: revert to conservative loading to prevent QR flash on self-hosted Issue: tokenData is undefined during loading, so isSelfHosted check fails Solution: Always hide QR during loading, show only after Redis confirmed * feat: implement proper biased loading for Telegram QR - Add getServerConfig endpoint to return deployment type - Frontend fetches server config early and passes to component - Cloud: Shows QR during loading (optimistic) - Self-hosted: Hides QR during loading (conservative) - No more flash on self-hosted instances * fix: use correct trpc import pattern in form-telegram - Change from 'import { trpc }' to 'import { useTRPC }' - Use useTRPC() hook pattern matching other dashboard components - Fixes build error: Module not found '@/lib/trpc' * fix: use React Query pattern with queryOptions - Add useQuery import from @tanstack/react-query - Use queryOptions() instead of direct useQuery() call - Fixes TypeScript error: Property 'useQuery' does not exist * fix: use build-time NEXT_PUBLIC_SELF_HOST to eliminate flash - Replace async tRPC query with immediate process.env check - Add NEXT_PUBLIC_SELF_HOST to .env.docker.example - No loading delay means no flash on self-hosted instances * fix: add NEXT_PUBLIC_SELF_HOST to dashboard Dockerfile - Add to builder stage ENV so it's available during next build - Allows client-side code to immediately detect self-hosted environment * style: remove excessive empty lines in telegram-connection-flow * ci: apply automated fixes * fix: address dofigen and dead code violations 1. Add NEXT_PUBLIC_SELF_HOST to dofigen.yml source of truth - Ensures env var persists when Dockerfile is regenerated - Manually updated dofigen.lock (hash needs 'dofigen update' to regenerate) 2. Remove misleading NEXT_PUBLIC_SELF_HOST from .env.docker.example - NEXT_PUBLIC_* vars are baked at build time, not runtime - Added clarifying comment that it's fixed by Dockerfile 3. Remove unused API endpoints from notification.ts - Removed getServerConfig endpoint (never consumed by frontend) - Removed isSelfHosted field from createTelegramToken - Frontend uses NEXT_PUBLIC_SELF_HOST directly, no need for API call * style: remove extra blank line in .env.docker.example * refactor: use NEXT_PUBLIC_SELF_HOST directly in TelegramConnectionFlow - Remove isSelfHosted prop from FormTelegram component - Check NEXT_PUBLIC_SELF_HOST env var directly in TelegramConnectionFlow - Remove unnecessary prop passing between components - Enhance documentation for NEXT_PUBLIC_SELF_HOST in .env.docker.example * ci: apply automated fixes * fix: remove dead tokenData.isSelfHosted reference The createTelegramToken procedure no longer returns an isSelfHosted field (only token and redisAvailable), so the tokenData?.isSelfHosted check was dead code causing a TypeScript error. Simplified to rely solely on the build-time NEXT_PUBLIC_SELF_HOST env var. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .env.docker.example | 4 ++++ apps/dashboard/Dockerfile | 1 + apps/dashboard/dofigen.lock | 2 ++ apps/dashboard/dofigen.yml | 1 + .../components/telegram-connection-flow.tsx | 17 ++++++++++++++++- packages/api/src/router/notification.ts | 16 +++++++++++----- 6 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index e248359b..19c102bd 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -61,6 +61,10 @@ AUTH_SECRET=your-random-secret-here-min-32-chars # [REQUIRED] Self-hosted mode - enables magic link authentication # Set to "true" to allow email login without OAuth +# Note: NEXT_PUBLIC_SELF_HOST is the client-side build-time version of this flag, +# baked into the Docker image at build time (see apps/dashboard/Dockerfile). +# It controls UI behavior like the Telegram connection flow (QR vs manual input), +# and cannot be changed via this runtime config file. SELF_HOST="true" # GitHub OAuth (optional) diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile index 6c2d045a..e991e714 100644 --- a/apps/dashboard/Dockerfile +++ b/apps/dashboard/Dockerfile @@ -22,6 +22,7 @@ ENV \ UPSTASH_REDIS_REST_URL="https://test.upstash.io" \ UNKEY_TOKEN="test" \ SELF_HOST="true" \ + NEXT_PUBLIC_SELF_HOST="true" \ UNKEY_API_ID="test" \ STRIPE_SECRET_KEY="test" \ NEXT_PUBLIC_OPENPANEL_CLIENT_ID="test" \ diff --git a/apps/dashboard/dofigen.lock b/apps/dashboard/dofigen.lock index 16e94a74..a7c7bbd2 100644 --- a/apps/dashboard/dofigen.lock +++ b/apps/dashboard/dofigen.lock @@ -23,6 +23,7 @@ effective: | UPSTASH_REDIS_REST_URL: https://test.upstash.io UNKEY_TOKEN: test SELF_HOST: 'true' + NEXT_PUBLIC_SELF_HOST: 'true' UNKEY_API_ID: test STRIPE_SECRET_KEY: test NEXT_PUBLIC_OPENPANEL_CLIENT_ID: test @@ -123,6 +124,7 @@ resources: STRIPE_SECRET_KEY: test AUTH_SECRET: build-time-placeholder-min-32-chars-long SELF_HOST: "true" + NEXT_PUBLIC_SELF_HOST: "true" run: - corepack enable - pnpm install --frozen-lockfile diff --git a/apps/dashboard/dofigen.yml b/apps/dashboard/dofigen.yml index 0fca0805..e2c1bad4 100644 --- a/apps/dashboard/dofigen.yml +++ b/apps/dashboard/dofigen.yml @@ -28,6 +28,7 @@ builders: STRIPE_SECRET_KEY: test AUTH_SECRET: build-time-placeholder-min-32-chars-long SELF_HOST: "true" + NEXT_PUBLIC_SELF_HOST: "true" run: - corepack enable - pnpm install --frozen-lockfile diff --git a/apps/dashboard/src/components/forms/components/telegram-connection-flow.tsx b/apps/dashboard/src/components/forms/components/telegram-connection-flow.tsx index 93b3a53b..6a465a80 100644 --- a/apps/dashboard/src/components/forms/components/telegram-connection-flow.tsx +++ b/apps/dashboard/src/components/forms/components/telegram-connection-flow.tsx @@ -37,6 +37,21 @@ export function TelegramConnectionFlow({ confirmPrivateChat, } = useTelegramConnection({ form, mode }); + // Check build-time env var for deployment type + const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOST === "true"; + const redisAvailable = tokenData?.redisAvailable === true; + + // Biased loading behavior: + // - Self-hosted: Hide QR during loading (conservative, prevents flash) + // - Cloud: Show QR during loading (optimistic, better UX) + // After loading: always check actual redisAvailable value + if ( + (isSelfHosted && isTokenLoading) || + (!isTokenLoading && !redisAvailable) + ) { + return ; + } + return ( Date: Tue, 25 Aug 2026 16:00:15 +0200 Subject: [PATCH 156/266] icmp check (#2405) * icmp check * fix tests * add tb * ci: apply automated fixes * api creation * fix pr * fix pr * fix pr --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- apps/checker/checker/icmp.go | 291 ++++++++++ apps/checker/checker/icmp_internal_test.go | 149 +++++ apps/checker/checker/icmp_test.go | 70 +++ apps/checker/cmd/server/main.go | 8 + apps/checker/go.mod | 2 +- apps/checker/handlers/icmp.go | 371 +++++++++++++ apps/checker/handlers/icmp_test.go | 79 +++ apps/checker/handlers/otel_wiring_test.go | 29 + apps/checker/pkg/job/icmp_job.go | 163 ++++++ apps/checker/pkg/job/icmp_job_test.go | 126 +++++ apps/checker/pkg/job/job.go | 1 + apps/checker/pkg/otel/otel.go | 50 +- apps/checker/pkg/otel/otel_test.go | 64 +++ apps/checker/pkg/scheduler/scheduler.go | 55 ++ apps/checker/pkg/scheduler/scheduler_test.go | 8 + .../private_location/v1/icmp_monitor.pb.go | 183 ++++++ .../v1/private_location.connect.go | 29 + .../v1/private_location.pb.go | 281 +++++++++- apps/checker/request/request.go | 17 + .../(dashboard)/monitors/[id]/logs/client.tsx | 4 +- .../(dashboard)/monitors/[id]/nav-actions.tsx | 22 +- .../monitors/[id]/overview/client.tsx | 8 +- .../components/chart/chart-area-latency.tsx | 2 +- .../chart/chart-bar-uptime-light.tsx | 2 +- .../src/components/chart/chart-bar-uptime.tsx | 2 +- .../response-logs/data-table-basics.tsx | 169 ++++++ .../response-logs/data-table-sheet-test.tsx | 28 +- .../components/forms/monitor/form-general.tsx | 49 +- .../src/components/forms/monitor/update.tsx | 2 +- .../metric/global-uptime/section.tsx | 2 +- apps/dashboard/src/data/monitors.client.ts | 6 + apps/private-location/README.md | 14 + .../internal/database/models.go | 1 + .../internal/server/ingest_icmp.go | 91 +++ .../internal/server/monitors.go | 22 +- .../internal/server/validation.go | 14 + .../internal/tinybird/client.go | 1 + .../private_location/v1/icmp_monitor.pb.go | 183 ++++++ .../v1/private_location.connect.go | 29 + .../v1/private_location.pb.go | 281 +++++++++- apps/server/src/libs/checker/utils.test.ts | 119 ++++ apps/server/src/libs/checker/utils.ts | 57 +- .../monitor/__tests__/monitor.test.ts | 394 ++++++++++++- .../rpc/handlers/monitor/converters/index.ts | 1 + .../handlers/monitor/converters/monitors.ts | 27 + .../src/routes/rpc/handlers/monitor/index.ts | 109 +++- .../rpc/handlers/monitor/validators.test.ts | 81 +++ .../routes/rpc/handlers/monitor/validators.ts | 103 ++++ .../interceptors/__tests__/tracking.test.ts | 56 +- .../src/routes/rpc/interceptors/tracking.ts | 29 +- .../src/routes/rpc/interceptors/validation.ts | 1 + .../server/src/routes/v1/monitors/run/post.ts | 3 + apps/server/src/routes/v1/monitors/schema.ts | 1 + .../routes/v1/monitors/trigger/post.test.ts | 56 ++ .../src/routes/v1/monitors/trigger/post.ts | 3 + apps/server/src/routes/v1/monitors/utils.ts | 18 + apps/server/static/openapi.yaml | 225 +++++++- apps/web/src/content/docs.config.ts | 1 + .../pages/changelog/icmp-monitoring.mdx | 14 + .../pages/docs/reference/icmp-monitor.mdx | 98 ++++ apps/web/src/lib/tb.ts | 23 +- apps/workflows/src/cron/checker.ts | 22 + apps/workflows/src/cron/uptime-freeze.ts | 1 + packages/api/src/router/checker.ts | 120 ++++ packages/api/src/router/monitor.ts | 6 +- packages/api/src/router/statusPage.ts | 38 +- packages/api/src/router/tinybird/index.ts | 40 +- .../src/providers/betterstack/mapper.test.ts | 2 + .../src/providers/betterstack/mapper.ts | 4 +- .../openstatus/monitor/v1/icmp_monitor.proto | 87 +++ .../api/openstatus/monitor/v1/service.proto | 43 +- packages/proto/gen/openapi.yaml | 225 +++++++- .../openstatus/monitor/v1/icmp_monitor_pb.ts | 132 +++++ .../gen/ts/openstatus/monitor/v1/index.ts | 1 + .../ts/openstatus/monitor/v1/service_pb.ts | 190 +++++-- .../private_location/v1/icmp_monitor.proto | 22 + .../v1/private_location.proto | 24 + .../__tests__/get-history.test.ts | 4 +- .../src/frozen-uptime/__tests__/run.test.ts | 3 +- .../services/src/frozen-uptime/get-history.ts | 1 + packages/services/src/frozen-uptime/run.ts | 14 +- .../src/monitor/__tests__/reads.test.ts | 78 ++- .../services/src/monitor/get-daily-summary.ts | 12 +- .../src/monitor/get-monitor-summary.ts | 10 +- .../check_icmp_response__v0.datasource | 21 + .../datasources/icmp_response__v0.datasource | 22 + .../datasources/mv__icmp_14d__v0.datasource | 22 + .../datasources/mv__icmp_1d__v0.datasource | 22 + .../datasources/mv__icmp_30d__v0.datasource | 22 + .../datasources/mv__icmp_7d__v0.datasource | 22 + .../datasources/mv__icmp_90d__v0.datasource | 22 + .../mv__icmp_full_14d__v0.datasource | 26 + .../mv__icmp_full_30d__v0.datasource | 26 + .../mv__icmp_status_45d__v0.datasource | 14 + .../mv__icmp_status_7d__v0.datasource | 12 + .../mv__icmp_uptime_30d__v0.datasource | 13 + .../mv__icmp_uptime_7d__v0.datasource | 13 + .../mv__icmp_uptime_90d__v0.datasource | 13 + .../mv__icmp_workspace_30d__v0.datasource | 12 + .../endpoints/endpoint__icmp_get_14d__v0.pipe | 15 + .../endpoints/endpoint__icmp_get_30d__v0.pipe | 15 + .../endpoint__icmp_list_14d__v0.pipe | 19 + .../endpoints/endpoint__icmp_list_1d__v0.pipe | 19 + .../endpoints/endpoint__icmp_list_7d__v0.pipe | 19 + .../endpoint__icmp_metrics_14d__v0.pipe | 43 ++ .../endpoint__icmp_metrics_1d__v0.pipe | 43 ++ .../endpoint__icmp_metrics_30d__v0.pipe | 42 ++ .../endpoint__icmp_metrics_7d__v0.pipe | 43 ++ .../endpoint__icmp_metrics_90d__v0.pipe | 39 ++ ...int__icmp_metrics_by_interval_14d__v0.pipe | 28 + ...oint__icmp_metrics_by_interval_1d__v0.pipe | 28 + ...int__icmp_metrics_by_interval_30d__v0.pipe | 26 + ...oint__icmp_metrics_by_interval_7d__v0.pipe | 28 + ...int__icmp_metrics_by_interval_90d__v0.pipe | 26 + ...point__icmp_metrics_by_region_14d__v0.pipe | 22 + ...dpoint__icmp_metrics_by_region_1d__v0.pipe | 22 + ...dpoint__icmp_metrics_by_region_7d__v0.pipe | 22 + .../endpoint__icmp_metrics_global_1d__v0.pipe | 25 + ...endpoint__icmp_metrics_latency_1d__v0.pipe | 24 + ...nt__icmp_metrics_latency_1d_multi__v0.pipe | 25 + ...ndpoint__icmp_metrics_latency_30d__v0.pipe | 23 + ...endpoint__icmp_metrics_latency_7d__v0.pipe | 24 + ...ndpoint__icmp_metrics_latency_90d__v0.pipe | 23 + .../endpoint__icmp_status_45d__v0.pipe | 20 + .../endpoint__icmp_status_7d__v0.pipe | 20 + .../endpoint__icmp_uptime_30d__v0.pipe | 22 + .../endpoint__icmp_uptime_7d__v0.pipe | 22 + .../endpoint__icmp_uptime_90d__v0.pipe | 21 + .../endpoint__icmp_workspace_30d__v0.pipe | 16 + .../aggregate__icmp_full_30d__v0.pipe | 32 ++ .../aggregate__icmp_status_7d__v0.pipe | 17 + .../pipes/aggregate__icmp_14d__v0.pipe | 24 + .../pipes/aggregate__icmp_1d__v0.pipe | 24 + .../pipes/aggregate__icmp_30d__v0.pipe | 24 + .../pipes/aggregate__icmp_7d__v0.pipe | 24 + .../pipes/aggregate__icmp_90d__v0.pipe | 24 + .../pipes/aggregate__icmp_full_14d__v0.pipe | 16 + .../pipes/aggregate__icmp_status_45d__v0.pipe | 19 + .../pipes/aggregate__icmp_uptime_30d__v0.pipe | 13 + .../pipes/aggregate__icmp_uptime_7d__v0.pipe | 13 + .../pipes/aggregate__icmp_uptime_90d__v0.pipe | 13 + .../aggregate__icmp_workspace_30d__v0.pipe | 18 + packages/tinybird/src/client.ts | 520 ++++++++++++++++++ packages/utils/src/index.ts | 2 + packages/utils/src/payloads.ts | 20 + 145 files changed, 7090 insertions(+), 165 deletions(-) create mode 100644 apps/checker/checker/icmp.go create mode 100644 apps/checker/checker/icmp_internal_test.go create mode 100644 apps/checker/checker/icmp_test.go create mode 100644 apps/checker/handlers/icmp.go create mode 100644 apps/checker/handlers/icmp_test.go create mode 100644 apps/checker/pkg/job/icmp_job.go create mode 100644 apps/checker/pkg/job/icmp_job_test.go create mode 100644 apps/checker/proto/private_location/v1/icmp_monitor.pb.go create mode 100644 apps/private-location/internal/server/ingest_icmp.go create mode 100644 apps/private-location/proto/private_location/v1/icmp_monitor.pb.go create mode 100644 apps/server/src/libs/checker/utils.test.ts create mode 100644 apps/web/src/content/pages/changelog/icmp-monitoring.mdx create mode 100644 apps/web/src/content/pages/docs/reference/icmp-monitor.mdx create mode 100644 packages/proto/api/openstatus/monitor/v1/icmp_monitor.proto create mode 100644 packages/proto/gen/ts/openstatus/monitor/v1/icmp_monitor_pb.ts create mode 100644 packages/proto/internal/private_location/v1/icmp_monitor.proto create mode 100644 packages/tinybird/datasources/check_icmp_response__v0.datasource create mode 100644 packages/tinybird/datasources/icmp_response__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_14d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_1d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_30d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_7d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_90d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_full_14d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_full_30d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_status_45d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_status_7d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_uptime_30d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_uptime_7d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_uptime_90d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_workspace_30d__v0.datasource create mode 100644 packages/tinybird/endpoints/endpoint__icmp_get_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_get_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_list_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_list_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_list_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_global_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d_multi__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_latency_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_latency_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_metrics_latency_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_status_45d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_status_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_uptime_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_uptime_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_uptime_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_workspace_30d__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__icmp_full_30d__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__icmp_status_7d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_14d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_1d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_30d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_7d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_90d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_full_14d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_status_45d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_uptime_30d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_uptime_7d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_uptime_90d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__icmp_workspace_30d__v0.pipe diff --git a/apps/checker/checker/icmp.go b/apps/checker/checker/icmp.go new file mode 100644 index 00000000..f1339377 --- /dev/null +++ b/apps/checker/checker/icmp.go @@ -0,0 +1,291 @@ +package checker + +import ( + "fmt" + "net" + "os" + "sync/atomic" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +// icmpEchoCounter hands each probe its own echo identifier. A raw socket +// receives every ICMP packet delivered to the host, so probes are told apart by +// the echo id alone; a per-process value (the pid) makes two concurrent probes +// to the same target indistinguishable. Seeded from the pid so a restart does +// not immediately reuse the ids of packets still in flight. +var icmpEchoCounter = func() *atomic.Uint32 { + var c atomic.Uint32 + c.Store(uint32(os.Getpid())) + return &c +}() + +func nextEchoID() int { + return int(icmpEchoCounter.Add(1) & 0xffff) +} + +const ( + icmpPacketCount = 3 + icmpPacketInterval = 100 * time.Millisecond + // Applied when a caller omits the timeout. Without it the deadline below + // lands in the past, the send loop breaks before the first packet, and the + // check reports "no reply" having probed nothing. + icmpDefaultTimeout = 45_000 +) + +type ICMPResponseTiming struct { + // RTTs holds one entry per sent packet in send order; -1 marks a lost packet. + RTTs []int64 `json:"rtts"` +} + +type ICMPResult struct { + Timing ICMPResponseTiming + Latency int64 + LatencyMin int64 + LatencyMax int64 + PacketsSent uint8 + PacketsReceived uint8 +} + +type ICMPResponse struct { + Region string `json:"region"` + ErrorMessage string `json:"errorMessage"` + JobType string `json:"jobType"` + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + LatencyMin int64 `json:"latencyMin"` + LatencyMax int64 `json:"latencyMax"` + PacketsSent uint8 `json:"packetsSent"` + PacketsReceived uint8 `json:"packetsReceived"` + Timing ICMPResponseTiming `json:"timing"` + Error uint8 `json:"error,omitempty"` +} + +func PingICMP(timeoutMs int64, hostname string) (ICMPResult, error) { + if timeoutMs <= 0 { + timeoutMs = icmpDefaultTimeout + } + + dst, err := net.ResolveIPAddr("ip", hostname) + if err != nil { + return ICMPResult{}, fmt.Errorf("resolve error: %w", err) + } + + var ( + udpNetwork string + rawNetwork string + proto int + echoType icmp.Type + ) + if dst.IP.To4() != nil { + udpNetwork, rawNetwork, proto, echoType = "udp4", "ip4:icmp", ipv4.ICMPTypeEcho.Protocol(), ipv4.ICMPTypeEcho + } else { + udpNetwork, rawNetwork, proto, echoType = "udp6", "ip6:ipv6-icmp", ipv6.ICMPTypeEchoRequest.Protocol(), ipv6.ICMPTypeEchoRequest + } + + conn, isRaw, err := listenICMP(udpNetwork, rawNetwork) + if err != nil { + return ICMPResult{}, fmt.Errorf("icmp socket error: %w", err) + } + defer conn.Close() + + deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + id := nextEchoID() + + timing := ICMPResponseTiming{RTTs: make([]int64, 0, icmpPacketCount)} + received := make([]int64, 0, icmpPacketCount) + var packetsSent uint8 + var lastErr error + + for seq := 0; seq < icmpPacketCount; seq++ { + if seq > 0 { + time.Sleep(icmpPacketInterval) + } + + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + perPacket := remaining / time.Duration(icmpPacketCount-seq) + packetsSent++ + + rtt, err := sendEcho(conn, isRaw, proto, echoType, dst, id, seq, time.Now().Add(perPacket)) + if err != nil { + lastErr = err + timing.RTTs = append(timing.RTTs, -1) + continue + } + timing.RTTs = append(timing.RTTs, rtt) + received = append(received, rtt) + } + + if len(received) == 0 { + if lastErr != nil { + return ICMPResult{}, lastErr + } + return ICMPResult{}, fmt.Errorf("no reply from %s", hostname) + } + + var sum, min, max int64 + for i, rtt := range received { + sum += rtt + if i == 0 || rtt < min { + min = rtt + } + if i == 0 || rtt > max { + max = rtt + } + } + + return ICMPResult{ + Timing: timing, + Latency: sum / int64(len(received)), + LatencyMin: min, + LatencyMax: max, + PacketsSent: packetsSent, + PacketsReceived: uint8(len(received)), + }, nil +} + +// listenICMP prefers an unprivileged datagram socket and falls back to a raw +// socket when the runtime lacks ping_group_range but holds CAP_NET_RAW. +func listenICMP(udpNetwork, rawNetwork string) (*icmp.PacketConn, bool, error) { + udpBind, rawBind := "0.0.0.0", "0.0.0.0" + if udpNetwork == "udp6" { + udpBind, rawBind = "::", "::" + } + + conn, err := icmp.ListenPacket(udpNetwork, udpBind) + if err == nil { + return conn, false, nil + } + + rawConn, rawErr := icmp.ListenPacket(rawNetwork, rawBind) + if rawErr != nil { + return nil, false, fmt.Errorf("udp: %v, raw: %w", err, rawErr) + } + return rawConn, true, nil +} + +func sendEcho(conn *icmp.PacketConn, isRaw bool, proto int, echoType icmp.Type, dst *net.IPAddr, id, seq int, deadline time.Time) (int64, error) { + var writeAddr net.Addr = &net.UDPAddr{IP: dst.IP, Zone: dst.Zone} + if isRaw { + writeAddr = &net.IPAddr{IP: dst.IP, Zone: dst.Zone} + } + + msg := icmp.Message{ + Type: echoType, + Code: 0, + Body: &icmp.Echo{ID: id, Seq: seq, Data: []byte("openstatus")}, + } + wb, err := msg.Marshal(nil) + if err != nil { + return 0, fmt.Errorf("marshal error: %w", err) + } + + if err := conn.SetDeadline(deadline); err != nil { + return 0, err + } + + start := time.Now() + if _, err := conn.WriteTo(wb, writeAddr); err != nil { + return 0, fmt.Errorf("write error: %w", err) + } + + rb := make([]byte, 1500) + for { + n, peer, err := conn.ReadFrom(rb) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + return 0, fmt.Errorf("timeout") + } + return 0, fmt.Errorf("read error: %w", err) + } + + rm, err := icmp.ParseMessage(proto, rb[:n]) + if err != nil { + continue + } + + switch body := rm.Body.(type) { + case *icmp.Echo: + // A reply to our probe can only come from the target. A raw socket + // is not demultiplexed the way the datagram path is, so without + // this it also sees replies belonging to other probes. + if !addrIP(peer).Equal(dst.IP) { + continue + } + // The kernel rewrites the Echo ID on datagram sockets, so only raw + // sockets can trust it; datagram replies are matched on Seq alone. + if body.Seq != seq || (isRaw && body.ID != id) { + continue + } + return time.Since(start).Milliseconds(), nil + case *icmp.DstUnreach: + // Errors come from whichever hop rejected the packet, not from the + // target, so the peer says nothing about ownership — the quoted + // datagram does. + if !provokedByProbe(body.Data, proto, id, seq, isRaw) { + continue + } + return 0, fmt.Errorf("destination unreachable") + case *icmp.TimeExceeded: + if !provokedByProbe(body.Data, proto, id, seq, isRaw) { + continue + } + return 0, fmt.Errorf("time exceeded") + } + } +} + +// addrIP pulls the IP out of the address shape each socket type reports: +// *net.IPAddr for raw, *net.UDPAddr for the unprivileged datagram path. +func addrIP(addr net.Addr) net.IP { + switch a := addr.(type) { + case *net.IPAddr: + return a.IP + case *net.UDPAddr: + return a.IP + } + return nil +} + +// provokedByProbe reports whether an ICMP error quotes the packet we sent. The +// error carries the original datagram — IP header plus at least its first eight +// bytes, which is the whole echo header — so the quoted id and sequence +// identify the sender. Without this a raw socket would treat an unrelated +// flow's "destination unreachable" as its own probe failing. +func provokedByProbe(data []byte, proto, id, seq int, isRaw bool) bool { + var quoted []byte + switch proto { + case ipv4.ICMPTypeEcho.Protocol(): + h, err := icmp.ParseIPv4Header(data) + if err != nil || len(data) < h.Len { + return false + } + quoted = data[h.Len:] + default: + if len(data) < ipv6.HeaderLen { + return false + } + quoted = data[ipv6.HeaderLen:] + } + + msg, err := icmp.ParseMessage(proto, quoted) + if err != nil { + return false + } + echo, ok := msg.Body.(*icmp.Echo) + if !ok { + return false + } + // Same asymmetry as the echo path: the kernel owns the id on datagram + // sockets, so only raw probes can match on it. + return echo.Seq == seq && (!isRaw || echo.ID == id) +} diff --git a/apps/checker/checker/icmp_internal_test.go b/apps/checker/checker/icmp_internal_test.go new file mode 100644 index 00000000..2f5a110a --- /dev/null +++ b/apps/checker/checker/icmp_internal_test.go @@ -0,0 +1,149 @@ +package checker + +import ( + "net" + "sync" + "testing" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +func TestNextEchoID_DistinguishesConcurrentProbes(t *testing.T) { + // A raw socket tells probes apart by echo id alone, so two probes running + // at once must never share one — a per-process id (the pid) did. + const n = 512 + + var wg sync.WaitGroup + ids := make([]int, n) + for i := range n { + wg.Add(1) + go func() { + defer wg.Done() + ids[i] = nextEchoID() + }() + } + wg.Wait() + + seen := make(map[int]struct{}, n) + for _, id := range ids { + if _, dup := seen[id]; dup { + t.Fatalf("nextEchoID() handed out %d twice across %d concurrent probes", id, n) + } + seen[id] = struct{}{} + if id < 0 || id > 0xffff { + t.Errorf("nextEchoID() = %d, outside the 16-bit echo id field", id) + } + } +} + +func TestAddrIP(t *testing.T) { + ip := net.ParseIP("192.0.2.7") + + for _, tc := range []struct { + name string + addr net.Addr + want net.IP + }{ + {name: "raw socket", addr: &net.IPAddr{IP: ip}, want: ip}, + {name: "datagram socket", addr: &net.UDPAddr{IP: ip}, want: ip}, + {name: "unknown shape", addr: &net.TCPAddr{IP: ip}, want: nil}, + {name: "nil", addr: nil, want: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + got := addrIP(tc.addr) + if !got.Equal(tc.want) { + t.Errorf("addrIP() = %v, want %v", got, tc.want) + } + // An unrecognised address must never be mistaken for the target. + if tc.want == nil && got.Equal(ip) { + t.Error("addrIP() matched the target for an address it cannot read") + } + }) + } +} + +// quotedError builds the payload an ICMP error carries: the original IP header +// followed by the datagram that provoked it. +func quotedError(t *testing.T, v4 bool, id, seq int) []byte { + t.Helper() + + echoType := icmp.Type(ipv4.ICMPTypeEcho) + if !v4 { + echoType = ipv6.ICMPTypeEchoRequest + } + echo, err := (&icmp.Message{ + Type: echoType, + Body: &icmp.Echo{ID: id, Seq: seq, Data: []byte("openstatus")}, + }).Marshal(nil) + if err != nil { + t.Fatalf("marshal echo: %v", err) + } + + if v4 { + // Minimal 20-byte IPv4 header; only the length nibble is read back. + header := make([]byte, 20) + header[0] = 0x45 + return append(header, echo...) + } + return append(make([]byte, ipv6.HeaderLen), echo...) +} + +func TestProvokedByProbe(t *testing.T) { + const ( + v4proto = 1 // ipv4.ICMPTypeEcho.Protocol() + id = 4242 + seq = 1 + ) + + t.Run("accepts the error quoting our own probe", func(t *testing.T) { + data := quotedError(t, true, id, seq) + if !provokedByProbe(data, v4proto, id, seq, true) { + t.Error("provokedByProbe() rejected an error quoting this probe") + } + }) + + t.Run("rejects another probe's error on the raw path", func(t *testing.T) { + // The cross-talk case: same sequence (0/1/2 are always reused), a + // different probe's id. A raw socket sees this packet too. + data := quotedError(t, true, id+1, seq) + if provokedByProbe(data, v4proto, id, seq, true) { + t.Error("provokedByProbe() accepted an error belonging to another probe") + } + }) + + t.Run("rejects a different sequence", func(t *testing.T) { + data := quotedError(t, true, id, seq+1) + if provokedByProbe(data, v4proto, id, seq, true) { + t.Error("provokedByProbe() accepted an error for a different packet") + } + }) + + t.Run("ignores the id on the datagram path", func(t *testing.T) { + // The kernel rewrites the id on datagram sockets, so the quoted value + // is not ours — but that socket is demultiplexed, so seq is enough. + data := quotedError(t, true, id+1, seq) + if !provokedByProbe(data, v4proto, id, seq, false) { + t.Error("provokedByProbe() matched on an id the kernel owns") + } + }) + + t.Run("rejects a truncated or unparsable quote", func(t *testing.T) { + for _, data := range [][]byte{nil, {}, {0x45}, make([]byte, 20)} { + if provokedByProbe(data, v4proto, id, seq, true) { + t.Errorf("provokedByProbe() accepted an unusable quote %v", data) + } + } + }) + + t.Run("handles ipv6", func(t *testing.T) { + const v6proto = 58 // ipv6.ICMPTypeEchoRequest.Protocol() + if !provokedByProbe(quotedError(t, false, id, seq), v6proto, id, seq, true) { + t.Error("provokedByProbe() rejected a v6 error quoting this probe") + } + if provokedByProbe(quotedError(t, false, id+1, seq), v6proto, id, seq, true) { + t.Error("provokedByProbe() accepted another v6 probe's error") + } + }) +} diff --git a/apps/checker/checker/icmp_test.go b/apps/checker/checker/icmp_test.go new file mode 100644 index 00000000..86b4f854 --- /dev/null +++ b/apps/checker/checker/icmp_test.go @@ -0,0 +1,70 @@ +package checker_test + +import ( + "strings" + "testing" + + "github.com/openstatushq/openstatus/apps/checker/checker" +) + +// skipIfNoICMP skips when the runtime cannot open an ICMP socket at all +// (unprivileged Linux CI without ping_group_range). macOS grants udp4 natively. +func skipIfNoICMP(t *testing.T, err error) { + t.Helper() + if err != nil && strings.Contains(err.Error(), "icmp socket error") { + t.Skipf("icmp sockets unavailable in this environment: %v", err) + } +} + +func TestPingICMP_Loopback(t *testing.T) { + res, err := checker.PingICMP(2000, "127.0.0.1") + skipIfNoICMP(t, err) + if err != nil { + t.Fatalf("PingICMP() error = %v", err) + } + if res.PacketsReceived == 0 { + t.Fatalf("PingICMP() received no packets: %+v", res) + } + if res.PacketsSent < res.PacketsReceived { + t.Fatalf("PingICMP() received more than sent: %+v", res) + } + if res.LatencyMin > res.LatencyMax { + t.Fatalf("PingICMP() min > max: %+v", res) + } + if len(res.Timing.RTTs) == 0 { + t.Fatalf("PingICMP() empty timing: %+v", res) + } +} + +func TestPingICMP_ResolveError(t *testing.T) { + _, err := checker.PingICMP(1000, "nonexistent-host-openstatus-test.invalid") + if err == nil { + t.Fatal("PingICMP() expected a resolve error, got nil") + } +} + +func TestPingICMP_Timeout(t *testing.T) { + // 192.0.2.1 is TEST-NET-1 (RFC 5737): routable-looking but never answers. + res, err := checker.PingICMP(1000, "192.0.2.1") + skipIfNoICMP(t, err) + if err == nil { + t.Fatalf("PingICMP() expected timeout error, got %+v", res) + } +} + +// A zero timeout used to make the deadline land in the past, so the send loop +// broke before the first packet and every check reported "no reply" without +// probing. Callers that omit the field must still get a real check. +func TestPingICMP_ZeroTimeoutStillProbes(t *testing.T) { + res, err := checker.PingICMP(0, "127.0.0.1") + skipIfNoICMP(t, err) + if err != nil { + t.Fatalf("PingICMP() with a zero timeout must still probe, got %v", err) + } + if res.PacketsSent == 0 { + t.Error("PingICMP() sent no packets with a zero timeout") + } + if res.PacketsReceived == 0 { + t.Errorf("PingICMP() received no replies from loopback: %+v", res) + } +} diff --git a/apps/checker/cmd/server/main.go b/apps/checker/cmd/server/main.go index 542486eb..ed17e4ac 100644 --- a/apps/checker/cmd/server/main.go +++ b/apps/checker/cmd/server/main.go @@ -159,6 +159,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Best-effort: allow unprivileged ICMP datagram sockets across all GIDs so + // PingICMP can avoid the raw-socket fallback. Ignored if not writable. + if err := os.WriteFile("/proc/sys/net/ipv4/ping_group_range", []byte("0 2147483647"), 0644); err != nil { + log.Warn().Err(err).Msg("could not widen ping_group_range; icmp will use the raw-socket fallback") + } + done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) @@ -243,9 +249,11 @@ func main() { router.POST("/checker/http", h.HTTPCheckerHandler) router.POST("/checker/tcp", h.TCPHandler) router.POST("/checker/dns", h.DNSHandler) + router.POST("/checker/icmp", h.ICMPHandler) router.POST("/ping/:region", h.PingRegionHandler) router.POST("/tcp/:region", h.TCPHandlerRegion) router.POST("/dns/:region", h.DNSHandlerRegion) + router.POST("/icmp/:region", h.ICMPHandlerRegion) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong", "region": region, "provider": cloudProvider}) diff --git a/apps/checker/go.mod b/apps/checker/go.mod index e458e57b..46ca4305 100644 --- a/apps/checker/go.mod +++ b/apps/checker/go.mod @@ -22,6 +22,7 @@ require ( go.opentelemetry.io/otel/sdk v1.41.0 go.opentelemetry.io/otel/sdk/log v0.17.0 go.opentelemetry.io/otel/sdk/metric v1.41.0 + golang.org/x/net v0.51.0 google.golang.org/api v0.269.0 google.golang.org/protobuf v1.36.11 ) @@ -72,7 +73,6 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/apps/checker/handlers/icmp.go b/apps/checker/handlers/icmp.go new file mode 100644 index 00000000..c50b90da --- /dev/null +++ b/apps/checker/handlers/icmp.go @@ -0,0 +1,371 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/openstatushq/openstatus/apps/checker/checker" + otelOS "github.com/openstatushq/openstatus/apps/checker/pkg/otel" + "github.com/openstatushq/openstatus/apps/checker/request" + "github.com/rs/zerolog/log" +) + +// Only used for Tinybird. +type ICMPData struct { + ID string `json:"id"` + Timing string `json:"timing"` + ErrorMessage string `json:"errorMessage"` + Region string `json:"region"` + Trigger string `json:"trigger"` + URI string `json:"uri"` + RequestStatus string `json:"requestStatus,omitempty"` + + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + LatencyMin int64 `json:"latencyMin"` + LatencyMax int64 `json:"latencyMax"` + CronTimestamp int64 `json:"cronTimestamp"` + + PacketsSent uint8 `json:"packetsSent"` + PacketsReceived uint8 `json:"packetsReceived"` + + Error uint8 `json:"error"` +} + +func (h Handler) ICMPHandler(c *gin.Context) { + ctx := c.Request.Context() + dataSourceName := "icmp_response__v0" + + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + + return + } + + if h.CloudProvider == "fly" { + // if the request has been routed to a wrong region, we forward it to the correct one. + region := c.GetHeader("fly-prefer-region") + if region != "" && region != h.Region { + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) + c.String(http.StatusAccepted, "Forwarding request to %s", region) + + return + } + } + + var req request.ICMPCheckerRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + workspaceId, err := strconv.ParseInt(req.WorkspaceID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + monitorId, err := strconv.ParseInt(req.MonitorID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + var trigger = "cron" + if req.Trigger != "" { + trigger = req.Trigger + } + + e, f := c.Get("event") + if f { + t := e.(map[string]any) + t["checker"] = map[string]string{ + "uri": req.URI, + "workspace_id": req.WorkspaceID, + "monitor_id": req.MonitorID, + "trigger": trigger, + "type": "icmp", + } + c.Set("event", t) + } + + var response checker.ICMPResponse + + var retry int + if req.Retry != 0 { + retry = int(req.Retry) + } else { + retry = 3 + } + + op := func() error { + res, err := checker.PingICMP(req.Timeout, req.URI) + if err != nil { + return fmt.Errorf("unable to check icmp %s", err) + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + } + + latency := res.Latency + + var requestStatus = "" + switch req.Status { + case "active": + requestStatus = "success" + case "error": + requestStatus = "error" + case "degraded": + requestStatus = "degraded" + } + + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("error while generating uuid %w", err) + } + + timestamp := time.Now().UTC().UnixMilli() + + data := ICMPData{ + ID: id.String(), + WorkspaceID: workspaceId, + Timestamp: timestamp, + Error: 0, + ErrorMessage: "", + Region: h.Region, + MonitorID: monitorId, + Timing: string(timingAsString), + Latency: latency, + LatencyMin: res.LatencyMin, + LatencyMax: res.LatencyMax, + PacketsSent: res.PacketsSent, + PacketsReceived: res.PacketsReceived, + CronTimestamp: req.CronTimestamp, + Trigger: trigger, + URI: req.URI, + RequestStatus: requestStatus, + } + + response = checker.ICMPResponse{ + Timestamp: timestamp, + Timing: res.Timing, + Latency: latency, + LatencyMin: res.LatencyMin, + LatencyMax: res.LatencyMax, + PacketsSent: res.PacketsSent, + PacketsReceived: res.PacketsReceived, + Region: h.Region, + JobType: "icmp", + } + + if req.DegradedAfter == 0 && req.Status != "active" { + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "active", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: latency, + }) + data.RequestStatus = "success" + } + + if (req.DegradedAfter > 0 && latency < req.DegradedAfter) && req.Status != "active" { + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "active", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: latency, + }) + data.RequestStatus = "success" + } + + if req.DegradedAfter > 0 && latency > req.DegradedAfter && req.Status != "degraded" { + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "degraded", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: latency, + }) + data.RequestStatus = "degraded" + } + + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + + return nil + } + + if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), uint64(retry))); err != nil { + id, e := uuid.NewV7() + if e != nil { + log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") + return + } + data := ICMPData{ + ID: id.String(), + WorkspaceID: workspaceId, + CronTimestamp: req.CronTimestamp, + ErrorMessage: err.Error(), + Region: h.Region, + MonitorID: monitorId, + Error: 1, + Trigger: trigger, + URI: req.URI, + RequestStatus: "error", + } + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "error", + Message: err.Error(), + Region: h.Region, + CronTimestamp: req.CronTimestamp, + }) + + // Only the success path inside op() fills these in, so a check that + // exhausted its retries would otherwise be returned under `?data=true` + // with an empty jobType and region — a shape no caller can parse. + response.JobType = "icmp" + response.Region = h.Region + response.Error = 1 + } + + if req.OtelConfig.Endpoint != "" { + otelOS.RecordICMPMetrics(ctx, req, response, h.Region) + } + + returnData := c.Query("data") + if returnData == "true" { + c.JSON(http.StatusOK, response) + + return + } + + c.JSON(http.StatusOK, nil) +} + +func (h Handler) ICMPHandlerRegion(c *gin.Context) { + ctx := c.Request.Context() + dataSourceName := "check_icmp_response__v0" + + region := c.Param("region") + if region == "" { + c.String(http.StatusBadRequest, "region is required") + + return + } + + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + + return + } + + if h.CloudProvider == "fly" { + // if the request has been routed to a wrong region, we forward it to the correct one. + region := c.GetHeader("fly-prefer-region") + if region != "" && region != h.Region { + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) + c.String(http.StatusAccepted, "Forwarding request to %s", region) + + return + } + } + + var req request.ICMPCheckerRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + var response checker.ICMPResponse + + op := func() error { + timestamp := time.Now().UTC().UnixMilli() + res, err := checker.PingICMP(req.Timeout, req.URI) + if err != nil { + return fmt.Errorf("unable to check icmp %s", err) + } + + response = checker.ICMPResponse{ + Timestamp: timestamp, + Timing: res.Timing, + Latency: res.Latency, + LatencyMin: res.LatencyMin, + LatencyMax: res.LatencyMax, + PacketsSent: res.PacketsSent, + PacketsReceived: res.PacketsReceived, + Region: h.Region, + JobType: "icmp", + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + } + + data := ICMPData{ + CronTimestamp: req.CronTimestamp, + Timestamp: timestamp, + Error: 0, + ErrorMessage: "", + Region: h.Region, + Timing: string(timingAsString), + Latency: res.Latency, + LatencyMin: res.LatencyMin, + LatencyMax: res.LatencyMax, + PacketsSent: res.PacketsSent, + PacketsReceived: res.PacketsReceived, + RequestId: req.RequestId, + Trigger: "api", + URI: req.URI, + } + + if req.RequestId != 0 { + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + } + + return nil + } + + err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)) + if err != nil { + response.Error = 1 + } + + if req.OtelConfig.Endpoint != "" { + otelOS.RecordICMPMetrics(ctx, req, response, region) + } + + if err != nil { + c.JSON(http.StatusOK, gin.H{"message": "uri not reachable"}) + + return + } + + c.JSON(http.StatusOK, response) +} diff --git a/apps/checker/handlers/icmp_test.go b/apps/checker/handlers/icmp_test.go new file mode 100644 index 00000000..7ba944f8 --- /dev/null +++ b/apps/checker/handlers/icmp_test.go @@ -0,0 +1,79 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/openstatushq/openstatus/apps/checker/handlers" + "github.com/stretchr/testify/assert" +) + +func TestICMPHandler_RejectsUnauthorized(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/icmp", h.ICMPHandler) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/icmp", strings.NewReader(`{"uri":"1.1.1.1"}`)) + r.Header.Set("Authorization", "Basic wrong") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestICMPHandler_RejectsBadPayload(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/icmp", h.ICMPHandler) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/icmp", strings.NewReader(`{not json`)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestICMPHandlerRegion_RequiresRegion(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/icmp/:region", h.ICMPHandlerRegion) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/icmp/local", strings.NewReader(`{"uri":"1.1.1.1"}`)) + r.Header.Set("Authorization", "Basic wrong") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +// A check that exhausts its retries is still returned under `?data=true`, so it +// has to carry the same identifying fields as a successful one — the success +// path sets them inside op(), which never runs when every attempt fails. +func TestICMPHandler_FailureResponseKeepsJobTypeAndRegion(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/icmp", h.ICMPHandler) + + w := httptest.NewRecorder() + // 192.0.2.1 is TEST-NET-1 (RFC 5737): never answers, so every retry fails. + body := `{"uri":"192.0.2.1","timeout":200,"retry":1,"workspaceId":"1","monitorId":"1"}` + r, _ := http.NewRequest(http.MethodPost, "/checker/icmp?data=true", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + + var res map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("failed to decode response %q: %v", w.Body.String(), err) + } + + assert.Equal(t, "icmp", res["jobType"], "jobType is the discriminator callers match on") + assert.Equal(t, "local", res["region"]) + assert.Equal(t, float64(1), res["error"]) +} diff --git a/apps/checker/handlers/otel_wiring_test.go b/apps/checker/handlers/otel_wiring_test.go index 1b913e42..4bbbf03e 100644 --- a/apps/checker/handlers/otel_wiring_test.go +++ b/apps/checker/handlers/otel_wiring_test.go @@ -108,6 +108,35 @@ func TestTCPHandlerRegion_ExportsOTLPOnFailure(t *testing.T) { "expected an OTLP export on TCP failure") } +func TestICMPHandlerRegion_ExportsOTLPOnFailure(t *testing.T) { + otlp, count := countingOTLPServer(t) + + h := handlers.Handler{ + TbClient: testTinybird(t), + Secret: "test", + Region: "local", + } + router := gin.New() + router.POST("/icmp/:region", h.ICMPHandlerRegion) + + req := request.ICMPCheckerRequest{ + URI: "nonexistent-host-openstatus-test.invalid", // resolve failure, no socket needed + Status: "active", + Timeout: 1000, + } + req.OtelConfig.Endpoint = otlp.URL + body, _ := json.Marshal(req) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/icmp/local", strings.NewReader(string(body))) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Eventually(t, func() bool { return atomic.LoadInt64(count) > 0 }, 10*time.Second, 50*time.Millisecond, + "expected an OTLP export on ICMP failure") +} + func TestDNSHandler_ExportsOTLPOnFailure(t *testing.T) { otlp, count := countingOTLPServer(t) diff --git a/apps/checker/pkg/job/icmp_job.go b/apps/checker/pkg/job/icmp_job.go new file mode 100644 index 00000000..0cbce60f --- /dev/null +++ b/apps/checker/pkg/job/icmp_job.go @@ -0,0 +1,163 @@ +package job + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/cenkalti/backoff/v5" + "github.com/google/uuid" + "github.com/openstatushq/openstatus/apps/checker/checker" + "github.com/openstatushq/openstatus/apps/checker/pkg/otel" + v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" + "github.com/openstatushq/openstatus/apps/checker/request" +) + +// ICMPPrivateRegionData represents the result of an ICMP monitor check +type ICMPPrivateRegionData struct { + ID string `json:"id"` + URI string `json:"uri"` + RequestStatus string `json:"request_status"` + Message string `json:"message"` + Latency int64 `json:"latency"` + LatencyMin int64 `json:"latency_min"` + LatencyMax int64 `json:"latency_max"` + PacketsSent int64 `json:"packets_sent"` + PacketsReceived int64 `json:"packets_received"` + Timestamp int64 `json:"timestamp"` + CronTimestamp int64 `json:"cron_timestamp"` + Error int `json:"error"` + Timing string `json:"timing"` +} + +func (jobRunner) ICMPJob(ctx context.Context, monitor *v1.ICMPMonitor, region string) (*ICMPPrivateRegionData, error) { + retry := monitor.Retry + if retry == 0 { + retry = 3 + } + + var degradedAfter int64 + if monitor.DegradedAt != nil { + degradedAfter = *monitor.DegradedAt + } + + req := icmpCheckerRequest(monitor) + + var called int + var lastResult checker.ICMPResponse + + op := func() (*ICMPPrivateRegionData, error) { + called++ + start := time.Now().UTC().UnixMilli() + res, err := checker.PingICMP(monitor.Timeout, monitor.Uri) + if err != nil { + if called < int(retry) { + return nil, fmt.Errorf("ICMP check failed: %w", err) + } + + data, dataErr := newICMPData(monitor.Uri, start) + if dataErr != nil { + return nil, dataErr + } + + lastResult = checker.ICMPResponse{Error: 1} + + data.RequestStatus = "error" + data.Error = 1 + data.Message = err.Error() + + return data, nil + } + + lastResult = checker.ICMPResponse{ + Latency: res.Latency, + LatencyMin: res.LatencyMin, + LatencyMax: res.LatencyMax, + PacketsSent: res.PacketsSent, + PacketsReceived: res.PacketsReceived, + Timing: res.Timing, + } + + // "success", not "active": the Tinybird ICMP status and uptime pipes + // count `requestStatus = 'success'`, and the HTTP/TCP/DNS jobs all + // report it that way. + var requestStatus = "success" + if degradedAfter > 0 && res.Latency > degradedAfter { + requestStatus = "degraded" + } + + data, err := newICMPData(monitor.Uri, start) + if err != nil { + return nil, err + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return nil, fmt.Errorf("error while parsing timing data %s: %w", monitor.Uri, err) + } + + data.Latency = res.Latency + data.LatencyMin = res.LatencyMin + data.LatencyMax = res.LatencyMax + data.PacketsSent = int64(res.PacketsSent) + data.PacketsReceived = int64(res.PacketsReceived) + data.RequestStatus = requestStatus + data.Error = 0 + data.Message = fmt.Sprintf("Successfully pinged %s", monitor.Uri) + data.Timing = string(timingAsString) + + return data, nil + } + + resp, err := backoff.Retry(ctx, op, + backoff.WithMaxTries(uint(retry)), + backoff.WithBackOff(backoff.NewExponentialBackOff()), + ) + + recordICMPOtel(ctx, req, lastResult, region, err != nil) + + if err != nil { + return nil, fmt.Errorf("ICMP job failed after %d retries: %w", retry, err) + } + return resp, nil +} + +// newICMPData stamps the fields every result must carry regardless of outcome. +// `Timestamp`/`CronTimestamp` are required: ValidateIngestICMPRequest rejects a +// non-positive timestamp, so a result missing them is dropped at ingest. +func newICMPData(uri string, start int64) (*ICMPPrivateRegionData, error) { + id, err := uuid.NewV7() + if err != nil { + return nil, fmt.Errorf("failed to generate UUID: %w", err) + } + + return &ICMPPrivateRegionData{ + ID: id.String(), + URI: uri, + Timestamp: start, + CronTimestamp: start, + }, nil +} + +func icmpCheckerRequest(monitor *v1.ICMPMonitor) request.ICMPCheckerRequest { + req := request.ICMPCheckerRequest{URI: monitor.Uri} + if otelCfg := monitor.GetOtelConfig(); otelCfg.GetEndpoint() != "" { + req.OtelConfig.Endpoint = otelCfg.GetEndpoint() + req.OtelConfig.Headers = headersToMap(otelCfg.GetHeaders()) + } + + return req +} + +func recordICMPOtel(ctx context.Context, req request.ICMPCheckerRequest, result checker.ICMPResponse, region string, failed bool) { + if req.OtelConfig.Endpoint == "" { + return + } + + if failed { + result.Error = 1 + } + + otel.RecordICMPMetrics(ctx, req, result, region) +} diff --git a/apps/checker/pkg/job/icmp_job_test.go b/apps/checker/pkg/job/icmp_job_test.go new file mode 100644 index 00000000..114a0dac --- /dev/null +++ b/apps/checker/pkg/job/icmp_job_test.go @@ -0,0 +1,126 @@ +package job_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/openstatushq/openstatus/apps/checker/pkg/job" + v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" +) + +func TestICMPJob_Success(t *testing.T) { + monitor := &v1.ICMPMonitor{ + Uri: "127.0.0.1", + Timeout: 2000, + Retry: 1, + } + data, err := job.NewJobRunner().ICMPJob(context.Background(), monitor, "test-region") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + // unprivileged Linux CI (no ping_group_range) can't open the socket + if data.RequestStatus == "error" && strings.Contains(data.Message, "icmp socket error") { + t.Skipf("icmp sockets unavailable in this environment: %s", data.Message) + } + if data.RequestStatus != "success" { + t.Errorf("expected RequestStatus 'success', got '%s'", data.RequestStatus) + } + if data.Error != 0 { + t.Errorf("expected Error 0, got %d", data.Error) + } + if data.PacketsReceived == 0 { + t.Errorf("expected at least one packet received, got %d", data.PacketsReceived) + } +} + +func TestICMPJob_Failure(t *testing.T) { + // 192.0.2.1 is TEST-NET-1 (RFC 5737): never answers. + monitor := &v1.ICMPMonitor{ + Uri: "192.0.2.1", + Timeout: 1000, + Retry: 1, + } + data, err := job.NewJobRunner().ICMPJob(context.Background(), monitor, "test-region") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if data.RequestStatus != "error" { + t.Errorf("expected RequestStatus 'error', got '%s'", data.RequestStatus) + } + if data.Error != 1 { + t.Errorf("expected Error 1, got %d", data.Error) + } +} + +// The private-location server rejects an ingest whose timestamp is not strictly +// positive (ValidateIngestICMPRequest), and the scheduler does not retry a +// rejected ingest — so a result without timestamps is silently dropped. +func TestICMPJob_StampsTimestamps(t *testing.T) { + before := time.Now().UTC().UnixMilli() + + for _, tc := range []struct { + name string + uri string + }{ + {name: "success", uri: "127.0.0.1"}, + // 192.0.2.1 is TEST-NET-1 (RFC 5737): never answers. + {name: "failure", uri: "192.0.2.1"}, + } { + t.Run(tc.name, func(t *testing.T) { + monitor := &v1.ICMPMonitor{Uri: tc.uri, Timeout: 1000, Retry: 1} + data, err := job.NewJobRunner().ICMPJob(context.Background(), monitor, "test-region") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if data.RequestStatus == "error" && strings.Contains(data.Message, "icmp socket error") { + t.Skipf("icmp sockets unavailable in this environment: %s", data.Message) + } + + after := time.Now().UTC().UnixMilli() + + if data.Timestamp <= 0 { + t.Errorf("Timestamp must be positive or the ingest is rejected, got %d", data.Timestamp) + } + if data.CronTimestamp <= 0 { + t.Errorf("CronTimestamp must be positive, got %d", data.CronTimestamp) + } + if data.Timestamp < before || data.Timestamp > after { + t.Errorf("Timestamp %d outside the run window [%d, %d]", data.Timestamp, before, after) + } + if data.ID == "" { + t.Error("ID must be set") + } + if data.URI != tc.uri { + t.Errorf("URI = %q, want %q", data.URI, tc.uri) + } + }) + } +} + +// The Tinybird ICMP aggregations bucket on the literal string — see +// aggregate__icmp_status_45d__v0.pipe and the icmp_uptime_* endpoints, which +// count `requestStatus = 'success'`. A healthy check reported under any other +// label is ingested but invisible to uptime and status. +func TestICMPJob_SuccessUsesTinybirdStatusVocabulary(t *testing.T) { + monitor := &v1.ICMPMonitor{Uri: "127.0.0.1", Timeout: 2000, Retry: 1} + data, err := job.NewJobRunner().ICMPJob(context.Background(), monitor, "test-region") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if data.RequestStatus == "error" && strings.Contains(data.Message, "icmp socket error") { + t.Skipf("icmp sockets unavailable in this environment: %s", data.Message) + } + + // The full vocabulary the pipes recognise; "active" is not part of it. + switch data.RequestStatus { + case "success", "degraded", "error": + default: + t.Errorf("RequestStatus %q is not counted by the Tinybird ICMP pipes", data.RequestStatus) + } + + if data.RequestStatus != "success" { + t.Errorf("a healthy ping must report \"success\", got %q", data.RequestStatus) + } +} diff --git a/apps/checker/pkg/job/job.go b/apps/checker/pkg/job/job.go index 944e4de0..74518d41 100644 --- a/apps/checker/pkg/job/job.go +++ b/apps/checker/pkg/job/job.go @@ -31,6 +31,7 @@ type JobRunner interface { TCPJob(ctx context.Context, monitor *v1.TCPMonitor, region string) (*TCPPrivateRegionData, error) HTTPJob(ctx context.Context, monitor *v1.HTTPMonitor, region string) (*HttpPrivateRegionData, error) DNSJob(ctx context.Context, monitor *v1.DNSMonitor) (*DNSPrivateRegionData, error) + ICMPJob(ctx context.Context, monitor *v1.ICMPMonitor, region string) (*ICMPPrivateRegionData, error) } type jobRunner struct{} diff --git a/apps/checker/pkg/otel/otel.go b/apps/checker/pkg/otel/otel.go index 51f83a0b..c4938d36 100644 --- a/apps/checker/pkg/otel/otel.go +++ b/apps/checker/pkg/otel/otel.go @@ -80,10 +80,25 @@ func withMeter(ctx context.Context, endpoint string, headers map[string]string, fn(otel.Meter("OpenStatus")) } -// recordGauge creates a Float64Gauge and records a value. +// UCUM unit codes, as OpenTelemetry expects them. +const ( + unitMilliseconds = "ms" + unitPercent = "%" +) + +// recordGauge creates a Float64Gauge for a duration in milliseconds and records +// a value. Use recordGaugeWithUnit for anything that is not a duration — +// backends render a gauge according to its unit, so a mislabelled one is read +// as a time span. func recordGauge(ctx context.Context, meter metric.Meter, name, description string, value float64, att metric.MeasurementOption) error { + return recordGaugeWithUnit(ctx, meter, name, description, unitMilliseconds, value, att) +} + +// recordGaugeWithUnit creates a Float64Gauge carrying an explicit unit and +// records a value. +func recordGaugeWithUnit(ctx context.Context, meter metric.Meter, name, description, unit string, value float64, att metric.MeasurementOption) error { gauge, err := meter.Float64Gauge(name, - metric.WithDescription(description), metric.WithUnit("ms")) + metric.WithDescription(description), metric.WithUnit(unit)) if err != nil { return err } @@ -169,6 +184,37 @@ func RecordDNSMetrics(ctx context.Context, req request.DNSCheckerRequest, latenc }) } +func RecordICMPMetrics(ctx context.Context, req request.ICMPCheckerRequest, result checker.ICMPResponse, region string) { + withMeter(ctx, req.OtelConfig.Endpoint, req.OtelConfig.Headers, func(meter metric.Meter) { + att := metric.WithAttributes( + attribute.String("openstatus.probes", region), + attribute.String("openstatus.target", req.URI), + ) + recordICMPInstruments(ctx, meter, result, att) + }) +} + +// recordICMPInstruments is split out of RecordICMPMetrics so tests can supply a +// collectable meter: withMeter installs a real OTLP provider globally, which +// leaves nothing to assert against. +func recordICMPInstruments(ctx context.Context, meter metric.Meter, result checker.ICMPResponse, att metric.MeasurementOption) { + if result.Error == 1 { + recordErrorCounter(ctx, meter, att) + return + } + + recordStatusCounter(ctx, meter, att) + + if err := recordGauge(ctx, meter, "openstatus.icmp.request.duration", "Duration of the check", float64(result.Latency), att); err != nil { + log.Ctx(ctx).Error().Err(err).Str("metric", "openstatus.icmp.request.duration").Msg("Error creating gauge") + } + + packetLoss := float64(result.PacketsSent-result.PacketsReceived) / float64(result.PacketsSent) * 100 + if err := recordGaugeWithUnit(ctx, meter, "openstatus.icmp.packet.loss", "Packet loss percentage", unitPercent, packetLoss, att); err != nil { + log.Ctx(ctx).Error().Err(err).Str("metric", "openstatus.icmp.packet.loss").Msg("Error creating gauge") + } +} + func RecordTCPMetrics(ctx context.Context, req request.TCPCheckerRequest, result checker.TCPResponse, region string) { withMeter(ctx, req.OtelConfig.Endpoint, req.OtelConfig.Headers, func(meter metric.Meter) { att := metric.WithAttributes( diff --git a/apps/checker/pkg/otel/otel_test.go b/apps/checker/pkg/otel/otel_test.go index 628e46f5..c3428119 100644 --- a/apps/checker/pkg/otel/otel_test.go +++ b/apps/checker/pkg/otel/otel_test.go @@ -74,6 +74,70 @@ func TestRecordGauge(t *testing.T) { assert.Equal(t, "test-value", val.AsString()) } +func TestRecordGaugeWithUnit(t *testing.T) { + for _, tc := range []struct { + name string + unit string + }{ + {name: "percentage", unit: unitPercent}, + {name: "duration", unit: unitMilliseconds}, + {name: "unitless", unit: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + meter, reader := newTestMeter(t) + att := metric.WithAttributes(attribute.String("region", "ams")) + + err := recordGaugeWithUnit(context.Background(), meter, "test.gauge", "A test gauge", tc.unit, 12.5, att) + require.NoError(t, err) + + rm := collectMetrics(t, reader) + require.Len(t, rm.ScopeMetrics, 1) + require.Len(t, rm.ScopeMetrics[0].Metrics, 1) + + m := rm.ScopeMetrics[0].Metrics[0] + assert.Equal(t, tc.unit, m.Unit) + + gauge, ok := m.Data.(metricdata.Gauge[float64]) + require.True(t, ok) + require.Len(t, gauge.DataPoints, 1) + assert.Equal(t, 12.5, gauge.DataPoints[0].Value) + }) + } +} + +// The two ICMP gauges carry different units, so they cannot share the +// ms-defaulting helper: a percentage exported as "ms" is rendered as a duration +// by the backend. +func TestICMPGaugeUnits(t *testing.T) { + meter, reader := newTestMeter(t) + att := metric.WithAttributes(attribute.String("openstatus.probes", "ams")) + + recordICMPInstruments(context.Background(), meter, checker.ICMPResponse{ + Latency: 42, + PacketsSent: 3, + PacketsReceived: 2, + }, att) + + units := map[string]string{} + values := map[string]float64{} + for _, sm := range collectMetrics(t, reader).ScopeMetrics { + for _, m := range sm.Metrics { + units[m.Name] = m.Unit + if gauge, ok := m.Data.(metricdata.Gauge[float64]); ok && len(gauge.DataPoints) == 1 { + values[m.Name] = gauge.DataPoints[0].Value + } + } + } + + assert.Equal(t, "ms", units["openstatus.icmp.request.duration"]) + assert.Equal(t, "%", units["openstatus.icmp.packet.loss"], + "packet loss is a percentage, not a duration") + + assert.Equal(t, float64(42), values["openstatus.icmp.request.duration"]) + assert.InDelta(t, 33.33, values["openstatus.icmp.packet.loss"], 0.01, + "1 of 3 packets lost is ~33%, confirming the value really is a percentage") +} + func TestRecordGauge_MultipleMetrics(t *testing.T) { meter, reader := newTestMeter(t) ctx := context.Background() diff --git a/apps/checker/pkg/scheduler/scheduler.go b/apps/checker/pkg/scheduler/scheduler.go index 6a12834c..87cab525 100644 --- a/apps/checker/pkg/scheduler/scheduler.go +++ b/apps/checker/pkg/scheduler/scheduler.go @@ -243,6 +243,61 @@ func (mm *MonitorManager) UpdateMonitors(ctx context.Context) { log.Printf("Started DNS monitoring job for %s (%s)", m.Id, m.Uri) } + for _, m := range res.Msg.IcmpMonitors { + currentIDs[m.Id] = struct{}{} + if mm.shouldSchedule(m.Id, m) { + + interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second + task := tasks.Task{ + Interval: interval, + RunOnce: false, + RunSingleInstance: true, + FuncWithTaskContext: func(ctx tasks.TaskContext) error { + + monitor := m + c := context.Background() + log.Printf("Starting ICMP job for monitor %s (%s)", monitor.Id, monitor.Uri) + data, err := mm.JobRunner.ICMPJob(c, monitor, res.Msg.Region) + if err != nil { + log.Printf("ICMP monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) + return err + } + resp, ingestErr := mm.Client.IngestICMP(c, &connect.Request[v1.IngestICMPRequest]{ + Msg: &v1.IngestICMPRequest{ + MonitorId: monitor.Id, + Id: data.ID, + Uri: monitor.Uri, + Message: data.Message, + Latency: data.Latency, + LatencyMin: data.LatencyMin, + LatencyMax: data.LatencyMax, + PacketsSent: data.PacketsSent, + PacketsReceived: data.PacketsReceived, + RequestStatus: data.RequestStatus, + Error: int64(data.Error), + CronTimestamp: data.CronTimestamp, + Timestamp: data.Timestamp, + Timing: data.Timing, + }, + }) + if ingestErr != nil { + log.Printf("Failed to ingest ICMP result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) + return ingestErr + } + log.Printf("ICMP monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Uri, resp) + + return nil + }, + } + err := mm.Scheduler.AddWithID(m.Id, &task) + if err != nil { + log.Printf("Failed to add ICMP monitor job for %s (%s): %v", m.Id, m.Uri, err) + continue + } + log.Printf("Started ICMP monitoring job for %s (%s)", m.Id, m.Uri) + } + } + mm.mu.Lock() for id := range mm.Scheduler.Tasks() { if _, stillExists := currentIDs[id]; !stillExists { diff --git a/apps/checker/pkg/scheduler/scheduler_test.go b/apps/checker/pkg/scheduler/scheduler_test.go index 45514456..ba1013a5 100644 --- a/apps/checker/pkg/scheduler/scheduler_test.go +++ b/apps/checker/pkg/scheduler/scheduler_test.go @@ -74,12 +74,17 @@ func (m *mockJobRunner) DNSJob(ctx context.Context, monitor *v1.DNSMonitor) (*jo }, nil } +func (m *mockJobRunner) ICMPJob(ctx context.Context, monitor *v1.ICMPMonitor, region string) (*job.ICMPPrivateRegionData, error) { + return &job.ICMPPrivateRegionData{}, nil +} + // mockClient implements v1.PrivateLocationServiceClient for testing type mockClient struct { MonitorsFunc func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) IngestHTTPFunc func(ctx context.Context, req *connect.Request[v1.IngestHTTPRequest]) (*connect.Response[v1.IngestHTTPResponse], error) IngestTCPFunc func(ctx context.Context, req *connect.Request[v1.IngestTCPRequest]) (*connect.Response[v1.IngestTCPResponse], error) IngestDNSFunc func(ctx context.Context, req *connect.Request[v1.IngestDNSRequest]) (*connect.Response[v1.IngestDNSResponse], error) + IngestICMPFunc func(ctx context.Context, req *connect.Request[v1.IngestICMPRequest]) (*connect.Response[v1.IngestICMPResponse], error) } func (m *mockClient) Monitors(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { @@ -94,6 +99,9 @@ func (m *mockClient) IngestTCP(ctx context.Context, req *connect.Request[v1.Inge func (m *mockClient) IngestDNS(ctx context.Context, req *connect.Request[v1.IngestDNSRequest]) (*connect.Response[v1.IngestDNSResponse], error) { return m.IngestDNSFunc(ctx, req) } +func (m *mockClient) IngestICMP(ctx context.Context, req *connect.Request[v1.IngestICMPRequest]) (*connect.Response[v1.IngestICMPResponse], error) { + return m.IngestICMPFunc(ctx, req) +} func TestMonitorManager_StartAndStopJobs_WithJobRunner(t *testing.T) { ctx := t.Context() diff --git a/apps/checker/proto/private_location/v1/icmp_monitor.pb.go b/apps/checker/proto/private_location/v1/icmp_monitor.pb.go new file mode 100644 index 00000000..d842fba5 --- /dev/null +++ b/apps/checker/proto/private_location/v1/icmp_monitor.pb.go @@ -0,0 +1,183 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: private_location/v1/icmp_monitor.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ICMPMonitor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` + Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` + DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` + Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` + Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` + OtelConfig *OtelConfig `protobuf:"bytes,20,opt,name=otel_config,json=otelConfig,proto3" json:"otel_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ICMPMonitor) Reset() { + *x = ICMPMonitor{} + mi := &file_private_location_v1_icmp_monitor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ICMPMonitor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ICMPMonitor) ProtoMessage() {} + +func (x *ICMPMonitor) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_icmp_monitor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ICMPMonitor.ProtoReflect.Descriptor instead. +func (*ICMPMonitor) Descriptor() ([]byte, []int) { + return file_private_location_v1_icmp_monitor_proto_rawDescGZIP(), []int{0} +} + +func (x *ICMPMonitor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ICMPMonitor) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *ICMPMonitor) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *ICMPMonitor) GetDegradedAt() int64 { + if x != nil && x.DegradedAt != nil { + return *x.DegradedAt + } + return 0 +} + +func (x *ICMPMonitor) GetPeriodicity() string { + if x != nil { + return x.Periodicity + } + return "" +} + +func (x *ICMPMonitor) GetRetry() int64 { + if x != nil { + return x.Retry + } + return 0 +} + +func (x *ICMPMonitor) GetOtelConfig() *OtelConfig { + if x != nil { + return x.OtelConfig + } + return nil +} + +var File_private_location_v1_icmp_monitor_proto protoreflect.FileDescriptor + +const file_private_location_v1_icmp_monitor_proto_rawDesc = "" + + "\n" + + "&private_location/v1/icmp_monitor.proto\x12\x13private_location.v1\x1a\x1eprivate_location/v1/otel.proto\"\xf9\x01\n" + + "\vICMPMonitor\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + + "degradedAt\x88\x01\x01\x12 \n" + + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + + "\x05retry\x18\x06 \x01(\x03R\x05retry\x12@\n" + + "\votel_config\x18\x14 \x01(\v2\x1f.private_location.v1.OtelConfigR\n" + + "otelConfigB\x0e\n" + + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + +var ( + file_private_location_v1_icmp_monitor_proto_rawDescOnce sync.Once + file_private_location_v1_icmp_monitor_proto_rawDescData []byte +) + +func file_private_location_v1_icmp_monitor_proto_rawDescGZIP() []byte { + file_private_location_v1_icmp_monitor_proto_rawDescOnce.Do(func() { + file_private_location_v1_icmp_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_icmp_monitor_proto_rawDesc), len(file_private_location_v1_icmp_monitor_proto_rawDesc))) + }) + return file_private_location_v1_icmp_monitor_proto_rawDescData +} + +var file_private_location_v1_icmp_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_private_location_v1_icmp_monitor_proto_goTypes = []any{ + (*ICMPMonitor)(nil), // 0: private_location.v1.ICMPMonitor + (*OtelConfig)(nil), // 1: private_location.v1.OtelConfig +} +var file_private_location_v1_icmp_monitor_proto_depIdxs = []int32{ + 1, // 0: private_location.v1.ICMPMonitor.otel_config:type_name -> private_location.v1.OtelConfig + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_private_location_v1_icmp_monitor_proto_init() } +func file_private_location_v1_icmp_monitor_proto_init() { + if File_private_location_v1_icmp_monitor_proto != nil { + return + } + file_private_location_v1_otel_proto_init() + file_private_location_v1_icmp_monitor_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_icmp_monitor_proto_rawDesc), len(file_private_location_v1_icmp_monitor_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_private_location_v1_icmp_monitor_proto_goTypes, + DependencyIndexes: file_private_location_v1_icmp_monitor_proto_depIdxs, + MessageInfos: file_private_location_v1_icmp_monitor_proto_msgTypes, + }.Build() + File_private_location_v1_icmp_monitor_proto = out.File + file_private_location_v1_icmp_monitor_proto_goTypes = nil + file_private_location_v1_icmp_monitor_proto_depIdxs = nil +} diff --git a/apps/checker/proto/private_location/v1/private_location.connect.go b/apps/checker/proto/private_location/v1/private_location.connect.go index 40235485..8138ab7c 100644 --- a/apps/checker/proto/private_location/v1/private_location.connect.go +++ b/apps/checker/proto/private_location/v1/private_location.connect.go @@ -44,6 +44,9 @@ const ( // PrivateLocationServiceIngestDNSProcedure is the fully-qualified name of the // PrivateLocationService's IngestDNS RPC. PrivateLocationServiceIngestDNSProcedure = "/private_location.v1.PrivateLocationService/IngestDNS" + // PrivateLocationServiceIngestICMPProcedure is the fully-qualified name of the + // PrivateLocationService's IngestICMP RPC. + PrivateLocationServiceIngestICMPProcedure = "/private_location.v1.PrivateLocationService/IngestICMP" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService @@ -53,6 +56,7 @@ type PrivateLocationServiceClient interface { IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) + IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) } // NewPrivateLocationServiceClient constructs a client for the @@ -90,6 +94,12 @@ func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL stri connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithClientOptions(opts...), ), + ingestICMP: connect.NewClient[IngestICMPRequest, IngestICMPResponse]( + httpClient, + baseURL+PrivateLocationServiceIngestICMPProcedure, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), + connect.WithClientOptions(opts...), + ), } } @@ -99,6 +109,7 @@ type privateLocationServiceClient struct { ingestTCP *connect.Client[IngestTCPRequest, IngestTCPResponse] ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] + ingestICMP *connect.Client[IngestICMPRequest, IngestICMPResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. @@ -121,6 +132,11 @@ func (c *privateLocationServiceClient) IngestDNS(ctx context.Context, req *conne return c.ingestDNS.CallUnary(ctx, req) } +// IngestICMP calls private_location.v1.PrivateLocationService.IngestICMP. +func (c *privateLocationServiceClient) IngestICMP(ctx context.Context, req *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) { + return c.ingestICMP.CallUnary(ctx, req) +} + // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { @@ -128,6 +144,7 @@ type PrivateLocationServiceHandler interface { IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) + IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It @@ -161,6 +178,12 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithHandlerOptions(opts...), ) + privateLocationServiceIngestICMPHandler := connect.NewUnaryHandler( + PrivateLocationServiceIngestICMPProcedure, + svc.IngestICMP, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), + connect.WithHandlerOptions(opts...), + ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: @@ -171,6 +194,8 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. privateLocationServiceIngestHTTPHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestDNSProcedure: privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) + case PrivateLocationServiceIngestICMPProcedure: + privateLocationServiceIngestICMPHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -195,3 +220,7 @@ func (UnimplementedPrivateLocationServiceHandler) IngestHTTP(context.Context, *c func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestDNS is not implemented")) } + +func (UnimplementedPrivateLocationServiceHandler) IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestICMP is not implemented")) +} diff --git a/apps/checker/proto/private_location/v1/private_location.pb.go b/apps/checker/proto/private_location/v1/private_location.pb.go index e31eb986..a28112bc 100644 --- a/apps/checker/proto/private_location/v1/private_location.pb.go +++ b/apps/checker/proto/private_location/v1/private_location.pb.go @@ -62,6 +62,7 @@ type MonitorsResponse struct { HttpMonitors []*HTTPMonitor `protobuf:"bytes,1,rep,name=http_monitors,json=httpMonitors,proto3" json:"http_monitors,omitempty"` TcpMonitors []*TCPMonitor `protobuf:"bytes,2,rep,name=tcp_monitors,json=tcpMonitors,proto3" json:"tcp_monitors,omitempty"` DnsMonitors []*DNSMonitor `protobuf:"bytes,3,rep,name=dns_monitors,json=dnsMonitors,proto3" json:"dns_monitors,omitempty"` + IcmpMonitors []*ICMPMonitor `protobuf:"bytes,5,rep,name=icmp_monitors,json=icmpMonitors,proto3" json:"icmp_monitors,omitempty"` Region string `protobuf:"bytes,4,opt,name=region,proto3" json:"region,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -118,6 +119,13 @@ func (x *MonitorsResponse) GetDnsMonitors() []*DNSMonitor { return nil } +func (x *MonitorsResponse) GetIcmpMonitors() []*ICMPMonitor { + if x != nil { + return x.IcmpMonitors + } + return nil +} + func (x *MonitorsResponse) GetRegion() string { if x != nil { return x.Region @@ -657,16 +665,201 @@ func (*IngestDNSResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{8} } +type IngestICMPRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` + Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` + LatencyMin int64 `protobuf:"varint,4,opt,name=latencyMin,proto3" json:"latencyMin,omitempty"` + LatencyMax int64 `protobuf:"varint,5,opt,name=latencyMax,proto3" json:"latencyMax,omitempty"` + PacketsSent int64 `protobuf:"varint,6,opt,name=packetsSent,proto3" json:"packetsSent,omitempty"` + PacketsReceived int64 `protobuf:"varint,7,opt,name=packetsReceived,proto3" json:"packetsReceived,omitempty"` + Timestamp int64 `protobuf:"varint,8,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + CronTimestamp int64 `protobuf:"varint,9,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` + Uri string `protobuf:"bytes,10,opt,name=uri,proto3" json:"uri,omitempty"` + Message string `protobuf:"bytes,11,opt,name=message,proto3" json:"message,omitempty"` + RequestStatus string `protobuf:"bytes,12,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` + Error int64 `protobuf:"varint,13,opt,name=error,proto3" json:"error,omitempty"` + Timing string `protobuf:"bytes,14,opt,name=timing,proto3" json:"timing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestICMPRequest) Reset() { + *x = IngestICMPRequest{} + mi := &file_private_location_v1_private_location_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestICMPRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestICMPRequest) ProtoMessage() {} + +func (x *IngestICMPRequest) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestICMPRequest.ProtoReflect.Descriptor instead. +func (*IngestICMPRequest) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{9} +} + +func (x *IngestICMPRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *IngestICMPRequest) GetMonitorId() string { + if x != nil { + return x.MonitorId + } + return "" +} + +func (x *IngestICMPRequest) GetLatency() int64 { + if x != nil { + return x.Latency + } + return 0 +} + +func (x *IngestICMPRequest) GetLatencyMin() int64 { + if x != nil { + return x.LatencyMin + } + return 0 +} + +func (x *IngestICMPRequest) GetLatencyMax() int64 { + if x != nil { + return x.LatencyMax + } + return 0 +} + +func (x *IngestICMPRequest) GetPacketsSent() int64 { + if x != nil { + return x.PacketsSent + } + return 0 +} + +func (x *IngestICMPRequest) GetPacketsReceived() int64 { + if x != nil { + return x.PacketsReceived + } + return 0 +} + +func (x *IngestICMPRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *IngestICMPRequest) GetCronTimestamp() int64 { + if x != nil { + return x.CronTimestamp + } + return 0 +} + +func (x *IngestICMPRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *IngestICMPRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *IngestICMPRequest) GetRequestStatus() string { + if x != nil { + return x.RequestStatus + } + return "" +} + +func (x *IngestICMPRequest) GetError() int64 { + if x != nil { + return x.Error + } + return 0 +} + +func (x *IngestICMPRequest) GetTiming() string { + if x != nil { + return x.Timing + } + return "" +} + +type IngestICMPResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestICMPResponse) Reset() { + *x = IngestICMPResponse{} + mi := &file_private_location_v1_private_location_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestICMPResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestICMPResponse) ProtoMessage() {} + +func (x *IngestICMPResponse) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestICMPResponse.ProtoReflect.Descriptor instead. +func (*IngestICMPResponse) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{10} +} + var File_private_location_v1_private_location_proto protoreflect.FileDescriptor const file_private_location_v1_private_location_proto_rawDesc = "" + "\n" + - "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + - "\x0fMonitorsRequest\"\xf9\x01\n" + + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a&private_location/v1/icmp_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + + "\x0fMonitorsRequest\"\xc0\x02\n" + "\x10MonitorsResponse\x12E\n" + "\rhttp_monitors\x18\x01 \x03(\v2 .private_location.v1.HTTPMonitorR\fhttpMonitors\x12B\n" + "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\x12B\n" + - "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\x12\x16\n" + + "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\x12E\n" + + "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12\x16\n" + "\x06region\x18\x04 \x01(\tR\x06region\"\x9e\x02\n" + "\x10IngestTCPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + @@ -717,13 +910,36 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "\fRecordsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + "\x05value\x18\x02 \x01(\v2\x1c.private_location.v1.RecordsR\x05value:\x028\x01\"\x13\n" + - "\x11IngestDNSResponse2\x90\x03\n" + + "\x11IngestDNSResponse\"\xab\x03\n" + + "\x11IngestICMPRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1e\n" + + "\n" + + "latencyMin\x18\x04 \x01(\x03R\n" + + "latencyMin\x12\x1e\n" + + "\n" + + "latencyMax\x18\x05 \x01(\x03R\n" + + "latencyMax\x12 \n" + + "\vpacketsSent\x18\x06 \x01(\x03R\vpacketsSent\x12(\n" + + "\x0fpacketsReceived\x18\a \x01(\x03R\x0fpacketsReceived\x12\x1c\n" + + "\ttimestamp\x18\b \x01(\x03R\ttimestamp\x12$\n" + + "\rcronTimestamp\x18\t \x01(\x03R\rcronTimestamp\x12\x10\n" + + "\x03uri\x18\n" + + " \x01(\tR\x03uri\x12\x18\n" + + "\amessage\x18\v \x01(\tR\amessage\x12$\n" + + "\rrequestStatus\x18\f \x01(\tR\rrequestStatus\x12\x14\n" + + "\x05error\x18\r \x01(\x03R\x05error\x12\x16\n" + + "\x06timing\x18\x0e \x01(\tR\x06timing\"\x14\n" + + "\x12IngestICMPResponse2\xf1\x03\n" + "\x16PrivateLocationService\x12Y\n" + "\bMonitors\x12$.private_location.v1.MonitorsRequest\x1a%.private_location.v1.MonitorsResponse\"\x00\x12\\\n" + "\tIngestTCP\x12%.private_location.v1.IngestTCPRequest\x1a&.private_location.v1.IngestTCPResponse\"\x00\x12_\n" + "\n" + "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00\x12\\\n" + - "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00\x12_\n" + + "\n" + + "IngestICMP\x12&.private_location.v1.IngestICMPRequest\x1a'.private_location.v1.IngestICMPResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once @@ -737,7 +953,7 @@ func file_private_location_v1_private_location_proto_rawDescGZIP() []byte { return file_private_location_v1_private_location_proto_rawDescData } -var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse @@ -748,30 +964,36 @@ var file_private_location_v1_private_location_proto_goTypes = []any{ (*Records)(nil), // 6: private_location.v1.Records (*IngestDNSRequest)(nil), // 7: private_location.v1.IngestDNSRequest (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse - nil, // 9: private_location.v1.IngestDNSRequest.RecordsEntry - (*HTTPMonitor)(nil), // 10: private_location.v1.HTTPMonitor - (*TCPMonitor)(nil), // 11: private_location.v1.TCPMonitor - (*DNSMonitor)(nil), // 12: private_location.v1.DNSMonitor + (*IngestICMPRequest)(nil), // 9: private_location.v1.IngestICMPRequest + (*IngestICMPResponse)(nil), // 10: private_location.v1.IngestICMPResponse + nil, // 11: private_location.v1.IngestDNSRequest.RecordsEntry + (*HTTPMonitor)(nil), // 12: private_location.v1.HTTPMonitor + (*TCPMonitor)(nil), // 13: private_location.v1.TCPMonitor + (*DNSMonitor)(nil), // 14: private_location.v1.DNSMonitor + (*ICMPMonitor)(nil), // 15: private_location.v1.ICMPMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ - 10, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor - 11, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor - 12, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor - 9, // 3: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry - 6, // 4: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records - 0, // 5: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest - 2, // 6: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest - 4, // 7: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest - 7, // 8: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest - 1, // 9: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse - 3, // 10: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse - 5, // 11: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse - 8, // 12: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse - 9, // [9:13] is the sub-list for method output_type - 5, // [5:9] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 12, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor + 13, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor + 14, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor + 15, // 3: private_location.v1.MonitorsResponse.icmp_monitors:type_name -> private_location.v1.ICMPMonitor + 11, // 4: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry + 6, // 5: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records + 0, // 6: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest + 2, // 7: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest + 4, // 8: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest + 7, // 9: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest + 9, // 10: private_location.v1.PrivateLocationService.IngestICMP:input_type -> private_location.v1.IngestICMPRequest + 1, // 11: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse + 3, // 12: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse + 5, // 13: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse + 8, // 14: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse + 10, // 15: private_location.v1.PrivateLocationService.IngestICMP:output_type -> private_location.v1.IngestICMPResponse + 11, // [11:16] is the sub-list for method output_type + 6, // [6:11] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } @@ -781,6 +1003,7 @@ func file_private_location_v1_private_location_proto_init() { } file_private_location_v1_dns_monitor_proto_init() file_private_location_v1_http_monitor_proto_init() + file_private_location_v1_icmp_monitor_proto_init() file_private_location_v1_tcp_monitor_proto_init() type x struct{} out := protoimpl.TypeBuilder{ @@ -788,7 +1011,7 @@ func file_private_location_v1_private_location_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc)), NumEnums: 0, - NumMessages: 10, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/checker/request/request.go b/apps/checker/request/request.go index 2828e334..ac5b9626 100644 --- a/apps/checker/request/request.go +++ b/apps/checker/request/request.go @@ -108,6 +108,23 @@ type TCPCheckerRequest struct { } `json:"otelConfig"` } +type ICMPCheckerRequest struct { + Status string `json:"status"` + WorkspaceID string `json:"workspaceId"` + URI string `json:"uri"` + MonitorID string `json:"monitorId"` + Trigger string `json:"trigger,omitempty"` + RequestId int64 `json:"requestId,omitempty"` + CronTimestamp int64 `json:"cronTimestamp"` + Timeout int64 `json:"timeout"` + DegradedAfter int64 `json:"degradedAfter,omitempty"` + Retry int64 `json:"retry,omitempty"` + OtelConfig struct { + Endpoint string `json:"endpoint"` + Headers map[string]string `json:"headers,omitempty"` + } `json:"otelConfig"` +} + type TCPRequest struct { WorkspaceID string `json:"workspaceId"` URL string `json:"url"` diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx index 5afeb6c1..26680f14 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx @@ -131,7 +131,9 @@ export function Client() { setPagination={setPagination} paginationComponent={DataTablePagination} defaultColumnVisibility={ - monitor.jobType === "tcp" || monitor.jobType === "dns" + monitor.jobType === "tcp" || + monitor.jobType === "dns" || + monitor.jobType === "icmp" ? { timing: false, statusCode: false } : {} } diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx index 6a1a7517..7197d2c8 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx @@ -26,10 +26,13 @@ import { useTRPC } from "@/lib/trpc/client"; type TestTCP = RouterOutputs["checker"]["testTcp"]; type TestHTTP = RouterOutputs["checker"]["testHttp"]; type TestDNS = RouterOutputs["checker"]["testDns"]; +type TestICMP = RouterOutputs["checker"]["testIcmp"]; export function NavActions() { const { id } = useParams<{ id: string }>(); - const [test, setTest] = useState(null); + const [test, setTest] = useState< + TestTCP | TestHTTP | TestDNS | TestICMP | null + >(null); const queryClient = useQueryClient(); const trpc = useTRPC(); const router = useRouter(); @@ -66,6 +69,7 @@ export function NavActions() { const testHttpMutation = useMutation(trpc.checker.testHttp.mutationOptions()); const testTcpMutation = useMutation(trpc.checker.testTcp.mutationOptions()); const testDnsMutation = useMutation(trpc.checker.testDns.mutationOptions()); + const testIcmpMutation = useMutation(trpc.checker.testIcmp.mutationOptions()); // curl only speaks HTTP — the action is hidden for tcp/dns monitors const curlCommand = @@ -160,6 +164,22 @@ export function NavActions() { return "DNS test failed"; }, }); + } else if (monitor?.jobType === "icmp") { + const promise = testIcmpMutation.mutateAsync({ url: monitor.url }); + + toast.promise(promise, { + loading: "Testing ICMP request...", + success: (data) => { + setTest(data); + return "ICMP test completed successfully"; + }, + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "ICMP test failed"; + }, + }); } } diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx index 0b9f5167..35e9c6ad 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx @@ -83,7 +83,7 @@ export function Client() { ...trpc.tinybird.metricsRegions.queryOptions({ monitorId: id, period: effectivePeriod, - type: (monitor?.jobType ?? "http") as "http" | "tcp", + type: (monitor?.jobType ?? "http") as "http" | "tcp" | "dns" | "icmp", regions: selectedRegions, // bucket by period (daily at 30d/90d) to keep payload + chart readable interval: periodToInterval[effectivePeriod], @@ -144,7 +144,7 @@ export function Client() {
@@ -158,7 +158,7 @@ export function Client() { @@ -197,7 +197,7 @@ export function Client() { monitorId={id} percentile={percentile} degradedAfter={monitor.degradedAfter} - type={monitor.jobType as "http" | "tcp"} + type={monitor.jobType as "http" | "tcp" | "dns" | "icmp"} period={effectivePeriod} regions={selectedRegions} /> diff --git a/apps/dashboard/src/components/chart/chart-area-latency.tsx b/apps/dashboard/src/components/chart/chart-area-latency.tsx index 98cb3d70..b1dfc888 100644 --- a/apps/dashboard/src/components/chart/chart-area-latency.tsx +++ b/apps/dashboard/src/components/chart/chart-area-latency.tsx @@ -51,7 +51,7 @@ export function ChartAreaLatency({ degradedAfter: number | null; percentile: (typeof PERCENTILES)[number]; period: (typeof PERIODS)[number]; - type: "http" | "tcp"; + type: "http" | "tcp" | "dns" | "icmp"; regions: string[] | undefined; }) { const trpc = useTRPC(); diff --git a/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx b/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx index 7f8493fc..ac4672c7 100644 --- a/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx +++ b/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx @@ -36,7 +36,7 @@ export function ChartBarUptimeLight({ regions, }: { monitorId: string; - type: "http" | "tcp"; + type: "http" | "tcp" | "dns" | "icmp"; regions?: Region[]; }) { const trpc = useTRPC(); diff --git a/apps/dashboard/src/components/chart/chart-bar-uptime.tsx b/apps/dashboard/src/components/chart/chart-bar-uptime.tsx index f408334a..cfbb1f7e 100644 --- a/apps/dashboard/src/components/chart/chart-bar-uptime.tsx +++ b/apps/dashboard/src/components/chart/chart-bar-uptime.tsx @@ -44,7 +44,7 @@ export function ChartBarUptime({ }: { monitorId: string; period: (typeof PERIODS)[number]; - type: "http" | "tcp"; + type: "http" | "tcp" | "dns" | "icmp"; regions: string[] | undefined; }) { const isMobile = useIsMobile(); diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx index debc22e6..41f23c05 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx @@ -50,6 +50,11 @@ export function DataTableBasics({ ); } + if (data.type === "icmp") { + return ( + + ); + } return null; } @@ -467,6 +472,170 @@ export function DataTableBasicsTCP({ ); } +export function DataTableBasicsICMP({ + data, + privateLocations, +}: { + data: Extract & { + trigger?: "cron" | "api" | "test" | null; + }; + privateLocations?: PrivateLocation[]; +}) { + const privateLocataion = privateLocations?.find( + (location) => String(location.id) === String(data.region), + ); + const regionConfig = getRegionInfo(data.region, { + location: privateLocataion?.name, + }); + const packetLoss = + data.packetsSent > 0 + ? (data.packetsSent - data.packetsReceived) / data.packetsSent + : 0; + return ( +
+ + + + + + + Request + + + + Result + + +
+
+
+ {data?.requestStatus ?? "unknown"} +
+
+ + + {data.id ? ( + + + ID + + + {data.id} + + + ) : null} + + + Timestamp + + + + + + + + Host + + + {data.uri} + + + + + Latency (avg) + + + + + + + + Latency (min / max) + + + {formatMilliseconds(data.latencyMin)} /{" "} + {formatMilliseconds(data.latencyMax)} + + + + + Packets + + + {data.packetsReceived} / {data.packetsSent} received + + + + + Packet Loss + + + {formatPercentage(packetLoss)} + + + + + Region + + + {regionConfig?.flag} {regionConfig?.code}{" "} + + {regionConfig?.location} + + + + + + Cloud Provider + + + + + {regionConfig?.provider} + + + + {data.trigger ? ( + + + Trigger + + + {data?.trigger} + + + ) : null} + {data?.errorMessage ? ( + <> + + Error Message + + + +
+                  {data.errorMessage}
+                
+
+
+ + ) : null} + +
+ ); +} + export function DataTableBasicsDNS({ data, privateLocations, diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx index b01dd701..71ecdfdd 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx @@ -15,6 +15,7 @@ import { DataTableBasics } from "./data-table-basics"; type TestTCP = RouterOutputs["checker"]["testTcp"]; type TestHTTP = RouterOutputs["checker"]["testHttp"]; type TestDNS = RouterOutputs["checker"]["testDns"]; +type TestICMP = RouterOutputs["checker"]["testIcmp"]; type Monitor = NonNullable; export function DataTableSheetTest({ @@ -22,7 +23,7 @@ export function DataTableSheetTest({ monitor, onClose, }: { - data: TestTCP | TestHTTP | TestDNS | null; + data: TestTCP | TestHTTP | TestDNS | TestICMP | null; monitor: Monitor; onClose: () => void; }) { @@ -45,7 +46,10 @@ export function DataTableSheetTest({ ); } -function mapping(data: TestTCP | TestHTTP | TestDNS, monitor: Monitor) { +function mapping( + data: TestTCP | TestHTTP | TestDNS | TestICMP, + monitor: Monitor, +) { switch (data.type) { case "http": return { @@ -102,6 +106,26 @@ function mapping(data: TestTCP | TestHTTP | TestDNS, monitor: Monitor) { errorMessage: null, assertions: null, } as const; + case "icmp": + return { + id: null, + trigger: null, + timestamp: data.timestamp, + cronTimestamp: data.timestamp, + region: data.region, + type: data.type, + requestStatus: "success", + error: false, + latency: data.latency ?? 0, + latencyMin: data.latencyMin ?? 0, + latencyMax: data.latencyMax ?? 0, + packetsSent: data.packetsSent ?? 0, + packetsReceived: data.packetsReceived ?? 0, + uri: monitor.url, + monitorId: String(monitor.id), + errorMessage: null, + assertions: null, + } as const; default: return null; } diff --git a/apps/dashboard/src/components/forms/monitor/form-general.tsx b/apps/dashboard/src/components/forms/monitor/form-general.tsx index 70179e26..5233f2d6 100644 --- a/apps/dashboard/src/components/forms/monitor/form-general.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-general.tsx @@ -13,7 +13,7 @@ import { textBodyAssertion, } from "@openstatus/assertions"; import { monitorMethods } from "@openstatus/db/src/schema/monitors/constants"; -import { Globe, Network, Add, Server, Close } from "@openstatus/icons"; +import { Globe, Network, Add, Speed, Server, Close } from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, @@ -72,7 +72,7 @@ import { FormCardTitle, } from "@/components/forms/form-card"; -const TYPES = ["http", "tcp", "dns"] as const; +const TYPES = ["http", "tcp", "dns", "icmp"] as const; const HTTP_ASSERTION_TYPES = ["status", "header", "textBody"] as const; const DNS_ASSERTION_TYPES = dnsRecords; @@ -273,13 +273,14 @@ export function FormGeneral({ {[ { value: "http", icon: Globe, label: "HTTP" }, { value: "tcp", icon: Network, label: "TCP" }, { value: "dns", icon: Server, label: "DNS" }, + { value: "icmp", icon: Speed, label: "ICMP" }, ].map((type) => { return ( @@ -724,6 +725,48 @@ export function FormGeneral({
)} + {watchType === "icmp" && ( + + ( + + Host + + + + + + The host to ping. Supports a domain, IPv4, or IPv6 address + (no port). + + + )} + /> +
+ Examples: +
    +
  • + Domain:{" "} + + openstatus.dev + +
  • +
  • + IPv4:{" "} + 1.1.1.1 +
  • +
  • + IPv6:{" "} + + 2001:4860:4860::8888 + +
  • +
+
+
+ )} {watchType === "dns" && ( <> diff --git a/apps/dashboard/src/components/forms/monitor/update.tsx b/apps/dashboard/src/components/forms/monitor/update.tsx index 7a23f94e..071607f4 100644 --- a/apps/dashboard/src/components/forms/monitor/update.tsx +++ b/apps/dashboard/src/components/forms/monitor/update.tsx @@ -119,7 +119,7 @@ export function FormMonitorUpdate() { `) + or run it as root. The agent falls back to this automatically when datagram + sockets are unavailable. + +Without either, ICMP checks fail to open a socket and are reported as errors. diff --git a/apps/private-location/internal/database/models.go b/apps/private-location/internal/database/models.go index 037a2b4f..9334198c 100644 --- a/apps/private-location/internal/database/models.go +++ b/apps/private-location/internal/database/models.go @@ -10,6 +10,7 @@ const ( JobTypeUDP JobType = "udp" JobTypeHTTP JobType = "http" JobTypeDNS JobType = "dns" + JobTypeICMP JobType = "icmp" ) type Monitor struct { diff --git a/apps/private-location/internal/server/ingest_icmp.go b/apps/private-location/internal/server/ingest_icmp.go new file mode 100644 index 00000000..1a66a70c --- /dev/null +++ b/apps/private-location/internal/server/ingest_icmp.go @@ -0,0 +1,91 @@ +package server + +import ( + "context" + "strconv" + + "connectrpc.com/connect" + "github.com/openstatushq/openstatus/apps/private-location/internal/tinybird" + private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" +) + +type ICMPData struct { + ID string `json:"id"` + Timing string `json:"timing"` + ErrorMessage string `json:"errorMessage"` + Region string `json:"region"` + Trigger string `json:"trigger"` + URI string `json:"uri"` + RequestStatus string `json:"requestStatus,omitempty"` + + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + LatencyMin int64 `json:"latencyMin"` + LatencyMax int64 `json:"latencyMax"` + CronTimestamp int64 `json:"cronTimestamp"` + + PacketsSent uint8 `json:"packetsSent"` + PacketsReceived uint8 `json:"packetsReceived"` + + Error uint8 `json:"error"` +} + +func (h *privateLocationHandler) IngestICMP(ctx context.Context, req *connect.Request[private_locationv1.IngestICMPRequest]) (*connect.Response[private_locationv1.IngestICMPResponse], error) { + token := req.Header().Get("openstatus-token") + if token == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, ErrMissingToken) + } + + if err := ValidateIngestICMPRequest(req.Msg); err != nil { + return nil, NewValidationError(err) + } + + ic, err := h.getIngestContext(ctx, token, req.Msg.MonitorId) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Enrich wide event with business context + if holder := GetEvent(ctx); holder != nil { + holder.Event["private_location"] = map[string]any{ + "monitor_id": req.Msg.MonitorId, + "workspace_id": ic.Monitor.WorkspaceID, + "region_id": ic.Region.ID, + "datasource": tinybird.DatasourceICMP, + } + } + + data := ICMPData{ + ID: req.Msg.Id, + WorkspaceID: int64(ic.Monitor.WorkspaceID), + Timestamp: req.Msg.Timestamp, + Error: uint8(req.Msg.Error), + Region: strconv.Itoa(ic.Region.ID), + MonitorID: int64(ic.Monitor.ID), + Timing: req.Msg.Timing, + Latency: req.Msg.Latency, + LatencyMin: req.Msg.LatencyMin, + LatencyMax: req.Msg.LatencyMax, + PacketsSent: uint8(req.Msg.PacketsSent), + PacketsReceived: uint8(req.Msg.PacketsReceived), + CronTimestamp: req.Msg.CronTimestamp, + Trigger: "cron", + URI: req.Msg.Uri, + RequestStatus: req.Msg.RequestStatus, + } + + h.sendEventAndUpdateLastSeen(ctx, data, tinybird.DatasourceICMP, ic.Region.ID) + + h.forwardStatusUpdate(ctx, ic, statusUpdateInput{ + RequestStatus: data.RequestStatus, + Message: data.ErrorMessage, + Latency: data.Latency, + CronTimestamp: data.CronTimestamp, + ErrorFlag: data.Error, + }) + + return connect.NewResponse(&private_locationv1.IngestICMPResponse{}), nil +} diff --git a/apps/private-location/internal/server/monitors.go b/apps/private-location/internal/server/monitors.go index e227e02c..04cece34 100644 --- a/apps/private-location/internal/server/monitors.go +++ b/apps/private-location/internal/server/monitors.go @@ -195,7 +195,7 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } - httpMonitors, tcpMonitors, dnsMonitors, workspaceId := mapMonitors(ctx, monitors) + httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, workspaceId := mapMonitors(ctx, monitors) // Enrich wide event with monitor counts if holder := GetEvent(ctx); holder != nil { @@ -204,6 +204,7 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ "http_monitors": len(httpMonitors), "tcp_monitors": len(tcpMonitors), "dns_monitors": len(dnsMonitors), + "icmp_monitors": len(icmpMonitors), "total_monitors": len(monitors), } } @@ -212,6 +213,7 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ HttpMonitors: httpMonitors, TcpMonitors: tcpMonitors, DnsMonitors: dnsMonitors, + IcmpMonitors: icmpMonitors, Region: location.Name, }), nil } @@ -220,12 +222,14 @@ func mapMonitors(ctx context.Context, monitors []database.Monitor) ( []*private_locationv1.HTTPMonitor, []*private_locationv1.TCPMonitor, []*private_locationv1.DNSMonitor, + []*private_locationv1.ICMPMonitor, int, ) { var workspaceId int var httpMonitors []*private_locationv1.HTTPMonitor var tcpMonitors []*private_locationv1.TCPMonitor var dnsMonitors []*private_locationv1.DNSMonitor + var icmpMonitors []*private_locationv1.ICMPMonitor for _, monitor := range monitors { if workspaceId == 0 { workspaceId = monitor.WorkspaceID @@ -238,10 +242,12 @@ func mapMonitors(ctx context.Context, monitors []database.Monitor) ( tcpMonitors = append(tcpMonitors, toTCPMonitor(ctx, monitor)) case database.JobTypeDNS: dnsMonitors = append(dnsMonitors, toDNSMonitor(ctx, monitor)) + case database.JobTypeICMP: + icmpMonitors = append(icmpMonitors, toICMPMonitor(ctx, monitor)) } } - return httpMonitors, tcpMonitors, dnsMonitors, workspaceId + return httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, workspaceId } func toHTTPMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.HTTPMonitor { @@ -283,6 +289,18 @@ func toTCPMonitor(ctx context.Context, monitor database.Monitor) *private_locati } } +func toICMPMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.ICMPMonitor { + return &private_locationv1.ICMPMonitor{ + Id: strconv.Itoa(monitor.ID), + Uri: monitor.URL, + Timeout: monitor.Timeout, + DegradedAt: &monitor.DegradedAfter.Int64, + Periodicity: monitor.Periodicity, + Retry: int64(monitor.Retry), + OtelConfig: buildOtelConfig(ctx, monitor), + } +} + func toDNSMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.DNSMonitor { return &private_locationv1.DNSMonitor{ Id: strconv.Itoa(monitor.ID), diff --git a/apps/private-location/internal/server/validation.go b/apps/private-location/internal/server/validation.go index 43e42b6f..cf9f11f2 100644 --- a/apps/private-location/internal/server/validation.go +++ b/apps/private-location/internal/server/validation.go @@ -58,6 +58,20 @@ func ValidateIngestDNSRequest(req *private_locationv1.IngestDNSRequest) error { return nil } +// ValidateIngestICMPRequest validates an ICMP ingest request +func ValidateIngestICMPRequest(req *private_locationv1.IngestICMPRequest) error { + if req.MonitorId == "" { + return ErrEmptyMonitorID + } + if req.Latency < 0 { + return ErrInvalidLatency + } + if req.Timestamp <= 0 { + return ErrInvalidTimestamp + } + return nil +} + // NewValidationError creates a Connect error for validation failures func NewValidationError(err error) *connect.Error { return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation error: %w", err)) diff --git a/apps/private-location/internal/tinybird/client.go b/apps/private-location/internal/tinybird/client.go index 98a30905..b50fd538 100644 --- a/apps/private-location/internal/tinybird/client.go +++ b/apps/private-location/internal/tinybird/client.go @@ -15,6 +15,7 @@ const ( DatasourceHTTP = "ping_response__v8" DatasourceTCP = "tcp_response__v0" DatasourceDNS = "dns_response__v0" + DatasourceICMP = "icmp_response__v0" ) func getBaseURL() string { diff --git a/apps/private-location/proto/private_location/v1/icmp_monitor.pb.go b/apps/private-location/proto/private_location/v1/icmp_monitor.pb.go new file mode 100644 index 00000000..d842fba5 --- /dev/null +++ b/apps/private-location/proto/private_location/v1/icmp_monitor.pb.go @@ -0,0 +1,183 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: private_location/v1/icmp_monitor.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ICMPMonitor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` + Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` + DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` + Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` + Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` + OtelConfig *OtelConfig `protobuf:"bytes,20,opt,name=otel_config,json=otelConfig,proto3" json:"otel_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ICMPMonitor) Reset() { + *x = ICMPMonitor{} + mi := &file_private_location_v1_icmp_monitor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ICMPMonitor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ICMPMonitor) ProtoMessage() {} + +func (x *ICMPMonitor) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_icmp_monitor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ICMPMonitor.ProtoReflect.Descriptor instead. +func (*ICMPMonitor) Descriptor() ([]byte, []int) { + return file_private_location_v1_icmp_monitor_proto_rawDescGZIP(), []int{0} +} + +func (x *ICMPMonitor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ICMPMonitor) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *ICMPMonitor) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *ICMPMonitor) GetDegradedAt() int64 { + if x != nil && x.DegradedAt != nil { + return *x.DegradedAt + } + return 0 +} + +func (x *ICMPMonitor) GetPeriodicity() string { + if x != nil { + return x.Periodicity + } + return "" +} + +func (x *ICMPMonitor) GetRetry() int64 { + if x != nil { + return x.Retry + } + return 0 +} + +func (x *ICMPMonitor) GetOtelConfig() *OtelConfig { + if x != nil { + return x.OtelConfig + } + return nil +} + +var File_private_location_v1_icmp_monitor_proto protoreflect.FileDescriptor + +const file_private_location_v1_icmp_monitor_proto_rawDesc = "" + + "\n" + + "&private_location/v1/icmp_monitor.proto\x12\x13private_location.v1\x1a\x1eprivate_location/v1/otel.proto\"\xf9\x01\n" + + "\vICMPMonitor\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + + "degradedAt\x88\x01\x01\x12 \n" + + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + + "\x05retry\x18\x06 \x01(\x03R\x05retry\x12@\n" + + "\votel_config\x18\x14 \x01(\v2\x1f.private_location.v1.OtelConfigR\n" + + "otelConfigB\x0e\n" + + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + +var ( + file_private_location_v1_icmp_monitor_proto_rawDescOnce sync.Once + file_private_location_v1_icmp_monitor_proto_rawDescData []byte +) + +func file_private_location_v1_icmp_monitor_proto_rawDescGZIP() []byte { + file_private_location_v1_icmp_monitor_proto_rawDescOnce.Do(func() { + file_private_location_v1_icmp_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_icmp_monitor_proto_rawDesc), len(file_private_location_v1_icmp_monitor_proto_rawDesc))) + }) + return file_private_location_v1_icmp_monitor_proto_rawDescData +} + +var file_private_location_v1_icmp_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_private_location_v1_icmp_monitor_proto_goTypes = []any{ + (*ICMPMonitor)(nil), // 0: private_location.v1.ICMPMonitor + (*OtelConfig)(nil), // 1: private_location.v1.OtelConfig +} +var file_private_location_v1_icmp_monitor_proto_depIdxs = []int32{ + 1, // 0: private_location.v1.ICMPMonitor.otel_config:type_name -> private_location.v1.OtelConfig + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_private_location_v1_icmp_monitor_proto_init() } +func file_private_location_v1_icmp_monitor_proto_init() { + if File_private_location_v1_icmp_monitor_proto != nil { + return + } + file_private_location_v1_otel_proto_init() + file_private_location_v1_icmp_monitor_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_icmp_monitor_proto_rawDesc), len(file_private_location_v1_icmp_monitor_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_private_location_v1_icmp_monitor_proto_goTypes, + DependencyIndexes: file_private_location_v1_icmp_monitor_proto_depIdxs, + MessageInfos: file_private_location_v1_icmp_monitor_proto_msgTypes, + }.Build() + File_private_location_v1_icmp_monitor_proto = out.File + file_private_location_v1_icmp_monitor_proto_goTypes = nil + file_private_location_v1_icmp_monitor_proto_depIdxs = nil +} diff --git a/apps/private-location/proto/private_location/v1/private_location.connect.go b/apps/private-location/proto/private_location/v1/private_location.connect.go index 40235485..8138ab7c 100644 --- a/apps/private-location/proto/private_location/v1/private_location.connect.go +++ b/apps/private-location/proto/private_location/v1/private_location.connect.go @@ -44,6 +44,9 @@ const ( // PrivateLocationServiceIngestDNSProcedure is the fully-qualified name of the // PrivateLocationService's IngestDNS RPC. PrivateLocationServiceIngestDNSProcedure = "/private_location.v1.PrivateLocationService/IngestDNS" + // PrivateLocationServiceIngestICMPProcedure is the fully-qualified name of the + // PrivateLocationService's IngestICMP RPC. + PrivateLocationServiceIngestICMPProcedure = "/private_location.v1.PrivateLocationService/IngestICMP" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService @@ -53,6 +56,7 @@ type PrivateLocationServiceClient interface { IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) + IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) } // NewPrivateLocationServiceClient constructs a client for the @@ -90,6 +94,12 @@ func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL stri connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithClientOptions(opts...), ), + ingestICMP: connect.NewClient[IngestICMPRequest, IngestICMPResponse]( + httpClient, + baseURL+PrivateLocationServiceIngestICMPProcedure, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), + connect.WithClientOptions(opts...), + ), } } @@ -99,6 +109,7 @@ type privateLocationServiceClient struct { ingestTCP *connect.Client[IngestTCPRequest, IngestTCPResponse] ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] + ingestICMP *connect.Client[IngestICMPRequest, IngestICMPResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. @@ -121,6 +132,11 @@ func (c *privateLocationServiceClient) IngestDNS(ctx context.Context, req *conne return c.ingestDNS.CallUnary(ctx, req) } +// IngestICMP calls private_location.v1.PrivateLocationService.IngestICMP. +func (c *privateLocationServiceClient) IngestICMP(ctx context.Context, req *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) { + return c.ingestICMP.CallUnary(ctx, req) +} + // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { @@ -128,6 +144,7 @@ type PrivateLocationServiceHandler interface { IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) + IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It @@ -161,6 +178,12 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithHandlerOptions(opts...), ) + privateLocationServiceIngestICMPHandler := connect.NewUnaryHandler( + PrivateLocationServiceIngestICMPProcedure, + svc.IngestICMP, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), + connect.WithHandlerOptions(opts...), + ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: @@ -171,6 +194,8 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. privateLocationServiceIngestHTTPHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestDNSProcedure: privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) + case PrivateLocationServiceIngestICMPProcedure: + privateLocationServiceIngestICMPHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -195,3 +220,7 @@ func (UnimplementedPrivateLocationServiceHandler) IngestHTTP(context.Context, *c func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestDNS is not implemented")) } + +func (UnimplementedPrivateLocationServiceHandler) IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestICMP is not implemented")) +} diff --git a/apps/private-location/proto/private_location/v1/private_location.pb.go b/apps/private-location/proto/private_location/v1/private_location.pb.go index e31eb986..a28112bc 100644 --- a/apps/private-location/proto/private_location/v1/private_location.pb.go +++ b/apps/private-location/proto/private_location/v1/private_location.pb.go @@ -62,6 +62,7 @@ type MonitorsResponse struct { HttpMonitors []*HTTPMonitor `protobuf:"bytes,1,rep,name=http_monitors,json=httpMonitors,proto3" json:"http_monitors,omitempty"` TcpMonitors []*TCPMonitor `protobuf:"bytes,2,rep,name=tcp_monitors,json=tcpMonitors,proto3" json:"tcp_monitors,omitempty"` DnsMonitors []*DNSMonitor `protobuf:"bytes,3,rep,name=dns_monitors,json=dnsMonitors,proto3" json:"dns_monitors,omitempty"` + IcmpMonitors []*ICMPMonitor `protobuf:"bytes,5,rep,name=icmp_monitors,json=icmpMonitors,proto3" json:"icmp_monitors,omitempty"` Region string `protobuf:"bytes,4,opt,name=region,proto3" json:"region,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -118,6 +119,13 @@ func (x *MonitorsResponse) GetDnsMonitors() []*DNSMonitor { return nil } +func (x *MonitorsResponse) GetIcmpMonitors() []*ICMPMonitor { + if x != nil { + return x.IcmpMonitors + } + return nil +} + func (x *MonitorsResponse) GetRegion() string { if x != nil { return x.Region @@ -657,16 +665,201 @@ func (*IngestDNSResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{8} } +type IngestICMPRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` + Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` + LatencyMin int64 `protobuf:"varint,4,opt,name=latencyMin,proto3" json:"latencyMin,omitempty"` + LatencyMax int64 `protobuf:"varint,5,opt,name=latencyMax,proto3" json:"latencyMax,omitempty"` + PacketsSent int64 `protobuf:"varint,6,opt,name=packetsSent,proto3" json:"packetsSent,omitempty"` + PacketsReceived int64 `protobuf:"varint,7,opt,name=packetsReceived,proto3" json:"packetsReceived,omitempty"` + Timestamp int64 `protobuf:"varint,8,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + CronTimestamp int64 `protobuf:"varint,9,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` + Uri string `protobuf:"bytes,10,opt,name=uri,proto3" json:"uri,omitempty"` + Message string `protobuf:"bytes,11,opt,name=message,proto3" json:"message,omitempty"` + RequestStatus string `protobuf:"bytes,12,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` + Error int64 `protobuf:"varint,13,opt,name=error,proto3" json:"error,omitempty"` + Timing string `protobuf:"bytes,14,opt,name=timing,proto3" json:"timing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestICMPRequest) Reset() { + *x = IngestICMPRequest{} + mi := &file_private_location_v1_private_location_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestICMPRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestICMPRequest) ProtoMessage() {} + +func (x *IngestICMPRequest) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestICMPRequest.ProtoReflect.Descriptor instead. +func (*IngestICMPRequest) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{9} +} + +func (x *IngestICMPRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *IngestICMPRequest) GetMonitorId() string { + if x != nil { + return x.MonitorId + } + return "" +} + +func (x *IngestICMPRequest) GetLatency() int64 { + if x != nil { + return x.Latency + } + return 0 +} + +func (x *IngestICMPRequest) GetLatencyMin() int64 { + if x != nil { + return x.LatencyMin + } + return 0 +} + +func (x *IngestICMPRequest) GetLatencyMax() int64 { + if x != nil { + return x.LatencyMax + } + return 0 +} + +func (x *IngestICMPRequest) GetPacketsSent() int64 { + if x != nil { + return x.PacketsSent + } + return 0 +} + +func (x *IngestICMPRequest) GetPacketsReceived() int64 { + if x != nil { + return x.PacketsReceived + } + return 0 +} + +func (x *IngestICMPRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *IngestICMPRequest) GetCronTimestamp() int64 { + if x != nil { + return x.CronTimestamp + } + return 0 +} + +func (x *IngestICMPRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *IngestICMPRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *IngestICMPRequest) GetRequestStatus() string { + if x != nil { + return x.RequestStatus + } + return "" +} + +func (x *IngestICMPRequest) GetError() int64 { + if x != nil { + return x.Error + } + return 0 +} + +func (x *IngestICMPRequest) GetTiming() string { + if x != nil { + return x.Timing + } + return "" +} + +type IngestICMPResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestICMPResponse) Reset() { + *x = IngestICMPResponse{} + mi := &file_private_location_v1_private_location_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestICMPResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestICMPResponse) ProtoMessage() {} + +func (x *IngestICMPResponse) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestICMPResponse.ProtoReflect.Descriptor instead. +func (*IngestICMPResponse) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{10} +} + var File_private_location_v1_private_location_proto protoreflect.FileDescriptor const file_private_location_v1_private_location_proto_rawDesc = "" + "\n" + - "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + - "\x0fMonitorsRequest\"\xf9\x01\n" + + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a&private_location/v1/icmp_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + + "\x0fMonitorsRequest\"\xc0\x02\n" + "\x10MonitorsResponse\x12E\n" + "\rhttp_monitors\x18\x01 \x03(\v2 .private_location.v1.HTTPMonitorR\fhttpMonitors\x12B\n" + "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\x12B\n" + - "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\x12\x16\n" + + "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\x12E\n" + + "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12\x16\n" + "\x06region\x18\x04 \x01(\tR\x06region\"\x9e\x02\n" + "\x10IngestTCPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + @@ -717,13 +910,36 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "\fRecordsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + "\x05value\x18\x02 \x01(\v2\x1c.private_location.v1.RecordsR\x05value:\x028\x01\"\x13\n" + - "\x11IngestDNSResponse2\x90\x03\n" + + "\x11IngestDNSResponse\"\xab\x03\n" + + "\x11IngestICMPRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1e\n" + + "\n" + + "latencyMin\x18\x04 \x01(\x03R\n" + + "latencyMin\x12\x1e\n" + + "\n" + + "latencyMax\x18\x05 \x01(\x03R\n" + + "latencyMax\x12 \n" + + "\vpacketsSent\x18\x06 \x01(\x03R\vpacketsSent\x12(\n" + + "\x0fpacketsReceived\x18\a \x01(\x03R\x0fpacketsReceived\x12\x1c\n" + + "\ttimestamp\x18\b \x01(\x03R\ttimestamp\x12$\n" + + "\rcronTimestamp\x18\t \x01(\x03R\rcronTimestamp\x12\x10\n" + + "\x03uri\x18\n" + + " \x01(\tR\x03uri\x12\x18\n" + + "\amessage\x18\v \x01(\tR\amessage\x12$\n" + + "\rrequestStatus\x18\f \x01(\tR\rrequestStatus\x12\x14\n" + + "\x05error\x18\r \x01(\x03R\x05error\x12\x16\n" + + "\x06timing\x18\x0e \x01(\tR\x06timing\"\x14\n" + + "\x12IngestICMPResponse2\xf1\x03\n" + "\x16PrivateLocationService\x12Y\n" + "\bMonitors\x12$.private_location.v1.MonitorsRequest\x1a%.private_location.v1.MonitorsResponse\"\x00\x12\\\n" + "\tIngestTCP\x12%.private_location.v1.IngestTCPRequest\x1a&.private_location.v1.IngestTCPResponse\"\x00\x12_\n" + "\n" + "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00\x12\\\n" + - "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00\x12_\n" + + "\n" + + "IngestICMP\x12&.private_location.v1.IngestICMPRequest\x1a'.private_location.v1.IngestICMPResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once @@ -737,7 +953,7 @@ func file_private_location_v1_private_location_proto_rawDescGZIP() []byte { return file_private_location_v1_private_location_proto_rawDescData } -var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse @@ -748,30 +964,36 @@ var file_private_location_v1_private_location_proto_goTypes = []any{ (*Records)(nil), // 6: private_location.v1.Records (*IngestDNSRequest)(nil), // 7: private_location.v1.IngestDNSRequest (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse - nil, // 9: private_location.v1.IngestDNSRequest.RecordsEntry - (*HTTPMonitor)(nil), // 10: private_location.v1.HTTPMonitor - (*TCPMonitor)(nil), // 11: private_location.v1.TCPMonitor - (*DNSMonitor)(nil), // 12: private_location.v1.DNSMonitor + (*IngestICMPRequest)(nil), // 9: private_location.v1.IngestICMPRequest + (*IngestICMPResponse)(nil), // 10: private_location.v1.IngestICMPResponse + nil, // 11: private_location.v1.IngestDNSRequest.RecordsEntry + (*HTTPMonitor)(nil), // 12: private_location.v1.HTTPMonitor + (*TCPMonitor)(nil), // 13: private_location.v1.TCPMonitor + (*DNSMonitor)(nil), // 14: private_location.v1.DNSMonitor + (*ICMPMonitor)(nil), // 15: private_location.v1.ICMPMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ - 10, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor - 11, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor - 12, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor - 9, // 3: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry - 6, // 4: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records - 0, // 5: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest - 2, // 6: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest - 4, // 7: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest - 7, // 8: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest - 1, // 9: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse - 3, // 10: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse - 5, // 11: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse - 8, // 12: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse - 9, // [9:13] is the sub-list for method output_type - 5, // [5:9] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 12, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor + 13, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor + 14, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor + 15, // 3: private_location.v1.MonitorsResponse.icmp_monitors:type_name -> private_location.v1.ICMPMonitor + 11, // 4: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry + 6, // 5: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records + 0, // 6: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest + 2, // 7: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest + 4, // 8: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest + 7, // 9: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest + 9, // 10: private_location.v1.PrivateLocationService.IngestICMP:input_type -> private_location.v1.IngestICMPRequest + 1, // 11: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse + 3, // 12: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse + 5, // 13: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse + 8, // 14: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse + 10, // 15: private_location.v1.PrivateLocationService.IngestICMP:output_type -> private_location.v1.IngestICMPResponse + 11, // [11:16] is the sub-list for method output_type + 6, // [6:11] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } @@ -781,6 +1003,7 @@ func file_private_location_v1_private_location_proto_init() { } file_private_location_v1_dns_monitor_proto_init() file_private_location_v1_http_monitor_proto_init() + file_private_location_v1_icmp_monitor_proto_init() file_private_location_v1_tcp_monitor_proto_init() type x struct{} out := protoimpl.TypeBuilder{ @@ -788,7 +1011,7 @@ func file_private_location_v1_private_location_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc)), NumEnums: 0, - NumMessages: 10, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/server/src/libs/checker/utils.test.ts b/apps/server/src/libs/checker/utils.test.ts new file mode 100644 index 00000000..aa41d39e --- /dev/null +++ b/apps/server/src/libs/checker/utils.test.ts @@ -0,0 +1,119 @@ +import type { z } from "@hono/zod-openapi"; +import type { selectMonitorSchema } from "@openstatus/db/src/schema"; +import { expect, test } from "@openstatus/test-utils"; + +import { OpenStatusApiError } from "@/libs/errors"; + +import { getCheckerPayload, getCheckerUrl } from "./utils"; + +type Monitor = z.infer; + +function buildMonitor(overrides: Partial = {}): Monitor { + return { + id: 1, + workspaceId: 1, + jobType: "http", + active: true, + public: false, + name: "test", + description: "", + url: "https://example.openstatus.dev", + method: "GET", + body: "", + headers: [], + assertions: null, + periodicity: "10m", + regions: ["ams"], + timeout: 30_000, + degradedAfter: null, + retry: 3, + followRedirects: true, + otelEndpoint: null, + otelHeaders: null, + status: "active", + createdAt: null, + updatedAt: null, + deletedAt: null, + ...overrides, + } as Monitor; +} + +test("getCheckerUrl routes each job type to its own checker endpoint", () => { + for (const jobType of ["http", "tcp", "dns", "icmp"] as const) { + const url = getCheckerUrl(buildMonitor({ jobType })); + expect(url).toContain(`/checker/${jobType}?`); + expect(url).toContain("monitor_id=1"); + } +}); + +test("getCheckerUrl rejects an unsupported job type", () => { + expect(() => + getCheckerUrl(buildMonitor({ jobType: "unknown" as Monitor["jobType"] })), + ).toThrow(OpenStatusApiError); +}); + +test("getCheckerPayload builds an ICMP payload without assertions", () => { + const payload = getCheckerPayload( + buildMonitor({ + jobType: "icmp", + url: "1.1.1.1", + // Assertions are meaningless for ICMP and must not leak into the payload + // even when the row still carries some from an earlier job type. + assertions: + '[{"version":"v1","type":"status","compare":"eq","target":200}]', + }), + "active", + ); + + expect(payload).toMatchObject({ + uri: "1.1.1.1", + monitorId: "1", + workspaceId: "1", + status: "active", + trigger: "api", + timeout: 30_000, + }); + expect("assertions" in payload).toBe(false); + expect("url" in payload).toBe(false); +}); + +test("getCheckerPayload builds a DNS payload with assertions", () => { + const payload = getCheckerPayload( + buildMonitor({ + jobType: "dns", + url: "openstatus.dev", + assertions: + '[{"version":"v1","type":"dnsRecord","record":"A","compare":"eq","target":"1.2.3.4"}]', + }), + "active", + ); + + expect(payload).toMatchObject({ uri: "openstatus.dev", trigger: "api" }); + expect("assertions" in payload).toBe(true); +}); + +test("getCheckerPayload forwards the OTel config when configured", () => { + const payload = getCheckerPayload( + buildMonitor({ + jobType: "icmp", + url: "1.1.1.1", + otelEndpoint: "https://otel.example.com:4318", + otelHeaders: [{ key: "Authorization", value: "Basic dGVzdA==" }], + }), + "active", + ); + + expect(payload.otelConfig).toEqual({ + endpoint: "https://otel.example.com:4318", + headers: { Authorization: "Basic dGVzdA==" }, + }); +}); + +test("getCheckerPayload rejects an unsupported job type", () => { + expect(() => + getCheckerPayload( + buildMonitor({ jobType: "unknown" as Monitor["jobType"] }), + "active", + ), + ).toThrow(OpenStatusApiError); +}); diff --git a/apps/server/src/libs/checker/utils.ts b/apps/server/src/libs/checker/utils.ts index 01748d18..5e411a20 100644 --- a/apps/server/src/libs/checker/utils.ts +++ b/apps/server/src/libs/checker/utils.ts @@ -1,7 +1,9 @@ import type { z } from "@hono/zod-openapi"; import type { selectMonitorSchema } from "@openstatus/db/src/schema"; import { + type DNSPayloadSchema, type httpPayloadSchema, + type icmpPayloadSchema, type tpcPayloadSchema, transformHeaders, } from "@openstatus/utils"; @@ -11,7 +13,11 @@ import { OpenStatusApiError } from "@/libs/errors"; export function getCheckerPayload( monitor: z.infer, status: z.infer["status"], -): z.infer | z.infer { +): + | z.infer + | z.infer + | z.infer + | z.infer { const timestamp = new Date().getTime(); switch (monitor.jobType) { case "http": @@ -57,11 +63,48 @@ export function getCheckerPayload( retry: monitor.retry ?? 0, followRedirects: monitor.followRedirects ?? false, }; + case "dns": + return { + workspaceId: String(monitor.workspaceId), + monitorId: String(monitor.id), + uri: monitor.url, + status: status, + assertions: monitor.assertions ? JSON.parse(monitor.assertions) : null, + cronTimestamp: timestamp, + degradedAfter: monitor.degradedAfter, + timeout: monitor.timeout, + trigger: "api", + otelConfig: monitor.otelEndpoint + ? { + endpoint: monitor.otelEndpoint, + headers: transformHeaders(monitor.otelHeaders), + } + : undefined, + retry: monitor.retry ?? 0, + }; + case "icmp": + // No assertions: an ICMP check only reports reachability and latency. + return { + workspaceId: String(monitor.workspaceId), + monitorId: String(monitor.id), + uri: monitor.url, + status: status, + cronTimestamp: timestamp, + degradedAfter: monitor.degradedAfter, + timeout: monitor.timeout, + trigger: "api", + otelConfig: monitor.otelEndpoint + ? { + endpoint: monitor.otelEndpoint, + headers: transformHeaders(monitor.otelHeaders), + } + : undefined, + retry: monitor.retry ?? 0, + }; default: throw new OpenStatusApiError({ code: "BAD_REQUEST", - message: - "Invalid jobType, currently only 'http' and 'tcp' are supported", + message: `Invalid jobType '${monitor.jobType}'`, }); } } @@ -83,14 +126,14 @@ export function getCheckerUrl( ): string { switch (monitor.jobType) { case "http": - return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; case "tcp": - return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; + case "dns": + case "icmp": + return `https://openstatus-checker.fly.dev/checker/${monitor.jobType}?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; default: throw new OpenStatusApiError({ code: "BAD_REQUEST", - message: - "Invalid jobType, currently only 'http' and 'tcp' are supported", + message: `Invalid jobType '${monitor.jobType}'`, }); } } diff --git a/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts b/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts index 91b33ac2..833685d7 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts @@ -7,6 +7,7 @@ import { privateLocationToMonitors, workspace, } from "@openstatus/db/src/schema"; +import { monitorRun } from "@openstatus/db/src/schema/monitor_run/monitor_run"; import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; import { createTestWorkspace } from "@openstatus/db/src/test/factories"; import { @@ -48,6 +49,7 @@ let FREE_PLAN_KEY: string; let testHttpMonitorId: number; let testTcpMonitorId: number; let testDnsMonitorId: number; +let testIcmpMonitorId: number; let testMonitorToDeleteId: number; let testMonitorWithStatusId: number; @@ -119,6 +121,7 @@ beforeAll(async () => { await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-http`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-tcp`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-dns`)); + await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-icmp`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-to-delete`)); await db .delete(monitor) @@ -190,6 +193,23 @@ beforeAll(async () => { .get(); testDnsMonitorId = dnsMon.id; + // Create test ICMP monitor + const icmpMon = await db + .insert(monitor) + .values({ + workspaceId: 1, + name: `${TEST_PREFIX}-icmp`, + url: "1.1.1.1", + periodicity: "10m", + active: true, + regions: "ams", + jobType: "icmp", + timeout: 5000, + }) + .returning() + .get(); + testIcmpMonitorId = icmpMon.id; + // Create monitor to be deleted const deleteMon = await db .insert(monitor) @@ -241,6 +261,7 @@ afterAll(async () => { await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-http`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-tcp`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-dns`)); + await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-icmp`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-to-delete`)); await db .delete(monitor) @@ -343,6 +364,28 @@ describe("MonitorService.ListMonitors", () => { expect(dnsMon.recordAssertions).toBeDefined(); }); + test("returns ICMP monitors with correct structure", async () => { + const res = await connectRequest( + "ListMonitors", + { limit: 100 }, + { + "x-openstatus-key": "1", + }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + const icmpMonitors = data.icmpMonitors || []; + const icmpMon = icmpMonitors.find( + (m: { id: string }) => m.id === String(testIcmpMonitorId), + ); + + expect(icmpMon).toBeDefined(); + expect(icmpMon.uri).toBe("1.1.1.1"); + expect(icmpMon.periodicity).toBe("PERIODICITY_10M"); + }); + test("returns 401 when no auth key provided", async () => { const res = await connectRequest("ListMonitors", {}); @@ -363,7 +406,8 @@ describe("MonitorService.ListMonitors", () => { const totalMonitors = (data.httpMonitors?.length || 0) + (data.tcpMonitors?.length || 0) + - (data.dnsMonitors?.length || 0); + (data.dnsMonitors?.length || 0) + + (data.icmpMonitors?.length || 0); // Should return at most 2 monitors total expect(totalMonitors).toBeLessThanOrEqual(2); @@ -842,6 +886,53 @@ describe("MonitorService.CreateDNSMonitor", () => { }); }); +describe("MonitorService.CreateICMPMonitor", () => { + test("successfully creates ICMP monitor", async () => { + const res = await connectRequest( + "CreateICMPMonitor", + { + monitor: { + name: "test-create-icmp", + uri: "8.8.8.8", + periodicity: "PERIODICITY_5M", + timeout: "5000", + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor).toBeDefined(); + expect(data.monitor.uri).toBe("8.8.8.8"); + expect(data.monitor.periodicity).toBe("PERIODICITY_5M"); + + // The row must carry the icmp job type so the cron dispatches a ping. + const row = await db + .select() + .from(monitor) + .where(eq(monitor.id, Number(data.monitor.id))) + .get(); + expect(row?.jobType).toBe("icmp"); + + // Clean up + if (data.monitor.id) { + await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); + } + }); + + test("returns error when monitor is missing", async () => { + const res = await connectRequest( + "CreateICMPMonitor", + {}, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + }); +}); + describe("MonitorService.UpdateHTTPMonitor", () => { test("successfully updates HTTP monitor with partial data", async () => { const res = await connectRequest( @@ -1343,6 +1434,212 @@ describe("MonitorService.UpdateDNSMonitor", () => { }); }); +describe("MonitorService.UpdateICMPMonitor", () => { + test("successfully updates ICMP monitor with partial data", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { + name: "updated-icmp-name", + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor).toBeDefined(); + expect(data.monitor.name).toBe("updated-icmp-name"); + // Original URI should be preserved + expect(data.monitor.uri).toBe("1.1.1.1"); + + // Restore original name + await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { + name: `${TEST_PREFIX}-icmp`, + }, + }, + { "x-openstatus-key": "1" }, + ); + }); + + test("successfully updates ICMP monitor URI", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { + uri: "9.9.9.9", + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor.uri).toBe("9.9.9.9"); + + // Restore original URI + await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { + uri: "1.1.1.1", + }, + }, + { "x-openstatus-key": "1" }, + ); + }); + + test("leaves active untouched when the patch omits it", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { name: `${TEST_PREFIX}-icmp` }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor.active).toBe(true); + }); + + test("returns current monitor when no monitor data provided", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor).toBeDefined(); + expect(data.monitor.id).toBe(String(testIcmpMonitorId)); + }); + + test("returns 404 for non-existent monitor", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: "99999", + monitor: { name: "test" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(404); + }); + + test("returns error when trying to update HTTP monitor as ICMP", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testHttpMonitorId), + monitor: { name: "test" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.message).toContain("type mismatch"); + }); + + test("returns 401 when no auth key provided", async () => { + const res = await connectRequest("UpdateICMPMonitor", { + id: String(testIcmpMonitorId), + monitor: { name: "test" }, + }); + + expect(res.status).toBe(401); + }); + + // This method is in SKIP_VALIDATION_METHODS, so protovalidate never sees the + // patch — the bounds it would have applied are enforced in the handler, and + // nothing downstream re-checks them. + test("enforces the proto bounds the validation interceptor skips", async () => { + for (const [field, monitor] of [ + ["retry above max", { retry: "11" }], + ["negative retry", { retry: "-1" }], + ["timeout above max", { timeout: "120001" }], + ["degradedAt above max", { degradedAt: "120001" }], + ["over-long description", { description: "d".repeat(1025) }], + ["over-long name", { name: "n".repeat(257) }], + ["over-long uri", { uri: "u".repeat(2049) }], + ] as const) { + const res = await connectRequest( + "UpdateICMPMonitor", + { id: String(testIcmpMonitorId), monitor }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + } + }); + + test("leaves the stored monitor untouched when a patch is rejected", async () => { + const before = await db + .select() + .from(monitor) + .where(eq(monitor.id, testIcmpMonitorId)) + .get(); + + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + // A valid name alongside an out-of-range retry: the whole patch must be + // refused, not partially applied. + monitor: { name: "should-not-be-written", retry: "99" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + + const after = await db + .select() + .from(monitor) + .where(eq(monitor.id, testIcmpMonitorId)) + .get(); + expect(after?.name).toBe(before?.name); + expect(after?.retry).toBe(before?.retry); + }); + + test("still accepts values at the documented limits", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { retry: "10", timeout: "120000" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + // Restore + await connectRequest( + "UpdateICMPMonitor", + { id: String(testIcmpMonitorId), monitor: { timeout: "5000" } }, + { "x-openstatus-key": "1" }, + ); + }); +}); + describe("MonitorService - private and internal URLs", () => { // Valid URIs, so protovalidate passes them through to the service guard. const BLOCKED = [ @@ -1450,6 +1747,47 @@ describe("MonitorService.TriggerMonitor", () => { expect(res.status).toBe(401); }); + + test("dispatches an ICMP monitor to the icmp checker endpoint", async () => { + // A throwaway monitor: triggering records a monitorRun that references it, + // and the suite's afterAll deletes monitors without clearing runs. + const icmpMon = await createMonitor(1, { + name: `${TEST_PREFIX}-icmp-trigger`, + url: "1.1.1.1", + jobType: "icmp", + periodicity: "10m", + active: true, + regions: "ams", + }); + + // Scoped to this test so the surrounding suite keeps the real fetch. + const realFetch = globalThis.fetch; + const calls: { url: string; body: string }[] = []; + globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), body: String(init?.body ?? "") }); + return Promise.resolve(new Response(null, { status: 200 })); + }) as typeof fetch; + + try { + const res = await connectRequest( + "TriggerMonitor", + { id: String(icmpMon.id) }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0].url).toContain("/checker/icmp?"); + expect(JSON.parse(calls[0].body)).toMatchObject({ + uri: "1.1.1.1", + monitorId: String(icmpMon.id), + }); + } finally { + globalThis.fetch = realFetch; + await db.delete(monitorRun).where(eq(monitorRun.monitorId, icmpMon.id)); + await db.delete(monitor).where(eq(monitor.id, icmpMon.id)); + } + }); }); describe("MonitorService - Authentication", () => { @@ -2522,17 +2860,21 @@ describe("MonitorService.GetMonitor", () => { }); describe("MonitorService - Private Locations", () => { - async function seedMonitorWithPrivateLocation(suffix: string) { + async function seedMonitorWithPrivateLocation( + suffix: string, + jobType: "http" | "icmp" = "http", + ) { const mon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-pl-${suffix}`, - url: `https://pl-${suffix}.example.com`, + url: + jobType === "icmp" ? "1.1.1.1" : `https://pl-${suffix}.example.com`, periodicity: "1m", active: true, regions: "ams", - jobType: "http", + jobType, }) .returning() .get(); @@ -2621,6 +2963,50 @@ describe("MonitorService - Private Locations", () => { } }); + test("GetMonitor returns the attached private location id for ICMP", async () => { + const { mon, pl } = await seedMonitorWithPrivateLocation( + "get-icmp", + "icmp", + ); + try { + const res = await connectRequest( + "GetMonitor", + { id: String(mon.id) }, + { "x-openstatus-key": "1" }, + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.monitor.icmp.privateLocationIds).toEqual([String(pl.id)]); + } finally { + await cleanupMonitorWithPrivateLocation(mon.id, pl.id); + } + }); + + test("ListMonitors returns private_location_ids for ICMP monitors", async () => { + const { mon, pl } = await seedMonitorWithPrivateLocation( + "list-icmp", + "icmp", + ); + try { + const res = await connectRequest( + "ListMonitors", + { limit: 100 }, + { "x-openstatus-key": "1" }, + ); + expect(res.status).toBe(200); + const data = await res.json(); + const icmpMonitors = (data.icmpMonitors ?? []) as Array<{ + id: string; + privateLocationIds?: string[]; + }>; + + const attached = icmpMonitors.find((m) => m.id === String(mon.id)); + expect(attached?.privateLocationIds).toEqual([String(pl.id)]); + } finally { + await cleanupMonitorWithPrivateLocation(mon.id, pl.id); + } + }); + test("UpdateHTTPMonitor keeps the attached private location id", async () => { const { mon, pl } = await seedMonitorWithPrivateLocation("update"); try { diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts index dffcab83..be77f8d6 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts @@ -47,6 +47,7 @@ export { dbMonitorToHttpProto, dbMonitorToTcpProto, dbMonitorToDnsProto, + dbMonitorToIcmpProto, } from "./monitors"; // Regions diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts index a7cc9a33..b8aba3d0 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts @@ -2,6 +2,7 @@ import type { Monitor } from "@openstatus/db/src/schema/monitors/validation"; import type { DNSMonitor, HTTPMonitor, + ICMPMonitor, TCPMonitor, } from "@openstatus/proto/monitor/v1"; @@ -76,6 +77,32 @@ export function dbMonitorToTcpProto( }; } +/** + * Transform database ICMP monitor to proto ICMPMonitor. + */ +export function dbMonitorToIcmpProto( + dbMon: Monitor, + privateLocationIds: string[] = [], +): ICMPMonitor { + return { + $typeName: "openstatus.monitor.v1.ICMPMonitor", + id: String(dbMon.id), + name: dbMon.name, + uri: dbMon.url, + periodicity: stringToPeriodicity(dbMon.periodicity), + timeout: BigInt(dbMon.timeout), + degradedAt: dbMon.degradedAfter ? BigInt(dbMon.degradedAfter) : undefined, + retry: BigInt(dbMon.retry ?? MONITOR_DEFAULTS.retry), + description: dbMon.description, + active: dbMon.active ?? MONITOR_DEFAULTS.active, + public: dbMon.public ?? MONITOR_DEFAULTS.public, + regions: stringsToRegions(dbMon.regions), + openTelemetry: parseOpenTelemetry(dbMon.otelEndpoint, dbMon.otelHeaders), + status: stringToMonitorStatus(dbMon.status), + privateLocationIds, + }; +} + /** * Transform database DNS monitor to proto DNSMonitor. */ diff --git a/apps/server/src/routes/rpc/handlers/monitor/index.ts b/apps/server/src/routes/rpc/handlers/monitor/index.ts index 351ab90e..5967421b 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/index.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/index.ts @@ -8,6 +8,7 @@ import type { GetMonitorSummaryResponse, HTTPMonitor, HTTPResponseLogPagination, + ICMPMonitor, ListMonitorHTTPResponseLogsResponse, MonitorConfig, MonitorService, @@ -47,6 +48,7 @@ import { MONITOR_DEFAULTS, dbMonitorToDnsProto, dbMonitorToHttpProto, + dbMonitorToIcmpProto, dbMonitorToTcpProto, protoDnsAssertionsToService, protoHeadersToService, @@ -79,6 +81,7 @@ import { getCommonUpdateInput, toValidMethod, validateCommonMonitorFields, + validateMonitorPatchBounds, } from "./validators"; /** @@ -107,7 +110,7 @@ type DBMonitor = NonNullable>>; async function validateAndGetMonitor( id: string | undefined, workspaceId: number, - expectedJobType: "http" | "tcp" | "dns", + expectedJobType: "http" | "tcp" | "dns" | "icmp", ): Promise { if (!id || id.trim() === "") { throw monitorIdRequiredError(); @@ -264,6 +267,42 @@ export const monitorServiceImpl: ServiceImpl = { } }, + async createICMPMonitor(req, ctx) { + const rpcCtx = getRpcContext(ctx); + const workspaceId = rpcCtx.workspace.id; + const limits = rpcCtx.workspace.limits; + + if (!req.monitor) { + throw monitorRequiredError(); + } + + const mon = req.monitor; + + // Validate required fields (proto validation handles name, uri, periodicity) + validateCommonMonitorFields(mon); + + // Check workspace limits + await checkMonitorLimits(workspaceId, limits, mon.periodicity, mon.regions); + + try { + const created = await createMonitor({ + ctx: toServiceCtx(rpcCtx), + input: { + ...getCommonCreateInput(mon), + jobType: "icmp", + url: mon.uri, + method: "GET", + headers: [], + assertions: [], + }, + }); + + return { monitor: dbMonitorToIcmpProto(created) }; + } catch (err) { + toConnectError(err); + } + }, + async updateHTTPMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; @@ -445,6 +484,58 @@ export const monitorServiceImpl: ServiceImpl = { ); }, + async updateICMPMonitor(req, ctx) { + const rpcCtx = getRpcContext(ctx); + const workspaceId = rpcCtx.workspace.id; + const limits = rpcCtx.workspace.limits; + + const dbMon = await validateAndGetMonitor(req.id, workspaceId, "icmp"); + + const plMap = await getPrivateLocationIdsByMonitor({ + ctx: toServiceCtx(rpcCtx), + input: { monitorIds: [dbMon.id] }, + }); + const privateLocationIds = plMap.get(dbMon.id) ?? []; + + // If no monitor data provided, return current monitor + if (!req.monitor) { + const parsed = selectMonitorSchema.safeParse(dbMon); + if (!parsed.success) { + throw monitorParseFailedError(req.id); + } + return { + monitor: dbMonitorToIcmpProto(parsed.data, privateLocationIds), + }; + } + + const mon = req.monitor; + + // Validate regions if provided + validateCommonMonitorFields(mon); + // This method skips the protovalidate interceptor (see SKIP_VALIDATION_METHODS), + // so the message's own bounds have to be applied here. + validateMonitorPatchBounds(mon); + + // Check workspace limits if periodicity or regions are changing + checkMonitorConfigLimits( + limits, + mon.periodicity || undefined, + mon.regions && mon.regions.length > 0 ? mon.regions : undefined, + ); + + // Build update values - only include fields that are provided + const updateValues = getCommonUpdateInput(mon); + + // Handle ICMP-specific fields + if (mon.uri !== undefined && mon.uri !== "") { + updateValues.url = mon.uri; + } + + return applyUpdate(rpcCtx, dbMon.id, updateValues, (data) => + dbMonitorToIcmpProto(data, privateLocationIds), + ); + }, + async triggerMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const limits = rpcCtx.workspace.limits; @@ -564,6 +655,7 @@ export const monitorServiceImpl: ServiceImpl = { const httpMonitors: HTTPMonitor[] = []; const tcpMonitors: TCPMonitor[] = []; const dnsMonitors: DNSMonitor[] = []; + const icmpMonitors: ICMPMonitor[] = []; for (const data of parsedMonitors) { const privateLocationIds = plMap.get(data.id) ?? []; @@ -577,6 +669,9 @@ export const monitorServiceImpl: ServiceImpl = { case "dns": dnsMonitors.push(dbMonitorToDnsProto(data, privateLocationIds)); break; + case "icmp": + icmpMonitors.push(dbMonitorToIcmpProto(data, privateLocationIds)); + break; } } @@ -584,6 +679,7 @@ export const monitorServiceImpl: ServiceImpl = { httpMonitors, tcpMonitors, dnsMonitors, + icmpMonitors, totalSize: totalCount, }; }, @@ -664,10 +760,19 @@ export const monitorServiceImpl: ServiceImpl = { }, }; break; + case "icmp": + monitorConfig = { + $typeName: "openstatus.monitor.v1.MonitorConfig", + config: { + case: "icmp", + value: dbMonitorToIcmpProto(monitorData, privateLocationIds), + }, + }; + break; default: throw monitorTypeMismatchError( req.id, - "http, tcp, or dns", + "http, tcp, dns, or icmp", monitorData.jobType, ); } diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts index 90b56493..216e7720 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts @@ -1,3 +1,4 @@ +import { ConnectError } from "@connectrpc/connect"; import { Periodicity, Region } from "@openstatus/proto/monitor/v1"; import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; @@ -9,6 +10,7 @@ import { toValidMethod, toValidPeriodicity, validateCommonMonitorFields, + validateMonitorPatchBounds, } from "./validators"; describe("getCommonCreateInput", () => { @@ -167,3 +169,82 @@ describe("validateCommonMonitorFields", () => { ).toEqual(["ams"]); }); }); + +describe("validateMonitorPatchBounds", () => { + test("accepts an empty patch", () => { + validateMonitorPatchBounds({}); + }); + + test("accepts values at the documented limits", () => { + validateMonitorPatchBounds({ + name: "a".repeat(256), + uri: "b".repeat(2048), + description: "c".repeat(1024), + timeout: BigInt(120_000), + degradedAt: BigInt(120_000), + retry: BigInt(10), + }); + }); + + test("rejects a retry above the proto maximum", () => { + expect(() => validateMonitorPatchBounds({ retry: BigInt(11) })).toThrow( + ConnectError, + ); + }); + + test("rejects a negative retry", () => { + // Proto int64 accepts a negative; only the bound rejects it. Unchecked it + // reaches the checker, where `uint64(retry)` becomes a huge retry count. + expect(() => validateMonitorPatchBounds({ retry: BigInt(-1) })).toThrow( + ConnectError, + ); + }); + + test("rejects a timeout above the proto maximum", () => { + expect(() => + validateMonitorPatchBounds({ timeout: BigInt(120_001) }), + ).toThrow(ConnectError); + }); + + test("rejects a degradedAt outside its range", () => { + expect(() => + validateMonitorPatchBounds({ degradedAt: BigInt(120_001) }), + ).toThrow(ConnectError); + expect(() => + validateMonitorPatchBounds({ degradedAt: BigInt(-1) }), + ).toThrow(ConnectError); + }); + + test("rejects an over-long description, name and uri", () => { + expect(() => + validateMonitorPatchBounds({ description: "c".repeat(1025) }), + ).toThrow(ConnectError); + expect(() => validateMonitorPatchBounds({ name: "a".repeat(257) })).toThrow( + ConnectError, + ); + expect(() => validateMonitorPatchBounds({ uri: "b".repeat(2049) })).toThrow( + ConnectError, + ); + }); + + test("checks HTTP's `url` as well as the other types' `uri`", () => { + expect(() => validateMonitorPatchBounds({ url: "b".repeat(2049) })).toThrow( + ConnectError, + ); + }); + + test("skips fields the patch does not supply, matching getCommonUpdateInput", () => { + // Zero timeout/retry mean "not supplied", so they must not be range-checked + // — otherwise a name-only patch would be rejected on an unrelated field. + validateMonitorPatchBounds({ timeout: BigInt(0), retry: BigInt(0) }); + validateMonitorPatchBounds({ name: "" }); + validateMonitorPatchBounds({ uri: "" }); + }); + + test("rejects more regions than the proto allows", () => { + const tooMany = Array.from({ length: 29 }, () => 1 as Region); + expect(() => validateMonitorPatchBounds({ regions: tooMany })).toThrow( + ConnectError, + ); + }); +}); diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.ts index ce876ab9..23aeb2c8 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.ts @@ -58,6 +58,109 @@ export function validateCommonMonitorFields(mon: { regions?: Region[] }): void { } } +/** + * The bounds protovalidate enforces on a complete monitor message. Update RPCs + * skip the interceptor — a partial patch cannot satisfy `min_len` on the fields + * it omits — so nothing else checks these on an update: `updateMonitorConfig` + * writes whatever it is handed straight to the column. + * + * Keep in sync with the `buf.validate` constraints in `*_monitor.proto`. + */ +const MONITOR_BOUNDS = { + nameMaxLen: 256, + uriMaxLen: 2048, + descriptionMaxLen: 1024, + timeoutMaxMs: 120_000, + degradedAtMaxMs: 120_000, + retryMax: 10, + regionsMaxItems: 28, +} as const; + +function invalidArgument(message: string): never { + throw new ConnectError(message, Code.InvalidArgument); +} + +/** + * Apply those bounds to whatever an update actually supplied. The "supplied" + * tests mirror `getCommonUpdateInput` exactly: a field this skips is a field + * that never reaches the database. + */ +export function validateMonitorPatchBounds(mon: { + name?: string; + uri?: string; + url?: string; + timeout?: bigint; + degradedAt?: bigint; + retry?: bigint; + description?: string; + regions?: Region[]; +}): void { + if (mon.name !== undefined && mon.name !== "") { + if (mon.name.length > MONITOR_BOUNDS.nameMaxLen) { + invalidArgument( + `monitor.name: must be at most ${MONITOR_BOUNDS.nameMaxLen} characters [string.max_len]`, + ); + } + } + + // HTTP calls it `url`, the other three call it `uri`. + const target = mon.uri ?? mon.url; + if (target !== undefined && target !== "") { + if (target.length > MONITOR_BOUNDS.uriMaxLen) { + invalidArgument( + `monitor.uri: must be at most ${MONITOR_BOUNDS.uriMaxLen} characters [string.max_len]`, + ); + } + } + + if (mon.description !== undefined) { + if (mon.description.length > MONITOR_BOUNDS.descriptionMaxLen) { + invalidArgument( + `monitor.description: must be at most ${MONITOR_BOUNDS.descriptionMaxLen} characters [string.max_len]`, + ); + } + } + + if (mon.timeout !== undefined && mon.timeout !== BigInt(0)) { + if ( + mon.timeout < BigInt(0) || + mon.timeout > BigInt(MONITOR_BOUNDS.timeoutMaxMs) + ) { + invalidArgument( + `monitor.timeout: must be between 0 and ${MONITOR_BOUNDS.timeoutMaxMs} [int64.gte_lte]`, + ); + } + } + + if (mon.degradedAt !== undefined) { + if ( + mon.degradedAt < BigInt(0) || + mon.degradedAt > BigInt(MONITOR_BOUNDS.degradedAtMaxMs) + ) { + invalidArgument( + `monitor.degraded_at: must be between 0 and ${MONITOR_BOUNDS.degradedAtMaxMs} [int64.gte_lte]`, + ); + } + } + + if (mon.retry !== undefined && mon.retry !== BigInt(0)) { + if (mon.retry < BigInt(0) || mon.retry > BigInt(MONITOR_BOUNDS.retryMax)) { + invalidArgument( + `monitor.retry: must be between 0 and ${MONITOR_BOUNDS.retryMax} [int64.gte_lte]`, + ); + } + } + + if ( + mon.regions !== undefined && + mon.regions.length > MONITOR_BOUNDS.regionsMaxItems + ) { + invalidArgument( + `monitor.regions: must contain at most ${MONITOR_BOUNDS.regionsMaxItems} items [repeated.max_items]`, + ); + } +} + /** * Extract the fields every monitor type shares, in the shape * `createMonitor` takes. Defaults are applied here rather than left to diff --git a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts index 38a05bdb..6ebe54be 100644 --- a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts +++ b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts @@ -1,5 +1,6 @@ import type { Interceptor } from "@connectrpc/connect"; import { Events } from "@openstatus/analytics"; +import { MonitorService } from "@openstatus/proto/monitor/v1"; // @ts-nocheck — ConnectRPC's deep generic types (AnyFn, UnaryResponse, etc.) // are incompatible with the test mocks. All runtime behavior is correct. import { @@ -115,12 +116,12 @@ describe("trackingInterceptor", () => { }); }); - test("extracts additional props from message", async () => { + test("extracts additional props from nested create message", async () => { const interceptor = trackingInterceptor(); const req = createMockRequest( "openstatus.monitor.v1.MonitorService", "CreateHTTPMonitor", - { url: "https://example.com", jobType: "http", name: "my-monitor" }, + { monitor: { url: "https://example.com", name: "my-monitor" } }, ); const next = mockNext({}); @@ -133,6 +134,24 @@ describe("trackingInterceptor", () => { }); }); + test("maps icmp uri to url and stamps jobType", async () => { + const interceptor = trackingInterceptor(); + const req = createMockRequest( + "openstatus.monitor.v1.MonitorService", + "CreateICMPMonitor", + { monitor: { name: "ping", uri: "example.com" } }, + ); + const next = mockNext({}); + + await interceptor(next)(req as never); + await Promise.resolve(); + + expect(mockTrack).toHaveBeenCalledWith({ + ...Events.CreateMonitor, + additionalProps: { url: "example.com", jobType: "icmp" }, + }); + }); + test("silently skips unmapped methods", async () => { const interceptor = trackingInterceptor(); const req = createMockRequest( @@ -237,4 +256,37 @@ describe("RPC_EVENT_MAP", () => { expect(mapping.event.channel).toBeDefined(); } }); + + // Derived from the service definition rather than a hand-written list, so a + // newly added Create*/Update*/Delete* RPC fails here instead of silently + // going untracked — which is how ICMP was missed. + test("every mutating MonitorService RPC is tracked", () => { + const mutating = Object.values(MonitorService.method) + .map((m) => m.name) + .filter((name) => /^(Create|Update|Delete)/.test(name)); + + expect(mutating.length).toBeGreaterThan(0); + + const untracked = mutating.filter( + (name) => + !(`openstatus.monitor.v1.MonitorService/${name}` in RPC_EVENT_MAP), + ); + + expect(untracked).toEqual([]); + }); + + test("ICMP monitor mutations are tracked like the other monitor types", () => { + const create = + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/CreateICMPMonitor"]; + const update = + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/UpdateICMPMonitor"]; + + expect(create?.event).toEqual(Events.CreateMonitor); + expect(update?.event).toEqual(Events.UpdateMonitor); + // Same props as the sibling create entries. + expect(create?.eventProps).toEqual( + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/CreateDNSMonitor"] + ?.eventProps, + ); + }); }); diff --git a/apps/server/src/routes/rpc/interceptors/tracking.ts b/apps/server/src/routes/rpc/interceptors/tracking.ts index 0cb8794f..4ae4ff35 100644 --- a/apps/server/src/routes/rpc/interceptors/tracking.ts +++ b/apps/server/src/routes/rpc/interceptors/tracking.ts @@ -14,8 +14,23 @@ const logger = getLogger("api-server"); type RpcEventMapping = { event: EventProps; eventProps?: string[]; + normalizeInput?: (message: unknown) => Record; }; +// Create*Monitor requests nest the config under `monitor`, so top-level +// extraction yields nothing; ICMP names its target `uri` and none of them +// carries jobType on the wire. +function monitorCreateInput(jobType: string) { + return (message: unknown): Record => { + if (typeof message !== "object" || message === null) return {}; + const { monitor } = message as { + monitor?: Record | undefined; + }; + if (!monitor) return {}; + return { ...monitor, url: monitor.uri ?? monitor.url, jobType }; + }; +} + /** * Mapping from "ServiceTypeName/MethodName" to OpenPanel event + optional props. * Keys use PascalCase method names matching DescMethod.name (the proto source name). @@ -26,14 +41,22 @@ export const RPC_EVENT_MAP: Record = { "openstatus.monitor.v1.MonitorService/CreateHTTPMonitor": { event: Events.CreateMonitor, eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("http"), }, "openstatus.monitor.v1.MonitorService/CreateTCPMonitor": { event: Events.CreateMonitor, eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("tcp"), }, "openstatus.monitor.v1.MonitorService/CreateDNSMonitor": { event: Events.CreateMonitor, eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("dns"), + }, + "openstatus.monitor.v1.MonitorService/CreateICMPMonitor": { + event: Events.CreateMonitor, + eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("icmp"), }, "openstatus.monitor.v1.MonitorService/UpdateHTTPMonitor": { event: Events.UpdateMonitor, @@ -44,6 +67,9 @@ export const RPC_EVENT_MAP: Record = { "openstatus.monitor.v1.MonitorService/UpdateDNSMonitor": { event: Events.UpdateMonitor, }, + "openstatus.monitor.v1.MonitorService/UpdateICMPMonitor": { + event: Events.UpdateMonitor, + }, "openstatus.monitor.v1.MonitorService/DeleteMonitor": { event: Events.DeleteMonitor, }, @@ -138,7 +164,8 @@ export function trackingInterceptor(): Interceptor { return response; } - const additionalProps = parseInputToProps(req.message, mapping.eventProps); + const input = mapping.normalizeInput?.(req.message) ?? req.message; + const additionalProps = parseInputToProps(input, mapping.eventProps); setupAnalytics({ userId: `api_${rpcCtx.workspace.id}`, diff --git a/apps/server/src/routes/rpc/interceptors/validation.ts b/apps/server/src/routes/rpc/interceptors/validation.ts index 34b427d0..84611c3e 100644 --- a/apps/server/src/routes/rpc/interceptors/validation.ts +++ b/apps/server/src/routes/rpc/interceptors/validation.ts @@ -7,6 +7,7 @@ const SKIP_VALIDATION_METHODS = new Set([ "UpdateHTTPMonitor", "UpdateTCPMonitor", "UpdateDNSMonitor", + "UpdateICMPMonitor", ]); // protovalidate >=1.2 dropped the "value " prefix from the string.pattern diff --git a/apps/server/src/routes/v1/monitors/run/post.ts b/apps/server/src/routes/v1/monitors/run/post.ts index c49a9782..0c34dcb8 100644 --- a/apps/server/src/routes/v1/monitors/run/post.ts +++ b/apps/server/src/routes/v1/monitors/run/post.ts @@ -21,6 +21,7 @@ import { HTTPException } from "hono/http-exception"; import type { monitorsApi } from ".."; import { ParamsSchema, TriggerResult } from "../schema"; +import { assertLegacyRunnableJobType } from "../utils"; import { QuerySchema } from "./schema"; const postMonitor = createRoute({ @@ -98,6 +99,8 @@ export function registerRunMonitor(api: typeof monitorsApi) { const row = parseMonitor.data; + assertLegacyRunnableJobType(row.jobType); + // Maybe later overwrite the region const monitorStatusData = await db diff --git a/apps/server/src/routes/v1/monitors/schema.ts b/apps/server/src/routes/v1/monitors/schema.ts index bc24a6a8..21ee0ed3 100644 --- a/apps/server/src/routes/v1/monitors/schema.ts +++ b/apps/server/src/routes/v1/monitors/schema.ts @@ -306,6 +306,7 @@ export const TCPTriggerResult = z.object({ errorMessage: z.string().optional().nullable(), }); +// Only the two types the v1 API can run — see `assertLegacyRunnableJobType`. export const TriggerResult = z.discriminatedUnion("jobType", [ HTTPTriggerResult, TCPTriggerResult, diff --git a/apps/server/src/routes/v1/monitors/trigger/post.test.ts b/apps/server/src/routes/v1/monitors/trigger/post.test.ts index 028a0a71..f0b322c2 100644 --- a/apps/server/src/routes/v1/monitors/trigger/post.test.ts +++ b/apps/server/src/routes/v1/monitors/trigger/post.test.ts @@ -1,3 +1,4 @@ +import { createMonitor } from "@openstatus/db/src/test/factories"; import { afterEach, expect, mock, test } from "@openstatus/test-utils"; import { app } from "@/index"; @@ -184,3 +185,58 @@ test("trigger monitor with multiple regions should return result id", async () = expect(result.success).toBe(true); expect(json.resultId).toBeDefined(); }); + +test("trigger ICMP monitor is rejected by the legacy v1 API", async () => { + const icmpMonitor = await createMonitor(1, { + jobType: "icmp", + url: "1.1.1.1", + active: true, + regions: "ams", + periodicity: "10m", + }); + + mockFetch.mockReturnValue( + Promise.resolve(new Response(null, { status: 200 })), + ); + + const res = await app.request(`/v1/monitor/${icmpMonitor.id}/trigger`, { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(400); + + const json = await res.json(); + expect(json.message).toContain("not supported by the v1 API"); + + // Rejected before any probe leaves the process. + expect(mockFetch.mock.calls.length).toBe(0); +}); + +test("trigger DNS monitor is rejected by the legacy v1 API", async () => { + const dnsMonitor = await createMonitor(1, { + jobType: "dns", + url: "openstatus.dev", + active: true, + regions: "ams", + periodicity: "10m", + }); + + mockFetch.mockReturnValue( + Promise.resolve(new Response(null, { status: 200 })), + ); + + const res = await app.request(`/v1/monitor/${dnsMonitor.id}/trigger`, { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(400); + expect(mockFetch.mock.calls.length).toBe(0); +}); diff --git a/apps/server/src/routes/v1/monitors/trigger/post.ts b/apps/server/src/routes/v1/monitors/trigger/post.ts index 3e502967..70f0da06 100644 --- a/apps/server/src/routes/v1/monitors/trigger/post.ts +++ b/apps/server/src/routes/v1/monitors/trigger/post.ts @@ -17,6 +17,7 @@ import { import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { monitorsApi } from ".."; +import { assertLegacyRunnableJobType } from "../utils"; import { ParamsSchema, TriggerSchema } from "./schema"; const postRoute = createRoute({ @@ -100,6 +101,8 @@ export function registerTriggerMonitor(api: typeof monitorsApi) { const row = validateMonitor.data; + assertLegacyRunnableJobType(row.jobType); + // Maybe later overwrite the region const _monitorStatus = await db diff --git a/apps/server/src/routes/v1/monitors/utils.ts b/apps/server/src/routes/v1/monitors/utils.ts index 25fd4cb1..92225ffc 100644 --- a/apps/server/src/routes/v1/monitors/utils.ts +++ b/apps/server/src/routes/v1/monitors/utils.ts @@ -33,6 +33,24 @@ export function assertSafeMonitorUrl(args: { } } +/** + * The v1 API is legacy and frozen to the monitor types it already shipped: + * `POST /v1/monitor` refuses anything but http/tcp, so a newer type can only + * reach these routes on a monitor created elsewhere. Running one here would + * dispatch a probe the route cannot describe back to the caller. New types are + * served by the ConnectRPC API (`TriggerMonitor`) instead. + */ +const LEGACY_RUNNABLE_JOB_TYPES = ["http", "tcp"]; + +export function assertLegacyRunnableJobType(jobType: string): void { + if (!LEGACY_RUNNABLE_JOB_TYPES.includes(jobType)) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: `Running a '${jobType}' monitor is not supported by the v1 API. Use the ConnectRPC MonitorService.TriggerMonitor instead.`, + }); + } +} + export const getAssertions = ( assertions: z.infer[], ): Assertion[] => { diff --git a/apps/server/static/openapi.yaml b/apps/server/static/openapi.yaml index 507f9fae..532ee048 100644 --- a/apps/server/static/openapi.yaml +++ b/apps/server/static/openapi.yaml @@ -470,6 +470,28 @@ components: title: CreateHTTPMonitorResponse additionalProperties: false description: CreateHTTPMonitorResponse is the response after creating an HTTP monitor. + openstatus.monitor.v1.CreateICMPMonitorRequest: + type: object + properties: + monitor: + title: monitor + description: Monitor configuration (required). + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: CreateICMPMonitorRequest + required: + - monitor + additionalProperties: false + description: CreateICMPMonitorRequest is the request to create a new ICMP monitor. + openstatus.monitor.v1.CreateICMPMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The created monitor with assigned ID. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: CreateICMPMonitorResponse + additionalProperties: false + description: CreateICMPMonitorResponse is the response after creating an ICMP monitor. openstatus.monitor.v1.CreateTCPMonitorRequest: type: object properties: @@ -665,7 +687,7 @@ components: properties: monitor: title: monitor - description: The monitor configuration (one of HTTP, TCP, or DNS). + description: The monitor configuration (one of HTTP, TCP, DNS, or ICMP). $ref: '#/components/schemas/openstatus.monitor.v1.MonitorConfig' title: GetMonitorResponse additionalProperties: false @@ -1189,6 +1211,109 @@ components: title: Headers additionalProperties: false description: Headers represents a key-value pair for HTTP headers. + openstatus.monitor.v1.ICMPMonitor: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the monitor (output only for create requests). + name: + type: string + examples: + - Ping Gateway + title: name + maxLength: 256 + minLength: 1 + description: Name of the monitor (required, max 256 characters). + uri: + type: string + examples: + - 1.1.1.1 + title: uri + maxLength: 2048 + minLength: 1 + description: URI to monitor in format "host or IP" (required, max 2048 characters). + periodicity: + not: + enum: + - PERIODICITY_UNSPECIFIED + title: periodicity + description: Check periodicity (required). + $ref: '#/components/schemas/openstatus.monitor.v1.Periodicity' + timeout: + type: + - integer + - string + title: timeout + maximum: 120000 + minimum: 0 + format: int64 + description: Timeout in milliseconds (0-120000, defaults to 45000). + degradedAt: + type: + - integer + - string + - "null" + title: degraded_at + maximum: 120000 + minimum: 0 + format: int64 + description: Latency threshold for degraded status in milliseconds (optional, 0-120000). + retry: + type: + - integer + - string + title: retry + maximum: 10 + minimum: 0 + format: int64 + description: Number of retry attempts (0-10, defaults to 3). + description: + type: + - string + - "null" + title: description + maxLength: 1024 + description: Description of the monitor (optional). + active: + type: + - boolean + - "null" + title: active + description: Whether the monitor is active (defaults to false). + public: + type: + - boolean + - "null" + title: public + description: Whether the monitor is publicly visible (defaults to false). + regions: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.Region' + title: regions + maxItems: 28 + description: Geographic regions to run checks from. + openTelemetry: + title: open_telemetry + description: OpenTelemetry configuration for exporting metrics. + $ref: '#/components/schemas/openstatus.monitor.v1.OpenTelemetryConfig' + status: + title: status + description: Current operational status of the monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.MonitorStatus' + privateLocationIds: + type: array + items: + type: string + readOnly: true + title: private_location_ids + description: IDs of private locations that run this monitor. Read-only. + readOnly: true + title: ICMPMonitor + additionalProperties: false + description: ICMPMonitor defines the configuration for a ICMP monitor. openstatus.monitor.v1.ListMonitorHTTPResponseLogsRequest: type: object properties: @@ -1293,6 +1418,12 @@ components: $ref: '#/components/schemas/openstatus.monitor.v1.DNSMonitor' title: dns_monitors description: DNS monitors in the workspace. + icmpMonitors: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: icmp_monitors + description: ICMP monitors in the workspace. totalSize: type: integer title: total_size @@ -1322,6 +1453,15 @@ components: title: http required: - http + - type: object + properties: + icmp: + title: icmp + description: ICMP monitor configuration. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: icmp + required: + - icmp - type: object properties: tcp: @@ -1695,6 +1835,33 @@ components: title: UpdateHTTPMonitorResponse additionalProperties: false description: UpdateHTTPMonitorResponse is the response after updating an HTTP monitor. + openstatus.monitor.v1.UpdateICMPMonitorRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: Monitor ID to update (required). + monitor: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + - type: "null" + title: monitor + description: Updated monitor configuration (all fields optional for partial updates). + title: UpdateICMPMonitorRequest + additionalProperties: false + description: UpdateICMPMonitorRequest is the request to update an existing ICMP monitor. + openstatus.monitor.v1.UpdateICMPMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The updated monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: UpdateICMPMonitorResponse + additionalProperties: false + description: UpdateICMPMonitorResponse is the response after updating an ICMP monitor. openstatus.monitor.v1.UpdateTCPMonitorRequest: type: object properties: @@ -5046,6 +5213,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateHTTPMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/CreateICMPMonitor: + post: + tags: + - MonitorService + summary: CreateICMPMonitor + description: CreateICMPMonitor creates a new ICMP monitor. + operationId: MonitorService_CreateICMPMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateICMPMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateICMPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateTCPMonitor: post: tags: @@ -5105,7 +5298,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor.get parameters: - name: message @@ -5133,7 +5326,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor requestBody: content: @@ -5492,6 +5685,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateHTTPMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/UpdateICMPMonitor: + post: + tags: + - MonitorService + summary: UpdateICMPMonitor + description: UpdateICMPMonitor updates an existing ICMP monitor. + operationId: MonitorService_UpdateICMPMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateICMPMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateICMPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateTCPMonitor: post: tags: diff --git a/apps/web/src/content/docs.config.ts b/apps/web/src/content/docs.config.ts index ab2a78d6..85573dfd 100644 --- a/apps/web/src/content/docs.config.ts +++ b/apps/web/src/content/docs.config.ts @@ -243,6 +243,7 @@ export const docsNav: DocsNavSection[] = [ { slug: "reference/mcp-server", label: "MCP Server" }, { slug: "reference/dns-monitor", label: "DNS Monitor Reference" }, { slug: "reference/http-monitor", label: "HTTP Monitor Reference" }, + { slug: "reference/icmp-monitor", label: "ICMP Monitor Reference" }, { slug: "reference/incident", label: "Incident Reference" }, { slug: "reference/tcp-monitor", label: "TCP Monitor Reference" }, { diff --git a/apps/web/src/content/pages/changelog/icmp-monitoring.mdx b/apps/web/src/content/pages/changelog/icmp-monitoring.mdx new file mode 100644 index 00000000..8501892b --- /dev/null +++ b/apps/web/src/content/pages/changelog/icmp-monitoring.mdx @@ -0,0 +1,14 @@ +--- +title: "ICMP Monitoring" +description: "Monitor your hosts with ICMP ping from openstatus." +publishedAt: "2026-07-21" +author: "openstatus" +category: "monitoring" +--- + +We're excited to announce that ICMP monitoring is now available in openstatus! You can now ping any host — routers, gateways, or bare network endpoints — to track reachability, round-trip latency, and packet loss, even when there's no TCP or HTTP service to check. + +Each check sends three echo requests and reports the average latency along with packet loss, so you get an early signal on lossy or degraded links. + +#### Get Started 🚀 +To start monitoring a host, add a new monitor and select "ICMP" as your monitor type, then enter the host or IP address you want to ping. diff --git a/apps/web/src/content/pages/docs/reference/icmp-monitor.mdx b/apps/web/src/content/pages/docs/reference/icmp-monitor.mdx new file mode 100644 index 00000000..d22f3cd7 --- /dev/null +++ b/apps/web/src/content/pages/docs/reference/icmp-monitor.mdx @@ -0,0 +1,98 @@ +--- +category: Reference +title: ICMP Monitor Reference +description: Complete technical specification for ICMP (ping) monitoring. +--- + +An ICMP monitor sends ICMP echo requests ("pings") to a host to verify that it is reachable and to measure round-trip latency. This is useful for monitoring hosts that expose no TCP or HTTP service, such as routers, gateways, and bare network endpoints. + +Each check sends **3 echo requests** spaced ~100ms apart. The check is considered up as long as **at least one** reply is received; it fails only when all three packets are lost. The reported latency is the **average** round-trip time of the replies received, and packet loss is recorded per check. + +**Use cases:** + +- Router, gateway, and firewall reachability. +- Baseline network latency and packet-loss tracking. +- Monitoring IoT and network devices without an application-layer service. + +## Configuration + +### Host + +**Type:** String (required) +**Format:** Hostname or IP address, without a port + +The host to ping. Both IPv4 and IPv6 targets are supported — the resolved address family selects the protocol automatically. + +**Examples:** +- `openstatus.dev` +- `1.1.1.1` +- `2001:4860:4860::8888` + +### Regions + +**Type:** Array of strings (required) +**Format:** Region identifiers (e.g., `iad`, `jnb`) + +The geographical regions from which the ping is sent. See the [Location Reference](/docs/reference/location) for the full list of regions and the IPs to allowlist. + +### Frequency + +**Type:** String (required) +**Format:** Duration string (e.g., `30s`, `1m`, `1h`) + +The interval at which the ICMP monitor pings the target host. Supported frequencies: +- `30 seconds` +- `1 minute` +- `5 minutes` +- `10 minutes` +- `30 minutes` +- `1 hour` + +### Response time thresholds + +#### Timeout + +**Type:** Duration (optional) +**Default:** `45 seconds` + +The total budget for the whole check (all three echo requests). Each packet waits at most the remaining budget for a reply. + +#### Degraded + +**Type:** Duration (optional) + +The average latency after which a check is considered to be in a degraded performance state, giving early warning of network slowdowns. + +### Retry + +**Type:** Integer (optional) +**Default:** `3` + +The number of times the monitor retries a fully failed check (all packets lost) before reporting a definitive error. + +### OpenTelemetry + +Configures the export of monitoring metrics to an OpenTelemetry-compatible observability platform. + +#### OTLP endpoint + +**Type:** String (optional) +**Protocol:** HTTP only + +The OTLP endpoint URL where collected metrics — including latency and packet loss — are exported. Only HTTP endpoints are supported. + +#### OTLP headers + +**Type:** Key-value pairs (optional) + +Custom headers to include when sending metrics to your OTLP endpoint, commonly used for authentication or tenant identification. + +**Common example:** +``` +Authorization: Bearer +``` + +## Related resources + +- **[Create your first monitor](/docs/tutorial/create-your-first-monitor)** — step-by-step tutorial on setting up a monitor. +- **[CLI reference](/docs/reference/cli-reference)** — manage monitors programmatically from the command line. diff --git a/apps/web/src/lib/tb.ts b/apps/web/src/lib/tb.ts index 1634abf6..437bc573 100644 --- a/apps/web/src/lib/tb.ts +++ b/apps/web/src/lib/tb.ts @@ -11,7 +11,7 @@ export const tb = new OSTinybird({ // REMINDER: we could extend the limits (WorkspacePlan) by // knowing which plan the user is on and disable some periods const periods = ["1d", "7d", "14d"] as const; -const types = ["http", "tcp"] as const; +const types = ["http", "tcp", "icmp"] as const; // FIXME: check we we can also use Period from elswhere type Period = (typeof periods)[number]; @@ -25,6 +25,7 @@ export function prepareListByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpListDaily, tcp: tb.legacy_tcpListDaily, + icmp: tb.icmpListDaily, } as const; return { getData: getData[type] }; } @@ -32,6 +33,7 @@ export function prepareListByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpListWeekly, tcp: tb.legacy_tcpListWeekly, + icmp: tb.icmpListWeekly, } as const; return { getData: getData[type] }; } @@ -39,6 +41,7 @@ export function prepareListByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpListBiweekly, tcp: tb.legacy_tcpListBiweekly, + icmp: tb.icmpListBiweekly, } as const; return { getData: getData[type] }; } @@ -46,6 +49,7 @@ export function prepareListByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpListDaily, tcp: tb.legacy_tcpListDaily, + icmp: tb.icmpListDaily, } as const; return { getData: getData[type] }; } @@ -58,6 +62,7 @@ export function prepareMetricsByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpMetricsDaily, tcp: tb.legacy_tcpMetricsDaily, + icmp: tb.icmpMetricsDaily, } as const; return { getData: getData[type] }; } @@ -65,6 +70,7 @@ export function prepareMetricsByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpMetricsWeekly, tcp: tb.legacy_tcpMetricsWeekly, + icmp: tb.icmpMetricsWeekly, } as const; return { getData: getData[type] }; } @@ -72,6 +78,7 @@ export function prepareMetricsByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpMetricsBiweekly, tcp: tb.legacy_tcpMetricsBiweekly, + icmp: tb.icmpMetricsBiweekly, } as const; return { getData: getData[type] }; } @@ -79,6 +86,7 @@ export function prepareMetricsByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpMetricsDaily, tcp: tb.legacy_tcpMetricsDaily, + icmp: tb.icmpMetricsDaily, } as const; return { getData: getData[type] }; } @@ -94,6 +102,7 @@ export function prepareMetricByRegionByPeriod( const getData = { http: tb.httpMetricsByRegionDaily, tcp: tb.tcpMetricsByRegionDaily, + icmp: tb.icmpMetricsByRegionDaily, } as const; return { getData: getData[type] }; } @@ -101,6 +110,7 @@ export function prepareMetricByRegionByPeriod( const getData = { http: tb.httpMetricsByRegionWeekly, tcp: tb.tcpMetricsByRegionWeekly, + icmp: tb.icmpMetricsByRegionWeekly, } as const; return { getData: getData[type] }; } @@ -108,6 +118,7 @@ export function prepareMetricByRegionByPeriod( const getData = { http: tb.httpMetricsByRegionBiweekly, tcp: tb.tcpMetricsByRegionBiweekly, + icmp: tb.icmpMetricsByRegionBiweekly, } as const; return { getData: getData[type] }; } @@ -115,6 +126,7 @@ export function prepareMetricByRegionByPeriod( const getData = { http: tb.httpMetricsByRegionDaily, tcp: tb.tcpMetricsByRegionDaily, + icmp: tb.icmpMetricsByRegionDaily, } as const; return { getData: getData[type] }; } @@ -130,6 +142,7 @@ export function prepareMetricByIntervalByPeriod( const getData = { http: tb.httpMetricsByIntervalDaily, tcp: tb.tcpMetricsByIntervalDaily, + icmp: tb.icmpMetricsByIntervalDaily, } as const; return { getData: getData[type] }; } @@ -137,6 +150,7 @@ export function prepareMetricByIntervalByPeriod( const getData = { http: tb.httpMetricsByIntervalWeekly, tcp: tb.tcpMetricsByIntervalWeekly, + icmp: tb.icmpMetricsByIntervalWeekly, } as const; return { getData: getData[type] }; } @@ -144,6 +158,7 @@ export function prepareMetricByIntervalByPeriod( const getData = { http: tb.httpMetricsByIntervalBiweekly, tcp: tb.tcpMetricsByIntervalBiweekly, + icmp: tb.icmpMetricsByIntervalBiweekly, } as const; return { getData: getData[type] }; } @@ -151,6 +166,7 @@ export function prepareMetricByIntervalByPeriod( const getData = { http: tb.httpMetricsByIntervalDaily, tcp: tb.tcpMetricsByIntervalDaily, + icmp: tb.icmpMetricsByIntervalDaily, } as const; return { getData: getData[type] }; } @@ -166,6 +182,7 @@ export function prepareStatusByPeriod( const getData = { http: tb.httpStatusWeekly, tcp: tb.tcpStatusWeekly, + icmp: tb.icmpStatusWeekly, } as const; return { getData: getData[type] }; } @@ -173,6 +190,7 @@ export function prepareStatusByPeriod( const getData = { http: tb.legacy_httpStatus45d, tcp: tb.legacy_tcpStatus45d, + icmp: tb.icmpStatus45d, } as const; return { getData: getData[type] }; } @@ -180,6 +198,7 @@ export function prepareStatusByPeriod( const getData = { http: tb.httpStatusWeekly, tcp: tb.tcpStatusWeekly, + icmp: tb.icmpStatusWeekly, } as const; return { getData: getData[type] }; } @@ -192,6 +211,7 @@ export function prepareGetByPeriod(period: "30d", type: Type = "http") { const getData = { http: tb.httpGetMonthly, tcp: tb.tcpGetMonthly, + icmp: tb.icmpGetMonthly, } as const; return { getData: getData[type] }; } @@ -199,6 +219,7 @@ export function prepareGetByPeriod(period: "30d", type: Type = "http") { const getData = { http: tb.httpGetMonthly, tcp: tb.tcpGetMonthly, + icmp: tb.icmpGetMonthly, } as const; return { getData: getData[type] }; } diff --git a/apps/workflows/src/cron/checker.ts b/apps/workflows/src/cron/checker.ts index 73d0dbb1..e39d950b 100644 --- a/apps/workflows/src/cron/checker.ts +++ b/apps/workflows/src/cron/checker.ts @@ -28,6 +28,7 @@ import { regionDict } from "@openstatus/regions"; import { type DNSPayloadSchema, type httpPayloadSchema, + type icmpPayloadSchema, type tpcPayloadSchema, transformHeaders, } from "@openstatus/utils"; @@ -285,6 +286,7 @@ const createCronTask = async ( | z.infer | z.infer | z.infer + | z.infer | null = null; // @@ -354,6 +356,26 @@ const createCronTask = async ( }; } + if (row.jobType === "icmp") { + payload = { + workspaceId: String(row.workspaceId), + monitorId: String(row.id), + uri: row.url, + cronTimestamp: timestamp, + status: status, + degradedAfter: row.degradedAfter, + timeout: row.timeout, + trigger: "cron", + otelConfig: row.otelEndpoint + ? { + endpoint: row.otelEndpoint, + headers: transformHeaders(row.otelHeaders), + } + : undefined, + retry: row.retry || 3, + }; + } + if (!payload) { throw new Error("Invalid jobType"); } diff --git a/apps/workflows/src/cron/uptime-freeze.ts b/apps/workflows/src/cron/uptime-freeze.ts index 7a93f8bd..706bb21e 100644 --- a/apps/workflows/src/cron/uptime-freeze.ts +++ b/apps/workflows/src/cron/uptime-freeze.ts @@ -21,6 +21,7 @@ const pipes: UptimeFreezePipes = { http: tb.httpStatus45d, tcp: tb.tcpStatus45d, dns: tb.dnsStatus45d, + icmp: tb.icmpStatus45d, }; export async function handleUptimeFreezeCron(c: Context) { diff --git a/packages/api/src/router/checker.ts b/packages/api/src/router/checker.ts index f99bfeca..0e011912 100644 --- a/packages/api/src/router/checker.ts +++ b/packages/api/src/router/checker.ts @@ -13,6 +13,7 @@ import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; import { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; import { type httpPayloadSchema, + type icmpPayloadSchema, safeUrlSchema, type tpcPayloadSchema, transformHeaders, @@ -25,6 +26,12 @@ import { createTRPCRouter, protectedProcedure } from "../trpc"; const ABORT_TIMEOUT = 10000; +// PingICMP treats its timeout as the deadline for the whole check, so omitting +// it means a deadline of "now": the send loop breaks before the first packet +// and every test reports "no reply". Kept under ABORT_TIMEOUT so the checker +// answers before the fetch above gives up. +const ICMP_TEST_TIMEOUT = 5000; + // Input schemas const httpTestInput = z.object({ url: safeUrlSchema, @@ -78,6 +85,37 @@ const dnsTestInput = z.object({ .prefault([]), }); +const icmpTestInput = z.object({ + url: z.string(), + region: monitorRegionSchema.optional().prefault("ams"), +}); + +export const icmpOutput = z + .object({ + state: z.literal("success").prefault("success"), + type: z.literal("icmp").prefault("icmp"), + requestId: z.number().optional(), + workspaceId: z.number().optional(), + monitorId: z.number().optional(), + timestamp: z.number(), + timing: z.object({ + rtts: z.array(z.number()), + }), + latency: z.number().optional(), + latencyMin: z.number().optional(), + latencyMax: z.number().optional(), + packetsSent: z.number().optional(), + packetsReceived: z.number().optional(), + error: z.string().optional(), + region: monitorRegionSchema, + }) + .or( + z.object({ + state: z.literal("error").prefault("error"), + message: z.string(), + }), + ); + export const tcpOutput = z .object({ state: z.literal("success").prefault("success"), @@ -370,12 +408,67 @@ export async function testDns(input: z.infer) { } } +export async function testIcmp(input: z.infer) { + try { + const res = await fetch( + `https://openstatus-checker.fly.dev/icmp/${input.region}`, + { + method: "POST", + headers: { + Authorization: `Basic ${env.CRON_SECRET}`, + "Content-Type": "application/json", + "fly-prefer-region": input.region, + }, + body: JSON.stringify({ + uri: input.url, + timeout: ICMP_TEST_TIMEOUT, + }), + signal: AbortSignal.timeout(ABORT_TIMEOUT), + }, + ); + + const json = await res.json(); + const result = icmpOutput.safeParse(json); + + if (!result.success) { + console.error( + `Checker ICMP test failed for ${input.url}:`, + result.error.message, + ); + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Checker response is not valid. Please try again. If the problem persists, please contact support. ${result.error.message}`, + }); + } + + if (result.data.state === "error") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: result.data.message, + }); + } + + return result.data; + } catch (error) { + console.error("Checker ICMP test failed", error); + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "ICMP check failed", + }); + } +} + export async function triggerChecker( input: z.infer, ) { let payload: | z.infer | z.infer + | z.infer | null = null; if (process.env.NODE_ENV !== "production") { @@ -450,6 +543,25 @@ export async function triggerChecker( followRedirects: input.followRedirects || true, }; } + if (input.jobType === "icmp") { + payload = { + workspaceId: String(input.workspaceId), + monitorId: String(input.id), + uri: input.url, + status: "active", + cronTimestamp: timestamp, + degradedAfter: input.degradedAfter, + timeout: input.timeout, + trigger: "cron", + retry: input.retry || 3, + otelConfig: input.otelEndpoint + ? { + endpoint: input.otelEndpoint, + headers: transformHeaders(input.otelHeaders), + } + : undefined, + }; + } const allResult = []; for (const region of input.regions) { @@ -477,6 +589,8 @@ function generateUrl({ row }: { row: z.infer }) { return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${row.id}`; case "dns": return `https://openstatus-checker.fly.dev/checker/dns?monitor_id=${row.id}`; + case "icmp": + return `https://openstatus-checker.fly.dev/checker/icmp?monitor_id=${row.id}`; default: throw new Error("Invalid jobType"); } @@ -502,6 +616,12 @@ export const checkerRouter = createTRPCRouter({ .mutation(async ({ input }) => { return testDns(input); }), + testIcmp: protectedProcedure + .meta({ track: Events.TestMonitor }) + .input(icmpTestInput) + .mutation(async ({ input }) => { + return testIcmp(input); + }), triggerChecker: protectedProcedure .input(z.object({ id: z.number() })) diff --git a/packages/api/src/router/monitor.ts b/packages/api/src/router/monitor.ts index 08f0fadf..a1a5b080 100644 --- a/packages/api/src/router/monitor.ts +++ b/packages/api/src/router/monitor.ts @@ -35,7 +35,7 @@ import { z } from "zod"; import { env } from "../env"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { testDns, testHttp, testTcp } from "./checker"; +import { testDns, testHttp, testIcmp, testTcp } from "./checker"; // self-host has no access to the openstatus checker fleet, so the pre-save // endpoint test can never succeed — skip it entirely. @@ -342,6 +342,8 @@ export const monitorRouter = createTRPCRouter({ (a) => a.type === "dnsRecord", ), }); + } else if (input.jobType === "icmp") { + await testIcmp({ url: input.url, region: "ams" }); } } @@ -406,6 +408,8 @@ export const monitorRouter = createTRPCRouter({ (a) => a.type === "dnsRecord", ), }); + } else if (input.jobType === "icmp") { + await testIcmp({ url: input.url, region: "ams" }); } } diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index 684acc4b..c25b4fc2 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -704,12 +704,14 @@ export const statusPageRouter = createTRPCRouter({ http: monitors.filter((c) => c.monitor.jobType === "http"), tcp: monitors.filter((c) => c.monitor.jobType === "tcp"), dns: monitors.filter((c) => c.monitor.jobType === "dns"), + icmp: monitors.filter((c) => c.monitor.jobType === "icmp"), }; const proceduresByType = { http: getStatusProcedure("45d", "http"), tcp: getStatusProcedure("45d", "tcp"), dns: getStatusProcedure("45d", "dns"), + icmp: getStatusProcedure("45d", "icmp"), }; // Manual mode never touches Tinybird. Otherwise race the reads against @@ -717,7 +719,7 @@ export const statusPageRouter = createTRPCRouter({ // whole page to manual mode so bars still render from DB events. const tinybird = await withTinybirdFallback(() => input.barType === "manual" - ? Promise.resolve([null, null, null]) + ? Promise.resolve([null, null, null, null]) : Promise.all( Object.entries(proceduresByType).map(([type, procedure]) => { const monitorIds = monitorsByType[ @@ -730,7 +732,8 @@ export const statusPageRouter = createTRPCRouter({ ); const tinybirdUnhealthy = !tinybird.ok; - const [statusHttp, statusTcp, statusDns] = tinybird.data ?? [ + const [statusHttp, statusTcp, statusDns, statusIcmp] = tinybird.data ?? [ + null, null, null, null, @@ -741,10 +744,16 @@ export const statusPageRouter = createTRPCRouter({ | Awaited>["data"] | Awaited>["data"] | Awaited>["data"] + | Awaited>["data"] >(); // Consolidate status data from all monitor types into the map - for (const statusResult of [statusHttp, statusTcp, statusDns]) { + for (const statusResult of [ + statusHttp, + statusTcp, + statusDns, + statusIcmp, + ]) { if (statusResult?.data) { statusResult.data.forEach((status) => { const monitorId = status.monitorId; @@ -1010,12 +1019,14 @@ export const statusPageRouter = createTRPCRouter({ http: publicMonitors.filter((c) => c.monitor.jobType === "http"), tcp: publicMonitors.filter((c) => c.monitor.jobType === "tcp"), dns: publicMonitors.filter((c) => c.monitor.jobType === "dns"), + icmp: publicMonitors.filter((c) => c.monitor.jobType === "icmp"), }; const proceduresByType = { http: getMetricsLatencyMultiProcedure("1d", "http"), tcp: getMetricsLatencyMultiProcedure("1d", "tcp"), dns: getMetricsLatencyMultiProcedure("1d", "dns"), + icmp: getMetricsLatencyMultiProcedure("1d", "icmp"), }; // Slow/erroring Tinybird → empty latency data so the page still renders. @@ -1035,13 +1046,15 @@ export const statusPageRouter = createTRPCRouter({ metricsLatencyMultiHttp, metricsLatencyMultiTcp, metricsLatencyMultiDns, - ] = metrics.data ?? [null, null, null]; + metricsLatencyMultiIcmp, + ] = metrics.data ?? [null, null, null, null]; const metricsDataByMonitorId = new Map< string, | Awaited>["data"] | Awaited>["data"] | Awaited>["data"] + | Awaited>["data"] >(); if (metricsLatencyMultiHttp?.data) { @@ -1074,6 +1087,16 @@ export const statusPageRouter = createTRPCRouter({ }); } + if (metricsLatencyMultiIcmp?.data) { + metricsLatencyMultiIcmp.data.forEach((metric) => { + const monitorId = metric.monitorId; + if (!metricsDataByMonitorId.has(monitorId)) { + metricsDataByMonitorId.set(monitorId, []); + } + metricsDataByMonitorId.get(monitorId)?.push(metric); + }); + } + return publicMonitors.map((c) => { const monitorId = c.monitor.id.toString(); const data = metricsDataByMonitorId.get(monitorId) || []; @@ -1118,7 +1141,7 @@ export const statusPageRouter = createTRPCRouter({ if (!_monitor.public) return null; if (_monitor.deletedAt) return null; - const type = _monitor.jobType as "http" | "tcp"; + const type = _monitor.jobType as "http" | "tcp" | "dns" | "icmp"; const proceduresByType = { http: { @@ -1136,6 +1159,11 @@ export const statusPageRouter = createTRPCRouter({ regions: getMetricsRegionsProcedure("7d", "dns"), uptime: getUptimeProcedure("7d", "dns"), }, + icmp: { + latency: getMetricsLatencyProcedure("7d", "icmp"), + regions: getMetricsRegionsProcedure("7d", "icmp"), + uptime: getUptimeProcedure("7d", "icmp"), + }, }; const fromDate = startOfDay(subDays(new Date(), 7)).toISOString(); diff --git a/packages/api/src/router/tinybird/index.ts b/packages/api/src/router/tinybird/index.ts index 9f939c7f..2e38de87 100644 --- a/packages/api/src/router/tinybird/index.ts +++ b/packages/api/src/router/tinybird/index.ts @@ -9,7 +9,7 @@ import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { calculatePeriod } from "./utils"; const periods = ["1d", "7d", "14d", "30d", "90d"] as const; -const types = ["http", "tcp", "dns"] as const; +const types = ["http", "tcp", "dns", "icmp"] as const; type Period = (typeof periods)[number]; type Type = (typeof types)[number]; @@ -42,7 +42,9 @@ function clampInterval(period: Period, interval?: number) { // NEW: workspace-level counters helper export function getWorkspace30dProcedure(type: Type) { - return type === "http" ? tb.httpWorkspace30d : tb.tcpWorkspace30d; + if (type === "http") return tb.httpWorkspace30d; + if (type === "icmp") return tb.icmpWorkspace30d; + return tb.tcpWorkspace30d; } // Helper functions to get the right procedure based on period and type export function getListProcedure(period: Period, type: Type) { @@ -51,21 +53,25 @@ export function getListProcedure(period: Period, type: Type) { if (type === "http") return tb.httpListDaily; if (type === "tcp") return tb.tcpListDaily; if (type === "dns") return tb.dnsListBiweekly; + if (type === "icmp") return tb.icmpListDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "http") return tb.httpListWeekly; if (type === "tcp") return tb.tcpListWeekly; if (type === "dns") return tb.dnsListBiweekly; + if (type === "icmp") return tb.icmpListWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "14d": if (type === "http") return tb.httpListBiweekly; if (type === "tcp") return tb.tcpListBiweekly; if (type === "dns") return tb.dnsListBiweekly; + if (type === "icmp") return tb.icmpListBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "http") return tb.httpListDaily; if (type === "tcp") return tb.tcpListDaily; if (type === "dns") return tb.dnsListBiweekly; + if (type === "icmp") return tb.icmpListDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -76,31 +82,37 @@ export function getMetricsProcedure(period: Period, type: Type) { if (type === "dns") return tb.dnsMetricsDaily; if (type === "http") return tb.httpMetricsDaily; if (type === "tcp") return tb.tcpMetricsDaily; + if (type === "icmp") return tb.icmpMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "dns") return tb.dnsMetricsWeekly; if (type === "http") return tb.httpMetricsWeekly; if (type === "tcp") return tb.tcpMetricsWeekly; + if (type === "icmp") return tb.icmpMetricsWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "14d": if (type === "dns") return tb.dnsMetricsBiweekly; if (type === "http") return tb.httpMetricsBiweekly; if (type === "tcp") return tb.tcpMetricsBiweekly; + if (type === "icmp") return tb.icmpMetricsBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "30d": if (type === "dns") return tb.dnsMetrics30d; if (type === "http") return tb.httpMetrics30d; if (type === "tcp") return tb.tcpMetrics30d; + if (type === "icmp") return tb.icmpMetrics30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsMetrics90d; if (type === "http") return tb.httpMetrics90d; if (type === "tcp") return tb.tcpMetrics90d; + if (type === "icmp") return tb.icmpMetrics90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsMetricsDaily; if (type === "http") return tb.httpMetricsDaily; if (type === "tcp") return tb.tcpMetricsDaily; + if (type === "icmp") return tb.icmpMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -112,31 +124,37 @@ export function getMetricsRegionsProcedure(period: Period, type: Type) { if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsDaily; if (type === "tcp") return tb.tcpMetricsByIntervalDaily; + if (type === "icmp") return tb.icmpMetricsByIntervalDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsWeekly; if (type === "tcp") return tb.tcpMetricsByIntervalWeekly; + if (type === "icmp") return tb.icmpMetricsByIntervalWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "14d": if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsBiweekly; if (type === "tcp") return tb.tcpMetricsByIntervalBiweekly; + if (type === "icmp") return tb.icmpMetricsByIntervalBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "30d": if (type === "dns") return tb.dnsMetricsRegions30d; if (type === "http") return tb.httpMetricsRegions30d; if (type === "tcp") return tb.tcpMetricsByInterval30d; + if (type === "icmp") return tb.icmpMetricsByInterval30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsMetricsRegions90d; if (type === "http") return tb.httpMetricsRegions90d; if (type === "tcp") return tb.tcpMetricsByInterval90d; + if (type === "icmp") return tb.icmpMetricsByInterval90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsDaily; if (type === "tcp") return tb.tcpMetricsByIntervalDaily; + if (type === "icmp") return tb.icmpMetricsByIntervalDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -145,6 +163,7 @@ export function getStatusProcedure(_period: "45d", type: Type) { if (type === "dns") return tb.dnsStatus45d; if (type === "http") return tb.httpStatus45d; if (type === "tcp") return tb.tcpStatus45d; + if (type === "icmp") return tb.icmpStatus45d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -154,11 +173,13 @@ export function getGetProcedure(period: "14d", type: Type) { if (type === "http") return tb.httpGetBiweekly; if (type === "tcp") return tb.tcpGetBiweekly; if (type === "dns") return tb.dnsGetBiweekly; + if (type === "icmp") return tb.icmpGetBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "http") return tb.httpGetBiweekly; if (type === "tcp") return tb.tcpGetBiweekly; if (type === "dns") return tb.dnsGetBiweekly; + if (type === "icmp") return tb.icmpGetBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -167,6 +188,7 @@ export function getGlobalMetricsProcedure(type: Type) { if (type === "http") return tb.httpGlobalMetricsDaily; if (type === "tcp") return tb.tcpGlobalMetricsDaily; if (type === "dns") return tb.dnsGlobalMetricsDaily; + if (type === "icmp") return tb.icmpGlobalMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -177,21 +199,25 @@ export function getUptimeProcedure(period: "7d" | "30d" | "90d", type: Type) { if (type === "dns") return tb.dnsUptime30d; if (type === "http") return tb.httpUptimeWeekly; if (type === "tcp") return tb.tcpUptimeWeekly; + if (type === "icmp") return tb.icmpUptimeWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "30d": if (type === "dns") return tb.dnsUptime30d; if (type === "http") return tb.httpUptime30d; if (type === "tcp") return tb.tcpUptime30d; + if (type === "icmp") return tb.icmpUptime30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsUptime90d; if (type === "http") return tb.httpUptime90d; if (type === "tcp") return tb.tcpUptime90d; + if (type === "icmp") return tb.icmpUptime90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsUptime30d; if (type === "http") return tb.httpUptime30d; if (type === "tcp") return tb.tcpUptime30d; + if (type === "icmp") return tb.icmpUptime30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -203,11 +229,13 @@ export function getMetricsLatencyProcedure(_period: Period, type: Type) { if (type === "dns") return tb.dnsMetricsLatency7d; if (type === "http") return tb.httpMetricsLatency1d; if (type === "tcp") return tb.tcpMetricsLatency1d; + if (type === "icmp") return tb.icmpMetricsLatency1d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "dns") return tb.dnsMetricsLatency7d; if (type === "http") return tb.httpMetricsLatency7d; if (type === "tcp") return tb.tcpMetricsLatency7d; + if (type === "icmp") return tb.icmpMetricsLatency7d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); // no dedicated 14d latency pipe; 30d MV is the smallest window covering 14d case "14d": @@ -215,16 +243,19 @@ export function getMetricsLatencyProcedure(_period: Period, type: Type) { if (type === "dns") return tb.dnsMetricsLatency30d; if (type === "http") return tb.httpMetricsLatency30d; if (type === "tcp") return tb.tcpMetricsLatency30d; + if (type === "icmp") return tb.icmpMetricsLatency30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsMetricsLatency90d; if (type === "http") return tb.httpMetricsLatency90d; if (type === "tcp") return tb.tcpMetricsLatency90d; + if (type === "icmp") return tb.icmpMetricsLatency90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsMetricsLatency7d; if (type === "http") return tb.httpMetricsLatency1d; if (type === "tcp") return tb.tcpMetricsLatency1d; + if (type === "icmp") return tb.icmpMetricsLatency1d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -233,6 +264,7 @@ export function getMetricsLatencyMultiProcedure(_period: Period, type: Type) { if (type === "dns") return tb.dnsMetricsLatency1dMulti; if (type === "http") return tb.httpMetricsLatency1dMulti; if (type === "tcp") return tb.tcpMetricsLatency1dMulti; + if (type === "icmp") return tb.icmpMetricsLatency1dMulti; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -280,7 +312,7 @@ export const tinybirdRouter = createTRPCRouter({ const procedure = getListProcedure( period, - _monitor.jobType as "http" | "tcp" | "dns", + _monitor.jobType as "http" | "tcp" | "dns" | "icmp", ); return await procedure({ ...opts.input, @@ -473,7 +505,7 @@ export const tinybirdRouter = createTRPCRouter({ const procedure = getGetProcedure( opts.input.period, - _monitor.jobType as "http" | "tcp" | "dns", + _monitor.jobType as "http" | "tcp" | "dns" | "icmp", ); return await procedure(opts.input); }), diff --git a/packages/importers/src/providers/betterstack/mapper.test.ts b/packages/importers/src/providers/betterstack/mapper.test.ts index 70dfab13..1d805155 100644 --- a/packages/importers/src/providers/betterstack/mapper.test.ts +++ b/packages/importers/src/providers/betterstack/mapper.test.ts @@ -76,6 +76,8 @@ describe("mapMonitorType", () => { expect(mapMonitorType("tcp")).toBe("tcp"); expect(mapMonitorType("udp")).toBe("udp"); expect(mapMonitorType("dns")).toBe("dns"); + expect(mapMonitorType("ping")).toBe("icmp"); + expect(mapMonitorType("ping_icmp")).toBe("icmp"); }); test("defaults to http for unknown types", () => { diff --git a/packages/importers/src/providers/betterstack/mapper.ts b/packages/importers/src/providers/betterstack/mapper.ts index 7dac8c21..4701bdb0 100644 --- a/packages/importers/src/providers/betterstack/mapper.ts +++ b/packages/importers/src/providers/betterstack/mapper.ts @@ -40,8 +40,8 @@ const MONITOR_TYPE_MAP: Record = { expected_status_code: "http", tcp: "tcp", udp: "udp", - ping: "http", - ping_icmp: "http", + ping: "icmp", + ping_icmp: "icmp", dns: "dns", smtp: "http", pop: "http", diff --git a/packages/proto/api/openstatus/monitor/v1/icmp_monitor.proto b/packages/proto/api/openstatus/monitor/v1/icmp_monitor.proto new file mode 100644 index 00000000..0982563a --- /dev/null +++ b/packages/proto/api/openstatus/monitor/v1/icmp_monitor.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +package openstatus.monitor.v1; + +import "buf/validate/validate.proto"; +import "gnostic/openapi/v3/annotations.proto"; +import "openstatus/monitor/v1/http_monitor.proto"; +import "openstatus/monitor/v1/monitor.proto"; + +option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/monitor/v1;monitorv1"; + +// ICMPMonitor defines the configuration for a ICMP monitor. +message ICMPMonitor { + // Unique identifier for the monitor (output only for create requests). + string id = 1; + + // Name of the monitor (required, max 256 characters). + string name = 2 [ + (buf.validate.field).string = { + min_len: 1 + max_len: 256 + }, + (gnostic.openapi.v3.property) = {example: {yaml: "Ping Gateway"}} + ]; + + // URI to monitor in format "host or IP" (required, max 2048 characters). + string uri = 3 [ + (buf.validate.field).string = { + min_len: 1 + max_len: 2048 + }, + (gnostic.openapi.v3.property) = {example: {yaml: "1.1.1.1"}} + ]; + + // Check periodicity (required). + Periodicity periodicity = 4 [(buf.validate.field).enum = { + not_in: [0] + }]; + + // Timeout in milliseconds (0-120000, defaults to 45000). + int64 timeout = 5 [(buf.validate.field).int64 = { + gte: 0 + lte: 120000 + }]; + + // Latency threshold for degraded status in milliseconds (optional, 0-120000). + optional int64 degraded_at = 6 [(buf.validate.field).int64 = { + gte: 0 + lte: 120000 + }]; + + // Number of retry attempts (0-10, defaults to 3). + int64 retry = 7 [(buf.validate.field).int64 = { + gte: 0 + lte: 10 + }]; + + // Description of the monitor (optional). + optional string description = 8 [(buf.validate.field).string.max_len = 1024]; + + // Whether the monitor is active (defaults to false). + optional bool active = 9; + + // Whether the monitor is publicly visible (defaults to false). + optional bool public = 10; + + // Geographic regions to run checks from. + repeated Region regions = 11 [(buf.validate.field).repeated = { + max_items: 28 + items: { + enum: { + not_in: [0] + } + } + }]; + + // OpenTelemetry configuration for exporting metrics. + OpenTelemetryConfig open_telemetry = 12; + + // Current operational status of the monitor. + MonitorStatus status = 13; + + // IDs of private locations that run this monitor. Read-only. + repeated string private_location_ids = 14 [ + (gnostic.openapi.v3.property) = {read_only: true} + ]; +} diff --git a/packages/proto/api/openstatus/monitor/v1/service.proto b/packages/proto/api/openstatus/monitor/v1/service.proto index 284664a7..6ebb9780 100644 --- a/packages/proto/api/openstatus/monitor/v1/service.proto +++ b/packages/proto/api/openstatus/monitor/v1/service.proto @@ -6,6 +6,7 @@ import "buf/validate/validate.proto"; import "gnostic/openapi/v3/annotations.proto"; import "openstatus/monitor/v1/dns_monitor.proto"; import "openstatus/monitor/v1/http_monitor.proto"; +import "openstatus/monitor/v1/icmp_monitor.proto"; import "openstatus/monitor/v1/monitor.proto"; import "openstatus/monitor/v1/tcp_monitor.proto"; @@ -38,6 +39,9 @@ service MonitorService { // CreateDNSMonitor creates a new DNS monitor. rpc CreateDNSMonitor(CreateDNSMonitorRequest) returns (CreateDNSMonitorResponse); + // CreateICMPMonitor creates a new ICMP monitor. + rpc CreateICMPMonitor(CreateICMPMonitorRequest) returns (CreateICMPMonitorResponse); + // UpdateHTTPMonitor updates an existing HTTP monitor. rpc UpdateHTTPMonitor(UpdateHTTPMonitorRequest) returns (UpdateHTTPMonitorResponse); @@ -47,6 +51,9 @@ service MonitorService { // UpdateDNSMonitor updates an existing DNS monitor. rpc UpdateDNSMonitor(UpdateDNSMonitorRequest) returns (UpdateDNSMonitorResponse); + // UpdateICMPMonitor updates an existing ICMP monitor. + rpc UpdateICMPMonitor(UpdateICMPMonitorRequest) returns (UpdateICMPMonitorResponse); + // TriggerMonitor initiates an immediate check for a monitor across all configured regions. rpc TriggerMonitor(TriggerMonitorRequest) returns (TriggerMonitorResponse) { option (gnostic.openapi.v3.operation) = { @@ -76,7 +83,7 @@ service MonitorService { } // GetMonitor returns a single monitor by ID within the authenticated workspace. - // Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + // Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. rpc GetMonitor(GetMonitorRequest) returns (GetMonitorResponse) { option idempotency_level = NO_SIDE_EFFECTS; } @@ -128,6 +135,18 @@ message CreateDNSMonitorResponse { DNSMonitor monitor = 1; } +// CreateICMPMonitorRequest is the request to create a new ICMP monitor. +message CreateICMPMonitorRequest { + // Monitor configuration (required). + ICMPMonitor monitor = 1 [(buf.validate.field).required = true]; +} + +// CreateICMPMonitorResponse is the response after creating an ICMP monitor. +message CreateICMPMonitorResponse { + // The created monitor with assigned ID. + ICMPMonitor monitor = 1; +} + // UpdateHTTPMonitorRequest is the request to update an existing HTTP monitor. message UpdateHTTPMonitorRequest { // Monitor ID to update (required). @@ -173,6 +192,21 @@ message UpdateDNSMonitorResponse { DNSMonitor monitor = 1; } +// UpdateICMPMonitorRequest is the request to update an existing ICMP monitor. +message UpdateICMPMonitorRequest { + // Monitor ID to update (required). + string id = 1 [(buf.validate.field).string.min_len = 1]; + + // Updated monitor configuration (all fields optional for partial updates). + optional ICMPMonitor monitor = 2; +} + +// UpdateICMPMonitorResponse is the response after updating an ICMP monitor. +message UpdateICMPMonitorResponse { + // The updated monitor. + ICMPMonitor monitor = 1; +} + // TriggerMonitorRequest is the request to trigger a monitor check. message TriggerMonitorRequest { // Monitor ID to trigger (required). @@ -220,6 +254,9 @@ message ListMonitorsResponse { // DNS monitors in the workspace. repeated DNSMonitor dns_monitors = 3; + // ICMP monitors in the workspace. + repeated ICMPMonitor icmp_monitors = 5; + // Total number of monitors across all types. int32 total_size = 4; } @@ -257,6 +294,8 @@ message MonitorConfig { TCPMonitor tcp = 2; // DNS monitor configuration. DNSMonitor dns = 3; + // ICMP monitor configuration. + ICMPMonitor icmp = 4; } } @@ -319,7 +358,7 @@ message GetMonitorRequest { // GetMonitorResponse is the response containing the monitor. message GetMonitorResponse { - // The monitor configuration (one of HTTP, TCP, or DNS). + // The monitor configuration (one of HTTP, TCP, DNS, or ICMP). MonitorConfig monitor = 1; } diff --git a/packages/proto/gen/openapi.yaml b/packages/proto/gen/openapi.yaml index 507f9fae..532ee048 100644 --- a/packages/proto/gen/openapi.yaml +++ b/packages/proto/gen/openapi.yaml @@ -470,6 +470,28 @@ components: title: CreateHTTPMonitorResponse additionalProperties: false description: CreateHTTPMonitorResponse is the response after creating an HTTP monitor. + openstatus.monitor.v1.CreateICMPMonitorRequest: + type: object + properties: + monitor: + title: monitor + description: Monitor configuration (required). + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: CreateICMPMonitorRequest + required: + - monitor + additionalProperties: false + description: CreateICMPMonitorRequest is the request to create a new ICMP monitor. + openstatus.monitor.v1.CreateICMPMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The created monitor with assigned ID. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: CreateICMPMonitorResponse + additionalProperties: false + description: CreateICMPMonitorResponse is the response after creating an ICMP monitor. openstatus.monitor.v1.CreateTCPMonitorRequest: type: object properties: @@ -665,7 +687,7 @@ components: properties: monitor: title: monitor - description: The monitor configuration (one of HTTP, TCP, or DNS). + description: The monitor configuration (one of HTTP, TCP, DNS, or ICMP). $ref: '#/components/schemas/openstatus.monitor.v1.MonitorConfig' title: GetMonitorResponse additionalProperties: false @@ -1189,6 +1211,109 @@ components: title: Headers additionalProperties: false description: Headers represents a key-value pair for HTTP headers. + openstatus.monitor.v1.ICMPMonitor: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the monitor (output only for create requests). + name: + type: string + examples: + - Ping Gateway + title: name + maxLength: 256 + minLength: 1 + description: Name of the monitor (required, max 256 characters). + uri: + type: string + examples: + - 1.1.1.1 + title: uri + maxLength: 2048 + minLength: 1 + description: URI to monitor in format "host or IP" (required, max 2048 characters). + periodicity: + not: + enum: + - PERIODICITY_UNSPECIFIED + title: periodicity + description: Check periodicity (required). + $ref: '#/components/schemas/openstatus.monitor.v1.Periodicity' + timeout: + type: + - integer + - string + title: timeout + maximum: 120000 + minimum: 0 + format: int64 + description: Timeout in milliseconds (0-120000, defaults to 45000). + degradedAt: + type: + - integer + - string + - "null" + title: degraded_at + maximum: 120000 + minimum: 0 + format: int64 + description: Latency threshold for degraded status in milliseconds (optional, 0-120000). + retry: + type: + - integer + - string + title: retry + maximum: 10 + minimum: 0 + format: int64 + description: Number of retry attempts (0-10, defaults to 3). + description: + type: + - string + - "null" + title: description + maxLength: 1024 + description: Description of the monitor (optional). + active: + type: + - boolean + - "null" + title: active + description: Whether the monitor is active (defaults to false). + public: + type: + - boolean + - "null" + title: public + description: Whether the monitor is publicly visible (defaults to false). + regions: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.Region' + title: regions + maxItems: 28 + description: Geographic regions to run checks from. + openTelemetry: + title: open_telemetry + description: OpenTelemetry configuration for exporting metrics. + $ref: '#/components/schemas/openstatus.monitor.v1.OpenTelemetryConfig' + status: + title: status + description: Current operational status of the monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.MonitorStatus' + privateLocationIds: + type: array + items: + type: string + readOnly: true + title: private_location_ids + description: IDs of private locations that run this monitor. Read-only. + readOnly: true + title: ICMPMonitor + additionalProperties: false + description: ICMPMonitor defines the configuration for a ICMP monitor. openstatus.monitor.v1.ListMonitorHTTPResponseLogsRequest: type: object properties: @@ -1293,6 +1418,12 @@ components: $ref: '#/components/schemas/openstatus.monitor.v1.DNSMonitor' title: dns_monitors description: DNS monitors in the workspace. + icmpMonitors: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: icmp_monitors + description: ICMP monitors in the workspace. totalSize: type: integer title: total_size @@ -1322,6 +1453,15 @@ components: title: http required: - http + - type: object + properties: + icmp: + title: icmp + description: ICMP monitor configuration. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: icmp + required: + - icmp - type: object properties: tcp: @@ -1695,6 +1835,33 @@ components: title: UpdateHTTPMonitorResponse additionalProperties: false description: UpdateHTTPMonitorResponse is the response after updating an HTTP monitor. + openstatus.monitor.v1.UpdateICMPMonitorRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: Monitor ID to update (required). + monitor: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + - type: "null" + title: monitor + description: Updated monitor configuration (all fields optional for partial updates). + title: UpdateICMPMonitorRequest + additionalProperties: false + description: UpdateICMPMonitorRequest is the request to update an existing ICMP monitor. + openstatus.monitor.v1.UpdateICMPMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The updated monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: UpdateICMPMonitorResponse + additionalProperties: false + description: UpdateICMPMonitorResponse is the response after updating an ICMP monitor. openstatus.monitor.v1.UpdateTCPMonitorRequest: type: object properties: @@ -5046,6 +5213,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateHTTPMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/CreateICMPMonitor: + post: + tags: + - MonitorService + summary: CreateICMPMonitor + description: CreateICMPMonitor creates a new ICMP monitor. + operationId: MonitorService_CreateICMPMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateICMPMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateICMPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateTCPMonitor: post: tags: @@ -5105,7 +5298,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor.get parameters: - name: message @@ -5133,7 +5326,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor requestBody: content: @@ -5492,6 +5685,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateHTTPMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/UpdateICMPMonitor: + post: + tags: + - MonitorService + summary: UpdateICMPMonitor + description: UpdateICMPMonitor updates an existing ICMP monitor. + operationId: MonitorService_UpdateICMPMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateICMPMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateICMPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateTCPMonitor: post: tags: diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/icmp_monitor_pb.ts b/packages/proto/gen/ts/openstatus/monitor/v1/icmp_monitor_pb.ts new file mode 100644 index 00000000..a05cb46b --- /dev/null +++ b/packages/proto/gen/ts/openstatus/monitor/v1/icmp_monitor_pb.ts @@ -0,0 +1,132 @@ +// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=.ts" +// @generated from file openstatus/monitor/v1/icmp_monitor.proto (package openstatus.monitor.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb.ts"; +import { file_gnostic_openapi_v3_annotations } from "../../../gnostic/openapi/v3/annotations_pb.ts"; +import type { OpenTelemetryConfig } from "./http_monitor_pb.ts"; +import { file_openstatus_monitor_v1_http_monitor } from "./http_monitor_pb.ts"; +import type { MonitorStatus, Periodicity, Region } from "./monitor_pb.ts"; +import { file_openstatus_monitor_v1_monitor } from "./monitor_pb.ts"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file openstatus/monitor/v1/icmp_monitor.proto. + */ +export const file_openstatus_monitor_v1_icmp_monitor: GenFile = /*@__PURE__*/ + fileDesc("CihvcGVuc3RhdHVzL21vbml0b3IvdjEvaWNtcF9tb25pdG9yLnByb3RvEhVvcGVuc3RhdHVzLm1vbml0b3IudjEi8wQKC0lDTVBNb25pdG9yEgoKAmlkGAEgASgJEisKBG5hbWUYAiABKAlCHbpHEDoOEgxQaW5nIEdhdGV3YXm6SAdyBRABGIACEiUKA3VyaRgDIAEoCUIYukcLOgkSBzEuMS4xLjG6SAdyBRABGIAQEkEKC3BlcmlvZGljaXR5GAQgASgOMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLlBlcmlvZGljaXR5Qgi6SAWCAQIgABIcCgd0aW1lb3V0GAUgASgDQgu6SAgiBhjAqQcoABIlCgtkZWdyYWRlZF9hdBgGIAEoA0ILukgIIgYYwKkHKABIAIgBARIYCgVyZXRyeRgHIAEoA0IJukgGIgQYCigAEiIKC2Rlc2NyaXB0aW9uGAggASgJQgi6SAVyAxiACEgBiAEBEhMKBmFjdGl2ZRgJIAEoCEgCiAEBEhMKBnB1YmxpYxgKIAEoCEgDiAEBEj8KB3JlZ2lvbnMYCyADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQg+6SAySAQkQHCIFggECIAASQgoOb3Blbl90ZWxlbWV0cnkYDCABKAsyKi5vcGVuc3RhdHVzLm1vbml0b3IudjEuT3BlblRlbGVtZXRyeUNvbmZpZxI0CgZzdGF0dXMYDSABKA4yJC5vcGVuc3RhdHVzLm1vbml0b3IudjEuTW9uaXRvclN0YXR1cxIjChRwcml2YXRlX2xvY2F0aW9uX2lkcxgOIAMoCUIFukcCGAFCDgoMX2RlZ3JhZGVkX2F0Qg4KDF9kZXNjcmlwdGlvbkIJCgdfYWN0aXZlQgkKB19wdWJsaWNCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_monitor]); + +/** + * ICMPMonitor defines the configuration for a ICMP monitor. + * + * @generated from message openstatus.monitor.v1.ICMPMonitor + */ +export type ICMPMonitor = Message<"openstatus.monitor.v1.ICMPMonitor"> & { + /** + * Unique identifier for the monitor (output only for create requests). + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * Name of the monitor (required, max 256 characters). + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * URI to monitor in format "host or IP" (required, max 2048 characters). + * + * @generated from field: string uri = 3; + */ + uri: string; + + /** + * Check periodicity (required). + * + * @generated from field: openstatus.monitor.v1.Periodicity periodicity = 4; + */ + periodicity: Periodicity; + + /** + * Timeout in milliseconds (0-120000, defaults to 45000). + * + * @generated from field: int64 timeout = 5; + */ + timeout: bigint; + + /** + * Latency threshold for degraded status in milliseconds (optional, 0-120000). + * + * @generated from field: optional int64 degraded_at = 6; + */ + degradedAt?: bigint | undefined; + + /** + * Number of retry attempts (0-10, defaults to 3). + * + * @generated from field: int64 retry = 7; + */ + retry: bigint; + + /** + * Description of the monitor (optional). + * + * @generated from field: optional string description = 8; + */ + description?: string | undefined; + + /** + * Whether the monitor is active (defaults to false). + * + * @generated from field: optional bool active = 9; + */ + active?: boolean | undefined; + + /** + * Whether the monitor is publicly visible (defaults to false). + * + * @generated from field: optional bool public = 10; + */ + public?: boolean | undefined; + + /** + * Geographic regions to run checks from. + * + * @generated from field: repeated openstatus.monitor.v1.Region regions = 11; + */ + regions: Region[]; + + /** + * OpenTelemetry configuration for exporting metrics. + * + * @generated from field: openstatus.monitor.v1.OpenTelemetryConfig open_telemetry = 12; + */ + openTelemetry?: OpenTelemetryConfig | undefined; + + /** + * Current operational status of the monitor. + * + * @generated from field: openstatus.monitor.v1.MonitorStatus status = 13; + */ + status: MonitorStatus; + + /** + * IDs of private locations that run this monitor. Read-only. + * + * @generated from field: repeated string private_location_ids = 14; + */ + privateLocationIds: string[]; +}; + +/** + * Describes the message openstatus.monitor.v1.ICMPMonitor. + * Use `create(ICMPMonitorSchema)` to create a new message. + */ +export const ICMPMonitorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_icmp_monitor, 0); + diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/index.ts b/packages/proto/gen/ts/openstatus/monitor/v1/index.ts index d6c08846..e068ed2e 100644 --- a/packages/proto/gen/ts/openstatus/monitor/v1/index.ts +++ b/packages/proto/gen/ts/openstatus/monitor/v1/index.ts @@ -2,6 +2,7 @@ export * from "./assertions_pb.js"; export * from "./dns_monitor_pb.js"; export * from "./http_monitor_pb.js"; +export * from "./icmp_monitor_pb.js"; export * from "./tcp_monitor_pb.js"; export * from "./monitor_pb.js"; export * from "./service_pb.js"; diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts b/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts index 94791733..9de06bc9 100644 --- a/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts +++ b/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts @@ -10,6 +10,8 @@ import type { DNSMonitor } from "./dns_monitor_pb.ts"; import { file_openstatus_monitor_v1_dns_monitor } from "./dns_monitor_pb.ts"; import type { HTTPMonitor } from "./http_monitor_pb.ts"; import { file_openstatus_monitor_v1_http_monitor } from "./http_monitor_pb.ts"; +import type { ICMPMonitor } from "./icmp_monitor_pb.ts"; +import { file_openstatus_monitor_v1_icmp_monitor } from "./icmp_monitor_pb.ts"; import type { MonitorStatus, Region } from "./monitor_pb.ts"; import { file_openstatus_monitor_v1_monitor } from "./monitor_pb.ts"; import type { TCPMonitor } from "./tcp_monitor_pb.ts"; @@ -20,7 +22,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file openstatus/monitor/v1/service.proto. */ export const file_openstatus_monitor_v1_service: GenFile = /*@__PURE__*/ - fileDesc("CiNvcGVuc3RhdHVzL21vbml0b3IvdjEvc2VydmljZS5wcm90bxIVb3BlbnN0YXR1cy5tb25pdG9yLnYxIlcKGENyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBI7Cgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yQga6SAPIAQEiUAoZQ3JlYXRlSFRUUE1vbml0b3JSZXNwb25zZRIzCgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yIlUKF0NyZWF0ZVRDUE1vbml0b3JSZXF1ZXN0EjoKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvckIGukgDyAEBIk4KGENyZWF0ZVRDUE1vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3IiVQoXQ3JlYXRlRE5TTW9uaXRvclJlcXVlc3QSOgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5ETlNNb25pdG9yQga6SAPIAQEiTgoYQ3JlYXRlRE5TTW9uaXRvclJlc3BvbnNlEjIKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvciJ1ChhVcGRhdGVIVFRQTW9uaXRvclJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESOAoHbW9uaXRvchgCIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIlAKGVVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvciJzChdVcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARI3Cgdtb25pdG9yGAIgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJOChhVcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2USMgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5UQ1BNb25pdG9yInMKF1VwZGF0ZUROU01vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjcKB21vbml0b3IYAiABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIk4KGFVwZGF0ZUROU01vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLkROU01vbml0b3IiLAoVVHJpZ2dlck1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIikKFlRyaWdnZXJNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCIrChREZWxldGVNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIoChVEZWxldGVNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCJnChNMaXN0TW9uaXRvcnNSZXF1ZXN0Eh0KBWxpbWl0GAEgASgFQgm6SAYaBBhkKAFIAIgBARIcCgZvZmZzZXQYAiABKAVCB7pIBBoCKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCLXAQoUTGlzdE1vbml0b3JzUmVzcG9uc2USOQoNaHR0cF9tb25pdG9ycxgBIAMoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvchI3Cgx0Y3BfbW9uaXRvcnMYAiADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvchI3CgxkbnNfbW9uaXRvcnMYAyADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvchISCgp0b3RhbF9zaXplGAQgASgFIi4KF0dldE1vbml0b3JTdGF0dXNSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABInMKDFJlZ2lvblN0YXR1cxItCgZyZWdpb24YASABKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uEjQKBnN0YXR1cxgCIAEoDjIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yU3RhdHVzIlwKGEdldE1vbml0b3JTdGF0dXNSZXNwb25zZRIKCgJpZBgBIAEoCRI0CgdyZWdpb25zGAIgAygLMiMub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvblN0YXR1cyKxAQoNTW9uaXRvckNvbmZpZxIyCgRodHRwGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9ySAASMAoDdGNwGAIgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3JIABIwCgNkbnMYAyABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvckgAQggKBmNvbmZpZyKfAQoYR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjQKCnRpbWVfcmFuZ2UYAiABKA4yIC5vcGVuc3RhdHVzLm1vbml0b3IudjEuVGltZVJhbmdlEjgKB3JlZ2lvbnMYAyADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQgi6SAWSAQIQHCKsAgoZR2V0TW9uaXRvclN1bW1hcnlSZXNwb25zZRIKCgJpZBgBIAEoCRIUCgxsYXN0X3BpbmdfYXQYAiABKAkSGAoQdG90YWxfc3VjY2Vzc2Z1bBgDIAEoAxIWCg50b3RhbF9kZWdyYWRlZBgEIAEoAxIUCgx0b3RhbF9mYWlsZWQYBSABKAMSCwoDcDUwGAYgASgDEgsKA3A3NRgHIAEoAxILCgNwOTAYCCABKAMSCwoDcDk1GAkgASgDEgsKA3A5ORgKIAEoAxI0Cgp0aW1lX3JhbmdlGAsgASgOMiAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRpbWVSYW5nZRIuCgdyZWdpb25zGAwgAygOMh0ub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvbiIoChFHZXRNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJLChJHZXRNb25pdG9yUmVzcG9uc2USNQoHbW9uaXRvchgBIAEoCzIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yQ29uZmlnImIKFUhUVFBSZXNwb25zZUxvZ1RpbWluZxILCgNkbnMYASABKAUSDwoHY29ubmVjdBgCIAEoBRILCgN0bHMYAyABKAUSDAoEdHRmYhgEIAEoBRIQCgh0cmFuc2ZlchgFIAEoBSK1AwoXSFRUUFJlc3BvbnNlTG9nTGlzdEl0ZW0SDwoCaWQYASABKAlIAIgBARIPCgdsYXRlbmN5GAIgASgFEhgKC3N0YXR1c19jb2RlGAMgASgFSAGIAQESEgoKbW9uaXRvcl9pZBgEIAEoCRJLCg5yZXF1ZXN0X3N0YXR1cxgFIAEoDjIzLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEi0KBnJlZ2lvbhgGIAEoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb24SFgoOY3Jvbl90aW1lc3RhbXAYByABKAMSPgoHdHJpZ2dlchgIIAEoDjItLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dUcmlnZ2VyEhEKCXRpbWVzdGFtcBgJIAEoAxJBCgZ0aW1pbmcYCiABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nVGltaW5nSAKIAQFCBQoDX2lkQg4KDF9zdGF0dXNfY29kZUIJCgdfdGltaW5nIrYCChVIVFRQUmVzcG9uc2VMb2dEZXRhaWwSOwoDbG9nGAEgASgLMi4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ0xpc3RJdGVtEgsKA3VybBgCIAEoCRINCgVlcnJvchgDIAEoCBIUCgdtZXNzYWdlGAQgASgJSACIAQESSgoHaGVhZGVycxgFIAMoCzI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dEZXRhaWwuSGVhZGVyc0VudHJ5EhcKCmFzc2VydGlvbnMYBiABKAlIAYgBARouCgxIZWFkZXJzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIKCghfbWVzc2FnZUINCgtfYXNzZXJ0aW9ucyLnAQoiTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIbCg5mcm9tX3RpbWVzdGFtcBgCIAEoA0gAiAEBEhkKDHRvX3RpbWVzdGFtcBgDIAEoA0gBiAEBEh0KBWxpbWl0GAQgASgFQgm6SAYaBBhkKAFIAogBARIcCgZvZmZzZXQYBSABKAVCB7pIBBoCKABIA4gBAUIRCg9fZnJvbV90aW1lc3RhbXBCDwoNX3RvX3RpbWVzdGFtcEIICgZfbGltaXRCCQoHX29mZnNldCJ2ChlIVFRQUmVzcG9uc2VMb2dQYWdpbmF0aW9uEg0KBWxpbWl0GAEgASgFEg4KBm9mZnNldBgCIAEoBRIQCghoYXNfbW9yZRgDIAEoCBIYCgtuZXh0X29mZnNldBgEIAEoBUgAiAEBQg4KDF9uZXh0X29mZnNldCKpAQojTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVzcG9uc2USPAoEbG9ncxgBIAMoCzIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dMaXN0SXRlbRJECgpwYWdpbmF0aW9uGAIgASgLMjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ1BhZ2luYXRpb24iUAogR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESFwoGbG9nX2lkGAIgASgJQge6SARyAhABIl4KIUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2dSZXNwb25zZRI5CgNsb2cYASABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nRGV0YWlsKmEKCVRpbWVSYW5nZRIaChZUSU1FX1JBTkdFX1VOU1BFQ0lGSUVEEAASEQoNVElNRV9SQU5HRV8xRBABEhEKDVRJTUVfUkFOR0VfN0QQAhISCg5USU1FX1JBTkdFXzE0RBADKtkBChxIVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEjAKLEhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX1VOU1BFQ0lGSUVEEAASLAooSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfU1VDQ0VTUxABEioKJkhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX0VSUk9SEAISLQopSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfREVHUkFERUQQAyqKAQoWSFRUUFJlc3BvbnNlTG9nVHJpZ2dlchIpCiVIVFRQX1JFU1BPTlNFX0xPR19UUklHR0VSX1VOU1BFQ0lGSUVEEAASIgoeSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9DUk9OEAESIQodSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9BUEkQAjKbFAoOTW9uaXRvclNlcnZpY2USuQMKEUNyZWF0ZUhUVFBNb25pdG9yEi8ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBowLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVIVFRQTW9uaXRvclJlc3BvbnNlIsACuke8Ahq5AkNyZWF0ZXMgYSBuZXcgSFRUUCBtb25pdG9yIGluIHRoZSBhdXRoZW50aWNhdGVkIHdvcmtzcGFjZS4gQ29uZmlndXJlIHRoZSB0YXJnZXQgVVJMLCBIVFRQIG1ldGhvZCwgcmVxdWVzdCBoZWFkZXJzIGFuZCBib2R5LCByZXNwb25zZSBhc3NlcnRpb25zIChzdGF0dXMgY29kZSwgYm9keSBjb250ZW50LCBoZWFkZXJzKSwgY2hlY2sgcGVyaW9kaWNpdHksIGdlb2dyYXBoaWMgcmVnaW9ucywgYW5kIG9wdGlvbmFsIE9wZW5UZWxlbWV0cnkgZXhwb3J0LiBUaGUgbW9uaXRvciBzdGFydHMgY2hlY2tpbmcgaW1tZWRpYXRlbHkgaWYgc2V0IHRvIGFjdGl2ZS4ScwoQQ3JlYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQQ3JlYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSFRUUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSFRUUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVzcG9uc2US6QIKDlRyaWdnZXJNb25pdG9yEiwub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRyaWdnZXJNb25pdG9yUmVxdWVzdBotLm9wZW5zdGF0dXMubW9uaXRvci52MS5UcmlnZ2VyTW9uaXRvclJlc3BvbnNlIvkBukf1ARryAU1hbnVhbGx5IHRyaWdnZXJzIGFuIGltbWVkaWF0ZSBjaGVjayBmb3IgdGhlIHNwZWNpZmllZCBtb25pdG9yIGFjcm9zcyBhbGwgY29uZmlndXJlZCByZWdpb25zLiBUaGlzIG9wZXJhdGlvbiBpcyByYXRlLWxpbWl0ZWQgdW5kZXIgdGhlIHN5bnRoZXRpYy1jaGVja3MgcXVvdGEuIEEgbW9uaXRvciBydW4gcmVjb3JkIGlzIGNyZWF0ZWQgYW5kIHRoZSBjaGVjayBpcyBkaXNwYXRjaGVkIHRvIHRoZSBjaGVja2VyIHNlcnZpY2UuEmoKDURlbGV0ZU1vbml0b3ISKy5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlcXVlc3QaLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlc3BvbnNlEmwKDExpc3RNb25pdG9ycxIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvcnNSZXF1ZXN0Gisub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9yc1Jlc3BvbnNlIgOQAgESeAoQR2V0TW9uaXRvclN0YXR1cxIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVzcG9uc2UiA5ACARKmAwoRR2V0TW9uaXRvclN1bW1hcnkSLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkdldE1vbml0b3JTdW1tYXJ5UmVzcG9uc2UirQKQAgG6R6YCGqMCUmV0dXJucyBhZ2dyZWdhdGVkIG1ldHJpY3MgZm9yIGEgbW9uaXRvciBpbmNsdWRpbmcgbGF0ZW5jeSBwZXJjZW50aWxlcyAocDUwLCBwNzUsIHA5MCwgcDk1LCBwOTkpLCByZXF1ZXN0IGNvdW50cyBieSBzdGF0dXMgKHN1Y2Nlc3NmdWwsIGRlZ3JhZGVkLCBmYWlsZWQpLCBhbmQgdGhlIHRpbWVzdGFtcCBvZiB0aGUgbGFzdCBjaGVjay4gTWV0cmljcyBjYW4gYmUgc2NvcGVkIHRvIGEgdGltZSByYW5nZSAoMSBkYXksIDcgZGF5cywgb3IgMTQgZGF5cykgYW5kIGZpbHRlcmVkIGJ5IHNwZWNpZmljIHJlZ2lvbnMuEmYKCkdldE1vbml0b3ISKC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlcXVlc3QaKS5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlc3BvbnNlIgOQAgESmQEKG0xpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9ncxI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvckhUVFBSZXNwb25zZUxvZ3NSZXF1ZXN0Gjoub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9nc1Jlc3BvbnNlIgOQAgESkwEKGUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2cSNy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QaOC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1Jlc3BvbnNlIgOQAgFCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_dns_monitor, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_monitor, file_openstatus_monitor_v1_tcp_monitor]); + fileDesc("CiNvcGVuc3RhdHVzL21vbml0b3IvdjEvc2VydmljZS5wcm90bxIVb3BlbnN0YXR1cy5tb25pdG9yLnYxIlcKGENyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBI7Cgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yQga6SAPIAQEiUAoZQ3JlYXRlSFRUUE1vbml0b3JSZXNwb25zZRIzCgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yIlUKF0NyZWF0ZVRDUE1vbml0b3JSZXF1ZXN0EjoKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvckIGukgDyAEBIk4KGENyZWF0ZVRDUE1vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3IiVQoXQ3JlYXRlRE5TTW9uaXRvclJlcXVlc3QSOgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5ETlNNb25pdG9yQga6SAPIAQEiTgoYQ3JlYXRlRE5TTW9uaXRvclJlc3BvbnNlEjIKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvciJXChhDcmVhdGVJQ01QTW9uaXRvclJlcXVlc3QSOwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvckIGukgDyAEBIlAKGUNyZWF0ZUlDTVBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvciJ1ChhVcGRhdGVIVFRQTW9uaXRvclJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESOAoHbW9uaXRvchgCIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIlAKGVVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvciJzChdVcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARI3Cgdtb25pdG9yGAIgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJOChhVcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2USMgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5UQ1BNb25pdG9yInMKF1VwZGF0ZUROU01vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjcKB21vbml0b3IYAiABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIk4KGFVwZGF0ZUROU01vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLkROU01vbml0b3IidQoYVXBkYXRlSUNNUE1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjgKB21vbml0b3IYAiABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSUNNUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJQChlVcGRhdGVJQ01QTW9uaXRvclJlc3BvbnNlEjMKB21vbml0b3IYASABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSUNNUE1vbml0b3IiLAoVVHJpZ2dlck1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIikKFlRyaWdnZXJNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCIrChREZWxldGVNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIoChVEZWxldGVNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCJnChNMaXN0TW9uaXRvcnNSZXF1ZXN0Eh0KBWxpbWl0GAEgASgFQgm6SAYaBBhkKAFIAIgBARIcCgZvZmZzZXQYAiABKAVCB7pIBBoCKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCKSAgoUTGlzdE1vbml0b3JzUmVzcG9uc2USOQoNaHR0cF9tb25pdG9ycxgBIAMoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvchI3Cgx0Y3BfbW9uaXRvcnMYAiADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvchI3CgxkbnNfbW9uaXRvcnMYAyADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvchI5Cg1pY21wX21vbml0b3JzGAUgAygLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLklDTVBNb25pdG9yEhIKCnRvdGFsX3NpemUYBCABKAUiLgoXR2V0TW9uaXRvclN0YXR1c1JlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEicwoMUmVnaW9uU3RhdHVzEi0KBnJlZ2lvbhgBIAEoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb24SNAoGc3RhdHVzGAIgASgOMiQub3BlbnN0YXR1cy5tb25pdG9yLnYxLk1vbml0b3JTdGF0dXMiXAoYR2V0TW9uaXRvclN0YXR1c1Jlc3BvbnNlEgoKAmlkGAEgASgJEjQKB3JlZ2lvbnMYAiADKAsyIy5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uU3RhdHVzIuUBCg1Nb25pdG9yQ29uZmlnEjIKBGh0dHAYASABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUE1vbml0b3JIABIwCgN0Y3AYAiABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvckgAEjAKA2RucxgDIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5ETlNNb25pdG9ySAASMgoEaWNtcBgEIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvckgAQggKBmNvbmZpZyKfAQoYR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjQKCnRpbWVfcmFuZ2UYAiABKA4yIC5vcGVuc3RhdHVzLm1vbml0b3IudjEuVGltZVJhbmdlEjgKB3JlZ2lvbnMYAyADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQgi6SAWSAQIQHCKsAgoZR2V0TW9uaXRvclN1bW1hcnlSZXNwb25zZRIKCgJpZBgBIAEoCRIUCgxsYXN0X3BpbmdfYXQYAiABKAkSGAoQdG90YWxfc3VjY2Vzc2Z1bBgDIAEoAxIWCg50b3RhbF9kZWdyYWRlZBgEIAEoAxIUCgx0b3RhbF9mYWlsZWQYBSABKAMSCwoDcDUwGAYgASgDEgsKA3A3NRgHIAEoAxILCgNwOTAYCCABKAMSCwoDcDk1GAkgASgDEgsKA3A5ORgKIAEoAxI0Cgp0aW1lX3JhbmdlGAsgASgOMiAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRpbWVSYW5nZRIuCgdyZWdpb25zGAwgAygOMh0ub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvbiIoChFHZXRNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJLChJHZXRNb25pdG9yUmVzcG9uc2USNQoHbW9uaXRvchgBIAEoCzIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yQ29uZmlnImIKFUhUVFBSZXNwb25zZUxvZ1RpbWluZxILCgNkbnMYASABKAUSDwoHY29ubmVjdBgCIAEoBRILCgN0bHMYAyABKAUSDAoEdHRmYhgEIAEoBRIQCgh0cmFuc2ZlchgFIAEoBSK1AwoXSFRUUFJlc3BvbnNlTG9nTGlzdEl0ZW0SDwoCaWQYASABKAlIAIgBARIPCgdsYXRlbmN5GAIgASgFEhgKC3N0YXR1c19jb2RlGAMgASgFSAGIAQESEgoKbW9uaXRvcl9pZBgEIAEoCRJLCg5yZXF1ZXN0X3N0YXR1cxgFIAEoDjIzLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEi0KBnJlZ2lvbhgGIAEoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb24SFgoOY3Jvbl90aW1lc3RhbXAYByABKAMSPgoHdHJpZ2dlchgIIAEoDjItLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dUcmlnZ2VyEhEKCXRpbWVzdGFtcBgJIAEoAxJBCgZ0aW1pbmcYCiABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nVGltaW5nSAKIAQFCBQoDX2lkQg4KDF9zdGF0dXNfY29kZUIJCgdfdGltaW5nIrYCChVIVFRQUmVzcG9uc2VMb2dEZXRhaWwSOwoDbG9nGAEgASgLMi4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ0xpc3RJdGVtEgsKA3VybBgCIAEoCRINCgVlcnJvchgDIAEoCBIUCgdtZXNzYWdlGAQgASgJSACIAQESSgoHaGVhZGVycxgFIAMoCzI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dEZXRhaWwuSGVhZGVyc0VudHJ5EhcKCmFzc2VydGlvbnMYBiABKAlIAYgBARouCgxIZWFkZXJzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIKCghfbWVzc2FnZUINCgtfYXNzZXJ0aW9ucyLnAQoiTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIbCg5mcm9tX3RpbWVzdGFtcBgCIAEoA0gAiAEBEhkKDHRvX3RpbWVzdGFtcBgDIAEoA0gBiAEBEh0KBWxpbWl0GAQgASgFQgm6SAYaBBhkKAFIAogBARIcCgZvZmZzZXQYBSABKAVCB7pIBBoCKABIA4gBAUIRCg9fZnJvbV90aW1lc3RhbXBCDwoNX3RvX3RpbWVzdGFtcEIICgZfbGltaXRCCQoHX29mZnNldCJ2ChlIVFRQUmVzcG9uc2VMb2dQYWdpbmF0aW9uEg0KBWxpbWl0GAEgASgFEg4KBm9mZnNldBgCIAEoBRIQCghoYXNfbW9yZRgDIAEoCBIYCgtuZXh0X29mZnNldBgEIAEoBUgAiAEBQg4KDF9uZXh0X29mZnNldCKpAQojTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVzcG9uc2USPAoEbG9ncxgBIAMoCzIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dMaXN0SXRlbRJECgpwYWdpbmF0aW9uGAIgASgLMjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ1BhZ2luYXRpb24iUAogR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESFwoGbG9nX2lkGAIgASgJQge6SARyAhABIl4KIUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2dSZXNwb25zZRI5CgNsb2cYASABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nRGV0YWlsKmEKCVRpbWVSYW5nZRIaChZUSU1FX1JBTkdFX1VOU1BFQ0lGSUVEEAASEQoNVElNRV9SQU5HRV8xRBABEhEKDVRJTUVfUkFOR0VfN0QQAhISCg5USU1FX1JBTkdFXzE0RBADKtkBChxIVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEjAKLEhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX1VOU1BFQ0lGSUVEEAASLAooSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfU1VDQ0VTUxABEioKJkhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX0VSUk9SEAISLQopSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfREVHUkFERUQQAyqKAQoWSFRUUFJlc3BvbnNlTG9nVHJpZ2dlchIpCiVIVFRQX1JFU1BPTlNFX0xPR19UUklHR0VSX1VOU1BFQ0lGSUVEEAASIgoeSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9DUk9OEAESIQodSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9BUEkQAjKLFgoOTW9uaXRvclNlcnZpY2USuQMKEUNyZWF0ZUhUVFBNb25pdG9yEi8ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBowLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVIVFRQTW9uaXRvclJlc3BvbnNlIsACuke8Ahq5AkNyZWF0ZXMgYSBuZXcgSFRUUCBtb25pdG9yIGluIHRoZSBhdXRoZW50aWNhdGVkIHdvcmtzcGFjZS4gQ29uZmlndXJlIHRoZSB0YXJnZXQgVVJMLCBIVFRQIG1ldGhvZCwgcmVxdWVzdCBoZWFkZXJzIGFuZCBib2R5LCByZXNwb25zZSBhc3NlcnRpb25zIChzdGF0dXMgY29kZSwgYm9keSBjb250ZW50LCBoZWFkZXJzKSwgY2hlY2sgcGVyaW9kaWNpdHksIGdlb2dyYXBoaWMgcmVnaW9ucywgYW5kIG9wdGlvbmFsIE9wZW5UZWxlbWV0cnkgZXhwb3J0LiBUaGUgbW9uaXRvciBzdGFydHMgY2hlY2tpbmcgaW1tZWRpYXRlbHkgaWYgc2V0IHRvIGFjdGl2ZS4ScwoQQ3JlYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQQ3JlYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRQ3JlYXRlSUNNUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuQ3JlYXRlSUNNUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUlDTVBNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSFRUUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSFRUUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSUNNUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSUNNUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUlDTVBNb25pdG9yUmVzcG9uc2US6QIKDlRyaWdnZXJNb25pdG9yEiwub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRyaWdnZXJNb25pdG9yUmVxdWVzdBotLm9wZW5zdGF0dXMubW9uaXRvci52MS5UcmlnZ2VyTW9uaXRvclJlc3BvbnNlIvkBukf1ARryAU1hbnVhbGx5IHRyaWdnZXJzIGFuIGltbWVkaWF0ZSBjaGVjayBmb3IgdGhlIHNwZWNpZmllZCBtb25pdG9yIGFjcm9zcyBhbGwgY29uZmlndXJlZCByZWdpb25zLiBUaGlzIG9wZXJhdGlvbiBpcyByYXRlLWxpbWl0ZWQgdW5kZXIgdGhlIHN5bnRoZXRpYy1jaGVja3MgcXVvdGEuIEEgbW9uaXRvciBydW4gcmVjb3JkIGlzIGNyZWF0ZWQgYW5kIHRoZSBjaGVjayBpcyBkaXNwYXRjaGVkIHRvIHRoZSBjaGVja2VyIHNlcnZpY2UuEmoKDURlbGV0ZU1vbml0b3ISKy5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlcXVlc3QaLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlc3BvbnNlEmwKDExpc3RNb25pdG9ycxIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvcnNSZXF1ZXN0Gisub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9yc1Jlc3BvbnNlIgOQAgESeAoQR2V0TW9uaXRvclN0YXR1cxIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVzcG9uc2UiA5ACARKmAwoRR2V0TW9uaXRvclN1bW1hcnkSLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkdldE1vbml0b3JTdW1tYXJ5UmVzcG9uc2UirQKQAgG6R6YCGqMCUmV0dXJucyBhZ2dyZWdhdGVkIG1ldHJpY3MgZm9yIGEgbW9uaXRvciBpbmNsdWRpbmcgbGF0ZW5jeSBwZXJjZW50aWxlcyAocDUwLCBwNzUsIHA5MCwgcDk1LCBwOTkpLCByZXF1ZXN0IGNvdW50cyBieSBzdGF0dXMgKHN1Y2Nlc3NmdWwsIGRlZ3JhZGVkLCBmYWlsZWQpLCBhbmQgdGhlIHRpbWVzdGFtcCBvZiB0aGUgbGFzdCBjaGVjay4gTWV0cmljcyBjYW4gYmUgc2NvcGVkIHRvIGEgdGltZSByYW5nZSAoMSBkYXksIDcgZGF5cywgb3IgMTQgZGF5cykgYW5kIGZpbHRlcmVkIGJ5IHNwZWNpZmljIHJlZ2lvbnMuEmYKCkdldE1vbml0b3ISKC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlcXVlc3QaKS5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlc3BvbnNlIgOQAgESmQEKG0xpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9ncxI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvckhUVFBSZXNwb25zZUxvZ3NSZXF1ZXN0Gjoub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9nc1Jlc3BvbnNlIgOQAgESkwEKGUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2cSNy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QaOC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1Jlc3BvbnNlIgOQAgFCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_dns_monitor, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_icmp_monitor, file_openstatus_monitor_v1_monitor, file_openstatus_monitor_v1_tcp_monitor]); /** * CreateHTTPMonitorRequest is the request to create a new HTTP monitor. @@ -148,6 +150,48 @@ export type CreateDNSMonitorResponse = Message<"openstatus.monitor.v1.CreateDNSM export const CreateDNSMonitorResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_openstatus_monitor_v1_service, 5); +/** + * CreateICMPMonitorRequest is the request to create a new ICMP monitor. + * + * @generated from message openstatus.monitor.v1.CreateICMPMonitorRequest + */ +export type CreateICMPMonitorRequest = Message<"openstatus.monitor.v1.CreateICMPMonitorRequest"> & { + /** + * Monitor configuration (required). + * + * @generated from field: openstatus.monitor.v1.ICMPMonitor monitor = 1; + */ + monitor?: ICMPMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.CreateICMPMonitorRequest. + * Use `create(CreateICMPMonitorRequestSchema)` to create a new message. + */ +export const CreateICMPMonitorRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 6); + +/** + * CreateICMPMonitorResponse is the response after creating an ICMP monitor. + * + * @generated from message openstatus.monitor.v1.CreateICMPMonitorResponse + */ +export type CreateICMPMonitorResponse = Message<"openstatus.monitor.v1.CreateICMPMonitorResponse"> & { + /** + * The created monitor with assigned ID. + * + * @generated from field: openstatus.monitor.v1.ICMPMonitor monitor = 1; + */ + monitor?: ICMPMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.CreateICMPMonitorResponse. + * Use `create(CreateICMPMonitorResponseSchema)` to create a new message. + */ +export const CreateICMPMonitorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 7); + /** * UpdateHTTPMonitorRequest is the request to update an existing HTTP monitor. * @@ -174,7 +218,7 @@ export type UpdateHTTPMonitorRequest = Message<"openstatus.monitor.v1.UpdateHTTP * Use `create(UpdateHTTPMonitorRequestSchema)` to create a new message. */ export const UpdateHTTPMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 6); + messageDesc(file_openstatus_monitor_v1_service, 8); /** * UpdateHTTPMonitorResponse is the response after updating an HTTP monitor. @@ -195,7 +239,7 @@ export type UpdateHTTPMonitorResponse = Message<"openstatus.monitor.v1.UpdateHTT * Use `create(UpdateHTTPMonitorResponseSchema)` to create a new message. */ export const UpdateHTTPMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 7); + messageDesc(file_openstatus_monitor_v1_service, 9); /** * UpdateTCPMonitorRequest is the request to update an existing TCP monitor. @@ -223,7 +267,7 @@ export type UpdateTCPMonitorRequest = Message<"openstatus.monitor.v1.UpdateTCPMo * Use `create(UpdateTCPMonitorRequestSchema)` to create a new message. */ export const UpdateTCPMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 8); + messageDesc(file_openstatus_monitor_v1_service, 10); /** * UpdateTCPMonitorResponse is the response after updating a TCP monitor. @@ -244,7 +288,7 @@ export type UpdateTCPMonitorResponse = Message<"openstatus.monitor.v1.UpdateTCPM * Use `create(UpdateTCPMonitorResponseSchema)` to create a new message. */ export const UpdateTCPMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 9); + messageDesc(file_openstatus_monitor_v1_service, 11); /** * UpdateDNSMonitorRequest is the request to update an existing DNS monitor. @@ -272,7 +316,7 @@ export type UpdateDNSMonitorRequest = Message<"openstatus.monitor.v1.UpdateDNSMo * Use `create(UpdateDNSMonitorRequestSchema)` to create a new message. */ export const UpdateDNSMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 10); + messageDesc(file_openstatus_monitor_v1_service, 12); /** * UpdateDNSMonitorResponse is the response after updating a DNS monitor. @@ -293,7 +337,56 @@ export type UpdateDNSMonitorResponse = Message<"openstatus.monitor.v1.UpdateDNSM * Use `create(UpdateDNSMonitorResponseSchema)` to create a new message. */ export const UpdateDNSMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 11); + messageDesc(file_openstatus_monitor_v1_service, 13); + +/** + * UpdateICMPMonitorRequest is the request to update an existing ICMP monitor. + * + * @generated from message openstatus.monitor.v1.UpdateICMPMonitorRequest + */ +export type UpdateICMPMonitorRequest = Message<"openstatus.monitor.v1.UpdateICMPMonitorRequest"> & { + /** + * Monitor ID to update (required). + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * Updated monitor configuration (all fields optional for partial updates). + * + * @generated from field: optional openstatus.monitor.v1.ICMPMonitor monitor = 2; + */ + monitor?: ICMPMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.UpdateICMPMonitorRequest. + * Use `create(UpdateICMPMonitorRequestSchema)` to create a new message. + */ +export const UpdateICMPMonitorRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 14); + +/** + * UpdateICMPMonitorResponse is the response after updating an ICMP monitor. + * + * @generated from message openstatus.monitor.v1.UpdateICMPMonitorResponse + */ +export type UpdateICMPMonitorResponse = Message<"openstatus.monitor.v1.UpdateICMPMonitorResponse"> & { + /** + * The updated monitor. + * + * @generated from field: openstatus.monitor.v1.ICMPMonitor monitor = 1; + */ + monitor?: ICMPMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.UpdateICMPMonitorResponse. + * Use `create(UpdateICMPMonitorResponseSchema)` to create a new message. + */ +export const UpdateICMPMonitorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 15); /** * TriggerMonitorRequest is the request to trigger a monitor check. @@ -314,7 +407,7 @@ export type TriggerMonitorRequest = Message<"openstatus.monitor.v1.TriggerMonito * Use `create(TriggerMonitorRequestSchema)` to create a new message. */ export const TriggerMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 12); + messageDesc(file_openstatus_monitor_v1_service, 16); /** * TriggerMonitorResponse is the response after triggering a monitor. @@ -335,7 +428,7 @@ export type TriggerMonitorResponse = Message<"openstatus.monitor.v1.TriggerMonit * Use `create(TriggerMonitorResponseSchema)` to create a new message. */ export const TriggerMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 13); + messageDesc(file_openstatus_monitor_v1_service, 17); /** * DeleteMonitorRequest is the request to delete a monitor. @@ -356,7 +449,7 @@ export type DeleteMonitorRequest = Message<"openstatus.monitor.v1.DeleteMonitorR * Use `create(DeleteMonitorRequestSchema)` to create a new message. */ export const DeleteMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 14); + messageDesc(file_openstatus_monitor_v1_service, 18); /** * DeleteMonitorResponse is the response after deleting a monitor. @@ -377,7 +470,7 @@ export type DeleteMonitorResponse = Message<"openstatus.monitor.v1.DeleteMonitor * Use `create(DeleteMonitorResponseSchema)` to create a new message. */ export const DeleteMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 15); + messageDesc(file_openstatus_monitor_v1_service, 19); /** * ListMonitorsRequest is the request to list monitors. @@ -405,7 +498,7 @@ export type ListMonitorsRequest = Message<"openstatus.monitor.v1.ListMonitorsReq * Use `create(ListMonitorsRequestSchema)` to create a new message. */ export const ListMonitorsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 16); + messageDesc(file_openstatus_monitor_v1_service, 20); /** * ListMonitorsResponse is the response containing a list of monitors. @@ -434,6 +527,13 @@ export type ListMonitorsResponse = Message<"openstatus.monitor.v1.ListMonitorsRe */ dnsMonitors: DNSMonitor[]; + /** + * ICMP monitors in the workspace. + * + * @generated from field: repeated openstatus.monitor.v1.ICMPMonitor icmp_monitors = 5; + */ + icmpMonitors: ICMPMonitor[]; + /** * Total number of monitors across all types. * @@ -447,7 +547,7 @@ export type ListMonitorsResponse = Message<"openstatus.monitor.v1.ListMonitorsRe * Use `create(ListMonitorsResponseSchema)` to create a new message. */ export const ListMonitorsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 17); + messageDesc(file_openstatus_monitor_v1_service, 21); /** * GetMonitorStatusRequest is the request to get the status of all regions for a monitor. @@ -468,7 +568,7 @@ export type GetMonitorStatusRequest = Message<"openstatus.monitor.v1.GetMonitorS * Use `create(GetMonitorStatusRequestSchema)` to create a new message. */ export const GetMonitorStatusRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 18); + messageDesc(file_openstatus_monitor_v1_service, 22); /** * RegionStatus represents the status of a monitor in a specific region. @@ -496,7 +596,7 @@ export type RegionStatus = Message<"openstatus.monitor.v1.RegionStatus"> & { * Use `create(RegionStatusSchema)` to create a new message. */ export const RegionStatusSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 19); + messageDesc(file_openstatus_monitor_v1_service, 23); /** * GetMonitorStatusResponse is the response containing the status of all regions for a monitor. @@ -524,7 +624,7 @@ export type GetMonitorStatusResponse = Message<"openstatus.monitor.v1.GetMonitor * Use `create(GetMonitorStatusResponseSchema)` to create a new message. */ export const GetMonitorStatusResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 20); + messageDesc(file_openstatus_monitor_v1_service, 24); /** * MonitorConfig represents the type-specific configuration for a monitor. @@ -559,6 +659,14 @@ export type MonitorConfig = Message<"openstatus.monitor.v1.MonitorConfig"> & { */ value: DNSMonitor; case: "dns"; + } | { + /** + * ICMP monitor configuration. + * + * @generated from field: openstatus.monitor.v1.ICMPMonitor icmp = 4; + */ + value: ICMPMonitor; + case: "icmp"; } | { case: undefined; value?: undefined }; }; @@ -567,7 +675,7 @@ export type MonitorConfig = Message<"openstatus.monitor.v1.MonitorConfig"> & { * Use `create(MonitorConfigSchema)` to create a new message. */ export const MonitorConfigSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 21); + messageDesc(file_openstatus_monitor_v1_service, 25); /** * GetMonitorSummaryRequest is the request to get aggregated metrics for a monitor. @@ -602,7 +710,7 @@ export type GetMonitorSummaryRequest = Message<"openstatus.monitor.v1.GetMonitor * Use `create(GetMonitorSummaryRequestSchema)` to create a new message. */ export const GetMonitorSummaryRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 22); + messageDesc(file_openstatus_monitor_v1_service, 26); /** * GetMonitorSummaryResponse is the response containing aggregated metrics for a monitor. @@ -700,7 +808,7 @@ export type GetMonitorSummaryResponse = Message<"openstatus.monitor.v1.GetMonito * Use `create(GetMonitorSummaryResponseSchema)` to create a new message. */ export const GetMonitorSummaryResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 23); + messageDesc(file_openstatus_monitor_v1_service, 27); /** * GetMonitorRequest is the request to get a single monitor by ID. @@ -721,7 +829,7 @@ export type GetMonitorRequest = Message<"openstatus.monitor.v1.GetMonitorRequest * Use `create(GetMonitorRequestSchema)` to create a new message. */ export const GetMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 24); + messageDesc(file_openstatus_monitor_v1_service, 28); /** * GetMonitorResponse is the response containing the monitor. @@ -730,7 +838,7 @@ export const GetMonitorRequestSchema: GenMessage = /*@__PURE_ */ export type GetMonitorResponse = Message<"openstatus.monitor.v1.GetMonitorResponse"> & { /** - * The monitor configuration (one of HTTP, TCP, or DNS). + * The monitor configuration (one of HTTP, TCP, DNS, or ICMP). * * @generated from field: openstatus.monitor.v1.MonitorConfig monitor = 1; */ @@ -742,7 +850,7 @@ export type GetMonitorResponse = Message<"openstatus.monitor.v1.GetMonitorRespon * Use `create(GetMonitorResponseSchema)` to create a new message. */ export const GetMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 25); + messageDesc(file_openstatus_monitor_v1_service, 29); /** * HTTPResponseLogTiming contains calculated timing phases in milliseconds. @@ -791,7 +899,7 @@ export type HTTPResponseLogTiming = Message<"openstatus.monitor.v1.HTTPResponseL * Use `create(HTTPResponseLogTimingSchema)` to create a new message. */ export const HTTPResponseLogTimingSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 26); + messageDesc(file_openstatus_monitor_v1_service, 30); /** * HTTPResponseLogListItem is a compact response log entry. @@ -875,7 +983,7 @@ export type HTTPResponseLogListItem = Message<"openstatus.monitor.v1.HTTPRespons * Use `create(HTTPResponseLogListItemSchema)` to create a new message. */ export const HTTPResponseLogListItemSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 27); + messageDesc(file_openstatus_monitor_v1_service, 31); /** * HTTPResponseLogDetail contains full response log debugging data. @@ -931,7 +1039,7 @@ export type HTTPResponseLogDetail = Message<"openstatus.monitor.v1.HTTPResponseL * Use `create(HTTPResponseLogDetailSchema)` to create a new message. */ export const HTTPResponseLogDetailSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 28); + messageDesc(file_openstatus_monitor_v1_service, 32); /** * ListMonitorHTTPResponseLogsRequest is the request to list response logs within the 14-day HTTP response-log window. @@ -980,7 +1088,7 @@ export type ListMonitorHTTPResponseLogsRequest = Message<"openstatus.monitor.v1. * Use `create(ListMonitorHTTPResponseLogsRequestSchema)` to create a new message. */ export const ListMonitorHTTPResponseLogsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 29); + messageDesc(file_openstatus_monitor_v1_service, 33); /** * HTTPResponseLogPagination contains offset pagination metadata. @@ -1022,7 +1130,7 @@ export type HTTPResponseLogPagination = Message<"openstatus.monitor.v1.HTTPRespo * Use `create(HTTPResponseLogPaginationSchema)` to create a new message. */ export const HTTPResponseLogPaginationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 30); + messageDesc(file_openstatus_monitor_v1_service, 34); /** * ListMonitorHTTPResponseLogsResponse is the response containing response logs. @@ -1050,7 +1158,7 @@ export type ListMonitorHTTPResponseLogsResponse = Message<"openstatus.monitor.v1 * Use `create(ListMonitorHTTPResponseLogsResponseSchema)` to create a new message. */ export const ListMonitorHTTPResponseLogsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 31); + messageDesc(file_openstatus_monitor_v1_service, 35); /** * GetMonitorHTTPResponseLogRequest is the request to get one response log. @@ -1078,7 +1186,7 @@ export type GetMonitorHTTPResponseLogRequest = Message<"openstatus.monitor.v1.Ge * Use `create(GetMonitorHTTPResponseLogRequestSchema)` to create a new message. */ export const GetMonitorHTTPResponseLogRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 32); + messageDesc(file_openstatus_monitor_v1_service, 36); /** * GetMonitorHTTPResponseLogResponse is the response containing one response log. @@ -1099,7 +1207,7 @@ export type GetMonitorHTTPResponseLogResponse = Message<"openstatus.monitor.v1.G * Use `create(GetMonitorHTTPResponseLogResponseSchema)` to create a new message. */ export const GetMonitorHTTPResponseLogResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 33); + messageDesc(file_openstatus_monitor_v1_service, 37); /** * TimeRange represents the time period for metrics aggregation. @@ -1253,6 +1361,16 @@ export const MonitorService: GenService<{ input: typeof CreateDNSMonitorRequestSchema; output: typeof CreateDNSMonitorResponseSchema; }, + /** + * CreateICMPMonitor creates a new ICMP monitor. + * + * @generated from rpc openstatus.monitor.v1.MonitorService.CreateICMPMonitor + */ + createICMPMonitor: { + methodKind: "unary"; + input: typeof CreateICMPMonitorRequestSchema; + output: typeof CreateICMPMonitorResponseSchema; + }, /** * UpdateHTTPMonitor updates an existing HTTP monitor. * @@ -1283,6 +1401,16 @@ export const MonitorService: GenService<{ input: typeof UpdateDNSMonitorRequestSchema; output: typeof UpdateDNSMonitorResponseSchema; }, + /** + * UpdateICMPMonitor updates an existing ICMP monitor. + * + * @generated from rpc openstatus.monitor.v1.MonitorService.UpdateICMPMonitor + */ + updateICMPMonitor: { + methodKind: "unary"; + input: typeof UpdateICMPMonitorRequestSchema; + output: typeof UpdateICMPMonitorResponseSchema; + }, /** * TriggerMonitor initiates an immediate check for a monitor across all configured regions. * @@ -1335,7 +1463,7 @@ export const MonitorService: GenService<{ }, /** * GetMonitor returns a single monitor by ID within the authenticated workspace. - * Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + * Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. * * @generated from rpc openstatus.monitor.v1.MonitorService.GetMonitor */ diff --git a/packages/proto/internal/private_location/v1/icmp_monitor.proto b/packages/proto/internal/private_location/v1/icmp_monitor.proto new file mode 100644 index 00000000..38df2a7b --- /dev/null +++ b/packages/proto/internal/private_location/v1/icmp_monitor.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package private_location.v1; + +import "private_location/v1/otel.proto"; + +option go_package = "github.com/openstatushq/openstatus/packages/proto/private_location/v1;v1"; + + + + +message ICMPMonitor { + string id = 1; + string uri = 2; + int64 timeout = 3; + optional int64 degraded_at = 4; + string periodicity = 5; + int64 retry = 6; + + OtelConfig otel_config = 20; + +} diff --git a/packages/proto/internal/private_location/v1/private_location.proto b/packages/proto/internal/private_location/v1/private_location.proto index 28ce249c..b8633c7d 100644 --- a/packages/proto/internal/private_location/v1/private_location.proto +++ b/packages/proto/internal/private_location/v1/private_location.proto @@ -4,6 +4,7 @@ package private_location.v1; import "private_location/v1/dns_monitor.proto"; import "private_location/v1/http_monitor.proto"; +import "private_location/v1/icmp_monitor.proto"; import "private_location/v1/tcp_monitor.proto"; @@ -14,6 +15,7 @@ service PrivateLocationService { rpc IngestTCP(IngestTCPRequest) returns (IngestTCPResponse) {} rpc IngestHTTP(IngestHTTPRequest) returns (IngestHTTPResponse) {} rpc IngestDNS(IngestDNSRequest) returns (IngestDNSResponse) {} + rpc IngestICMP(IngestICMPRequest) returns (IngestICMPResponse) {} } @@ -23,6 +25,7 @@ message MonitorsResponse { repeated HTTPMonitor http_monitors = 1; repeated TCPMonitor tcp_monitors = 2; repeated DNSMonitor dns_monitors = 3; + repeated ICMPMonitor icmp_monitors = 5; string region = 4; } @@ -85,3 +88,24 @@ message IngestDNSRequest { message IngestDNSResponse { } + +message IngestICMPRequest { + string id = 1; + string monitorId = 2; + int64 latency = 3; + int64 latencyMin = 4; + int64 latencyMax = 5; + int64 packetsSent = 6; + int64 packetsReceived = 7; + int64 timestamp = 8; + int64 cronTimestamp = 9; + string uri = 10; + string message = 11; + string requestStatus = 12; + int64 error = 13; + string timing = 14; +} + +message IngestICMPResponse { + +} diff --git a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts index 4a3f938a..5335f78e 100644 --- a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts +++ b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts @@ -77,12 +77,12 @@ function fullMonth( function makePipes(rows: ComputeCountRow[]): UptimeFreezePipes { const pipe = () => Promise.resolve({ data: rows }); - return { http: pipe, tcp: pipe, dns: pipe }; + return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe }; } function failingPipes(): UptimeFreezePipes { const pipe = () => Promise.reject(new Error("tinybird down")); - return { http: pipe, tcp: pipe, dns: pipe }; + return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe }; } type Tx = Parameters[0]>[0]; diff --git a/packages/services/src/frozen-uptime/__tests__/run.test.ts b/packages/services/src/frozen-uptime/__tests__/run.test.ts index a973fb1d..7c6a3df7 100644 --- a/packages/services/src/frozen-uptime/__tests__/run.test.ts +++ b/packages/services/src/frozen-uptime/__tests__/run.test.ts @@ -32,6 +32,7 @@ function makePipes( http: overrides.http ?? fallback, tcp: overrides.tcp ?? fallback, dns: overrides.dns ?? fallback, + icmp: overrides.icmp ?? fallback, }; } @@ -157,7 +158,7 @@ describe("fetchFreezeCounts", () => { const http = okPipe(); const { counts, failedMonitorIds } = await fetchFreezeCounts({ monitorIdsByJobType: new Map([ - ["icmp", new Set(["9"])], + ["udp", new Set(["9"])], ["ssl", new Set(["10"])], ]), pipes: makePipes({ http: http.pipe }), diff --git a/packages/services/src/frozen-uptime/get-history.ts b/packages/services/src/frozen-uptime/get-history.ts index bb4eea2e..ec1e3fa5 100644 --- a/packages/services/src/frozen-uptime/get-history.ts +++ b/packages/services/src/frozen-uptime/get-history.ts @@ -241,6 +241,7 @@ export async function getUptimeHistory(args: { http: defaultTb.httpStatus45d, tcp: defaultTb.tcpStatus45d, dns: defaultTb.dnsStatus45d, + icmp: defaultTb.icmpStatus45d, }; return fetchFreezeCounts({ monitorIdsByJobType, diff --git a/packages/services/src/frozen-uptime/run.ts b/packages/services/src/frozen-uptime/run.ts index 767ba790..ba66b6a1 100644 --- a/packages/services/src/frozen-uptime/run.ts +++ b/packages/services/src/frozen-uptime/run.ts @@ -19,9 +19,12 @@ export type StatusPipeFn = (params: { monitorIds: string[]; }) => Promise<{ data: ComputeCountRow[] }>; -// only these job types have a 45d status pipe; others (icmp/udp/ssl) have no +// only these job types have a 45d status pipe; others (udp/ssl) have no // counts on the live status page either and are skipped -export type UptimeFreezePipes = Record<"http" | "tcp" | "dns", StatusPipeFn>; +export type UptimeFreezePipes = Record< + "http" | "tcp" | "dns" | "icmp", + StatusPipeFn +>; export type ChunkFailure = { jobType: string; @@ -53,7 +56,12 @@ function chunk(items: T[], size: number): T[][] { function hasStatusPipe( jobType: string | null | undefined, ): jobType is keyof UptimeFreezePipes { - return jobType === "http" || jobType === "tcp" || jobType === "dns"; + return ( + jobType === "http" || + jobType === "tcp" || + jobType === "dns" || + jobType === "icmp" + ); } /** diff --git a/packages/services/src/monitor/__tests__/reads.test.ts b/packages/services/src/monitor/__tests__/reads.test.ts index 3c770511..bc4d24d8 100644 --- a/packages/services/src/monitor/__tests__/reads.test.ts +++ b/packages/services/src/monitor/__tests__/reads.test.ts @@ -146,13 +146,13 @@ describe("getMonitorSummary", () => { }); }); - test("throws ValidationError for unsupported jobType (icmp)", async () => { + test("throws ValidationError for unsupported jobType (udp)", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { - name: `${TEST_PREFIX}-icmp-summary`, - jobType: "icmp", + name: `${TEST_PREFIX}-udp-summary`, + jobType: "udp", url: "1.1.1.1", method: "GET", headers: [], @@ -169,6 +169,29 @@ describe("getMonitorSummary", () => { ).rejects.toBeInstanceOf(ValidationError); }); }); + + test("returns a summary for icmp jobType", async () => { + await withTestTransaction(async (tx) => { + const row = await createMonitor({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-icmp-summary`, + jobType: "icmp", + url: "1.1.1.1", + method: "GET", + headers: [], + assertions: [], + active: false, + regions: ["ams"], + }, + }); + const summary = await getMonitorSummary({ + ctx: { ...teamCtx, db: tx }, + input: { monitorId: row.id, timeRange: "1d" }, + }); + expect(summary.monitorId).toBe(row.id); + }); + }); }); describe("fetchMonitorDailyStats", () => { @@ -176,6 +199,7 @@ describe("fetchMonitorDailyStats", () => { httpStatus45d: () => Promise.resolve({ data: [] }), tcpStatus45d: () => Promise.resolve({ data: [] }), dnsStatus45d: () => Promise.resolve({ data: [] }), + icmpStatus45d: () => Promise.resolve({ data: [] }), } as unknown as NonNullable; test("skips cross-workspace monitorId (returns empty, no throw)", async () => { @@ -215,13 +239,13 @@ describe("fetchMonitorDailyStats", () => { }); }); - test("skips unsupported jobType (icmp)", async () => { + test("skips unsupported jobType (udp)", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { - name: `${TEST_PREFIX}-icmp-daily`, - jobType: "icmp", + name: `${TEST_PREFIX}-udp-daily`, + jobType: "udp", url: "1.1.1.1", method: "GET", headers: [], @@ -240,6 +264,48 @@ describe("fetchMonitorDailyStats", () => { }); }); + test("queries the icmp pipe for icmp jobType", async () => { + await withTestTransaction(async (tx) => { + const row = await createMonitor({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-icmp-daily`, + jobType: "icmp", + url: "1.1.1.1", + method: "GET", + headers: [], + assertions: [], + active: false, + regions: ["ams"], + }, + }); + const icmpTb = { + httpStatus45d: () => Promise.resolve({ data: [] }), + tcpStatus45d: () => Promise.resolve({ data: [] }), + dnsStatus45d: () => Promise.resolve({ data: [] }), + icmpStatus45d: ({ monitorIds }: { monitorIds: string[] }) => + Promise.resolve({ + data: monitorIds.map((monitorId) => ({ + day: "2024-01-01T00:00:00.000Z", + count: 1, + ok: 1, + degraded: 0, + error: 0, + monitorId, + })), + }), + } as unknown as NonNullable; + const stats = await fetchMonitorDailyStats({ + db: tx, + tb: icmpTb, + monitorIds: [row.id], + workspaceId: teamCtx.workspace.id, + }); + expect(stats).toHaveLength(1); + expect(stats[0]?.monitorId).toBe(String(row.id)); + }); + }); + test("queries each pipe with its own job type's ids and merges the rows", async () => { await withTestTransaction(async (tx) => { const httpMon = await createMonitor({ diff --git a/packages/services/src/monitor/get-daily-summary.ts b/packages/services/src/monitor/get-daily-summary.ts index 0d8c952f..00e5eefb 100644 --- a/packages/services/src/monitor/get-daily-summary.ts +++ b/packages/services/src/monitor/get-daily-summary.ts @@ -5,7 +5,7 @@ import type { OSTinybird } from "@openstatus/tinybird"; import type { DB } from "../context"; import type { StatusData } from "../status-timeline"; -type SupportedJobType = "http" | "tcp" | "dns"; +type SupportedJobType = "http" | "tcp" | "dns" | "icmp"; /** * Raw daily status buckets (one row per monitor per day) from the 45d Tinybird @@ -38,19 +38,21 @@ export async function fetchMonitorDailyStats(args: { http: [], tcp: [], dns: [], + icmp: [], }; for (const row of rows) { if ( row.jobType === "http" || row.jobType === "tcp" || - row.jobType === "dns" + row.jobType === "dns" || + row.jobType === "icmp" ) { idsByJobType[row.jobType].push(String(row.id)); } } const results = await Promise.all( - (["http", "tcp", "dns"] as const) + (["http", "tcp", "dns", "icmp"] as const) .filter((jobType) => idsByJobType[jobType].length > 0) .map((jobType) => { const monitorIds = idsByJobType[jobType]; @@ -59,7 +61,9 @@ export async function fetchMonitorDailyStats(args: { ? args.tb.httpStatus45d : jobType === "tcp" ? args.tb.tcpStatus45d - : args.tb.dnsStatus45d; + : jobType === "dns" + ? args.tb.dnsStatus45d + : args.tb.icmpStatus45d; return pipe({ monitorIds }); }), ); diff --git a/packages/services/src/monitor/get-monitor-summary.ts b/packages/services/src/monitor/get-monitor-summary.ts index bef9fe3c..5345242d 100644 --- a/packages/services/src/monitor/get-monitor-summary.ts +++ b/packages/services/src/monitor/get-monitor-summary.ts @@ -34,7 +34,7 @@ type MetricsRow = { lastTimestamp: number | null; }; -type SupportedJobType = "http" | "tcp" | "dns"; +type SupportedJobType = "http" | "tcp" | "dns" | "icmp"; function fetchMetrics( tb: NonNullable, @@ -58,6 +58,11 @@ function fetchMetrics( "7d": tb.dnsMetricsWeekly, "14d": tb.dnsMetricsBiweekly, }, + icmp: { + "1d": tb.icmpMetricsDaily, + "7d": tb.icmpMetricsWeekly, + "14d": tb.icmpMetricsBiweekly, + }, }[jobType][timeRange]; return pipe(params); } @@ -80,7 +85,8 @@ export async function getMonitorSummary(args: { if ( parsed.jobType !== "http" && parsed.jobType !== "tcp" && - parsed.jobType !== "dns" + parsed.jobType !== "dns" && + parsed.jobType !== "icmp" ) { throw new ValidationError( `getMonitorSummary does not support jobType '${parsed.jobType}'`, diff --git a/packages/tinybird/datasources/check_icmp_response__v0.datasource b/packages/tinybird/datasources/check_icmp_response__v0.datasource new file mode 100644 index 00000000..a5c1a925 --- /dev/null +++ b/packages/tinybird/datasources/check_icmp_response__v0.datasource @@ -0,0 +1,21 @@ +SCHEMA > + `monitorId` Int32 `json:$.monitorId`, + `region` String `json:$.region`, + `timestamp` Int64 `json:$.timestamp`, + `cronTimestamp` Int64 `json:$.cronTimestamp`, + `timing` String `json:$.timing`, + `latency` Int64 `json:$.latency`, + `latencyMin` Int64 `json:$.latencyMin`, + `latencyMax` Int64 `json:$.latencyMax`, + `packetsSent` UInt8 `json:$.packetsSent`, + `packetsReceived` UInt8 `json:$.packetsReceived`, + `errorMessage` Nullable(String) `json:$.errorMessage`, + `error` Int16 `json:$.error`, + `trigger` Nullable(String) `json:$.trigger`, + `uri` Nullable(String) `json:$.uri`, + `id` Nullable(String) `json:$.id`, + `requestStatus` Nullable(String) `json:$.requestStatus`, + `requestId` Int64 `json:$.requestId` + +ENGINE "MergeTree" +ENGINE_SORTING_KEY "monitorId, requestId, timestamp" diff --git a/packages/tinybird/datasources/icmp_response__v0.datasource b/packages/tinybird/datasources/icmp_response__v0.datasource new file mode 100644 index 00000000..9f4b3473 --- /dev/null +++ b/packages/tinybird/datasources/icmp_response__v0.datasource @@ -0,0 +1,22 @@ +SCHEMA > + `monitorId` Int32 `json:$.monitorId`, + `region` String `json:$.region`, + `timestamp` Int64 `json:$.timestamp`, + `cronTimestamp` Int64 `json:$.cronTimestamp`, + `timing` String `json:$.timing`, + `workspaceId` Int32 `json:$.workspaceId`, + `latency` Int64 `json:$.latency`, + `latencyMin` Int64 `json:$.latencyMin`, + `latencyMax` Int64 `json:$.latencyMax`, + `packetsSent` UInt8 `json:$.packetsSent`, + `packetsReceived` UInt8 `json:$.packetsReceived`, + `errorMessage` Nullable(String) `json:$.errorMessage`, + `error` Int16 `json:$.error`, + `trigger` Nullable(String) `json:$.trigger`, + `uri` Nullable(String) `json:$.uri`, + `id` Nullable(String) `json:$.id`, + `requestStatus` Nullable(String) `json:$.requestStatus` + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(fromUnixTimestamp64Milli(cronTimestamp))" +ENGINE_SORTING_KEY "monitorId, cronTimestamp" diff --git a/packages/tinybird/datasources/mv__icmp_14d__v0.datasource b/packages/tinybird/datasources/mv__icmp_14d__v0.datasource new file mode 100644 index 00000000..c7717dda --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_14d__v0.datasource @@ -0,0 +1,22 @@ +# Data Source created from Pipe 'aggregate__icmp_14d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(14)" diff --git a/packages/tinybird/datasources/mv__icmp_1d__v0.datasource b/packages/tinybird/datasources/mv__icmp_1d__v0.datasource new file mode 100644 index 00000000..f0eb16ea --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_1d__v0.datasource @@ -0,0 +1,22 @@ +# Data Source created from Pipe 'aggregate__icmp_1d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(1)" diff --git a/packages/tinybird/datasources/mv__icmp_30d__v0.datasource b/packages/tinybird/datasources/mv__icmp_30d__v0.datasource new file mode 100644 index 00000000..6a078052 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_30d__v0.datasource @@ -0,0 +1,22 @@ +# Data Source created from Pipe 'aggregate__icmp_30d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/datasources/mv__icmp_7d__v0.datasource b/packages/tinybird/datasources/mv__icmp_7d__v0.datasource new file mode 100644 index 00000000..de23c1df --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_7d__v0.datasource @@ -0,0 +1,22 @@ +# Data Source created from Pipe 'aggregate__icmp_7d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(7)" diff --git a/packages/tinybird/datasources/mv__icmp_90d__v0.datasource b/packages/tinybird/datasources/mv__icmp_90d__v0.datasource new file mode 100644 index 00000000..aabfb87e --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_90d__v0.datasource @@ -0,0 +1,22 @@ +# Data Source created from Pipe 'aggregate__icmp_90d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(90)" diff --git a/packages/tinybird/datasources/mv__icmp_full_14d__v0.datasource b/packages/tinybird/datasources/mv__icmp_full_14d__v0.datasource new file mode 100644 index 00000000..5fb53b61 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_full_14d__v0.datasource @@ -0,0 +1,26 @@ +# Data Source created from Pipe 'aggregate__icmp_full_14d__v0' + +SCHEMA > + `time` DateTime, + `monitorId` Int32, + `region` String, + `timestamp` Int64, + `cronTimestamp` Int64, + `timing` String, + `workspaceId` Int32, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `errorMessage` Nullable(String), + `error` Int16, + `trigger` Nullable(String), + `uri` Nullable(String), + `id` Nullable(String), + `requestStatus` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(14)" diff --git a/packages/tinybird/datasources/mv__icmp_full_30d__v0.datasource b/packages/tinybird/datasources/mv__icmp_full_30d__v0.datasource new file mode 100644 index 00000000..5f6514e4 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_full_30d__v0.datasource @@ -0,0 +1,26 @@ +# Data Source created from Pipe 'aggregate__icmp_full_30d__v0' + +SCHEMA > + `time` DateTime, + `monitorId` Int32, + `region` String, + `timestamp` Int64, + `cronTimestamp` Int64, + `timing` String, + `workspaceId` Int32, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `errorMessage` Nullable(String), + `error` Int16, + `trigger` Nullable(String), + `uri` Nullable(String), + `id` Nullable(String), + `requestStatus` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/datasources/mv__icmp_status_45d__v0.datasource b/packages/tinybird/datasources/mv__icmp_status_45d__v0.datasource new file mode 100644 index 00000000..440acac3 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_status_45d__v0.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__icmp_status_45d__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(46)" diff --git a/packages/tinybird/datasources/mv__icmp_status_7d__v0.datasource b/packages/tinybird/datasources/mv__icmp_status_7d__v0.datasource new file mode 100644 index 00000000..3813a525 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_status_7d__v0.datasource @@ -0,0 +1,12 @@ +# Data Source created from Pipe 'aggregate__icmp_status_7d__v0' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` Int32, + `count` AggregateFunction(count), + `ok` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(7)" diff --git a/packages/tinybird/datasources/mv__icmp_uptime_30d__v0.datasource b/packages/tinybird/datasources/mv__icmp_uptime_30d__v0.datasource new file mode 100644 index 00000000..1c560213 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_uptime_30d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__icmp_uptime_30d__v0' + +SCHEMA > + `time` DateTime, + `region` String, + `requestStatus` Nullable(String), + `monitorId` Int32, + `workspaceId` Int32 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/datasources/mv__icmp_uptime_7d__v0.datasource b/packages/tinybird/datasources/mv__icmp_uptime_7d__v0.datasource new file mode 100644 index 00000000..f255167d --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_uptime_7d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__icmp_uptime_7d__v0' + +SCHEMA > + `time` DateTime, + `region` String, + `requestStatus` Nullable(String), + `monitorId` Int32, + `workspaceId` Int32 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(7)" diff --git a/packages/tinybird/datasources/mv__icmp_uptime_90d__v0.datasource b/packages/tinybird/datasources/mv__icmp_uptime_90d__v0.datasource new file mode 100644 index 00000000..52b205df --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_uptime_90d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__icmp_uptime_90d__v0' + +SCHEMA > + `time` DateTime, + `region` String, + `requestStatus` Nullable(String), + `monitorId` Int32, + `workspaceId` Int32 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(90)" diff --git a/packages/tinybird/datasources/mv__icmp_workspace_30d__v0.datasource b/packages/tinybird/datasources/mv__icmp_workspace_30d__v0.datasource new file mode 100644 index 00000000..b6746934 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_workspace_30d__v0.datasource @@ -0,0 +1,12 @@ +# Data Source created from Pipe 'aggregate__icmp_workspace_30d__v0' + +SCHEMA > + `time` DateTime('UTC'), + `workspaceId` Int32, + `trigger` String, + `count_state` AggregateFunction(count) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "workspaceId, time, trigger" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/endpoints/endpoint__icmp_get_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_get_14d__v0.pipe new file mode 100644 index 00000000..1c88b0d5 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_get_14d__v0.pipe @@ -0,0 +1,15 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT * + FROM mv__icmp_full_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND id = {{ String(id, '', required=True) }} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_get_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_get_30d__v0.pipe new file mode 100644 index 00000000..54bf4e25 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_get_30d__v0.pipe @@ -0,0 +1,15 @@ +TAGS "icmp" + +NODE endpoint +SQL > + +% + SELECT * + FROM mv__icmp_full_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND cronTimestamp = {{ Int64(cronTimestamp, 1709477432205, required=True) }} + AND region = {{ String(region, 'ams', required=True) }} + ORDER BY cronTimestamp DESC + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_list_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_list_14d__v0.pipe new file mode 100644 index 00000000..242cd6cc --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_list_14d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__icmp_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_list_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_list_1d__v0.pipe new file mode 100644 index 00000000..f44a9ba2 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_list_1d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__icmp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_list_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_list_7d__v0.pipe new file mode 100644 index 00000000..803000bd --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_list_7d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_14d__v0.pipe new file mode 100644 index 00000000..ddf8581e --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_14d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__icmp_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__icmp_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 28 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 14 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_1d__v0.pipe new file mode 100644 index 00000000..178bb505 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_1d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__icmp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 1 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 2 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 1 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_30d__v0.pipe new file mode 100644 index 00000000..fe3da898 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_30d__v0.pipe @@ -0,0 +1,42 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__icmp_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 30 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp + FROM mv__icmp_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 60 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 30 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_7d__v0.pipe new file mode 100644 index 00000000..ab6b9d2f --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_7d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 7 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__icmp_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 7 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_90d__v0.pipe new file mode 100644 index 00000000..0b1d0ccd --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_90d__v0.pipe @@ -0,0 +1,39 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__icmp_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 90 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + -- the previous 90d window (90-180d ago) is past the 90d MV TTL, so there's no + -- real comparison data. emit an empty row to keep the 2-row contract; count 0 + -- makes the client suppress the trend badge (NaN). + SELECT + 0 AS p50Latency, + 0 AS p75Latency, + 0 AS p90Latency, + 0 AS p95Latency, + 0 AS p99Latency, + 0 AS count, + 0 AS success, + 0 AS degraded, + 0 AS error, + NULL AS lastTimestamp + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_14d__v0.pipe new file mode 100644 index 00000000..879c6c9e --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_14d__v0.pipe @@ -0,0 +1,28 @@ +VERSION 0 + +TAGS icmp + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), + INTERVAL {{ Int64(interval, 30) }} MINUTE -- use 2880 (2d) in case you want the 1d summary for the regions + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_1d__v0.pipe new file mode 100644 index 00000000..5aee2536 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_1d__v0.pipe @@ -0,0 +1,28 @@ +VERSION 0 + +TAGS icmp + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), + INTERVAL {{ Int64(interval, 30) }} MINUTE -- use 2880 (2d) in case you want the 1d summary for the regions + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_30d__v0.pipe new file mode 100644 index 00000000..094e6390 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_30d__v0.pipe @@ -0,0 +1,26 @@ +VERSION 0 + +TAGS icmp + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h, region + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_7d__v0.pipe new file mode 100644 index 00000000..638bccea --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_7d__v0.pipe @@ -0,0 +1,28 @@ +VERSION 0 + +TAGS icmp + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), + INTERVAL {{ Int64(interval, 30) }} MINUTE -- use 2880 (2d) in case you want the 1d summary for the regions + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_90d__v0.pipe new file mode 100644 index 00000000..b7c78807 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_90d__v0.pipe @@ -0,0 +1,26 @@ +VERSION 0 + +TAGS icmp + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h, region + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_14d__v0.pipe new file mode 100644 index 00000000..384b0339 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_14d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "icmp" + +NODE endpoint +SQL > + +% + SELECT + region, + round(quantile(0.5)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.9)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + count(if(error = 0, 1, NULL)) AS ok + FROM mv__icmp_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY region + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_1d__v0.pipe new file mode 100644 index 00000000..f4f409ac --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_1d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "icmp" + +NODE endpoint +SQL > + +% + SELECT + region, + round(quantile(0.5)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.9)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + count(if(error = 0, 1, NULL)) AS ok + FROM mv__icmp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY region + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_7d__v0.pipe new file mode 100644 index 00000000..d65688cd --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_7d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "icmp" + +NODE endpoint +SQL > + +% + SELECT + region, + round(quantile(0.5)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.9)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + count(if(error = 0, 1, NULL)) AS ok + FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY region + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_global_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_global_1d__v0.pipe new file mode 100644 index 00000000..2b6e5f9c --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_global_1d__v0.pipe @@ -0,0 +1,25 @@ +VERSION 0 + +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(min(latency), 0) as minLatency, + round(max(latency), 0) as maxLatency, + round(quantile(0.5)(latency), 0) as p50Latency, + round(quantile(0.75)(latency), 0) as p75Latency, + round(quantile(0.9)(latency), 0) as p90Latency, + round(quantile(0.95)(latency), 0) as p95Latency, + round(quantile(0.99)(latency), 0) as p99Latency, + max(cronTimestamp) as lastTimestamp, + count() as count, + monitorId + FROM mv__icmp_1d__v0 + WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY monitorId + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d__v0.pipe new file mode 100644 index 00000000..2e00ff92 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d_multi__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d_multi__v0.pipe new file mode 100644 index 00000000..c7e82e35 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d_multi__v0.pipe @@ -0,0 +1,25 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + monitorId, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_1d__v0 + WHERE + monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY h, monitorId + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_30d__v0.pipe new file mode 100644 index 00000000..7b6a79e7 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_30d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_7d__v0.pipe new file mode 100644 index 00000000..045e47c1 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_7d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_90d__v0.pipe new file mode 100644 index 00000000..e8c5d405 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_90d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_status_45d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_status_45d__v0.pipe new file mode 100644 index 00000000..53593c6b --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_status_45d__v0.pipe @@ -0,0 +1,20 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + time as day, + monitorId, + countMerge(count) as count, + countMerge(success) as ok, + countMerge(error) as error, + countMerge(degraded) as degraded + FROM mv__icmp_status_45d__v0 + WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY day, monitorId + ORDER BY day DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_status_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_status_7d__v0.pipe new file mode 100644 index 00000000..1dc9edf5 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_status_7d__v0.pipe @@ -0,0 +1,20 @@ +TAGS "icmp" + +NODE endpoint +SQL > + +% + SELECT time as day, countMerge(count) as count, countMerge(ok) as ok + FROM mv__icmp_status_7d__v0 + WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + GROUP BY day + ORDER BY day DESC + WITH FILL + FROM + toStartOfDay(toStartOfDay(toTimeZone(now(), 'UTC'))) + TO toStartOfDay( + date_sub(DAY, 7, now()) + ) STEP INTERVAL -1 DAY + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_uptime_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_uptime_30d__v0.pipe new file mode 100644 index 00000000..8bada9c9 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_uptime_30d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error + FROM mv__icmp_uptime_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY interval + ORDER BY interval DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_uptime_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_uptime_7d__v0.pipe new file mode 100644 index 00000000..98673f9b --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_uptime_7d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error + FROM mv__icmp_uptime_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY interval + ORDER BY interval DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_uptime_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_uptime_90d__v0.pipe new file mode 100644 index 00000000..c77799c5 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_uptime_90d__v0.pipe @@ -0,0 +1,21 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '1440', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error + FROM mv__icmp_uptime_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY interval + ORDER BY interval DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_workspace_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_workspace_30d__v0.pipe new file mode 100644 index 00000000..f9b73d39 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_workspace_30d__v0.pipe @@ -0,0 +1,16 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + time as day, + countMerge(count_state) as count + FROM mv__icmp_workspace_30d__v0 + WHERE workspaceId = {{ Int32(workspaceId, 1, required=True) }} + GROUP BY day + ORDER BY day DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/materializations/aggregate__icmp_full_30d__v0.pipe b/packages/tinybird/materializations/aggregate__icmp_full_30d__v0.pipe new file mode 100644 index 00000000..f959a515 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__icmp_full_30d__v0.pipe @@ -0,0 +1,32 @@ +DESCRIPTION > + Stores all the data from the icmp_response table for the last 30 days, mainly used for accessing the data details. + + +TAGS "icmp, full" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + monitorId, + region, + timestamp, + cronTimestamp, + timing, + workspaceId, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + errorMessage, + error, + trigger, + uri, + id, + requestStatus + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_full_30d__v0 diff --git a/packages/tinybird/materializations/aggregate__icmp_status_7d__v0.pipe b/packages/tinybird/materializations/aggregate__icmp_status_7d__v0.pipe new file mode 100644 index 00000000..3a491626 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__icmp_status_7d__v0.pipe @@ -0,0 +1,17 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(error = 0, 1, NULL)) AS ok + FROM icmp_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__icmp_status_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_14d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_14d__v0.pipe new file mode 100644 index 00000000..413bde96 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_14d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_14d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_1d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_1d__v0.pipe new file mode 100644 index 00000000..2f5cd5ab --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_1d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_1d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_30d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_30d__v0.pipe new file mode 100644 index 00000000..307dc5f4 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_30d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_30d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_7d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_7d__v0.pipe new file mode 100644 index 00000000..fd633ff2 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_7d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_90d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_90d__v0.pipe new file mode 100644 index 00000000..3a69d8af --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_90d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_90d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_full_14d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_full_14d__v0.pipe new file mode 100644 index 00000000..112c8d70 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_full_14d__v0.pipe @@ -0,0 +1,16 @@ +DESCRIPTION > + Stores all the data from the icmp_response table, mainly used for accessing the data details. + + +TAGS "icmp, full" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + * + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_full_14d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_status_45d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_status_45d__v0.pipe new file mode 100644 index 00000000..f3b10a87 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_status_45d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "icmp, statuspage" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(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_45d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_uptime_30d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_uptime_30d__v0.pipe new file mode 100644 index 00000000..fab45810 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_uptime_30d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_uptime_30d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_uptime_7d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_uptime_7d__v0.pipe new file mode 100644 index 00000000..9b7af250 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_uptime_7d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_uptime_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_uptime_90d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_uptime_90d__v0.pipe new file mode 100644 index 00000000..c53a633f --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_uptime_90d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_uptime_90d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_workspace_30d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_workspace_30d__v0.pipe new file mode 100644 index 00000000..936b369b --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_workspace_30d__v0.pipe @@ -0,0 +1,18 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + workspaceId, + ifNull(trigger, 'cron') AS trigger, + countState() AS count_state + FROM icmp_response__v0 + GROUP BY + time, + workspaceId, + trigger + +TYPE materialized +DATASOURCE mv__icmp_workspace_30d__v0 diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts index 1bff26fb..a6f43412 100644 --- a/packages/tinybird/src/client.ts +++ b/packages/tinybird/src/client.ts @@ -22,6 +22,75 @@ const externalStatusHistoryDailyShape = { snapshot_count: z.int(), }; +const icmpMetricsShape = z.object({ + p50Latency: z.number().nullable().prefault(0), + p75Latency: z.number().nullable().prefault(0), + p90Latency: z.number().nullable().prefault(0), + p95Latency: z.number().nullable().prefault(0), + p99Latency: z.number().nullable().prefault(0), + count: z.int().prefault(0), + success: z.int().prefault(0), + degraded: z.int().prefault(0), + error: z.int().prefault(0), + lastTimestamp: z.int().nullable(), +}); + +const icmpMetricsByIntervalParameters = z.object({ + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + interval: z.int().optional(), + monitorId: z.string(), +}); + +const icmpMetricsByIntervalShape = z.object({ + region: z.enum(monitorRegions).or(z.string()), + timestamp: z.int(), + p50Latency: z.number().nullable().prefault(0), + p75Latency: z.number().nullable().prefault(0), + p90Latency: z.number().nullable().prefault(0), + p95Latency: z.number().nullable().prefault(0), + p99Latency: z.number().nullable().prefault(0), +}); + +const icmpMetricsByRegionParameters = z.object({ + monitorId: z.string(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), +}); + +const icmpMetricsByRegionShape = z.object({ + region: z.enum(monitorRegions).or(z.string()), + count: z.int(), + ok: z.int(), + p50Latency: z.number().nullable().prefault(0), + p75Latency: z.number().nullable().prefault(0), + p90Latency: z.number().nullable().prefault(0), + p95Latency: z.number().nullable().prefault(0), + p99Latency: z.number().nullable().prefault(0), +}); + +const icmpMetricsLatencyShape = z.object({ + timestamp: z.int(), + p50Latency: z.int(), + p75Latency: z.int(), + p90Latency: z.int(), + p95Latency: z.int(), + p99Latency: z.int(), +}); + +const icmpUptimeParameters = z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + regions: z.enum(monitorRegions).or(z.string()).array().optional(), + interval: z.int().optional(), +}); + +const icmpUptimeShape = z.object({ + interval: z.coerce.date(), + success: z.int(), + degraded: z.int(), + error: z.int(), +}); + export const TINYBIRD_DEFAULT_URL = "https://api.tinybird.co"; /** @@ -1178,6 +1247,457 @@ export class OSTinybird { }); } + public get icmpListDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_list_1d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("icmp").prefault("icmp"), + id: z.string().nullable(), + latency: z.int(), + latencyMin: z.int().prefault(0), + latencyMax: z.int().prefault(0), + packetsSent: z.int().prefault(0), + packetsReceived: z.int().prefault(0), + monitorId: z.coerce.string(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpListWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_list_7d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("icmp").prefault("icmp"), + id: z.string().nullable(), + latency: z.int(), + latencyMin: z.int().prefault(0), + latencyMax: z.int().prefault(0), + packetsSent: z.int().prefault(0), + packetsReceived: z.int().prefault(0), + monitorId: z.coerce.string(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpListBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_list_14d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("icmp").prefault("icmp"), + id: z.string().nullable(), + latency: z.int(), + latencyMin: z.int().prefault(0), + latencyMax: z.int().prefault(0), + packetsSent: z.int().prefault(0), + packetsReceived: z.int().prefault(0), + monitorId: z.coerce.string(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpGetBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_get_14d__v0", + parameters: z.object({ + id: z.string().nullable(), + monitorId: z.string(), + }), + data: z.object({ + type: z.literal("icmp").prefault("icmp"), + id: z.string().nullable(), + uri: z.string(), + latency: z.int(), + latencyMin: z.int().prefault(0), + latencyMax: z.int().prefault(0), + packetsSent: z.int().prefault(0), + packetsReceived: z.int().prefault(0), + monitorId: z.coerce.string(), + error: z.coerce.boolean(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + errorMessage: z.string().nullable(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_1d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: icmpMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_7d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: icmpMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_14d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: icmpMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetrics30d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_30d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: icmpMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetrics90d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_90d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: icmpMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByIntervalDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_interval_1d__v0", + parameters: icmpMetricsByIntervalParameters, + data: icmpMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByIntervalWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_interval_7d__v0", + parameters: icmpMetricsByIntervalParameters, + data: icmpMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByIntervalBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_interval_14d__v0", + parameters: icmpMetricsByIntervalParameters, + data: icmpMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByInterval30d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_interval_30d__v0", + parameters: icmpMetricsByIntervalParameters, + data: icmpMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByInterval90d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_interval_90d__v0", + parameters: icmpMetricsByIntervalParameters, + data: icmpMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsLatency1d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_latency_1d__v0", + parameters: z.object({ + monitorId: z.string(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: icmpMetricsLatencyShape, + }); + } + + public get icmpMetricsLatency7d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_latency_7d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: icmpMetricsLatencyShape, + }); + } + + public get icmpMetricsLatency30d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_latency_30d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: icmpMetricsLatencyShape, + }); + } + + public get icmpMetricsLatency90d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_latency_90d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: icmpMetricsLatencyShape, + }); + } + + public get icmpMetricsLatency1dMulti() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_latency_1d_multi__v0", + parameters: z.object({ + monitorIds: z.string().array().min(1), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: z.object({ + timestamp: z.int(), + monitorId: z.coerce.string(), + p50Latency: z.int(), + p75Latency: z.int(), + p90Latency: z.int(), + p95Latency: z.int(), + p99Latency: z.int(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpStatus45d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_status_45d__v0", + parameters: z.object({ + monitorIds: z.string().array(), + days: z.int().max(45).optional(), + }), + data: z.object({ + day: z.string().transform((val) => { + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) + return new Date(`${val} GMT`).toISOString(); + }), + count: z.number().prefault(0), + ok: z.number().prefault(0), + degraded: z.number().prefault(0), + error: z.number().prefault(0), + monitorId: z.coerce.string(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpUptimeWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_uptime_7d__v0", + parameters: icmpUptimeParameters, + data: icmpUptimeShape, + }); + } + + public get icmpUptime30d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_uptime_30d__v0", + parameters: icmpUptimeParameters, + data: icmpUptimeShape, + }); + } + + public get icmpUptime90d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_uptime_90d__v0", + parameters: icmpUptimeParameters, + data: icmpUptimeShape, + }); + } + + public get icmpGlobalMetricsDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_global_1d__v0", + parameters: z.object({ + monitorIds: z.string().array(), + }), + data: z.object({ + minLatency: z.int(), + maxLatency: z.int(), + p50Latency: z.int(), + p75Latency: z.int(), + p90Latency: z.int(), + p95Latency: z.int(), + p99Latency: z.int(), + lastTimestamp: z.int(), + count: z.int(), + monitorId: z.coerce.string(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpWorkspace30d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_workspace_30d__v0", + parameters: z.object({ + workspaceId: z.string(), + }), + data: z.object({ + day: z + .string() + .transform((val) => new Date(`${val} GMT`).toISOString()), + count: z.int(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpGetMonthly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_get_30d__v0", + parameters: z.object({ + monitorId: z.string(), + region: z.enum(monitorRegions).or(z.string()).optional(), + cronTimestamp: z.int().optional(), + }), + data: z.object({ + type: z.literal("icmp").prefault("icmp"), + id: z.string().nullable(), + uri: z.string(), + latency: z.int(), + latencyMin: z.int().prefault(0), + latencyMax: z.int().prefault(0), + packetsSent: z.int().prefault(0), + packetsReceived: z.int().prefault(0), + monitorId: z.coerce.string(), + error: z.coerce.boolean(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + errorMessage: z.string().nullable(), + workspaceId: z.coerce.string(), + }), + // REMINDER: cache the result for accessing the data for a check as it won't change + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpStatusWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_status_7d__v0", + parameters: z.object({ + monitorId: z.string(), + }), + data: z.object({ + day: z.string().transform((val) => { + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) + return new Date(`${val} GMT`).toISOString(); + }), + count: z.number().prefault(0), + ok: z.number().prefault(0), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByRegionDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_region_1d__v0", + parameters: icmpMetricsByRegionParameters, + data: icmpMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByRegionWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_region_7d__v0", + parameters: icmpMetricsByRegionParameters, + data: icmpMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByRegionBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_region_14d__v0", + parameters: icmpMetricsByRegionParameters, + data: icmpMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + /** * Region + timestamp metrics (quantiles) – aggregated by interval. * NOTE: The Tinybird pipe returns one row per region & interval with latency quantiles. diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5561ea7d..ec9a4400 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -6,6 +6,8 @@ export { type TcpPayload, DNSPayloadSchema, type DNSPayload, + icmpPayloadSchema, + type IcmpPayload, } from "./payloads"; export { MONITOR_METHODS, diff --git a/packages/utils/src/payloads.ts b/packages/utils/src/payloads.ts index c1d41290..e213f435 100644 --- a/packages/utils/src/payloads.ts +++ b/packages/utils/src/payloads.ts @@ -69,3 +69,23 @@ export const DNSPayloadSchema = z.object({ }); export type DNSPayload = z.infer; + +export const icmpPayloadSchema = z.object({ + status: z.enum(MONITOR_STATUSES), + workspaceId: z.string(), + uri: z.string(), + monitorId: z.string(), + cronTimestamp: z.number(), + timeout: z.number().prefault(45000), + degradedAfter: z.number().nullable(), + trigger: z.enum(["cron", "api"]).optional().nullable().prefault("cron"), + otelConfig: z + .object({ + endpoint: z.string(), + headers: z.record(z.string(), z.string()), + }) + .optional(), + retry: z.number().prefault(3), +}); + +export type IcmpPayload = z.infer; -- 2.51.2 From d1d4e8e8d82dcf4d040547562dc024ac3c6eeb4a Mon Sep 17 00:00:00 2001 From: shaurya Date: Tue, 25 Aug 2026 21:42:18 +0530 Subject: [PATCH 157/266] docs: link ICMP monitor reference from the reference overview (#2603) Co-authored-by: no-hup --- apps/web/src/content/pages/docs/reference/overview.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/content/pages/docs/reference/overview.mdx b/apps/web/src/content/pages/docs/reference/overview.mdx index ef8bec5a..ec6a3103 100644 --- a/apps/web/src/content/pages/docs/reference/overview.mdx +++ b/apps/web/src/content/pages/docs/reference/overview.mdx @@ -12,6 +12,7 @@ How each monitor type runs checks and what you can configure on it. - **[HTTP monitor](/docs/reference/http-monitor)** — URL, method, headers, body, assertions, regions. - **[TCP monitor](/docs/reference/tcp-monitor)** — host:port checks for non-HTTP services. +- **[ICMP monitor](/docs/reference/icmp-monitor)** — ping checks for host reachability, latency, and packet loss. - **[DNS monitor](/docs/reference/dns-monitor)** — record-type assertions for A, AAAA, CNAME, MX, NS, TXT. ## Status pages -- 2.51.2 From 611c6c8021adbfbd25ecb0216cc7bee921490487 Mon Sep 17 00:00:00 2001 From: shaurya Date: Wed, 26 Aug 2026 13:50:12 +0530 Subject: [PATCH 158/266] fix: remove unused unauthenticated upload endpoint (#2601) Fixes #2595 Co-authored-by: Shaurya <19599684+no-hup@users.noreply.github.com> --- apps/web/src/app/api/upload/route.ts | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 apps/web/src/app/api/upload/route.ts diff --git a/apps/web/src/app/api/upload/route.ts b/apps/web/src/app/api/upload/route.ts deleted file mode 100644 index 3ef2985b..00000000 --- a/apps/web/src/app/api/upload/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { put } from "@vercel/blob"; -import { NextResponse } from "next/server"; - -export async function POST(request: Request): Promise { - const { searchParams } = new URL(request.url); - const filename = searchParams.get("filename"); - - if (!filename || !request.body) { - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 }, - ); - } - - const blob = await put(filename, request.body, { - access: "public", - }); - - return NextResponse.json(blob); -} -- 2.51.2 From dd2d94c3b6cc82709461dd3d4cca6892c26b8af1 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Wed, 26 Aug 2026 13:23:24 +0200 Subject: [PATCH 159/266] seo: retarget homepage and product page titles and metas (#2607) Homepage drops uptime from its title and meta and leads on the open-source status page cluster it already ranks 1.6-2.2 for; /uptime-monitoring leads on the API/service cluster it ranks 2.1-6.7 for instead of the head term it ranks 32.7 for. - HOMEPAGE_TITLE -> "Free & Open Source Status Page | openstatus" - DESCRIPTION drops the uptime clause - home.mdx hero subheading gains "open source", keeps the 28-region proof point - /status-page seo.title + seo.description target branded/hosted/public/private - /uptime-monitoring seo.title + seo.description lead with API and service - /uptime-monitoring H1 broadens to websites, APIs and services Product nav labels (frontmatter title) are unchanged. Co-authored-by: Claude Opus 5 (1M context) --- apps/web/src/content/pages/home.mdx | 2 +- apps/web/src/content/pages/product/status-page.mdx | 5 +++-- apps/web/src/content/pages/product/uptime-monitoring.mdx | 7 ++++--- apps/web/src/lib/metadata/shared-metadata.ts | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/web/src/content/pages/home.mdx b/apps/web/src/content/pages/home.mdx index 3879315c..a67b2eb1 100644 --- a/apps/web/src/content/pages/home.mdx +++ b/apps/web/src/content/pages/home.mdx @@ -2,7 +2,7 @@ title: "Ship your status page before your SOC 2 auditor asks for it" publishedAt: "2026-04-07" author: "openstatus" -description: "The status page trusted by growing teams. Communicate incidents, prove compliance readiness, and monitor uptime from 28 global regions." +description: "The open source status page trusted by growing teams. Communicate incidents, prove compliance readiness, and monitor uptime from 28 global regions." category: "product" faq: - question: "What is openstatus?" diff --git a/apps/web/src/content/pages/product/status-page.mdx b/apps/web/src/content/pages/product/status-page.mdx index de9e9b46..9ebf38bd 100644 --- a/apps/web/src/content/pages/product/status-page.mdx +++ b/apps/web/src/content/pages/product/status-page.mdx @@ -1,7 +1,8 @@ --- title: "Status Page" -seo: - title: "Hosted Status Pages - Public & Private" +seo: + title: "Create a Status Page — Hosted, Branded, Public or Private" + description: "Launch a branded status page on your own domain in minutes. Public or private, with password protection, IP allowlists, maintenance windows and RSS alerts." hero: "Status pages your users actually trust" publishedAt: "2025-11-10" author: "Maximilian Kaske" diff --git a/apps/web/src/content/pages/product/uptime-monitoring.mdx b/apps/web/src/content/pages/product/uptime-monitoring.mdx index 3c9b5980..13497f73 100644 --- a/apps/web/src/content/pages/product/uptime-monitoring.mdx +++ b/apps/web/src/content/pages/product/uptime-monitoring.mdx @@ -2,10 +2,11 @@ title: "Uptime Monitoring" publishedAt: "2025-11-10" seo: - title: "Uptime Monitoring - Website & API Uptime Monitor" -hero: "Uptime monitoring for websites and APIs" + title: "API & Service Uptime Monitoring for Developers" + description: "Monitor API and service uptime from 28 regions. Instant alerts via Slack, Discord, PagerDuty and email. Monitoring as code with YAML, CLI and Terraform." +hero: "Uptime monitoring for websites, APIs and services" author: "Thibault Le Ouay Ducasse" -description: "Monitor website and API uptime from around the world. Get instant downtime alerts via Slack, email and SMS. Free, open-source uptime monitoring" +description: "Monitor your websites, APIs and services from 28 regions. Get alerted the moment a check fails an assertion or exceeds your threshold." category: "Product" faq: - question: "How many regions should I monitor from?" diff --git a/apps/web/src/lib/metadata/shared-metadata.ts b/apps/web/src/lib/metadata/shared-metadata.ts index 68e9a67b..b505c29e 100644 --- a/apps/web/src/lib/metadata/shared-metadata.ts +++ b/apps/web/src/lib/metadata/shared-metadata.ts @@ -3,9 +3,9 @@ import type { Metadata } from "next"; import type { MDXData } from "../../content/utils"; export const TITLE = "openstatus"; -export const HOMEPAGE_TITLE = "The Compliance-First Status Page"; +export const HOMEPAGE_TITLE = "Free & Open Source Status Page "; export const DESCRIPTION = - "Ship your status page before your SOC 2 auditor asks for it. Communicate incidents, prove compliance readiness, and monitor uptime from 28 global regions. Open source and free to start."; + "Ship your status page before your SOC 2 auditor asks for it. Open source, free to start, self-hostable."; export const OG_DESCRIPTION = "The status page for compliance-ready teams"; -- 2.51.2 From 93a75dd71f3fc8f4621a36842c1e84a7b82fbbd1 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Wed, 26 Aug 2026 13:23:24 +0200 Subject: [PATCH 160/266] seo: internal links, homepage trim and product page content (#2608) Links: - /status/* templates now link to /status-page. That section is the site's highest-authority content (status/anthropic alone: 2,710 clicks, 156k impressions) and previously linked only to app.openstatus.dev, an external subdomain that passes no internal equity. The existing app CTAs are untouched. - The six surviving compare pages link to both product pages; none did before. - Homepage SOC 2 FAQ links to /use-case/compliance (frontmatter and body). - The two definitional guides link to their product pages. Homepage: - Fix subscription channel typo: SSH -> JSON. - Trim the uptime bullet list to prose plus link, matching the page no longer claiming uptime in its title or meta. Status page bullets stay. /status-page: - Drop the duplicated definition under 'Why do you need a status page?' and rewrite that section around ticket deflection, procurement and SOC 2 CC2.3. - Add CTA, pricing line and customer logos; the body had no conversion path. - Promote Customization and Audience to 'Branded status pages' and 'Internal & private status pages' H2s. - FAQ: Slack agent 3 -> 1, add create/public-vs-internal/cost. Now 11. /uptime-monitoring: - Move the ASCII diagram below the opening prose under a new H2. - Expand 'Why is uptime monitoring important?' (also fixes the heading grammar) and fix the broken assertions sentence. - Add CTA, pricing line and customer logos. - FAQ: add six entries covering definition, frequency, uptime vs availability, internal services, REST/GraphQL and cost. Now 14. - Notification list now includes Google Chat, Teams, Grafana OnCall, WhatsApp, which packages/notification already ships. Extract the shared logo grid into a CustomerLogos MDX component used by all three pages. Co-authored-by: Claude Opus 5 (1M context) --- .../status/[id]/[component]/page.tsx | 7 +- .../src/app/(landing)/status/[id]/page.tsx | 7 +- apps/web/src/app/(landing)/status/page.tsx | 7 +- .../content/mdx-components/customer-logos.tsx | 28 ++++++ apps/web/src/content/mdx-components/index.tsx | 2 + .../src/content/pages/compare/betterstack.mdx | 2 + .../web/src/content/pages/compare/checkly.mdx | 2 + .../web/src/content/pages/compare/datadog.mdx | 2 + .../src/content/pages/compare/incidentio.mdx | 2 + .../src/content/pages/compare/statusio.mdx | 2 + .../content/pages/compare/uptime-robot.mdx | 2 + .../pages/guides/what-is-a-status-page.mdx | 2 + .../guides/what-is-uptime-monitoring.mdx | 2 + apps/web/src/content/pages/home.mdx | 27 +----- .../src/content/pages/product/status-page.mdx | 76 ++++++++++----- .../pages/product/uptime-monitoring.mdx | 93 +++++++++++++++++-- 16 files changed, 205 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/content/mdx-components/customer-logos.tsx diff --git a/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx b/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx index c5f7e78d..5e9e139a 100644 --- a/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx +++ b/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx @@ -118,7 +118,12 @@ export default async function Page(args: { params: Promise }) { Looking for a status page? - Every service needs a status page. Run yours with OpenStatus. + Every service needs a status page. Run yours with OpenStatus — see + what a{" "} + + hosted status page + {" "} + includes. Create your status page diff --git a/apps/web/src/app/(landing)/status/[id]/page.tsx b/apps/web/src/app/(landing)/status/[id]/page.tsx index 20594bf5..915bfbd5 100644 --- a/apps/web/src/app/(landing)/status/[id]/page.tsx +++ b/apps/web/src/app/(landing)/status/[id]/page.tsx @@ -135,7 +135,12 @@ export default async function Page(args: { params: Promise }) { Looking for a status page? - Every service needs a status page. Run yours with OpenStatus. + Every service needs a status page. Run yours with OpenStatus — see + what a{" "} + + hosted status page + {" "} + includes. Create your status page diff --git a/apps/web/src/app/(landing)/status/page.tsx b/apps/web/src/app/(landing)/status/page.tsx index ef16c8e0..3ea748d7 100644 --- a/apps/web/src/app/(landing)/status/page.tsx +++ b/apps/web/src/app/(landing)/status/page.tsx @@ -86,7 +86,12 @@ export default async function Page() { Looking for a status page? - Every service needs a status page. Run yours with OpenStatus. + Every service needs a status page. Run yours with OpenStatus — see + what a{" "} + + hosted status page + {" "} + includes. Create your status page diff --git a/apps/web/src/content/mdx-components/customer-logos.tsx b/apps/web/src/content/mdx-components/customer-logos.tsx new file mode 100644 index 00000000..ac39a30e --- /dev/null +++ b/apps/web/src/content/mdx-components/customer-logos.tsx @@ -0,0 +1,28 @@ +import { Grid } from "./grid"; + +const customers: { name: string; href: string }[] = [ + { name: "Cal.com", href: "https://status.cal.com" }, + { name: "Documenso", href: "https://status.documenso.com" }, + { name: "WhiteBIT", href: "https://status.whitebit.com" }, + { name: "Traefik", href: "/customers/traefik" }, + { name: "OpenPanel", href: "https://status.openpanel.dev" }, + { name: "Probo", href: "https://probostatus.com" }, + { name: "Hanko", href: "https://status.hanko.io" }, + { name: "Superwall", href: "https://status.superwall.com" }, + { name: "StreamElements", href: "https://status.streamelements.com/" }, + { name: "Smplrspace", href: "https://status.smplrspace.com" }, + { name: "Passbolt", href: "https://passboltuptime.com" }, + { name: "TwentyCRM", href: "/customers/twenty" }, +]; + +export function CustomerLogos() { + return ( + + {customers.map((customer) => ( + + {customer.name} + + ))} + + ); +} diff --git a/apps/web/src/content/mdx-components/index.tsx b/apps/web/src/content/mdx-components/index.tsx index 7f21a062..a2bf3a1a 100644 --- a/apps/web/src/content/mdx-components/index.tsx +++ b/apps/web/src/content/mdx-components/index.tsx @@ -7,6 +7,7 @@ import { Card, CardGrid, LinkCard } from "./card"; import { Code } from "./code"; import { CustomImage } from "./custom-image"; import { CustomLink } from "./custom-link"; +import { CustomerLogos } from "./customer-logos"; import { Details } from "./details"; import { Grid } from "./grid"; import { createHeading } from "./heading"; @@ -33,6 +34,7 @@ export const components = { pre: Pre, table: Table, Grid, + CustomerLogos, Aside, Card, CardGrid, diff --git a/apps/web/src/content/pages/compare/betterstack.mdx b/apps/web/src/content/pages/compare/betterstack.mdx index b0bd273f..fbcaa455 100644 --- a/apps/web/src/content/pages/compare/betterstack.mdx +++ b/apps/web/src/content/pages/compare/betterstack.mdx @@ -86,6 +86,8 @@ Use our **[BetterStack import tool](/guides/migrate-from-betterstack)** to autom ## Related Resources +- [Status page](/status-page) +- [Uptime monitoring](/uptime-monitoring) - [Migrate from BetterStack](/guides/migrate-from-betterstack) - [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) - [SLA vs SLO vs SLI](/guides/sla-vs-slo-vs-sli) diff --git a/apps/web/src/content/pages/compare/checkly.mdx b/apps/web/src/content/pages/compare/checkly.mdx index e923ba93..66c2db8a 100644 --- a/apps/web/src/content/pages/compare/checkly.mdx +++ b/apps/web/src/content/pages/compare/checkly.mdx @@ -78,6 +78,8 @@ If you rely heavily on Playwright browser checks, openstatus is not a direct rep ## Related Resources +- [Status page](/status-page) +- [Uptime monitoring](/uptime-monitoring) - [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) - [SLA vs SLO vs SLI](/guides/sla-vs-slo-vs-sli) - [Status Pages for API Providers](/use-case/api-providers) diff --git a/apps/web/src/content/pages/compare/datadog.mdx b/apps/web/src/content/pages/compare/datadog.mdx index db86a874..4edf82f0 100644 --- a/apps/web/src/content/pages/compare/datadog.mdx +++ b/apps/web/src/content/pages/compare/datadog.mdx @@ -80,6 +80,8 @@ If you depend on Datadog's browser synthetics and trace correlation, keep those ## Related Resources +- [Status page](/status-page) +- [Uptime monitoring](/uptime-monitoring) - [Checkly vs openstatus](/compare/checkly) — another synthetic-monitoring comparison - [What Is Synthetic Monitoring?](/guides/what-is-synthetic-monitoring) - [What Is Uptime Monitoring?](/guides/what-is-uptime-monitoring) diff --git a/apps/web/src/content/pages/compare/incidentio.mdx b/apps/web/src/content/pages/compare/incidentio.mdx index e1d88d9d..fb8af301 100644 --- a/apps/web/src/content/pages/compare/incidentio.mdx +++ b/apps/web/src/content/pages/compare/incidentio.mdx @@ -78,6 +78,8 @@ If you only need the detection and communication layer, openstatus covers it alo ## Related Resources +- [Status page](/status-page) +- [Uptime monitoring](/uptime-monitoring) - [Incident Severity Matrix Guide](/guides/incident-severity-matrix) - [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) - [Status Pages for Compliance](/use-case/compliance) diff --git a/apps/web/src/content/pages/compare/statusio.mdx b/apps/web/src/content/pages/compare/statusio.mdx index f6d6fbff..70fb051c 100644 --- a/apps/web/src/content/pages/compare/statusio.mdx +++ b/apps/web/src/content/pages/compare/statusio.mdx @@ -78,6 +78,8 @@ Status.io costs nearly 3x more than openstatus's Starter plan — and doesn't in ## Related Resources +- [Status page](/status-page) +- [Uptime monitoring](/uptime-monitoring) - [Top 5 Atlassian Statuspage Alternatives](/guides/top-five-atlassian-statuspage-alternatives) - [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) - [Status Pages for Enterprise Sales](/use-case/enterprise-sales) diff --git a/apps/web/src/content/pages/compare/uptime-robot.mdx b/apps/web/src/content/pages/compare/uptime-robot.mdx index a838f1f7..9355e2ed 100644 --- a/apps/web/src/content/pages/compare/uptime-robot.mdx +++ b/apps/web/src/content/pages/compare/uptime-robot.mdx @@ -86,6 +86,8 @@ Most teams complete the switch in under an hour. If you need help, reach out at ## Related Resources +- [Status page](/status-page) +- [Uptime monitoring](/uptime-monitoring) - [Why Uptime Percentage Is Misleading](/guides/why-uptime-percentage-is-misleading) - [SLA vs SLO vs SLI](/guides/sla-vs-slo-vs-sli) - [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) diff --git a/apps/web/src/content/pages/guides/what-is-a-status-page.mdx b/apps/web/src/content/pages/guides/what-is-a-status-page.mdx index b01e6e11..38d37c42 100644 --- a/apps/web/src/content/pages/guides/what-is-a-status-page.mdx +++ b/apps/web/src/content/pages/guides/what-is-a-status-page.mdx @@ -123,6 +123,8 @@ A status page is the simplest, highest-leverage trust-building tool you have dur If you don't have one, you're either too small to need one (rare) or losing trust during every outage without realizing it (common). +If you are ready to run one, a [hosted status page](/status-page) gives you components, incident history, maintenance windows and subscriber notifications on your own domain without building any of it yourself. + ## Frequently asked questions
diff --git a/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx b/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx index 6a6cabc6..042fa52c 100644 --- a/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx +++ b/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx @@ -142,6 +142,8 @@ Uptime monitoring exists because you cannot trust your own infrastructure to tel Set it up. Monitor the things customers actually depend on. Use multiple regions. Tune the check frequency for the criticality. Push results into a [status page](/guides/what-is-a-status-page) so users can self-serve when things break. +If you would rather not run the probes yourself, [openstatus uptime monitoring](/uptime-monitoring) checks your websites, APIs and services from 28 regions and feeds the results straight into your status page. + ## Frequently asked questions
diff --git a/apps/web/src/content/pages/home.mdx b/apps/web/src/content/pages/home.mdx index a67b2eb1..f91afd17 100644 --- a/apps/web/src/content/pages/home.mdx +++ b/apps/web/src/content/pages/home.mdx @@ -8,7 +8,7 @@ faq: - question: "What is openstatus?" answer: "Openstatus gives you a branded status page and uptime monitoring that's audit-ready out of the box. Set up status.yourcompany.com, connect your monitors, and start communicating incidents — in minutes. It's open-source, self-hostable, and used by teams like Cal.com, WhiteBIT, and Documenso." - question: "Do I need a status page for SOC 2?" - answer: "SOC 2's CC2.3 criteria requires you to demonstrate incident communication with external parties — but it doesn't prescribe a specific tool. That said, a status page is the fastest, most auditor-friendly way to satisfy that requirement. Every status report on openstatus is timestamped and documented automatically, giving you an audit-ready trail of how you communicated during incidents. Most teams set it up in under 10 minutes." + answer: "SOC 2's CC2.3 criteria requires you to demonstrate incident communication with external parties — but it doesn't prescribe a specific tool. That said, a status page is the fastest, most auditor-friendly way to satisfy that requirement. Every status report on openstatus is timestamped and documented automatically, giving you an audit-ready trail of how you communicated during incidents. Most teams set it up in under 10 minutes. Read more about [SOC 2 status pages](/use-case/compliance)." - question: "How does openstatus help with SOC 2 compliance?" answer: "Openstatus gives you a branded status page with incident history, subscriber notifications, and maintenance windows — all the evidence an auditor needs to verify your incident communication process. Every status report and update is timestamped and documented automatically." - question: "What does the free plan include?" @@ -44,20 +44,7 @@ Free to start. Paid plans from $30/mo. ## Trusted by teams who ship transparency - - Cal.com - Documenso - WhiteBIT - Traefik - OpenPanel - Probo - Hanko - Superwall - StreamElements - Smplrspace - Passbolt - TwentyCRM - + ## The status page that closes enterprise deals @@ -69,7 +56,7 @@ Make it yours with [themes from our Theme Store](https://themes.openstatus.dev), - Public or **password protected** pages - **Custom domains** - **Status reports** and **maintenance windows** -- **Subscription channels**: email, RSS/Atom, SSH +- **Subscription channels**: email, RSS/Atom, JSON Read more [about status pages](/status-page). @@ -77,12 +64,6 @@ Read more [about status pages](/status-page). Monitor your endpoints from 28 regions across multiple clouds. Get alerted on Slack, Discord, PagerDuty, or email the moment something breaks. Your status page updates automatically — no manual work during incidents. -- **28 regions**, 3 cloud providers — no blind spots -- Monitor any **HTTP endpoint**, REST or GraphQL -- Version your monitors with **YAML** and **CI/CD** -- Monitor behind firewalls with a single **Docker container** -- Alerts on **Slack**, **Discord**, **PagerDuty**, email, webhooks, ... - Read more about [uptime monitoring](/uptime-monitoring). ## Managing openstatus — for humans and agents @@ -142,6 +123,8 @@ SOC 2's CC2.3 criteria requires you to demonstrate incident communication with e Every status report on openstatus is **timestamped** and documented automatically, giving you an audit-ready trail of how you communicated during incidents. Most teams set it up in under 2 minutes. +Read more about [SOC 2 status pages](/use-case/compliance). +
diff --git a/apps/web/src/content/pages/product/status-page.mdx b/apps/web/src/content/pages/product/status-page.mdx index 9ebf38bd..0f75f90c 100644 --- a/apps/web/src/content/pages/product/status-page.mdx +++ b/apps/web/src/content/pages/product/status-page.mdx @@ -23,12 +23,14 @@ faq: answer: "Users can subscribe to receive updates when you post status reports or maintenance notices. We support email notifications, RSS/Atom feeds for feed readers, and JSON feeds for programmatic consumption. Subscribers are automatically notified when you publish updates." - question: "Can I customize the appearance of my status page?" answer: "Yes, use the Theme Store to apply community themes or create your own. Themes control colors, fonts, and layout. For private custom themes, contact us. You can also define which data to share (uptime percentages, response times, or manual reports only)." + - question: "How do I create a status page?" + answer: "Sign up, create a status page, give it a name and a slug, then add page components — either monitors synced from your uptime monitoring or external services you manage by hand. Point a custom domain at it if you want status.yourcompany.com, pick a theme, and publish. Most teams are live in under ten minutes on the free plan." + - question: "Public vs internal status page?" + answer: "A public status page is customer-facing and indexed — it deflects support tickets and answers vendor questionnaires. An internal or private status page is for staff, contractors, or a single client, and access is controlled with password protection, magic link authentication, or an IP allowlist. You can run both from the same workspace." + - question: "How much does a status page cost?" + answer: "The free Hobby plan includes one status page with three components and no credit card. Paid plans start at $30/month for Starter (one status page, 20 components, custom domain, subscribers), $100/month for Pro, and $500/month for Scale. Annual billing gives you two months free. Extra status pages are $20/month each." - question: "What is the Slack agent and what can it do?" answer: "The Slack agent lets you manage your status page directly from Slack using natural language. @mention @openstatus in any channel or thread to create incidents, post updates, and resolve reports — without leaving Slack. No slash commands required." - - question: "Does the Slack agent publish status reports automatically?" - answer: "No. The agent always shows a confirmation card before publishing anything. You can review the drafted title, status, and message, then choose to Approve, Approve & Notify (sends notifications to all subscribers), or Cancel. Nothing goes public without your explicit approval." - - question: "Which plan includes the Slack agent?" - answer: "The Slack agent is available on paid plans. Install it from your dashboard under Settings > Integrations." --- ## What is a status page? @@ -44,9 +46,23 @@ A typical status page includes: - **Subscriber notifications** via email, RSS, or webhook - **Uptime history** showing reliability over time +
+ +
+ +Free to start. Paid plans from $30/mo. + +## Trusted by teams who ship transparency + + + ## Why do you need a status page? -A status page is a dedicated webpage that provides real-time information about the operational status of your services. It serves as a communication tool to inform your users about any ongoing incidents, maintenance activities, or service disruptions. +**It deflects support load.** During an outage, every user asks the same question: is it down, or is it me? Without somewhere to point them, that question arrives one ticket at a time while your team is already busy fixing the thing. A status page answers it once, for everyone. See [reducing support tickets](/use-case/reduce-support-tickets). + +**Enterprise buyers ask for one.** Security questionnaires and vendor reviews routinely ask how you notify customers during an incident. "We email the affected accounts" is a weaker answer than a public URL with a year of incident history behind it. See [status pages for enterprise sales](/use-case/enterprise-sales). + +**Auditors want the evidence.** SOC 2's CC2.3 criteria asks you to demonstrate that you communicate incidents to external parties. Every status report on openstatus is timestamped and archived automatically, so the trail already exists when the auditor asks for it. See [status pages for compliance](/use-case/compliance).
@@ -79,14 +95,6 @@ Page components provide a **flexible structure** that supports both: You can **group page components** by their services, locations, or any logical grouping, and they will be collapsible for better organization. -### Customization - -Match your brand **appearance** by contributing your own theme to the community. We have build a **[Theme Store](https://themes.openstatus.dev)** that helps you create and use custom themes on your status page. Contribute your own theme. If you a private custom theme let us know via [email](mailto:ping@openstatus.dev?subject=Private%20Theme). - -You can **define the values** you want to share with your users. If can decide between duration values/uptime ping values or purely based on manual status report updates. - -You can create **custom domains** to keep the domain your users are used to. - ### Links Add a **Get in touch** in touch button and add a specific **website** link or a **`mailto:`** address. @@ -109,11 +117,6 @@ We support following communication channels: Offer your status page in **multiple languages**. Set a default locale and enable a **locale switcher** so visitors can read updates in their preferred language. Currently supports English, French, German, Turkish, Hindi, and Korean — with more languages coming from community contributions. -### Audience - -By default, your status page is public. For internal or client-specific pages, you can protect access using **password protection**, **magic link** authentication, or **IP restriction** (CIDR-based network allowlist) to control who can view your updates. - - ### Slack Agent Manage incidents without leaving Slack. Install the **@openstatus** Slack agent and @mention it in any channel or thread to create, update, and resolve status reports using plain language - no slash commands, no tab switching. @@ -130,6 +133,24 @@ Already using Atlassian Statuspage, Better Stack, or Instatus? Import your entir Read the [migration guides](/blog/import-from-statuspage-betterstack-instatus) for details on each provider. +## Branded status pages + +Match your brand **appearance** by contributing your own theme to the community. We have build a **[Theme Store](https://themes.openstatus.dev)** that helps you create and use custom themes on your status page. Contribute your own theme. If you a private custom theme let us know via [email](mailto:ping@openstatus.dev?subject=Private%20Theme). + +You can **define the values** you want to share with your users. If can decide between duration values/uptime ping values or purely based on manual status report updates. + +You can create **custom domains** to keep the domain your users are used to. + +## Internal & private status pages + +By default, your status page is public. For internal or client-specific pages, you can protect access using **password protection**, **magic link** authentication, or **IP restriction** (CIDR-based network allowlist) to control who can view your updates. + +
+ +
+ +Free to start. Paid plans from $30/mo. + ## Frequently asked questions
@@ -174,20 +195,27 @@ Yes, use the Theme Store to apply community themes or create your own. Themes co
-
+
-The Slack agent lets you manage your status page directly from Slack using natural language. @mention @openstatus in any channel or thread to create incidents, post updates, and resolve reports — without leaving Slack. No slash commands required. +Sign up, create a status page, give it a name and a slug, then add page components — either monitors synced from your uptime monitoring or external services you manage by hand. Point a custom domain at it if you want status.yourcompany.com, pick a theme, and publish. Most teams are live in under ten minutes on the free plan. + +
+ +
+ +A public status page is customer-facing and indexed — it deflects support tickets and answers vendor questionnaires. An internal or private status page is for staff, contractors, or a single client, and access is controlled with password protection, magic link authentication, or an IP allowlist. You can run both from the same workspace.
-
+
-No. The agent always shows a confirmation card before publishing anything. You can review the drafted title, status, and message, then choose to Approve, Approve & Notify (sends notifications to all subscribers), or Cancel. Nothing goes public without your explicit approval. +The free Hobby plan includes one status page with three components and no credit card. Paid plans start at $30/month for Starter (one status page, 20 components, custom domain, subscribers), $100/month for Pro, and $500/month for Scale. Annual billing gives you two months free. Extra status pages are $20/month each.
-
+
-The Slack agent is available on paid plans. Install it from your dashboard under Settings > Integrations. +The Slack agent lets you manage your status page directly from Slack using natural language. @mention @openstatus in any channel or thread to create incidents, post updates, and resolve reports — without leaving Slack. No slash commands required.
+ diff --git a/apps/web/src/content/pages/product/uptime-monitoring.mdx b/apps/web/src/content/pages/product/uptime-monitoring.mdx index 13497f73..2b76f66f 100644 --- a/apps/web/src/content/pages/product/uptime-monitoring.mdx +++ b/apps/web/src/content/pages/product/uptime-monitoring.mdx @@ -9,6 +9,18 @@ author: "Thibault Le Ouay Ducasse" description: "Monitor your websites, APIs and services from 28 regions. Get alerted the moment a check fails an assertion or exceeds your threshold." category: "Product" faq: + - question: "What is uptime monitoring?" + answer: "Uptime monitoring is the practice of checking, on a fixed schedule and from outside your own network, whether a service is reachable and responding correctly. Checks typically run every 30 seconds to 10 minutes from probe locations around the world. Because the checks originate externally, they catch failures your internal dashboards cannot see — DNS problems, expired certificates, and regional outages." + - question: "How often should I check my service?" + answer: "Match the frequency to the cost of the outage. Revenue-critical endpoints justify 30-second checks; internal tooling is usually fine at 5 or 10 minutes. Higher frequency shortens the time between a failure starting and you hearing about it, but consumes more of your check quota. openstatus supports 30s, 1m, 5m and 10m intervals depending on your plan." + - question: "What is the difference between uptime and availability?" + answer: "Uptime is what your monitor measures: the proportion of checks that succeeded. Availability is what your users experienced, which includes degraded performance your checks may have passed. A service returning HTTP 200 in eight seconds is up but arguably not available. This is why thresholds matter alongside assertions — they let you count slow responses as degraded rather than healthy." + - question: "Can I monitor internal services?" + answer: "Yes. Deploy a private location probe inside your network as an 8.5MB Docker container and it appears as another monitoring region in your dashboard. The probe reaches out to openstatus, so no inbound firewall rule is needed. You can run as many private locations as you like across different VPCs or networks." + - question: "How do I monitor a REST or GraphQL API?" + answer: "Create an HTTP monitor pointing at the endpoint, choose the method, and add any headers your API needs for authentication. For GraphQL, send a POST with the query in the body. Then add assertions on the status code, response headers, or body content so the monitor verifies the response is correct rather than merely present, and set a threshold so slow responses register as degraded." + - question: "How much does uptime monitoring cost?" + answer: "The free Hobby plan includes one monitor at a 10-minute interval with no credit card. Paid plans start at $30/month for Starter (20 monitors, 1-minute checks, 6 regions per monitor), $100/month for Pro (50 monitors, 30-second checks, all 28 regions), and $500/month for Scale. Annual billing gives you two months free." - question: "How many regions should I monitor from?" answer: "Start with 3-5 regions covering your main user geographies. More regions provide better global coverage but use more check quota. For critical services, monitor from all major regions (North America, Europe, Asia) to catch regional issues quickly." - question: "Why monitor from multiple cloud providers instead of just one?" @@ -27,6 +39,30 @@ faq: answer: "Yes, you can deploy as many private location probes as needed across different networks, VPCs, or regions. Each gets its own API key and appears as a separate monitoring region in your dashboard. The Docker image is only 8.5MB and supports ARM64 and AMD64." --- +## Why is uptime monitoring important? + +Uptime monitoring is the practice of continuously asking one question on your users' behalf: is this service working right now? Instead of waiting for a customer to report a problem, checks run on a schedule from outside your own network and tell you the moment the answer changes. + +It answers three things a dashboard inside your own infrastructure cannot: + +1. **Is my service reachable?** Can users actually get to it, from where they are? +2. **Is it fast enough?** Are response times inside the range you promised? +3. **Is it correct?** Is it returning the data you expect, not just a 200? + +That external vantage point is the whole point. If your service runs on the same infrastructure as your monitoring, an outage that takes down one takes down the other — and you find out from your customers instead. + +
+ +
+ +Free to start. Paid plans from $30/mo. + +## Trusted by teams who ship transparency + + + +## How uptime monitoring works + ``` +----------------+ | Service to be | @@ -62,11 +98,6 @@ faq: ``` -## Why uptime monitoring is important? - -Uptime monitoring is your first line of defense to ensure your service is available for your customers. By monitoring your service from multiple regions around the world, you can be sure that your customers are able to reach your service. - -
@@ -118,13 +149,13 @@ When needed, you can export to your **OTLP endpoint** the metrics for every requ ### Notification channels -Set different notification channels and **get notified** whenever a not satisfying your assertions or are exceeding the thresholds. +Set different notification channels and **get notified** whenever a response fails your assertions or exceeds your thresholds. We support: -- Socials: Slack, Discord, Telegram Bot -- Direct: Email, SMS -- Incident Management: OpsGenie, PagerDuty +- Socials: Slack, Discord, Telegram Bot, Google Chat, Microsoft Teams +- Direct: Email, SMS, WhatsApp +- Incident Management: OpsGenie, PagerDuty, Grafana OnCall - Custom: Webhook, Ntfy ### Status Pages @@ -206,6 +237,42 @@ You can read more here: ## Frequently asked questions +
+ +Uptime monitoring is the practice of checking, on a fixed schedule and from outside your own network, whether a service is reachable and responding correctly. Checks typically run every 30 seconds to 10 minutes from probe locations around the world. Because the checks originate externally, they catch failures your internal dashboards cannot see — DNS problems, expired certificates, and regional outages. + +
+ +
+ +Match the frequency to the cost of the outage. Revenue-critical endpoints justify 30-second checks; internal tooling is usually fine at 5 or 10 minutes. Higher frequency shortens the time between a failure starting and you hearing about it, but consumes more of your check quota. openstatus supports 30s, 1m, 5m and 10m intervals depending on your plan. + +
+ +
+ +Uptime is what your monitor measures: the proportion of checks that succeeded. Availability is what your users experienced, which includes degraded performance your checks may have passed. A service returning HTTP 200 in eight seconds is up but arguably not available. This is why thresholds matter alongside assertions — they let you count slow responses as degraded rather than healthy. + +
+ +
+ +Yes. Deploy a private location probe inside your network as an 8.5MB Docker container and it appears as another monitoring region in your dashboard. The probe reaches out to openstatus, so no inbound firewall rule is needed. You can run as many private locations as you like across different VPCs or networks. + +
+ +
+ +Create an HTTP monitor pointing at the endpoint, choose the method, and add any headers your API needs for authentication. For GraphQL, send a POST with the query in the body. Then add assertions on the status code, response headers, or body content so the monitor verifies the response is correct rather than merely present, and set a threshold so slow responses register as degraded. + +
+ +
+ +The free Hobby plan includes one monitor at a 10-minute interval with no credit card. Paid plans start at $30/month for Starter (20 monitors, 1-minute checks, 6 regions per monitor), $100/month for Pro (50 monitors, 30-second checks, all 28 regions), and $500/month for Scale. Annual billing gives you two months free. + +
+
Start with 3-5 regions covering your main user geographies. More regions provide better global coverage but use more check quota. For critical services, monitor from all major regions (North America, Europe, Asia) to catch regional issues quickly. @@ -256,6 +323,14 @@ Yes, you can deploy as many private location probes as needed across different n --- +
+ +
+ +Free to start. Paid plans from $30/mo. + +--- + Check your website's latency Global Speed Checker -- 2.51.2 From 7aeea0cecddc3cdf504b7942a97a7d6395c47fd0 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Wed, 26 Aug 2026 13:23:25 +0200 Subject: [PATCH 161/266] seo: consolidate competitor pages and repoint three guides (#2609) Retire four compare pages into the guides that already outrank them, after porting their unique content across: - /compare/atlassian-statuspage -> /guides/top-five-atlassian-statuspage-alternatives - /compare/instatus -> /guides/top-five-instatus-alternatives - /compare/pingdom -> /guides/top-five-pingdom-alternatives - /compare/uptime-kuma -> /guides/hosted-uptime-kuma-alternative Each guide gains the head-to-head feature and pricing tables plus the FAQ entries it did not already answer. The Uptime Kuma guide had no comparison table at all, so that port matters most: the retired page held 80 clicks at position 7.4. Pingdom was not in the original brief but shows the same pattern as the other two low-traffic pairs, so it is treated the same way. All five migration guides are kept. Migration is a distinct bottom-of-funnel intent from comparison, and three of them document a real one-click importer. Uptime Robot keeps both pages with a reciprocal link making the split explicit. Repoint three guides toward the clusters they actually rank for, keeping their slugs so a rewrite and a redirect never land together: - how-openstatus-compares-to-other-status-page-tools -> status page pricing comparison (ranks 5.6-7.5 on pricing queries; its 'alternative' framing was interfering with the specific pages for 38 impressions and no clicks) - best-opensource-status-page-2026 -> self-hosted tools (ranks 18.1 for 'open source status page' where the homepage ranks 1.6) - what-is-a-status-page -> status page terminology, with a new glossary for operational/degraded/partial outage/major outage (ranks 3.1 for 'statuspage partial outage definition'; /status-page outranks it on the definitional query) Co-authored-by: Claude Opus 5 (1M context) --- apps/web/next.config.ts | 20 +++ .../pages/compare/atlassian-statuspage.mdx | 140 ------------------ .../web/src/content/pages/compare/checkly.mdx | 1 + .../src/content/pages/compare/instatus.mdx | 119 --------------- .../web/src/content/pages/compare/pingdom.mdx | 124 ---------------- .../src/content/pages/compare/uptime-kuma.mdx | 130 ---------------- .../content/pages/compare/uptime-robot.mdx | 1 + .../best-opensource-status-page-2026.mdx | 55 ++++--- .../guides/hosted-uptime-kuma-alternative.mdx | 44 +++++- ...us-compares-to-other-status-page-tools.mdx | 65 ++++---- .../guides/migrate-from-uptime-robot.mdx | 4 + ...five-atlassian-statuspage-alternatives.mdx | 63 ++++++++ .../guides/top-five-instatus-alternatives.mdx | 43 ++++++ .../guides/top-five-pingdom-alternatives.mdx | 48 +++++- .../pages/guides/what-is-a-status-page.mdx | 113 ++++++++------ 15 files changed, 353 insertions(+), 617 deletions(-) delete mode 100644 apps/web/src/content/pages/compare/atlassian-statuspage.mdx delete mode 100644 apps/web/src/content/pages/compare/instatus.mdx delete mode 100644 apps/web/src/content/pages/compare/pingdom.mdx delete mode 100644 apps/web/src/content/pages/compare/uptime-kuma.mdx diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index cfe7daf9..f3911b2a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -205,6 +205,26 @@ const nextConfig: NextConfig = { destination: "/docs/guides/how-to-connect-openstatus-to-claude-code", permanent: true, }, + { + source: "/compare/atlassian-statuspage", + destination: "/guides/top-five-atlassian-statuspage-alternatives", + permanent: true, + }, + { + source: "/compare/instatus", + destination: "/guides/top-five-instatus-alternatives", + permanent: true, + }, + { + source: "/compare/pingdom", + destination: "/guides/top-five-pingdom-alternatives", + permanent: true, + }, + { + source: "/compare/uptime-kuma", + destination: "/guides/hosted-uptime-kuma-alternative", + permanent: true, + }, ]; }, async rewrites() { diff --git a/apps/web/src/content/pages/compare/atlassian-statuspage.mdx b/apps/web/src/content/pages/compare/atlassian-statuspage.mdx deleted file mode 100644 index b6e7c044..00000000 --- a/apps/web/src/content/pages/compare/atlassian-statuspage.mdx +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: "Atlassian Statuspage vs openstatus" -publishedAt: "2026-02-21" -author: "openstatus" -description: "Looking for an Atlassian Statuspage alternative with built-in monitoring? openstatus includes 28-region uptime checks, flat pricing with no per-subscriber fees, and is fully open source." -category: "company" -faq: - - question: "Is openstatus a good Atlassian Statuspage alternative?" - answer: "Yes. Openstatus includes built-in uptime monitoring from 28 global regions — something Atlassian Statuspage does not offer at all. Openstatus pricing is flat ($30/mo) and does not scale with subscriber count. Atlassian Statuspage charges per subscriber tier, meaning your bill grows as your audience does. Openstatus is also open-source and self-hostable." - - question: "Does openstatus include uptime monitoring unlike Atlassian Statuspage?" - answer: "Yes. Atlassian Statuspage has no built-in monitoring. You must connect a separate tool (Datadog, Pingdom, etc.) to detect incidents. Openstatus monitors your endpoints from 28 regions simultaneously and can update your status page automatically based on check results." - - question: "How does openstatus pricing compare to Atlassian Statuspage?" - answer: "Atlassian Statuspage starts at $29/month for one page and 100 subscribers, jumping to $99/month for three pages and $399/month for custom HTML/CSS. Subscriber-count tiers add cost as your audience grows. Openstatus starts at $30/month with flat pricing, unlimited subscribers, and monitoring included." - - question: "What does the OpsGenie shutdown mean for Statuspage users?" - answer: "OpsGenie, Atlassian's incident management tool often used alongside Statuspage, is shutting down in April 2027. Teams using the Statuspage + OpsGenie combination will need to find replacements for both. Openstatus covers the monitoring and status page layer in one product." - - question: "Can I self-host openstatus?" - answer: "Yes. Openstatus is open-source (AGPL-3.0) and fully self-hostable. Atlassian Statuspage is a closed-source SaaS with no self-hosting option." ---- - -## Looking for an Atlassian Statuspage alternative? - -**TL;DR:** Atlassian Statuspage has no built-in monitoring, charges per subscriber tier, and gates custom styling behind a $399/month plan. Openstatus includes monitoring from 28 regions, flat pricing with unlimited subscribers, and is fully open-source. With OpsGenie shutting down in 2027, now is a good time to simplify your stack. - -If you're paying for Statuspage and a separate monitoring tool on top of it, openstatus replaces both. It includes uptime monitoring from 28 global regions and a native status page in one product — with flat pricing that doesn't scale with your subscriber count. Plus, with OpsGenie shutting down in 2027, now is a good time to simplify your stack. - -Atlassian Statuspage is the longest-established name in status pages, but it comes with significant trade-offs: **no built-in monitoring**, pricing that scales with your subscriber count, and customization gated behind its most expensive tier. Openstatus covers monitoring and status pages in one product at a fraction of the cost, with flat pricing and an open-source codebase. - -The most important structural difference: Atlassian Statuspage requires you to connect a separate monitoring tool (Datadog, Pingdom, New Relic) to detect incidents. Openstatus monitors your endpoints directly from 28 regions and updates your status page automatically — no second tool, no manual webhook wiring. - -Additionally, OpsGenie — Atlassian's incident management product often used alongside Statuspage — is shutting down in April 2027. - -## Feature Comparison - -| Feature | openstatus | Atlassian Statuspage | -| -------------------------- | ---------------- | ----------------------- | -| Open-source | Yes | No | -| Self-hosted | Yes | No | -| Built-in uptime monitoring | Yes | No | -| Multi-region | 28 regions | Not applicable | -| Monitoring as code | Yes | No | -| OpenTelemetry export | Yes | No | -| Subscriber-count billing | Flat (unlimited) | Scales with subscribers | -| Custom HTML/CSS | Themes included | $399/mo plan only | -| Team members | Unlimited | Restricted by plan | -| Developer tooling | CLI, Terraform, GitHub Actions | No official | - -## Pricing Comparison - -| | openstatus | Atlassian Statuspage | -| ------------------- | ---------------- | --------------------------- | -| Starting price | $30/mo | $29/mo | -| Monitoring included | Yes (28 regions) | No (requires separate tool) | -| Status pages | 1 (+$20/mo each) | 1 | -| Subscribers | Unlimited | 100 (scales with cost) | -| Custom Themes | Included | $399/mo plan | -| Team members | Unlimited | Restricted | -| 3 status pages | $70/mo | $99/mo | - -The starting prices look similar, but Atlassian Statuspage's $29/month plan only includes 100 subscribers and no monitoring. To get custom styling, you need the $399/month plan. And you still need to pay for a separate monitoring tool on top of that. Openstatus includes monitoring, unlimited subscribers, and theming from $30/month. - -## Built for AI and Agentic Workflows - -Openstatus ships a CLI that integrates natively into AI-driven workflows. Whether you're using an AI agent or building your own agentic automation, the openstatus CLI lets you create monitors, trigger checks, and manage incidents programmatically, no browser required. Atlassian Statuspage has no CLI and no tooling designed for machine-to-machine interaction. - -## When to Choose openstatus - -- You want **monitoring + status page** in a single product without wiring up a second tool -- You need **flat, predictable pricing** that does not scale with subscriber count -- You want **open-source** software or need to self-host -- You are not locked into the Atlassian ecosystem (Jira, Confluence) -- You need **monitoring-as-code**, OpenTelemetry export, or CI/CD integration - -## When to Choose Atlassian Statuspage - -- Your organization is deeply embedded in the **Atlassian ecosystem** and requires native Jira integration -- You need enterprise **subscriber management at very large scale** with Atlassian's compliance certifications -- Your contracts or procurement processes are already tied to **Atlassian Enterprise agreements** - -## Switching from Atlassian Statuspage to openstatus - -Atlassian Statuspage is one of the most common platforms teams switch from. Use our **[Statuspage import tool](/guides/migrate-from-atlassian-statuspage)** to automatically migrate your components, incidents, and maintenance history in minutes. - -1. **Sign up** for a free openstatus account — no credit card required -2. **Import your setup** — use the [step-by-step import guide](/guides/migrate-from-atlassian-statuspage) to bring over your components, incidents, and scheduled maintenance automatically -3. **Create monitors** — add HTTP, TCP, or DNS monitors for each component. Unlike Statuspage, openstatus detects incidents automatically from 28 regions — no need for a separate Datadog or Pingdom integration -4. **Configure alerts** — openstatus supports Slack, Discord, Email, PagerDuty, OpsGenie, Grafana OnCall, and more -5. **Update your DNS** — point your custom status page domain to openstatus -6. **Cancel Statuspage + monitoring tool** — since openstatus includes monitoring, you can cancel both your Statuspage subscription and the separate monitoring service - -With OpsGenie shutting down in April 2027, this is also a good time to consolidate your incident detection and communication into a single tool. - -## Related Resources - -- [Top 5 Atlassian Statuspage Alternatives](/guides/top-five-atlassian-statuspage-alternatives) -- [Migrate from Atlassian Statuspage](/guides/migrate-from-atlassian-statuspage) -- [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) -- [Status Pages for Compliance](/use-case/compliance) -- [Pricing](/pricing) - -## Frequently asked questions - -
- -Yes. Openstatus includes built-in uptime monitoring from 28 global regions — something Atlassian Statuspage does not offer at all. Openstatus pricing is flat ($30/mo) and does not scale with subscriber count. Atlassian Statuspage charges per subscriber tier, meaning your bill grows as your audience does. Openstatus is also open-source and self-hostable. - -
- -
- -Yes. Atlassian Statuspage has no built-in monitoring. You must connect a separate tool (Datadog, Pingdom, etc.) to detect incidents. Openstatus monitors your endpoints from 28 regions simultaneously and can update your status page automatically based on check results. - -
- -
- -Atlassian Statuspage starts at $29/month for one page and 100 subscribers, jumping to $99/month for three pages and $399/month for custom HTML/CSS. Subscriber-count tiers add cost as your audience grows. Openstatus starts at $30/month with flat pricing, unlimited subscribers, and monitoring included. - -
- -
- -OpsGenie, Atlassian's incident management tool often used alongside Statuspage, is shutting down in April 2027. Teams using the Statuspage + OpsGenie combination will need to find replacements for both. Openstatus covers the monitoring and status page layer in one product. - -
- -
- -Yes. Openstatus is open-source (AGPL-3.0) and fully self-hostable. Atlassian Statuspage is a closed-source SaaS with no self-hosting option. - -
- ---- - -Start monitoring from 28 regions today - - - Get Started Free - - ---- diff --git a/apps/web/src/content/pages/compare/checkly.mdx b/apps/web/src/content/pages/compare/checkly.mdx index 66c2db8a..1e648c50 100644 --- a/apps/web/src/content/pages/compare/checkly.mdx +++ b/apps/web/src/content/pages/compare/checkly.mdx @@ -80,6 +80,7 @@ If you rely heavily on Playwright browser checks, openstatus is not a direct rep - [Status page](/status-page) - [Uptime monitoring](/uptime-monitoring) +- [How to migrate from Checkly to openstatus](/guides/migrate-from-checkly) - [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) - [SLA vs SLO vs SLI](/guides/sla-vs-slo-vs-sli) - [Status Pages for API Providers](/use-case/api-providers) diff --git a/apps/web/src/content/pages/compare/instatus.mdx b/apps/web/src/content/pages/compare/instatus.mdx deleted file mode 100644 index d1a68872..00000000 --- a/apps/web/src/content/pages/compare/instatus.mdx +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: "Instatus vs openstatus" -publishedAt: "2026-02-21" -author: "openstatus" -description: "Looking for an Instatus alternative with real uptime monitoring? openstatus monitors from 28 regions simultaneously, is fully open source, and includes status pages with subscriber notifications." -category: "company" -faq: - - question: "Is openstatus a good Instatus alternative?" - answer: "Yes, especially if you need real uptime monitoring alongside your status page. Instatus is a status-page-first product with basic HTTP monitoring added later. Openstatus was built around monitoring first — checking from 28 global regions simultaneously — with status pages as a native part of the product, not a bolt-on." - - question: "Does openstatus include a status page like Instatus?" - answer: "Yes. Openstatus includes branded status pages with custom domains, maintenance windows, and subscriber notifications on all paid plans. The status page is tightly coupled to your monitors, so incidents and response times reflect real check results." - - question: "How does openstatus pricing compare to Instatus?" - answer: "Instatus starts at $20/month for one custom-domain status page. Openstatus starts at $30/month and includes uptime monitoring from 28 regions, unlimited team members, and monitoring-as-code tooling. If you need both monitoring and a status page, openstatus covers both in one plan." - - question: "Can I self-host openstatus?" - answer: "Yes. Openstatus is open-source (AGPL-3.0) and fully self-hostable. Instatus is a closed-source SaaS with no self-hosting option." ---- - -## Looking for an Instatus alternative? - -**TL;DR:** Instatus is a status-page-first product with basic monitoring bolted on. Openstatus was built around monitoring — 28 regions, parallel checks — with status pages as a native part of the product. If you want your status page backed by real monitoring data, choose openstatus. - -If you want your status page backed by real monitoring — not just a pretty page with bolted-on HTTP pings — openstatus is the stronger choice. It monitors from 28 regions simultaneously and surfaces results directly on your status page, so incidents are detected and communicated automatically. - -Openstatus and Instatus are both alternatives to Atlassian Statuspage, but they cover different ground. Instatus is focused purely on status pages — fast, CDN-delivered, with basic monitoring bolted on as a secondary feature. Openstatus treats **monitoring and status pages as equal parts of the same product**: checks run from 28 regions simultaneously, and your status page reflects those results in real time. - -The practical difference: with Instatus, your status page and your monitoring live in separate worlds. With openstatus, an incident detected by a monitor can surface directly on your status page — no manual update, no webhook wiring between tools. - -## Feature Comparison - -| Feature | openstatus | Instatus | -| --------------------- | ------------------------------ | ---------- | -| Open-source | Yes | No | -| Self-hosted | Yes | No | -| Scheduling strategy | Parallel (all regions at once) | Sequential | -| Multi-region | 28 regions | ~4 regions | -| Monitoring as code | Yes | No | -| OpenTelemetry export | Yes | No | -| Developer tooling | CLI, Terraform, GitHub Actions | No | -| Status page | Yes | Yes | -| Unlimited subscribers | Yes | Yes | -| Team members | Unlimited | Unlimited | - -## Pricing Comparison - -| | openstatus | Instatus | -| ----------------------- | --------------------------------------------------------------- | ---------------------------------------------- | -| Free plan | 1 monitor, 1 status page | 1 status page (no custom domain) | -| Starter/paid | $30/mo | $20/mo | -| What's included | 20 monitors + 28 regions + status page + unlimited team members | 1 custom-domain status page + basic monitoring | -| Additional status pages | $20/mo each | Included in higher plans | - -Instatus is $10/month cheaper on the base plan, but it does not include the depth of monitoring openstatus offers. If you're already paying for a separate monitoring tool alongside Instatus, openstatus replaces both at a lower combined cost. - -## When to Choose openstatus - -- You need **uptime monitoring** tightly integrated with your status page -- You want **28-region parallel checks** rather than basic single-location HTTP pings -- You need **monitoring-as-code** via YAML, CLI, Terraform, or GitHub Actions -- You prefer **open-source** software or need to self-host -- You want **OpenTelemetry export** to push check results into your existing observability stack - -## When to Choose Instatus - -- You want a **pure status page** with no monitoring requirements -- You need a **static, CDN-delivered** status page with fast global load times -- You are migrating from Atlassian Statuspage and monitoring is handled by a separate tool -- You want the **lowest possible price** for a standalone status page - -## Switching from Instatus to openstatus - -Use our **[Instatus import tool](/guides/migrate-from-instatus)** to automatically migrate your status pages, components, and subscribers in minutes — no manual recreation needed. - -1. **Sign up** for a free openstatus account — no credit card required -2. **Import your setup** — use the [step-by-step import guide](/guides/migrate-from-instatus) to bring over your status pages, components, and subscribers automatically -3. **Create monitors** — add HTTP, TCP, or DNS monitors for each component on your status page, with 28-region coverage -4. **Configure alerts** — openstatus supports Slack, Discord, Email, PagerDuty, OpsGenie, and more -5. **Update your DNS** — point your custom status page domain to openstatus - -## Related Resources - -- [Migrate from Instatus](/guides/migrate-from-instatus) -- [Top 5 Atlassian Statuspage Alternatives](/guides/top-five-atlassian-statuspage-alternatives) -- [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) -- [Status Pages for Compliance](/use-case/compliance) -- [Pricing](/pricing) - -## Frequently asked questions - -
- -Yes, especially if you need real uptime monitoring alongside your status page. Instatus is a status-page-first product with basic HTTP monitoring added later. Openstatus was built around monitoring first — checking from 28 global regions simultaneously — with status pages as a native part of the product, not a bolt-on. - -
- -
- -Yes. Openstatus includes branded status pages with custom domains, maintenance windows, and subscriber notifications on all paid plans. The status page is tightly coupled to your monitors, so incidents and response times reflect real check results. - -
- -
- -Instatus starts at $20/month for one custom-domain status page. Openstatus starts at $30/month and includes uptime monitoring from 28 regions, unlimited team members, and monitoring-as-code tooling. If you need both monitoring and a status page, openstatus covers both in one plan. - -
- -
- -Yes. Openstatus is open-source (AGPL-3.0) and fully self-hostable. Instatus is a closed-source SaaS with no self-hosting option. - -
- ---- - -Start monitoring from 28 regions today - -Get Started Free - ---- diff --git a/apps/web/src/content/pages/compare/pingdom.mdx b/apps/web/src/content/pages/compare/pingdom.mdx deleted file mode 100644 index 2cdf45ad..00000000 --- a/apps/web/src/content/pages/compare/pingdom.mdx +++ /dev/null @@ -1,124 +0,0 @@ ---- -title: "Pingdom vs openstatus" -publishedAt: "2026-06-19" -author: "openstatus" -description: "Looking for a Pingdom alternative? openstatus checks from 28 regions at once, includes a built-in status page, is open source, and starts free — while Pingdom has no free tier and starts at $15/month." -category: "company" -faq: - - question: "Is openstatus a good Pingdom alternative?" - answer: "Yes, for uptime monitoring and status pages. Openstatus monitors from 28 global regions simultaneously, is open-source and self-hostable, includes a built-in public status page, and starts free. Pingdom is a closed-source SolarWinds product with no free tier and no public status page, though it offers real user monitoring (RUM) and transaction checks that openstatus does not." - - question: "Does Pingdom still have a free plan?" - answer: "No. Pingdom discontinued its free tier after the SolarWinds acquisition. The cheapest paid plan starts at around $15/month (billed annually) for 10 uptime checks, with a 14-day trial. Openstatus has a permanent free plan with 1 monitor across 6 regions." - - question: "Does openstatus include a status page like Pingdom?" - answer: "Openstatus includes a built-in branded public status page with custom domains, maintenance windows, and subscriber notifications on every plan. Pingdom does not offer a public status page product — you would need a separate tool for incident communication." - - question: "Is openstatus open-source?" - answer: "Yes. Openstatus is AGPL-3.0-licensed and fully self-hostable. Pingdom is a closed-source SaaS owned by SolarWinds with no self-hosting option." ---- - -## Looking for a Pingdom alternative? - -**TL;DR:** Openstatus monitors from 28 regions simultaneously, includes a built-in status page, is open-source, and starts free. Pingdom is a mature closed-source SolarWinds product with real user monitoring and transaction checks — but no free tier and no public status page. - -If you're evaluating Pingdom and the lack of a free tier, the closed-source model, or the missing status page gives you pause, openstatus is the alternative worth a look. It's open-source, checks from 28 regions in parallel, bundles a public status page with every plan, and you can start for free. - -Openstatus and Pingdom overlap on uptime monitoring but diverge from there. Openstatus focuses on **uptime monitoring plus status pages** — HTTP, TCP, and DNS checks from 28 global regions, paired with a public page for incident communication. Pingdom is a broader, older **website-performance** suite that adds real user monitoring (RUM), transaction monitoring, and page-speed analysis on top of uptime checks. - -For teams that want global uptime checks, a status page, and developer tooling without a per-feature bill, openstatus is the more direct fit. For teams that specifically need real user monitoring or recorded transaction checks, Pingdom covers ground openstatus doesn't. - -## Feature Comparison - -| Feature | openstatus | Pingdom | -| ------------------------ | ---------------- | -------------------- | -| Free plan | Yes | No (14-day trial) | -| Multi-region | 28 regions | ~100 locations | -| Parallel scheduling | Yes | No (round-robin) | -| Status Page | Yes (built-in) | No | -| Open-source | Yes (AGPL-3.0) | No | -| Self-hostable | Yes | No | -| Monitoring as code | Yes (Terraform) | No | -| CLI | Yes | No | -| MCP server | Yes | No | -| OpenTelemetry exporter | Yes | No | -| Real user monitoring | No | Yes | -| Transaction monitoring | No | Yes | - -Pingdom checks from many locations but cycles through them one at a time. Openstatus checks all selected regions **simultaneously**, so a regional outage is caught on the same run rather than whenever the rotation comes back around. - -## Pricing Comparison - -| | openstatus | Pingdom | -| -------------- | ----------------------- | ----------------------------- | -| Free plan | 1 monitor, 6 regions | None | -| Entry paid | $30/mo (20 monitors) | ~$15/mo (10 uptime checks) | -| Status page | Included | Not available | -| Team members | Unlimited on paid plans | Per-plan limits | -| Open-source | Self-host for $0 | Not available | - -Pingdom's entry price looks low, but RUM and advanced features sit on separate add-ons, and there's no free tier to start on. Openstatus offers flat pricing with a status page and unlimited team members included. - -## When to Choose openstatus - -- You want uptime monitoring **with a built-in status page** -- You want **28 regions** checked in parallel, not in rotation -- You prefer **open-source** software or need to self-host -- You want **monitoring as code** (Terraform), a CLI, and an MCP server -- You want a **free tier** and predictable, low-cost pricing - -## When to Choose Pingdom - -- You need **real user monitoring (RUM)** to measure actual visitor experience -- You need **recorded transaction checks** for multi-step browser flows -- You want a **long-established** brand and don't need a public status page - -## Switching from Pingdom to openstatus - -If your Pingdom usage is primarily uptime and page checks, the move is straightforward: - -1. **Sign up** for a free openstatus account — no credit card required -2. **Recreate your checks** in the dashboard, or define them as code with the [Terraform provider](/docs/reference/terraform) and the openstatus CLI -3. **Set up your status page** — something Pingdom doesn't offer natively -4. **Configure alerts** — openstatus supports Slack, Discord, Email, PagerDuty, OpsGenie, and more - -If you rely on Pingdom's RUM or transaction monitoring, openstatus is not a drop-in replacement for those — pair openstatus for uptime and status pages alongside a RUM tool. - -## Related Resources - -- [What Is Uptime Monitoring?](/guides/what-is-uptime-monitoring) -- [Why Uptime Percentage Is Misleading](/guides/why-uptime-percentage-is-misleading) -- [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) -- [Reduce Support Tickets with a Status Page](/use-case/reduce-support-tickets) -- [Pricing](/pricing) - -## Frequently asked questions - -
- -Yes, for uptime monitoring and status pages. Openstatus monitors from 28 global regions simultaneously, is open-source and self-hostable, includes a built-in public status page, and starts free. Pingdom is a closed-source SolarWinds product with no free tier and no public status page, though it offers real user monitoring (RUM) and transaction checks that openstatus does not. - -
- -
- -No. Pingdom discontinued its free tier after the SolarWinds acquisition. The cheapest paid plan starts at around $15/month (billed annually) for 10 uptime checks, with a 14-day trial. Openstatus has a permanent free plan with 1 monitor across 6 regions. - -
- -
- -Openstatus includes a built-in branded public status page with custom domains, maintenance windows, and subscriber notifications on every plan. Pingdom does not offer a public status page product — you would need a separate tool for incident communication. - -
- -
- -Yes. Openstatus is AGPL-3.0-licensed and fully self-hostable. Pingdom is a closed-source SaaS owned by SolarWinds with no self-hosting option. - -
- ---- - -Start monitoring from 28 regions today - -Get Started Free - ---- diff --git a/apps/web/src/content/pages/compare/uptime-kuma.mdx b/apps/web/src/content/pages/compare/uptime-kuma.mdx deleted file mode 100644 index edcb604c..00000000 --- a/apps/web/src/content/pages/compare/uptime-kuma.mdx +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: "Uptime Kuma vs openstatus" -publishedAt: "2025-11-10" -author: "openstatus" -description: "openstatus vs Uptime Kuma compared side-by-side. Both open-source, but openstatus offers managed SaaS + 28-region global monitoring. Uptime Kuma is self-hosted only from 1 location." -category: "company" -faq: - - question: "Is openstatus a good Uptime Kuma alternative?" - answer: "Yes, especially if you want managed cloud hosting or global multi-region monitoring. Both are open-source, but openstatus is available as a SaaS (no server to maintain) and monitors from 28 regions worldwide. Uptime Kuma is self-hosted only and checks from a single server location." - - question: "What is the main difference between openstatus and Uptime Kuma?" - answer: "The main difference is hosting model and monitoring coverage. Uptime Kuma is self-hosted only — you run it on your own server and it monitors from that single location. openstatus is available as a managed SaaS or self-hosted, and checks from 28 regions across multiple cloud providers simultaneously." - - question: "Is openstatus free like Uptime Kuma?" - answer: "openstatus has a free Hobby plan (1 monitor, 6 regions, 1 status page) with no credit card required. Uptime Kuma is fully free and open-source but requires you to provision, host, and maintain your own server." - - question: "Does openstatus support self-hosting like Uptime Kuma?" - answer: "Yes. openstatus is AGPL-3.0 licensed and can be self-hosted with Docker. You also get the option to use the managed cloud service without managing any infrastructure." - - question: "Can openstatus monitor from multiple regions unlike Uptime Kuma?" - answer: "Yes. openstatus monitors from 28 regions across 3 cloud providers (Fly.io, Koyeb, and Railway) simultaneously. Uptime Kuma only checks from the single server where it is installed, which means it cannot detect regional outages." ---- - -## Looking for an Uptime Kuma alternative? - -**TL;DR:** Both are open-source. Openstatus offers managed SaaS hosting and monitors from 28 global regions simultaneously. Uptime Kuma is self-hosted only and checks from one server location. Choose openstatus for global coverage without infrastructure, Uptime Kuma for fully free self-managed monitoring. - -If you love the open-source ethos of Uptime Kuma but want managed hosting and global coverage, openstatus gives you both. Monitor from 28 regions across 3 cloud providers without maintaining your own server — or self-host it if you prefer. Either way, you get multi-region checks that Uptime Kuma's single-server architecture can't provide. - -Openstatus and Uptime Kuma are both open-source uptime monitoring tools, making this a comparison between two projects with shared values but different architectures. The fundamental difference is the **hosting model**: Uptime Kuma is self-hosted only — you run it on your own server and it monitors from that single location. Openstatus is available both as a **managed SaaS** and for self-hosting, and checks from **28 regions worldwide**. - -If you want zero infrastructure responsibility and global multi-region checks, openstatus is the natural choice. If you want a completely free, self-managed tool with full control and no external dependencies, Uptime Kuma is a solid option. - -## Feature Comparison - -| Feature | openstatus | Uptime Kuma | -| ------------------- | ------------------- | ------------------------ | -| Open-source | Yes | Yes | -| Hosting model | SaaS or self-hosted | Self-hosted only | -| Multi-cloud | 3 cloud providers | Single server | -| Multi-region | 28 regions | 1 (your server location) | -| OTel Export | Yes | No | -| GitHub Action | Yes | No | -| Team members | Unlimited | Unlimited | -| Managed SaaS option | Yes | No | -| Monitoring as code | Yes | No | - -## Pricing Comparison - -| | openstatus | Uptime Kuma | -| ------------------- | --------------------------------------------- | ------------------------------------- | -| Software cost | Free (Hobby), $30/mo (Starter), $100/mo (Pro) | Free | -| Infrastructure cost | $0 (managed SaaS) | Your server cost ($5-20+/mo VPS) | -| Maintenance | None (managed) | You manage updates, backups, uptime | -| Multi-region | Included (28 regions) | Requires additional server per region | -| Status page | Included | Included | - -Uptime Kuma is free software, but running it requires a server. A basic VPS costs $5-20/month, and you're responsible for updates, backups, and keeping the monitoring server itself online. Openstatus's managed SaaS eliminates that overhead. To get multi-region monitoring with Uptime Kuma, you'd need to run separate instances in each region. - -## When to Choose openstatus - -- You want **managed SaaS** with no infrastructure to maintain -- You need **28-region global monitoring** from multiple cloud providers -- You want **monitoring as code** via YAML and GitHub Actions -- You need **OpenTelemetry export** or a tightly integrated status page -- You want to **talk to the founders** directly (bootstrapped, small team) - -## When to Choose Uptime Kuma - -- You want **completely free** monitoring with no usage limits -- You are comfortable **running your own server** -- You need monitoring **behind a firewall** with no external SaaS dependency -- You prefer a **single-location** check from your own infrastructure - -## Switching from Uptime Kuma to openstatus - -1. **Sign up** for a free openstatus account — no credit card required -2. **Recreate your monitors** — use the dashboard or monitoring-as-code (YAML + CLI) to define your HTTP, TCP, and DNS checks -3. **Set up your status page** — openstatus includes a branded status page with custom domain support -4. **Configure alerts** — openstatus supports Slack, Discord, Email, PagerDuty, OpsGenie, and more -5. **Decommission your server** — once your monitors are running on openstatus, you can shut down your Uptime Kuma instance and stop paying for the VPS - -openstatus ships automated importers for Statuspage, Better Stack, Instatus, and Checkly, but not for Uptime Kuma — so monitors are recreated rather than imported. [Monitoring as code](/docs/concept/uptime-monitoring-as-code) turns that into a one-time YAML file rather than an afternoon of dashboard clicking, and the same file stays version-controlled afterwards. - -If you prefer to self-host openstatus instead, check the [GitHub repository](https://github.com/openstatusHQ/openstatus) for Docker setup instructions. - -## Related Resources - -- [A hosted Uptime Kuma alternative](/guides/hosted-uptime-kuma-alternative) — the case for managed hosting in more depth -- [Best Open Source Status Pages in 2026](/guides/best-opensource-status-page-2026) -- [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) -- [Status Pages for Open Source Projects](/use-case/open-source) -- [Uptime monitoring](/uptime-monitoring) -- [Pricing](/pricing) - -## Frequently asked questions - -
- -Yes, especially if you want managed cloud hosting or global multi-region monitoring. Both are open-source, but openstatus is available as a SaaS (no server to maintain) and monitors from 28 regions worldwide. Uptime Kuma is self-hosted only and checks from a single server location. - -
- -
- -The main difference is hosting model and monitoring coverage. Uptime Kuma is self-hosted only — you run it on your own server and it monitors from that single location. openstatus is available as a managed SaaS or self-hosted, and checks from 28 regions across multiple cloud providers simultaneously. - -
- -
- -openstatus has a free Hobby plan (1 monitor, 6 regions, 1 status page) with no credit card required. Uptime Kuma is fully free and open-source but requires you to provision, host, and maintain your own server. - -
- -
- -Yes. openstatus is AGPL-3.0 licensed and can be self-hosted with Docker. You also get the option to use the managed cloud service without managing any infrastructure. - -
- -
- -Yes. openstatus monitors from 28 regions across 3 cloud providers (Fly.io, Koyeb, and Railway) simultaneously. Uptime Kuma only checks from the single server where it is installed, which means it cannot detect regional outages. - -
- ---- - -Start monitoring from 28 regions today - -Get Started Free - ---- diff --git a/apps/web/src/content/pages/compare/uptime-robot.mdx b/apps/web/src/content/pages/compare/uptime-robot.mdx index 9355e2ed..734df904 100644 --- a/apps/web/src/content/pages/compare/uptime-robot.mdx +++ b/apps/web/src/content/pages/compare/uptime-robot.mdx @@ -88,6 +88,7 @@ Most teams complete the switch in under an hour. If you need help, reach out at - [Status page](/status-page) - [Uptime monitoring](/uptime-monitoring) +- [How to migrate from UptimeRobot to openstatus](/guides/migrate-from-uptime-robot) — the step-by-step move, once you have decided - [Why Uptime Percentage Is Misleading](/guides/why-uptime-percentage-is-misleading) - [SLA vs SLO vs SLI](/guides/sla-vs-slo-vs-sli) - [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) diff --git a/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx b/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx index 716c4c1a..a6f5110c 100644 --- a/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx +++ b/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx @@ -1,27 +1,32 @@ --- -title: "Best Open Source Status Page Tools in 2026" -description: "Compare the top open-source status page tools in 2026. We review openstatus, Cachet, Vigil, Statping-ng, and Upptime to help you pick the right one for your team." +title: "Best Self-Hosted Status Page Tools 2026" +seo: + title: "Best Self-Hosted Status Page Tools in 2026" + description: "The self-hosted status page tools worth running in 2026 — openstatus, Cachet, Vigil, Statping-ng and Upptime — compared on maintenance, monitoring, and what each one costs you to operate." +description: "The self-hosted status page tools worth running on your own infrastructure in 2026 — compared on maintenance, built-in monitoring, and the real operational cost of hosting them yourself." author: "openstatus" publishedAt: "2026-01-19" category: "alternative" faq: - - question: "What is the best open-source status page tool in 2026?" - answer: "openstatus is the top pick for 2026. It is actively maintained, offers both cloud-hosted and self-hosted deployments, includes built-in uptime monitoring, and integrates with Slack out of the box." - - question: "What should I look for when choosing an open-source status page?" - answer: "Focus on active maintenance, deployment flexibility (hosted vs. self-hosted), built-in monitoring, notification integrations (Slack, email, webhooks), and how easy it is for your end users to understand the current status at a glance." - - question: "Can I self-host an open-source status page for free?" - answer: "Yes. Tools like openstatus, Vigil, Cachet, and Statping-ng can all be self-hosted at no licensing cost. Keep in mind you will still need to provision and maintain your own infrastructure, so factor in server and operational costs." + - question: "What is the best self-hosted status page tool in 2026?" + answer: "openstatus is the strongest self-hosted option. It is actively maintained, ships as a Docker image you can run on your own infrastructure under AGPL-3.0, and unlike most self-hosted status pages it includes uptime monitoring rather than expecting you to wire in a separate tool. Cachet is the main alternative if you want something narrower and purely status-page focused." + - question: "What does self-hosting a status page actually cost?" + answer: "The software is free; the operation is not. Budget a VPS at $5-20/month, plus your time for upgrades, backups, TLS renewal, and keeping the box online. The subtler cost is architectural: a status page hosted on your own infrastructure can go down in the same incident it is supposed to report, which is the single strongest argument for hosting it somewhere else." + - question: "Should I self-host my status page or use a hosted one?" + answer: "Self-host when data residency, air-gapped networks, or full control of the stack are hard requirements, or when you genuinely enjoy running the infrastructure. Use a hosted page when you want it to stay up during your own outages and would rather not maintain another service. openstatus supports both from the same codebase, so the decision is not permanent." + - question: "Which self-hosted status pages include uptime monitoring?" + answer: "openstatus and Statping-ng monitor endpoints themselves. Cachet and Vigil are primarily status-page and reporting layers that expect check results from elsewhere, and Upptime uses GitHub Actions as its check runner. If you do not already have a monitoring stack, choosing a tool that includes checks saves you from running two services instead of one." - question: "Is Upptime still a good choice in 2026?" - answer: "Upptime pioneered a clever GitOps approach using GitHub Actions and Pages, but its last major release was in 2020. Because it is no longer actively maintained, we recommend choosing an actively developed alternative like OpenStatus for production use." + answer: "Upptime pioneered a clever GitOps approach using GitHub Actions and GitHub Pages, and it remains appealing if you want zero servers. But its last major release was in 2020, so for anything production-facing an actively maintained project is the safer choice." --- -# The State of Open-Source Status Pages in 2026: What Are Your Best Options? +# Best Self-Hosted Status Page Tools in 2026 -If there’s one thing we’ve learned in DevRel and platform engineering, it’s that **downtime is inevitable, but poor communication is a choice.** When your API goes down or latency spikes, your users shouldn't have to guess what's happening. A reliable, transparent status page is your frontline for maintaining developer trust. +Running your own status page is a deliberate trade. You get full control of the data, no vendor bill, and a page that lives on infrastructure you own. In exchange you take on the server, the upgrades, the backups — and the awkward problem that your status page depends on infrastructure that may go down at the same time as everything else. -But building a status page from scratch in 2026 is usually a waste of valuable engineering cycles. Instead, the open-source community has provided several excellent tools to do the heavy lifting for us. +If that trade is one you want to make, these are the tools worth running in 2026. Each is compared on how actively it is maintained, whether it monitors anything itself, and what it genuinely costs to operate once you include the server and your own time. -we’ve reviewed the current landscape of open-source status pages. Here is a breakdown of the top contenders this year, where they shine, and where they fall short. +If you would rather not run it at all, a [hosted status page](/status-page) removes the operational half of the trade while keeping the open-source codebase. --- @@ -82,7 +87,7 @@ Unfortunately, as of 2026, the project seems to have stalled. With its last majo --- -### Summary Comparison +### Self-hosting cost, side by side | Tool | Active in 2026? | Deployment | Built-in Monitoring | Standout Feature | | :-------------- | :-------------- | :------------------- | :------------------ | :-------------------------------- | @@ -92,32 +97,38 @@ Unfortunately, as of 2026, the project seems to have stalled. With its last majo | **Statping.ng** | ✅ Yes | Self-Hosted | ✅ Yes | All-in-one, but outdated UI | | **Upptime** | ❌ No | GitHub Actions | ✅ Yes | Zero-server GitOps | -### The Verdict +### Which one to self-host If you are starting a new project or migrating an old status page today, **openstatus** is the clear winner. Its modern architecture, active maintenance, and built-in integrations make it the easiest way to keep your users informed while your engineering team focuses on fixing the actual outages. ## Frequently asked questions -
+
-openstatus is the top pick for 2026. It is actively maintained, offers both cloud-hosted and self-hosted deployments, includes built-in uptime monitoring, and integrates with Slack out of the box. +openstatus is the strongest self-hosted option. It is actively maintained, ships as a Docker image you can run on your own infrastructure under AGPL-3.0, and unlike most self-hosted status pages it includes uptime monitoring rather than expecting you to wire in a separate tool. Cachet is the main alternative if you want something narrower and purely status-page focused.
-
+
-Focus on active maintenance, deployment flexibility (hosted vs. self-hosted), built-in monitoring, notification integrations (Slack, email, webhooks), and how easy it is for your end users to understand the current status at a glance. +The software is free; the operation is not. Budget a VPS at $5-20/month, plus your time for upgrades, backups, TLS renewal, and keeping the box online. The subtler cost is architectural: a status page hosted on your own infrastructure can go down in the same incident it is supposed to report, which is the single strongest argument for hosting it somewhere else.
-
+
-Yes. Tools like openstatus, Vigil, Cachet, and Statping-ng can all be self-hosted at no licensing cost. Keep in mind you will still need to provision and maintain your own infrastructure, so factor in server and operational costs. +Self-host when data residency, air-gapped networks, or full control of the stack are hard requirements, or when you genuinely enjoy running the infrastructure. Use a hosted page when you want it to stay up during your own outages and would rather not maintain another service. openstatus supports both from the same codebase, so the decision is not permanent. + +
+ +
+ +openstatus and Statping-ng monitor endpoints themselves. Cachet and Vigil are primarily status-page and reporting layers that expect check results from elsewhere, and Upptime uses GitHub Actions as its check runner. If you do not already have a monitoring stack, choosing a tool that includes checks saves you from running two services instead of one.
-Upptime pioneered a clever GitOps approach using GitHub Actions and Pages, but its last major release was in 2020. Because it is no longer actively maintained, we recommend choosing an actively developed alternative like OpenStatus for production use. +Upptime pioneered a clever GitOps approach using GitHub Actions and GitHub Pages, and it remains appealing if you want zero servers. But its last major release was in 2020, so for anything production-facing an actively maintained project is the safer choice.
diff --git a/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx b/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx index 987fae49..640c85c8 100644 --- a/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx +++ b/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx @@ -11,6 +11,10 @@ faq: answer: "Uptime Kuma checks from a single location — the server you run it on. If that server has a network blip, you get false alerts; if it goes down, your monitoring goes down with it. It also can't tell you whether an outage is regional, because there's only one vantage point. A managed, multi-region tool solves both." - question: "Can I keep Uptime Kuma and use openstatus too?" answer: "Yes. Some teams run Uptime Kuma internally for homelab or internal services and use openstatus for external, multi-region checks and a public status page. They complement each other." + - question: "Does openstatus support self-hosting like Uptime Kuma?" + answer: "Yes. openstatus is AGPL-3.0 licensed and fully self-hostable with Docker, so you can run it on your own infrastructure the same way you run Uptime Kuma. The difference is that you also have the option of the managed service — with Uptime Kuma, self-hosting is the only option." + - question: "Can openstatus monitor from multiple regions unlike Uptime Kuma?" + answer: "Yes. openstatus checks from 28 regions across 3 cloud providers, and all selected regions fire simultaneously rather than in rotation. Uptime Kuma checks from the single server you run it on, so it cannot distinguish a regional outage from a global one. Matching that with Uptime Kuma means running a separate instance per region." - question: "Is openstatus free like Uptime Kuma?" answer: "Uptime Kuma is free to run if you cover your own hosting. openstatus has a permanent free plan for the managed service (1 monitor, 6 regions), paid plans from $30/month, and is also free to self-host under AGPL-3.0." --- @@ -45,6 +49,32 @@ The point that matters most: openstatus checks from **28 regions at once**. A fa And if your reason for choosing Uptime Kuma was *"I want to own it"*, you don't have to give that up. openstatus is open-source and self-hostable too — the checker runs as a small Docker image. You can start on the managed cloud and move to self-hosted later, or run both. +## Feature Comparison + +| Feature | openstatus | Uptime Kuma | +| ------------------- | ------------------- | ------------------------ | +| Open-source | Yes | Yes | +| Hosting model | SaaS or self-hosted | Self-hosted only | +| Multi-cloud | 3 cloud providers | Single server | +| Multi-region | 28 regions | 1 (your server location) | +| OTel Export | Yes | No | +| GitHub Action | Yes | No | +| Team members | Unlimited | Unlimited | +| Managed SaaS option | Yes | No | +| Monitoring as code | Yes | No | + +## Pricing Comparison + +| | openstatus | Uptime Kuma | +| ------------------- | --------------------------------------------- | ------------------------------------- | +| Software cost | Free (Hobby), $30/mo (Starter), $100/mo (Pro) | Free | +| Infrastructure cost | $0 (managed SaaS) | Your server cost ($5-20+/mo VPS) | +| Maintenance | None (managed) | You manage updates, backups, uptime | +| Multi-region | Included (28 regions) | Requires additional server per region | +| Status page | Included | Included | + +Uptime Kuma is free software, but running it requires a server. A basic VPS costs $5-20/month, and you're responsible for updates, backups, and keeping the monitoring server itself online. Openstatus's managed SaaS eliminates that overhead. To get multi-region monitoring with Uptime Kuma, you'd need to run separate instances in each region. + ## When to stay on Uptime Kuma Be honest with yourself here — Kuma is the right call when: @@ -72,9 +102,9 @@ Most Kuma setups are small enough to move in well under an hour. ## Related Resources -- [Uptime Kuma vs openstatus](/compare/uptime-kuma) — full head-to-head - [Best Open-Source Status Page Tools in 2026](/guides/best-opensource-status-page-2026) - [What Is Uptime Monitoring?](/guides/what-is-uptime-monitoring) +- [Uptime monitoring](/uptime-monitoring) - [Status Pages for Open-Source Projects](/use-case/open-source) ## Frequently asked questions @@ -97,6 +127,18 @@ Yes. Some teams run Uptime Kuma internally for homelab or internal services and
+
+ +Yes. openstatus is AGPL-3.0 licensed and fully self-hostable with Docker, so you can run it on your own infrastructure the same way you run Uptime Kuma. The difference is that you also have the option of the managed service — with Uptime Kuma, self-hosting is the only option. + +
+ +
+ +Yes. openstatus checks from 28 regions across 3 cloud providers, and all selected regions fire simultaneously rather than in rotation. Uptime Kuma checks from the single server you run it on, so it cannot distinguish a regional outage from a global one. Matching that with Uptime Kuma means running a separate instance per region. + +
+
Uptime Kuma is free to run if you cover your own hosting. openstatus has a permanent free plan for the managed service (1 monitor, 6 regions), paid plans from $30/month, and is also free to self-host under AGPL-3.0. diff --git a/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx b/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx index a27171ad..cfc57b92 100644 --- a/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx +++ b/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx @@ -1,27 +1,30 @@ --- -title: "How openstatus Compares to Other Status Page Tools" -description: "A head-to-head comparison of openstatus vs. Atlassian Statuspage, Instatus, Betterstack, Datadog Status Page, and Status.io — features, pricing, monitoring, and developer experience." +title: "Status Page Pricing Compared: Atlassian, Instatus, Better Stack, Datadog & openstatus" +seo: + title: "Status Page Pricing Compared — Atlassian, Instatus, Better Stack & More" + description: "What a status page actually costs across Atlassian Statuspage, Instatus, Better Stack, Datadog and Status.io — list prices, per-seat charges, private-page add-ons and the cost of adding monitoring." +description: "What a status page actually costs across the major providers — list prices, per-seat charges, private-page add-ons, and what you pay separately for monitoring." author: "openstatus" publishedAt: "2026-06-09" -category: "alternative" +category: "pricing" faq: - - question: "What makes openstatus different from other status page tools?" - answer: "openstatus is the only tool that combines built-in synthetic monitoring, monitoring-as-code via Terraform, native OpenTelemetry export, private locations, an MCP server for AI coding agents, unlimited team members, and the option to self-host as open-source. Most competitors cover one or two of these — none cover all of them." - - question: "Is openstatus a replacement for Atlassian Statuspage?" - answer: "Yes, for most use cases. openstatus covers the core incident communication workflows (components, incidents, maintenances, subscribers) and adds built-in monitoring, monitoring-as-code, and an MCP server that Atlassian Statuspage doesn't ship. The one area where Atlassian still leads is deep Jira/Opsgenie integration for teams already standardized on Atlassian tooling." - - question: "How does openstatus pricing compare?" - answer: "openstatus starts at $30/month with unlimited team members and monitoring included. That's typically cheaper than Atlassian Statuspage ($99/month for private pages), Status.io ($349/month for advanced features), or Betterstack once you add private-page and styling add-ons. Instatus has a comparable free tier but no monitoring." - - question: "Can I migrate my existing status page to openstatus?" - answer: "Yes. openstatus ships a one-click importer for Atlassian Statuspage and Instatus that moves components, component groups, incidents with full update history, maintenances, and email subscribers. For other tools, components and subscribers can be imported via CSV." - - question: "Is openstatus production-ready?" - answer: "Yes. openstatus powers status pages for teams across SaaS, fintech, and infrastructure companies, and runs synthetic checks from multiple regions globally. The hosted platform is fully managed, and the same codebase backs the self-hosted distribution." + - question: "How much does a status page cost?" + answer: "It ranges from free to $349/month depending on what you need. Free tiers exist at openstatus, Instatus and Better Stack but are limited to a public page and a handful of components. A private, branded page on a custom domain starts at $30/month with openstatus, $50/month with Instatus, $99/month with Atlassian Statuspage, and $349/month with Status.io. Per-seat charges and monitoring are extra almost everywhere except openstatus." + - question: "Why is Atlassian Statuspage so expensive?" + answer: "Three multipliers stack. The $29/month entry plan caps you at 100 subscribers and the price climbs as that audience grows. A private page needs the $99/month tier. Custom HTML and CSS only appear on the $399/month plan. Seats are billed on top at roughly $20 each, and because Statuspage ships no monitoring, you also pay a separate monitoring vendor to feed it." + - question: "Which status page provider is cheapest?" + answer: "For a public page with no private access and a small component count, the free tiers at openstatus, Instatus and Better Stack all cost nothing. Once you need a private page, a custom domain, more than a couple of seats, and monitoring behind it, openstatus at $30/month is usually the lowest total because seats are unlimited and monitoring is included rather than billed separately." + - question: "Does the price include uptime monitoring?" + answer: "Usually not. Atlassian Statuspage, Instatus and Status.io ship no monitoring at all, so the checks that drive your components come from a separate tool you pay for on top. Better Stack and Datadog include monitoring but bill it per seat or per run. openstatus includes monitoring from 28 regions in the base price." + - question: "What hidden costs should I watch for?" + answer: "Subscriber-count billing, which grows with your audience rather than your usage. Per-seat pricing, which punishes you for adding engineers to an incident workflow. Private-page and custom-styling add-ons billed per page per month. And the separate monitoring bill, which is the largest hidden cost on any status-page-only product." --- -# How openstatus Compares to Other Status Page Tools +# Status Page Pricing Compared -If you're evaluating status page tools in 2026, the market is crowded: Atlassian Statuspage is the incumbent, Instatus is the design-forward newcomer, Betterstack is the all-in-one bundle, Datadog is the observability extension, and Status.io is the long-standing pure-play. +Status page pricing is hard to compare because almost nobody charges for one thing. The headline number buys a public page; private pages, extra seats, custom styling and the monitoring that feeds the page are billed separately, and the mix differs at every vendor. -This guide is the head-to-head: how **openstatus** stacks up against each of them on the dimensions that actually matter — monitoring, developer experience, pricing, and incident workflows. +This guide puts the actual cost side by side across **Atlassian Statuspage, Instatus, Better Stack, Datadog Status Page, Status.io and openstatus** — list price, what each charges per seat, what a private page costs, and what you still have to buy elsewhere. ## TL;DR @@ -36,7 +39,7 @@ openstatus is the only tool in this comparison that combines **all** of the foll Every other tool on this list is missing at least two of these. If those bullets describe what your team needs, the comparison is short. If you only need a small subset (e.g. just incident comms, or just a pretty page), some of the competitors are still credible — and we'll be honest about when. -## At a Glance +## What each provider costs | Feature | openstatus | Atlassian Statuspage | Instatus | Betterstack | Datadog | Status.io | | --- | --- | --- | --- | --- | --- | --- | @@ -52,7 +55,7 @@ Every other tool on this list is missing at least two of these. If those bullets Prices reflect publicly listed plans at time of writing and change frequently — always confirm with the vendor. -## How openstatus Compares on Each Dimension +## Where the cost actually comes from ### Monitoring depth @@ -92,7 +95,7 @@ Datadog also ships MCP. None of the others do. **openstatus wins on**: developer workflows, version-controlled config, and AI agent integration. -### Pricing at scale +### The bill at scale openstatus is **$30/month with unlimited team members** and includes monitoring. Compare that to: @@ -120,7 +123,7 @@ openstatus is the only tool here with an open-source codebase you can read, fork **openstatus wins on**: ownership and optionality. -## openstatus vs. Each Competitor +## Cost by provider ### openstatus vs. Atlassian Statuspage @@ -156,7 +159,7 @@ Status.io is the long-standing pure-play: status communication and nothing else, **Pick openstatus if**: you want monitoring included, modern dev tooling, or aren't willing to jump to a $349/month tier for private pages. **Pick Status.io if**: you specifically want a tool that does one thing and is decoupled from the rest of your stack. -## Where openstatus Isn't the Right Fit +## When paying more is the right call Being honest: openstatus is not for every team. @@ -193,32 +196,32 @@ If you need help along the way, feel free to join our [Discord community](https: ## Frequently asked questions -
+
-openstatus is the only tool that combines built-in synthetic monitoring, monitoring-as-code via Terraform, native OpenTelemetry export, private locations, an MCP server for AI coding agents, unlimited team members, and the option to self-host as open-source. Most competitors cover one or two of these — none cover all of them. +It ranges from free to $349/month depending on what you need. Free tiers exist at openstatus, Instatus and Better Stack but are limited to a public page and a handful of components. A private, branded page on a custom domain starts at $30/month with openstatus, $50/month with Instatus, $99/month with Atlassian Statuspage, and $349/month with Status.io. Per-seat charges and monitoring are extra almost everywhere except openstatus.
-
+
-Yes, for most use cases. openstatus covers the core incident communication workflows (components, incidents, maintenances, subscribers) and adds built-in monitoring, monitoring-as-code, and an MCP server that Atlassian Statuspage doesn't ship. The one area where Atlassian still leads is deep Jira/Opsgenie integration for teams already standardized on Atlassian tooling. +Three multipliers stack. The $29/month entry plan caps you at 100 subscribers and the price climbs as that audience grows. A private page needs the $99/month tier. Custom HTML and CSS only appear on the $399/month plan. Seats are billed on top at roughly $20 each, and because Statuspage ships no monitoring, you also pay a separate monitoring vendor to feed it.
-
+
-openstatus starts at $30/month with unlimited team members and monitoring included. That's typically cheaper than Atlassian Statuspage ($99/month for private pages), Status.io ($349/month for advanced features), or Betterstack once you add private-page and styling add-ons. Instatus has a comparable free tier but no monitoring. +For a public page with no private access and a small component count, the free tiers at openstatus, Instatus and Better Stack all cost nothing. Once you need a private page, a custom domain, more than a couple of seats, and monitoring behind it, openstatus at $30/month is usually the lowest total because seats are unlimited and monitoring is included rather than billed separately.
-
+
-Yes. openstatus ships a one-click importer for Atlassian Statuspage and Instatus that moves components, component groups, incidents with full update history, maintenances, and email subscribers. For other tools, components and subscribers can be imported via CSV. +Usually not. Atlassian Statuspage, Instatus and Status.io ship no monitoring at all, so the checks that drive your components come from a separate tool you pay for on top. Better Stack and Datadog include monitoring but bill it per seat or per run. openstatus includes monitoring from 28 regions in the base price.
-
+
-Yes. openstatus powers status pages for teams across SaaS, fintech, and infrastructure companies, and runs synthetic checks from multiple regions globally. The hosted platform is fully managed, and the same codebase backs the self-hosted distribution. +Subscriber-count billing, which grows with your audience rather than your usage. Per-seat pricing, which punishes you for adding engineers to an incident workflow. Private-page and custom-styling add-ons billed per page per month. And the separate monitoring bill, which is the largest hidden cost on any status-page-only product.
diff --git a/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx b/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx index 411aad3b..3cd3f416 100644 --- a/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx +++ b/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx @@ -141,6 +141,10 @@ Common reasons: UptimeRobot checks from a single location while openstatus check --- +Still deciding rather than migrating? [UptimeRobot vs openstatus](/compare/uptime-robot) is the feature-by-feature comparison. + +--- + Start monitoring from 28 regions today Get Started Free diff --git a/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx b/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx index 1d7ef84b..31ac109d 100644 --- a/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx +++ b/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx @@ -6,6 +6,12 @@ author: "openstatus" publishedAt: "2026-06-09" category: "alternative" faq: + - question: "Is openstatus a good Atlassian Statuspage alternative?" + answer: "Yes. Openstatus includes built-in uptime monitoring from 28 global regions — something Atlassian Statuspage does not offer at all. Openstatus pricing is flat ($30/mo) and does not scale with subscriber count. Atlassian Statuspage charges per subscriber tier, meaning your bill grows as your audience does. Openstatus is also open-source and self-hostable." + - question: "How does openstatus pricing compare to Atlassian Statuspage?" + answer: "Atlassian Statuspage starts at $29/month for one page and 100 subscribers, jumping to $99/month for three pages and $399/month for custom HTML/CSS. Subscriber-count tiers add cost as your audience grows. Openstatus starts at $30/month with flat pricing, unlimited subscribers, and monitoring included." + - question: "What does the OpsGenie shutdown mean for Statuspage users?" + answer: "OpsGenie, Atlassian's incident management tool often used alongside Statuspage, is shutting down in April 2027. Teams using the Statuspage + OpsGenie combination will need to find replacements for both. Openstatus covers the monitoring and status page layer in one product." - question: "How do I migrate from Atlassian Statuspage to an alternative?" answer: "openstatus offers a one-click importer that automatically transfers your components, component groups, incidents (with all updates), maintenances, and email subscribers from Atlassian Statuspage. Just paste your API key, preview what will be imported, and confirm. See our step-by-step migration guide for details." - question: "Which alternative is most cost-effective for small teams?" @@ -58,6 +64,39 @@ Here's a high-level look at how these five alternatives stack up on key features +## openstatus vs Atlassian Statuspage, feature by feature + +| Feature | openstatus | Atlassian Statuspage | +| -------------------------- | ---------------- | ----------------------- | +| Open-source | Yes | No | +| Self-hosted | Yes | No | +| Built-in uptime monitoring | Yes | No | +| Multi-region | 28 regions | Not applicable | +| Monitoring as code | Yes | No | +| OpenTelemetry export | Yes | No | +| Subscriber-count billing | Flat (unlimited) | Scales with subscribers | +| Custom HTML/CSS | Themes included | $399/mo plan only | +| Team members | Unlimited | Restricted by plan | +| Developer tooling | CLI, Terraform, GitHub Actions | No official | + +## openstatus vs Atlassian Statuspage pricing + +| | openstatus | Atlassian Statuspage | +| ------------------- | ---------------- | --------------------------- | +| Starting price | $30/mo | $29/mo | +| Monitoring included | Yes (28 regions) | No (requires separate tool) | +| Status pages | 1 (+$20/mo each) | 1 | +| Subscribers | Unlimited | 100 (scales with cost) | +| Custom Themes | Included | $399/mo plan | +| Team members | Unlimited | Restricted | +| 3 status pages | $70/mo | $99/mo | + +The starting prices look similar, but Atlassian Statuspage's $29/month plan only includes 100 subscribers and no monitoring. To get custom styling, you need the $399/month plan. And you still need to pay for a separate monitoring tool on top of that. Openstatus includes monitoring, unlimited subscribers, and theming from $30/month. + +## Built for AI and Agentic Workflows + +Openstatus ships a CLI that integrates natively into AI-driven workflows. Whether you're using an AI agent or building your own agentic automation, the openstatus CLI lets you create monitors, trigger checks, and manage incidents programmatically, no browser required. Atlassian Statuspage has no CLI and no tooling designed for machine-to-machine interaction. + ## Top Five Alternatives to Atlassian Statuspage ### 1. OpenStatus @@ -164,12 +203,36 @@ While Atlassian Statuspage pioneered the status page market, several alternative - **Betterstack** is a great option for those looking for an all-in-one monitoring and status page solution. +## Related Guides + +- [How to migrate from Atlassian Statuspage to openstatus](/guides/migrate-from-atlassian-statuspage) — the step-by-step move, once you have picked +- [Status page](/status-page) — what openstatus ships +- [Status Page Pricing Compared](/guides/how-openstatus-compares-to-other-status-page-tools) — what each option actually costs + ## Need Help or Have Questions? If you need help along the way, feel free to join our [Discord community](https://www.openstatus.dev/discord), check our [documentation](https://www.openstatus.dev/docs) for more information or reach out to us via [email](mailto:ping@openstatus.dev) ## Frequently asked questions +
+ +Yes. Openstatus includes built-in uptime monitoring from 28 global regions — something Atlassian Statuspage does not offer at all. Openstatus pricing is flat ($30/mo) and does not scale with subscriber count. Atlassian Statuspage charges per subscriber tier, meaning your bill grows as your audience does. Openstatus is also open-source and self-hostable. + +
+ +
+ +Atlassian Statuspage starts at $29/month for one page and 100 subscribers, jumping to $99/month for three pages and $399/month for custom HTML/CSS. Subscriber-count tiers add cost as your audience grows. Openstatus starts at $30/month with flat pricing, unlimited subscribers, and monitoring included. + +
+ +
+ +OpsGenie, Atlassian's incident management tool often used alongside Statuspage, is shutting down in April 2027. Teams using the Statuspage + OpsGenie combination will need to find replacements for both. Openstatus covers the monitoring and status page layer in one product. + +
+
openstatus offers a one-click importer that automatically transfers your components, component groups, incidents (with all updates), maintenances, and email subscribers from Atlassian Statuspage. Just paste your API key, preview what will be imported, and confirm. See our step-by-step migration guide for details. diff --git a/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx b/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx index 23249db7..d9d4d173 100644 --- a/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx +++ b/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx @@ -6,6 +6,10 @@ author: "openstatus" publishedAt: "2026-06-09" category: "alternative" faq: + - question: "Is openstatus a good Instatus alternative?" + answer: "Yes, especially if you need real uptime monitoring alongside your status page. Instatus is a status-page-first product with basic HTTP monitoring added later. Openstatus was built around monitoring first — checking from 28 global regions simultaneously — with status pages as a native part of the product, not a bolt-on." + - question: "How does openstatus pricing compare to Instatus?" + answer: "Instatus starts at $20/month for one custom-domain status page. Openstatus starts at $30/month and includes uptime monitoring from 28 regions, unlimited team members, and monitoring-as-code tooling. If you need both monitoring and a status page, openstatus covers both in one plan." - question: "How do I migrate from Instatus to an alternative?" answer: "openstatus offers a one-click importer that automatically transfers your components, component groups, incidents (with all updates), maintenances, and email subscribers. Paste your Instatus API key, preview what will be imported, and confirm. See our step-by-step migration guide for details." - question: "Which Instatus alternative is most cost-effective for small teams?" @@ -70,6 +74,32 @@ The comparison below is structured around these. Prices reflect publicly listed plans at time of writing and change frequently — always confirm with the vendor before deciding. +## openstatus vs Instatus, feature by feature + +| Feature | openstatus | Instatus | +| --------------------- | ------------------------------ | ---------- | +| Open-source | Yes | No | +| Self-hosted | Yes | No | +| Scheduling strategy | Parallel (all regions at once) | Sequential | +| Multi-region | 28 regions | ~4 regions | +| Monitoring as code | Yes | No | +| OpenTelemetry export | Yes | No | +| Developer tooling | CLI, Terraform, GitHub Actions | No | +| Status page | Yes | Yes | +| Unlimited subscribers | Yes | Yes | +| Team members | Unlimited | Unlimited | + +## openstatus vs Instatus pricing + +| | openstatus | Instatus | +| ----------------------- | --------------------------------------------------------------- | ---------------------------------------------- | +| Free plan | 1 monitor, 1 status page | 1 status page (no custom domain) | +| Starter/paid | $30/mo | $20/mo | +| What's included | 20 monitors + 28 regions + status page + unlimited team members | 1 custom-domain status page + basic monitoring | +| Additional status pages | $20/mo each | Included in higher plans | + +Instatus is $10/month cheaper on the base plan, but it does not include the depth of monitoring openstatus offers. If you're already paying for a separate monitoring tool alongside Instatus, openstatus replaces both at a lower combined cost. + ## The Five Alternatives ### 1. openstatus — best for developer teams that want one tool @@ -211,6 +241,7 @@ For the other alternatives, migration is typically a manual rebuild of component - [Best Hosted Status Page Tools in 2026](/guides/best-hosted-status-page-2026) — the wider SaaS landscape ranked - [Best Incident Communication Tools in 2026](/guides/best-incident-communication-tools-2026) — when you also need internal coordination - [Top Five Atlassian Statuspage Alternatives in 2026](/guides/top-five-atlassian-statuspage-alternatives) — switching from Atlassian instead +- [How to migrate from Instatus to openstatus](/guides/migrate-from-instatus) — the step-by-step move ## Need Help or Have Questions? @@ -218,6 +249,18 @@ If you need help along the way, feel free to join our [Discord community](https: ## Frequently asked questions +
+ +Yes, especially if you need real uptime monitoring alongside your status page. Instatus is a status-page-first product with basic HTTP monitoring added later. Openstatus was built around monitoring first — checking from 28 global regions simultaneously — with status pages as a native part of the product, not a bolt-on. + +
+ +
+ +Instatus starts at $20/month for one custom-domain status page. Openstatus starts at $30/month and includes uptime monitoring from 28 regions, unlimited team members, and monitoring-as-code tooling. If you need both monitoring and a status page, openstatus covers both in one plan. + +
+
openstatus offers a one-click importer that automatically transfers your components, component groups, incidents (with all updates), maintenances, and email subscribers. Paste your Instatus API key, preview what will be imported, and confirm. See our step-by-step migration guide for details. diff --git a/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx b/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx index 0b2d7a9f..54a618bc 100644 --- a/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx +++ b/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx @@ -5,6 +5,10 @@ author: "openstatus" publishedAt: "2026-06-19" category: "alternative" faq: + - question: "Is openstatus a good Pingdom alternative?" + answer: "Yes, for uptime monitoring and status pages. Openstatus monitors from 28 global regions simultaneously, is open-source and self-hostable, includes a built-in public status page, and starts free. Pingdom is a closed-source SolarWinds product with no free tier and no public status page, though it offers real user monitoring (RUM) and transaction checks that openstatus does not." + - question: "Does Pingdom still have a free plan?" + answer: "No. Pingdom discontinued its free tier after the SolarWinds acquisition. The cheapest paid plan starts at around $15/month (billed annually) for 10 uptime checks, with a 14-day trial. Openstatus has a permanent free plan with 1 monitor across 6 regions." - question: "Why do teams look for a Pingdom alternative?" answer: "The most common reasons are: Pingdom discontinued its free tier after the SolarWinds acquisition, so there's no way to start for free; pricing climbs once you add real user monitoring or transaction checks; it's closed-source with no self-hosting; and it has no built-in public status page, so you need a second tool for incident communication." - question: "Which Pingdom alternative has the best free tier?" @@ -55,6 +59,37 @@ If none of these apply, Pingdom is fine. If two or more do, the alternatives bel Prices and limits change frequently — confirm with each vendor before deciding. +## openstatus vs Pingdom, feature by feature + +| Feature | openstatus | Pingdom | +| ------------------------ | ---------------- | -------------------- | +| Free plan | Yes | No (14-day trial) | +| Multi-region | 28 regions | ~100 locations | +| Parallel scheduling | Yes | No (round-robin) | +| Status Page | Yes (built-in) | No | +| Open-source | Yes (AGPL-3.0) | No | +| Self-hostable | Yes | No | +| Monitoring as code | Yes (Terraform) | No | +| CLI | Yes | No | +| MCP server | Yes | No | +| OpenTelemetry exporter | Yes | No | +| Real user monitoring | No | Yes | +| Transaction monitoring | No | Yes | + +Pingdom checks from many locations but cycles through them one at a time. Openstatus checks all selected regions **simultaneously**, so a regional outage is caught on the same run rather than whenever the rotation comes back around. + +## openstatus vs Pingdom pricing + +| | openstatus | Pingdom | +| -------------- | ----------------------- | ----------------------------- | +| Free plan | 1 monitor, 6 regions | None | +| Entry paid | $30/mo (20 monitors) | ~$15/mo (10 uptime checks) | +| Status page | Included | Not available | +| Team members | Unlimited on paid plans | Per-plan limits | +| Open-source | Self-host for $0 | Not available | + +Pingdom's entry price looks low, but RUM and advanced features sit on separate add-ons, and there's no free tier to start on. Openstatus offers flat pricing with a status page and unlimited team members included. + ## The Five Alternatives ### 1. openstatus — best for teams that want monitoring and a status page in one open-source tool @@ -124,7 +159,6 @@ The trade-off is that you're now operating the monitor: it runs from a single lo ## Related Guides -- [Pingdom vs openstatus](/compare/pingdom) — full head-to-head - [What Is Uptime Monitoring?](/guides/what-is-uptime-monitoring) - [Best Hosted Status Page Tools in 2026](/guides/best-hosted-status-page-2026) - [Why Every SaaS Needs a Status Page](/guides/why-every-saas-needs-a-status-page) @@ -135,6 +169,18 @@ Join our [Discord community](https://www.openstatus.dev/discord), check the [doc ## Frequently asked questions +
+ +Yes, for uptime monitoring and status pages. Openstatus monitors from 28 global regions simultaneously, is open-source and self-hostable, includes a built-in public status page, and starts free. Pingdom is a closed-source SolarWinds product with no free tier and no public status page, though it offers real user monitoring (RUM) and transaction checks that openstatus does not. + +
+ +
+ +No. Pingdom discontinued its free tier after the SolarWinds acquisition. The cheapest paid plan starts at around $15/month (billed annually) for 10 uptime checks, with a 14-day trial. Openstatus has a permanent free plan with 1 monitor across 6 regions. + +
+
The most common reasons are: Pingdom discontinued its free tier after the SolarWinds acquisition, so there's no way to start for free; pricing climbs once you add real user monitoring or transaction checks; it's closed-source with no self-hosting; and it has no built-in public status page, so you need a second tool for incident communication. diff --git a/apps/web/src/content/pages/guides/what-is-a-status-page.mdx b/apps/web/src/content/pages/guides/what-is-a-status-page.mdx index 38d37c42..87908927 100644 --- a/apps/web/src/content/pages/guides/what-is-a-status-page.mdx +++ b/apps/web/src/content/pages/guides/what-is-a-status-page.mdx @@ -1,31 +1,64 @@ --- -title: "What Is a Status Page?" -description: "A status page is a public web page that communicates the real-time health of your service - uptime, ongoing incidents, scheduled maintenance, and historical reliability. Here's what they include, why they matter, and how to build one that actually helps users." +title: "Status Page Terminology: Partial Outage, Degraded and Major Outage Explained" +seo: + title: "Status Page Terminology — Partial Outage, Degraded & Major Outage" + description: "What each status on a status page actually means: operational, degraded performance, partial outage, major outage, and under maintenance — with guidance on which to pick during an incident." +description: "What each status on a status page actually means — operational, degraded performance, partial outage, major outage and under maintenance — and how to choose the right one while an incident is running." author: "openstatus" publishedAt: "2026-05-05" -category: "fundamentals" +category: "reference" faq: - - question: "What is a status page?" - answer: "A status page is a public-facing web page that displays the real-time operational health of a service. It shows current uptime, active incidents, scheduled maintenance, and historical reliability metrics. The point is to give users a single, trustworthy place to check whether a problem is on your end or theirs." - - question: "Why do companies need a status page?" - answer: "Without a status page, every outage floods your support inbox with the same question: 'is it down?' A status page deflects that load, builds trust by being transparent during incidents, and signals operational maturity to enterprise buyers who often require one before signing a contract." - - question: "What's the difference between a public and private status page?" - answer: "A public status page is visible to anyone and shows curated, user-facing reliability data. A private status page is gated behind authentication and shows real-time operational metrics to internal teams or specific customers - typically more granular and used for SLO tracking, not customer communication." - - question: "What should a status page include?" - answer: "At minimum: current component statuses (API, dashboard, auth, etc.), active incidents with timestamps and updates, scheduled maintenance windows, and historical uptime for the last 30-90 days. Optional but valuable: subscription options (email, SMS, Slack, RSS), incident postmortems, and per-region status." - - question: "Should my status page be on a separate domain?" - answer: "Yes. Host it on a separate domain or subdomain (e.g., status.yourcompany.com) on independent infrastructure. If your main service is down and your status page is on the same servers, users see nothing - exactly when they need information most." - - question: "How often should I update a status page during an incident?" - answer: "Every 15-30 minutes during an active incident, even if there's nothing new to report. 'Still investigating, next update at 14:30' is more useful than silence. Silence makes users assume you've abandoned them." - - question: "Do small companies need a status page?" - answer: "If you have paying customers, yes. It's not about scale - it's about trust. A simple status page with uptime history and an incident feed signals you take reliability seriously. It takes less than an hour to set up and pays for itself the first time something breaks." - - question: "What's the difference between a status page and a monitoring tool?" - answer: "A monitoring tool checks whether your service is up and alerts your team. A status page communicates that information to users. They're connected - monitoring data often drives status page updates - but they serve different audiences. Monitoring is for engineers; status pages are for customers." + - question: "What does partial outage mean on a status page?" + answer: "A partial outage means part of a component is fully unavailable, or the component is unavailable for a subset of users — one region, one plan tier, or one integration. Requests to the affected part fail outright rather than merely running slowly. It sits between degraded performance, where things still work but badly, and a major outage, where nothing works for anyone." + - question: "What is the difference between degraded performance and a partial outage?" + answer: "Degraded performance means the component still works but not properly — slow responses, intermittent failures that succeed on retry, or one feature unavailable while the rest functions. A partial outage means something is failing outright, just not for everyone or not across the whole component. The test is failure versus slowness." + - question: "What does major outage mean?" + answer: "A major outage means the component is fully unavailable to essentially all users and nothing useful is being served. It is scoped to the component, not the whole service — a major outage on billing does not imply a major outage on your API, which is the reason to break a status page into components in the first place." + - question: "What does operational mean on a status page?" + answer: "Operational means the component is responding correctly and within its normal latency range, with no known issues. It is the default state. Leaving something marked operational while you actively investigate user reports is the fastest way to make a status page untrustworthy — move it to degraded performance while you look." + - question: "Which status should I use during an incident?" + answer: "Work through it in order. Is anything failing outright? If not, and it is only slow or flaky, use degraded performance. If something is failing, does it affect every user and all functionality in that component? If not, use partial outage. If so, use major outage. When two statuses both fit, choose the more severe — under-reporting costs more trust than over-reporting." --- -When something breaks in production, users need to know if the problem is on your end or theirs. Without a single source of truth, they find out by filing duplicate support tickets, posting on social media, or assuming your whole company is down. +Status pages use a small, shared vocabulary — operational, degraded performance, partial outage, major outage, under maintenance — and almost none of it is defined on the page itself. The result is that the same words mean different things at different companies, and readers guess. -That single source of truth is the status page. It exists to deflect support load during outages, build trust through honest communication, and signal to enterprise buyers that you take reliability seriously. +This is the reference for what each status means, and for how to pick the right one while an incident is still running. If you are looking for the broader introduction instead, [what a status page is and why you need one](/status-page) covers that. + +## Status page terminology + +### Operational + +Everything works. The component is responding correctly and within its normal latency range. This is the default state, and it should genuinely mean "no known issues" — marking something operational while you investigate reports is how status pages lose credibility. + +### Degraded performance + +The component works, but not properly. Responses are slow, a subset of requests fail and succeed on retry, or a feature is unavailable while the rest of the component functions. + +This is the most under-used status and the most useful one. Most real incidents are degradations, not outages, and reaching for "operational" or "major outage" because degraded feels ambiguous is what makes a status page feel dishonest. + +### Partial outage + +Part of the component is fully unavailable, or the component is unavailable for a subset of users — one region, one plan tier, one integration. Requests to the affected part fail rather than merely slow down. + +The distinction from degraded is failure, not slowness. The distinction from a major outage is scope: some users or some functionality still work completely. + +### Major outage + +The component is fully unavailable to essentially all users. Nothing useful is being served. + +Use it sparingly and honestly. A major outage on one component does not mean a major outage everywhere — component-level accuracy is the point of having components at all. + +### Under maintenance + +Planned, announced downtime inside a stated window. It reads differently from an outage to anyone looking, which is precisely why scheduled work belongs here rather than showing as an unexplained outage. + +### Choosing between them during an incident + +The practical test, in order: is anything failing outright? If no, and it is merely slow or flaky, that is degraded. If yes, does it affect everyone and everything in that component? If no, that is a partial outage. If yes, that is a major outage. When two statuses both fit, pick the more severe one — under-reporting costs you more trust than over-reporting. + +## Where these statuses appear + +A status page exists to deflect support load during outages, build trust through honest communication, and signal to enterprise buyers that you take reliability seriously. The vocabulary above is how it does that. ## What a Status Page Actually Shows @@ -127,51 +160,33 @@ If you are ready to run one, a [hosted status page](/status-page) gives you comp ## Frequently asked questions -
- -A status page is a public-facing web page that displays the real-time operational health of a service. It shows current uptime, active incidents, scheduled maintenance, and historical reliability metrics. The point is to give users a single, trustworthy place to check whether a problem is on your end or theirs. - -
- -
- -Without a status page, every outage floods your support inbox with the same question: 'is it down?' A status page deflects that load, builds trust by being transparent during incidents, and signals operational maturity to enterprise buyers who often require one before signing a contract. - -
- -
- -A public status page is visible to anyone and shows curated, user-facing reliability data. A private status page is gated behind authentication and shows real-time operational metrics to internal teams or specific customers - typically more granular and used for SLO tracking, not customer communication. - -
- -
+
-At minimum: current component statuses (API, dashboard, auth, etc.), active incidents with timestamps and updates, scheduled maintenance windows, and historical uptime for the last 30-90 days. Optional but valuable: subscription options (email, SMS, Slack, RSS), incident postmortems, and per-region status. +A partial outage means part of a component is fully unavailable, or the component is unavailable for a subset of users — one region, one plan tier, or one integration. Requests to the affected part fail outright rather than merely running slowly. It sits between degraded performance, where things still work but badly, and a major outage, where nothing works for anyone.
-
+
-Yes. Host it on a separate domain or subdomain (e.g., status.yourcompany.com) on independent infrastructure. If your main service is down and your status page is on the same servers, users see nothing - exactly when they need information most. +Degraded performance means the component still works but not properly — slow responses, intermittent failures that succeed on retry, or one feature unavailable while the rest functions. A partial outage means something is failing outright, just not for everyone or not across the whole component. The test is failure versus slowness.
-
+
-Every 15-30 minutes during an active incident, even if there's nothing new to report. 'Still investigating, next update at 14:30' is more useful than silence. Silence makes users assume you've abandoned them. +A major outage means the component is fully unavailable to essentially all users and nothing useful is being served. It is scoped to the component, not the whole service — a major outage on billing does not imply a major outage on your API, which is the reason to break a status page into components in the first place.
-
+
-If you have paying customers, yes. It's not about scale - it's about trust. A simple status page with uptime history and an incident feed signals you take reliability seriously. It takes less than an hour to set up and pays for itself the first time something breaks. +Operational means the component is responding correctly and within its normal latency range, with no known issues. It is the default state. Leaving something marked operational while you actively investigate user reports is the fastest way to make a status page untrustworthy — move it to degraded performance while you look.
-
+
-A monitoring tool checks whether your service is up and alerts your team. A status page communicates that information to users. They're connected - monitoring data often drives status page updates - but they serve different audiences. Monitoring is for engineers; status pages are for customers. +Work through it in order. Is anything failing outright? If not, and it is only slow or flaky, use degraded performance. If something is failing, does it affect every user and all functionality in that component? If not, use partial outage. If so, use major outage. When two statuses both fit, choose the more severe — under-reporting costs more trust than over-reporting.
-- 2.51.2 From 45aeedb65bede8cad2726cdca6330b0083a6c44d Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Wed, 26 Aug 2026 13:51:05 +0200 Subject: [PATCH 162/266] seo: content on landing (#2611) --- apps/web/next-env.d.ts | 3 +- apps/web/src/content/pages/home.mdx | 75 +++++++++++++---------------- 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7..a419cbe4 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/content/pages/home.mdx b/apps/web/src/content/pages/home.mdx index f91afd17..5c5e7ce3 100644 --- a/apps/web/src/content/pages/home.mdx +++ b/apps/web/src/content/pages/home.mdx @@ -1,28 +1,29 @@ --- -title: "Ship your status page before your SOC 2 auditor asks for it" +title: "Open Source Status Page & Uptime Monitoring" +hero: "Ship your status page before your SOC 2 auditor asks for it" publishedAt: "2026-04-07" author: "openstatus" -description: "The open source status page trusted by growing teams. Communicate incidents, prove compliance readiness, and monitor uptime from 28 global regions." +description: "The status page trusted by growing teams. Communicate incidents, prove compliance readiness, and stay audit-ready on your own domain. Hosted for you, or self-host the open source stack." category: "product" faq: - question: "What is openstatus?" - answer: "Openstatus gives you a branded status page and uptime monitoring that's audit-ready out of the box. Set up status.yourcompany.com, connect your monitors, and start communicating incidents — in minutes. It's open-source, self-hostable, and used by teams like Cal.com, WhiteBIT, and Documenso." + answer: "Openstatus gives you a branded status page and uptime monitoring that's audit-ready out of the box. Set up status.yourcompany.com, connect your monitors, and start communicating incidents in minutes. It's open-source, self-hostable, and used by teams like Cal.com, WhiteBIT, and Documenso." - question: "Do I need a status page for SOC 2?" - answer: "SOC 2's CC2.3 criteria requires you to demonstrate incident communication with external parties — but it doesn't prescribe a specific tool. That said, a status page is the fastest, most auditor-friendly way to satisfy that requirement. Every status report on openstatus is timestamped and documented automatically, giving you an audit-ready trail of how you communicated during incidents. Most teams set it up in under 10 minutes. Read more about [SOC 2 status pages](/use-case/compliance)." + answer: "SOC 2's CC2.3 criteria requires you to demonstrate incident communication with external parties, but it doesn't prescribe a specific tool. That said, a status page is the fastest, most auditor-friendly way to satisfy that requirement. Every status report on openstatus is timestamped and documented automatically, giving you an audit-ready trail of how you communicated during incidents. Most teams set it up in under 10 minutes. Read more about [SOC 2 status pages](/use-case/compliance)." - question: "How does openstatus help with SOC 2 compliance?" - answer: "Openstatus gives you a branded status page with incident history, subscriber notifications, and maintenance windows — all the evidence an auditor needs to verify your incident communication process. Every status report and update is timestamped and documented automatically." + answer: "Openstatus gives you everything an auditor needs to verify your incident communication process: a branded status page with a custom domain, incident history with timestamped status reports, subscriber notifications so stakeholders are proactively informed, maintenance windows for planned changes, and password protection for internal or client-specific pages. You can be SOC 2-ready in minutes, not weeks." - question: "What does the free plan include?" answer: "The free plan includes one monitor, one status page with three page components, and a minimum check interval of 10 minutes. No credit card is required, and you can upgrade or cancel at any time." - question: "Who is behind openstatus?" - answer: "Openstatus is built by Thibault and Max, a bootstrapped two-person team. We're profitable and self-funded — we'll be here when your next audit comes around." + answer: "Openstatus is built by Thibault and Max, a bootstrapped two-person team building in public. We're profitable and self-funded, and we'll be here when your next audit comes around." - question: "What regions does openstatus monitor from?" answer: "Openstatus monitors from 28 regions worldwide: Europe (Amsterdam, Stockholm, Paris, Frankfurt, London), North America (Dallas, New Jersey, Los Angeles, San Jose, Chicago, Toronto), South America (São Paulo), Asia (Mumbai, Tokyo, Singapore), Africa (Johannesburg), and Oceania (Sydney)." - question: "Do you offer annual billing?" - answer: "Yes. All paid plans are available with monthly or annual billing. Choose annual billing to get 2 months free — that's Starter at $300/year ($25/mo) and Pro at $1,000/year (~$83/mo). You can switch between billing cycles at any time." + answer: "Yes. All paid plans are available with monthly or annual billing. Choose annual billing to get 2 months free: that's Starter at $300/year ($25/mo) and Pro at $1,000/year (~$83/mo). You can switch between billing cycles at any time." - question: "Can I self-host openstatus?" answer: "Yes. Openstatus is fully open source and can be self-hosted using its 8.5MB Docker image. You can also deploy private monitoring locations behind your firewall for internal services. The source code is available on GitHub." - question: "Does openstatus have an API?" - answer: "Yes. Openstatus exposes a typed JSON-over-HTTP API powered by ConnectRPC, with a published OpenAPI spec at api.openstatus.dev/openapi. Every action in the dashboard — managing monitors, status pages, status reports, maintenance windows, and notification channels — is reachable from the API. The same API key works across the API, CLI, Node SDK, Terraform provider, and MCP server, and every mutation lands in the audit log." + answer: "Yes. Openstatus exposes a typed JSON-over-HTTP API powered by ConnectRPC, with a published OpenAPI spec at api.openstatus.dev/openapi. Every action in the dashboard (managing monitors, status pages, status reports, maintenance windows, and notification channels) is reachable from the API. The same API key works across the API, CLI, Node SDK, Terraform provider, and MCP server, and every mutation lands in the audit log." - question: "Can I manage openstatus from Claude or ChatGPT?" answer: "Yes. Openstatus ships a remote MCP (Model Context Protocol) server at api.openstatus.dev/mcp that connects Claude Desktop, ChatGPT, Cursor, and any MCP-compatible client to your workspace. The assistant can list status pages, create and resolve status reports, and schedule maintenance windows. Every publishing tool requires the assistant to explicitly choose whether to notify subscribers, and every call is recorded in the audit log." - question: "Can I manage monitors as code?" @@ -38,7 +39,7 @@ faq: />
-Free to start. Paid plans from $30/mo. +Free to start. Paid plans from $30/mo. Open source and self-hostable. statuspage @@ -48,38 +49,30 @@ Free to start. Paid plans from $30/mo. ## The status page that closes enterprise deals -A status page helps you communicate incidents more effectively. It adds **transparency**, so users aren't left guessing. It enables **proactive communication**, giving updates without users needing to ask. And it shows **reliability**, not just in uptime but in how you handle downtime and keep people informed. +Security questionnaires ask how you notify customers during an incident. openstatus answers that in one link: a branded page on your own domain, with timestamped incident history an auditor can read without asking you for screenshots. -Make it yours with [themes from our Theme Store](https://themes.openstatus.dev), custom domains, and branding. Share publicly or password protect for internal teams. Keep everyone in the loop with status reports, maintenance windows, and subscriptions. +Themes, custom domains, public or password-protected access, status reports, maintenance windows and subscriptions: see it all on [status pages](/status-page). -- **Customization** with our Theme Store -- Public or **password protected** pages -- **Custom domains** -- **Status reports** and **maintenance windows** -- **Subscription channels**: email, RSS/Atom, JSON +## Monitor from 28 regions: know before your customers do -Read more [about status pages](/status-page). - -## Monitor from 28 regions — know before your customers do - -Monitor your endpoints from 28 regions across multiple clouds. Get alerted on Slack, Discord, PagerDuty, or email the moment something breaks. Your status page updates automatically — no manual work during incidents. +Monitor your endpoints from 28 regions across multiple clouds. Get alerted on Slack, Discord, PagerDuty, or email the moment something breaks. Your status page updates automatically, with no manual work during incidents. Read more about [uptime monitoring](/uptime-monitoring). -## Managing openstatus — for humans and agents +## Managing openstatus for humans and agents Every action in the dashboard is reachable programmatically. One API key, four ways in: -- **[CLI](/tooling/cli)** — manage from your terminal -- **[API](/tooling/api)** — typed HTTP endpoints with an [OpenAPI spec](https://api.openstatus.dev/openapi) -- **[MCP server](/tooling/mcp-server)** — let Claude, ChatGPT, or Cursor run your monitoring -- **[Terraform provider](/tooling/terraform)** — version monitors as HCL +- **[CLI](/tooling/cli)**: manage from your terminal +- **[API](/tooling/api)**: typed HTTP endpoints with an [OpenAPI spec](https://api.openstatus.dev/openapi) +- **[MCP server](/tooling/mcp-server)**: let Claude, ChatGPT, or Cursor run your monitoring +- **[Terraform provider](/tooling/terraform)**: version monitors as HCL Read more about our [tooling](/tooling). ---- +## Try it before you sign up -Check your website's latency +Check any URL's response time from every one of our 28 regions. No account, no credit card. It's the same probe network that powers your monitors. Global Speed Checker @@ -111,7 +104,7 @@ Check your website's latency
-Openstatus gives you a branded status page and uptime monitoring that's audit-ready out of the box. Set up status.yourcompany.com, connect your monitors, and start communicating incidents — in minutes. +Openstatus gives you a branded status page and uptime monitoring that's audit-ready out of the box. Set up status.yourcompany.com, connect your monitors, and start communicating incidents in minutes. It's open-source, self-hostable, and used by teams like [Cal.com](https://status.cal.com), [WhiteBIT](https://status.whitebit.com), and [Documenso](https://status.documenso.com). Available as a [managed SaaS](https://app.openstatus.dev) or for [self-hosting](https://github.com/openstatushq/openstatus). @@ -119,9 +112,9 @@ It's open-source, self-hostable, and used by teams like [Cal.com](https://status
-SOC 2's CC2.3 criteria requires you to demonstrate incident communication with external parties — but it doesn't prescribe a specific tool. That said, a status page is the **fastest, most auditor-friendly** way to satisfy that requirement. +SOC 2's CC2.3 criteria requires you to demonstrate incident communication with external parties, but it doesn't prescribe a specific tool. That said, a status page is the **fastest, most auditor-friendly** way to satisfy that requirement. -Every status report on openstatus is **timestamped** and documented automatically, giving you an audit-ready trail of how you communicated during incidents. Most teams set it up in under 2 minutes. +Every status report on openstatus is **timestamped** and documented automatically, giving you an audit-ready trail of how you communicated during incidents. Most teams set it up in under 10 minutes. Read more about [SOC 2 status pages](/use-case/compliance). @@ -145,15 +138,15 @@ You can be SOC 2-ready in minutes, not weeks. The free plan includes **one monitor**, **one status page** (with three page components), and a minimum check interval of `10m`. Check the pricing table for a full comparison. -No credit card required — upgrade or cancel at any time. +No credit card required. Upgrade or cancel at any time.
-Openstatus is built by [Thibault](https://bsky.app/profile/thibaultleouay.dev) and [Max](https://x.com/mxkaske) — a bootstrapped two-person team building in public. +Openstatus is built by [Thibault](https://bsky.app/profile/thibaultleouay.dev) and [Max](https://x.com/mxkaske), a bootstrapped two-person team building in public. -We're profitable and self-funded — we'll be here when your next audit comes around. +We're profitable and self-funded, and we'll be here when your next audit comes around. Read more on [our about page](/about). @@ -193,13 +186,13 @@ Johannesburg 🇿🇦 Sydney 🇦🇺 -*Need a specific region?* Feel free to [contact us](mailto:ping@openstatus.dev) or join our [Discord](https://discord.gg/openstatus) — we're always looking to expand our coverage! +*Need a specific region?* Feel free to [contact us](mailto:ping@openstatus.dev) or join our [Discord](https://discord.gg/openstatus). We're always looking to expand our coverage!
-Yes. All paid plans are available with **monthly** or **annual** billing. Choose annual billing to get **2 months free** — that's Starter at $300/year ($25/mo) and Pro at $1,000/year (~$83/mo). +Yes. All paid plans are available with **monthly** or **annual** billing. Choose annual billing to get **2 months free**: that's Starter at $300/year ($25/mo) and Pro at $1,000/year (~$83/mo). You can switch between billing cycles at any time. Check the [pricing page](/pricing) for a full comparison. @@ -220,7 +213,7 @@ The source code is available on [GitHub](https://openstatus.dev/github). Yes. Openstatus exposes a typed **JSON-over-HTTP API** powered by [ConnectRPC](/blog/migrating-from-zod-openapi-to-connectrpc), with a published [OpenAPI spec](https://api.openstatus.dev/openapi). -Every action in the dashboard — managing monitors, status pages, status reports, maintenance windows, and notification channels — is reachable from the API. The same API key works across the [API](/tooling/api), [CLI](/tooling/cli), Node SDK, [Terraform provider](/tooling/terraform), and [MCP server](/tooling/mcp-server), and every mutation lands in the audit log. +Every action in the dashboard (managing monitors, status pages, status reports, maintenance windows, and notification channels) is reachable from the API. The same API key works across the [API](/tooling/api), [CLI](/tooling/cli), Node SDK, [Terraform provider](/tooling/terraform), and [MCP server](/tooling/mcp-server), and every mutation lands in the audit log.
@@ -228,7 +221,7 @@ Every action in the dashboard — managing monitors, status pages, status report Yes. Openstatus ships a remote [MCP server](/tooling/mcp-server) at `api.openstatus.dev/mcp` that connects **Claude Desktop**, **ChatGPT**, **Cursor**, and any [Model Context Protocol](https://modelcontextprotocol.io) client to your workspace. -The assistant can list status pages, create and resolve status reports, and schedule maintenance windows. Every publishing tool requires the assistant to explicitly choose whether to notify subscribers — models can't quietly fan out an alert — and every call is recorded in the audit log under `actor_type = 'mcp'`. +The assistant can list status pages, create and resolve status reports, and schedule maintenance windows. Every publishing tool requires the assistant to explicitly choose whether to notify subscribers (models can't quietly fan out an alert), and every call is recorded in the audit log under `actor_type = 'mcp'`.
@@ -236,9 +229,9 @@ The assistant can list status pages, create and resolve status reports, and sche Yes. Openstatus offers two ways to treat monitoring as code: -- **YAML + [CLI](/tooling/cli)** — keep your monitor configuration in your repo and sync with `openstatus monitors apply`. Lighter to adopt, ships with a GitHub Action for CI/CD. -- **[Terraform provider](/tooling/terraform)** — for teams already managing infrastructure in HCL. Same plan-and-apply lifecycle as the rest of your stack. +- **YAML + [CLI](/tooling/cli)**: keep your monitor configuration in your repo and sync with `openstatus monitors apply`. Lighter to adopt, ships with a GitHub Action for CI/CD. +- **[Terraform provider](/tooling/terraform)**: for teams already managing infrastructure in HCL. Same plan-and-apply lifecycle as the rest of your stack. -Already have monitors in the dashboard? Run `openstatus terraform generate` from the CLI to bootstrap an HCL file with cross-references and import blocks — no hand-writing required. +Already have monitors in the dashboard? Run `openstatus terraform generate` from the CLI to bootstrap an HCL file with cross-references and import blocks. No hand-writing required.
-- 2.51.2 From cf1e21b4992d80649af39497652abf04ba659621 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Wed, 26 Aug 2026 14:14:11 +0200 Subject: [PATCH 163/266] seo: modified at jsonld (#2612) --- apps/web/src/content/pages/compare/betterstack.mdx | 1 + apps/web/src/content/pages/compare/checkly.mdx | 1 + apps/web/src/content/pages/compare/datadog.mdx | 1 + apps/web/src/content/pages/compare/incidentio.mdx | 1 + apps/web/src/content/pages/compare/statusio.mdx | 1 + apps/web/src/content/pages/compare/uptime-robot.mdx | 1 + .../guides/best-opensource-status-page-2026.mdx | 1 + .../pages/guides/hosted-uptime-kuma-alternative.mdx | 1 + ...penstatus-compares-to-other-status-page-tools.mdx | 1 + .../pages/guides/migrate-from-uptime-robot.mdx | 1 + .../top-five-atlassian-statuspage-alternatives.mdx | 1 + .../pages/guides/top-five-instatus-alternatives.mdx | 1 + .../pages/guides/top-five-pingdom-alternatives.mdx | 1 + .../content/pages/guides/what-is-a-status-page.mdx | 1 + .../pages/guides/what-is-uptime-monitoring.mdx | 1 + apps/web/src/content/pages/home.mdx | 1 + apps/web/src/content/pages/product/status-page.mdx | 1 + .../src/content/pages/product/uptime-monitoring.mdx | 1 + apps/web/src/content/utils/schema.ts | 3 +++ apps/web/src/lib/metadata/shared-metadata.ts | 4 +++- apps/web/src/lib/metadata/structured-data.ts | 12 ++++++++++-- 21 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/web/src/content/pages/compare/betterstack.mdx b/apps/web/src/content/pages/compare/betterstack.mdx index fbcaa455..0575d14a 100644 --- a/apps/web/src/content/pages/compare/betterstack.mdx +++ b/apps/web/src/content/pages/compare/betterstack.mdx @@ -1,6 +1,7 @@ --- title: "BetterStack vs openstatus" publishedAt: "2025-11-10" +updatedAt: "2026-08-26" author: "openstatus" description: "openstatus vs BetterStack compared side-by-side. 28 regions (parallel) vs 4 (round-robin), open-source vs closed-source, and transparent pricing with no add-on fees for status pages or subscribers." category: "company" diff --git a/apps/web/src/content/pages/compare/checkly.mdx b/apps/web/src/content/pages/compare/checkly.mdx index 1e648c50..52602bb9 100644 --- a/apps/web/src/content/pages/compare/checkly.mdx +++ b/apps/web/src/content/pages/compare/checkly.mdx @@ -1,6 +1,7 @@ --- title: "Checkly vs openstatus" publishedAt: "2025-11-10" +updatedAt: "2026-08-26" author: "openstatus" description: "Looking for a Checkly alternative focused on uptime monitoring and status pages? openstatus checks from 28 regions, includes public status pages, and is open source — starting free." category: "company" diff --git a/apps/web/src/content/pages/compare/datadog.mdx b/apps/web/src/content/pages/compare/datadog.mdx index 4edf82f0..c5d8bc6a 100644 --- a/apps/web/src/content/pages/compare/datadog.mdx +++ b/apps/web/src/content/pages/compare/datadog.mdx @@ -1,6 +1,7 @@ --- title: "Datadog Synthetics vs openstatus" publishedAt: "2026-06-19" +updatedAt: "2026-08-26" author: "openstatus" description: "Looking for a Datadog Synthetics alternative without the enterprise bill? openstatus does uptime and API monitoring from 28 regions with a built-in status page, open source, from $30/month — flat, no per-run fees." category: "company" diff --git a/apps/web/src/content/pages/compare/incidentio.mdx b/apps/web/src/content/pages/compare/incidentio.mdx index fb8af301..2e80e1d3 100644 --- a/apps/web/src/content/pages/compare/incidentio.mdx +++ b/apps/web/src/content/pages/compare/incidentio.mdx @@ -1,6 +1,7 @@ --- title: "Incident.io vs openstatus" publishedAt: "2026-02-21" +updatedAt: "2026-08-26" author: "openstatus" description: "Looking for an incident.io alternative that combines monitoring with status pages? openstatus detects outages from 28 regions and communicates them to users — open source and starting free." category: "company" diff --git a/apps/web/src/content/pages/compare/statusio.mdx b/apps/web/src/content/pages/compare/statusio.mdx index 70fb051c..a9510831 100644 --- a/apps/web/src/content/pages/compare/statusio.mdx +++ b/apps/web/src/content/pages/compare/statusio.mdx @@ -1,6 +1,7 @@ --- title: "Status.io vs openstatus" publishedAt: "2026-02-21" +updatedAt: "2026-08-26" author: "openstatus" description: "Looking for a Status.io alternative with built-in monitoring? openstatus includes 28-region uptime checks, starts at $30/mo with unlimited team members, and is open source and self-hostable." category: "company" diff --git a/apps/web/src/content/pages/compare/uptime-robot.mdx b/apps/web/src/content/pages/compare/uptime-robot.mdx index 734df904..1cf7e335 100644 --- a/apps/web/src/content/pages/compare/uptime-robot.mdx +++ b/apps/web/src/content/pages/compare/uptime-robot.mdx @@ -1,6 +1,7 @@ --- title: "UptimeRobot vs openstatus" publishedAt: "2025-11-10" +updatedAt: "2026-08-26" author: "openstatus" description: "Looking for an UptimeRobot alternative that checks from 28 regions at once? openstatus is open source, includes status pages and CI/CD integration, and supports OpenTelemetry export." category: "company" diff --git a/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx b/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx index a6f5110c..37d03cdf 100644 --- a/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx +++ b/apps/web/src/content/pages/guides/best-opensource-status-page-2026.mdx @@ -6,6 +6,7 @@ seo: description: "The self-hosted status page tools worth running on your own infrastructure in 2026 — compared on maintenance, built-in monitoring, and the real operational cost of hosting them yourself." author: "openstatus" publishedAt: "2026-01-19" +updatedAt: "2026-08-26" category: "alternative" faq: - question: "What is the best self-hosted status page tool in 2026?" diff --git a/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx b/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx index 640c85c8..66ee0190 100644 --- a/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx +++ b/apps/web/src/content/pages/guides/hosted-uptime-kuma-alternative.mdx @@ -3,6 +3,7 @@ title: "A Hosted Uptime Kuma Alternative" description: "Love Uptime Kuma but tired of running it yourself? openstatus is the open-source, managed, multi-region alternative — unlimited monitors, a status page, and no VPS to babysit. Self-host it too, if you want." author: "openstatus" publishedAt: "2026-06-19" +updatedAt: "2026-08-26" category: "alternative" faq: - question: "Is openstatus a hosted version of Uptime Kuma?" diff --git a/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx b/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx index cfc57b92..ee297e1b 100644 --- a/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx +++ b/apps/web/src/content/pages/guides/how-openstatus-compares-to-other-status-page-tools.mdx @@ -6,6 +6,7 @@ seo: description: "What a status page actually costs across the major providers — list prices, per-seat charges, private-page add-ons, and what you pay separately for monitoring." author: "openstatus" publishedAt: "2026-06-09" +updatedAt: "2026-08-26" category: "pricing" faq: - question: "How much does a status page cost?" diff --git a/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx b/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx index 3cd3f416..7fd85ddb 100644 --- a/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx +++ b/apps/web/src/content/pages/guides/migrate-from-uptime-robot.mdx @@ -3,6 +3,7 @@ title: "How to Migrate from UptimeRobot to openstatus" description: "A step-by-step guide to moving from UptimeRobot to openstatus — exporting your monitors, recreating them in the UI or as code, wiring up alerts, and publishing a status page. Most teams finish in under an hour." author: "openstatus" publishedAt: "2026-06-19" +updatedAt: "2026-08-26" category: "alternative" howto: totalTime: "PT1H" diff --git a/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx b/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx index 31ac109d..5ac95581 100644 --- a/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx +++ b/apps/web/src/content/pages/guides/top-five-atlassian-statuspage-alternatives.mdx @@ -4,6 +4,7 @@ image: "/assets/posts/top-five-atlassian-statuspage-alternatives/hero.png" description: "Explore the best alternatives to Atlassian Statuspage including OpenStatus, Status-io, Datadog Status Page, Instatus, and Betterstack." author: "openstatus" publishedAt: "2026-06-09" +updatedAt: "2026-08-26" category: "alternative" faq: - question: "Is openstatus a good Atlassian Statuspage alternative?" diff --git a/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx b/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx index d9d4d173..b9015218 100644 --- a/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx +++ b/apps/web/src/content/pages/guides/top-five-instatus-alternatives.mdx @@ -4,6 +4,7 @@ image: "/assets/posts/top-five-instatus-alternatives/hero.png" description: "The honest guide to Instatus alternatives in 2026 — including openstatus, Atlassian Statuspage, Status.io, Datadog, and Betterstack — with pricing, monitoring depth, and who each is actually for." author: "openstatus" publishedAt: "2026-06-09" +updatedAt: "2026-08-26" category: "alternative" faq: - question: "Is openstatus a good Instatus alternative?" diff --git a/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx b/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx index 54a618bc..2f986378 100644 --- a/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx +++ b/apps/web/src/content/pages/guides/top-five-pingdom-alternatives.mdx @@ -3,6 +3,7 @@ title: "Top Five Pingdom Alternatives in 2026" description: "The honest guide to Pingdom alternatives in 2026 — including openstatus, UptimeRobot, Better Stack, Checkly, and Uptime Kuma — with pricing, monitoring depth, free tiers, and who each is actually for." author: "openstatus" publishedAt: "2026-06-19" +updatedAt: "2026-08-26" category: "alternative" faq: - question: "Is openstatus a good Pingdom alternative?" diff --git a/apps/web/src/content/pages/guides/what-is-a-status-page.mdx b/apps/web/src/content/pages/guides/what-is-a-status-page.mdx index 87908927..a0a0d172 100644 --- a/apps/web/src/content/pages/guides/what-is-a-status-page.mdx +++ b/apps/web/src/content/pages/guides/what-is-a-status-page.mdx @@ -6,6 +6,7 @@ seo: description: "What each status on a status page actually means — operational, degraded performance, partial outage, major outage and under maintenance — and how to choose the right one while an incident is running." author: "openstatus" publishedAt: "2026-05-05" +updatedAt: "2026-08-26" category: "reference" faq: - question: "What does partial outage mean on a status page?" diff --git a/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx b/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx index 042fa52c..af351203 100644 --- a/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx +++ b/apps/web/src/content/pages/guides/what-is-uptime-monitoring.mdx @@ -3,6 +3,7 @@ title: "What Is Uptime Monitoring?" description: "Uptime monitoring is the practice of continuously checking whether your service is reachable and responding correctly from outside your own network. Here's how it works, how uptime percentages are actually calculated, and what to monitor." author: "openstatus" publishedAt: "2026-05-06" +updatedAt: "2026-08-26" category: "fundamentals" faq: - question: "What is uptime monitoring?" diff --git a/apps/web/src/content/pages/home.mdx b/apps/web/src/content/pages/home.mdx index 5c5e7ce3..1aedb34a 100644 --- a/apps/web/src/content/pages/home.mdx +++ b/apps/web/src/content/pages/home.mdx @@ -2,6 +2,7 @@ title: "Open Source Status Page & Uptime Monitoring" hero: "Ship your status page before your SOC 2 auditor asks for it" publishedAt: "2026-04-07" +updatedAt: "2026-08-26" author: "openstatus" description: "The status page trusted by growing teams. Communicate incidents, prove compliance readiness, and stay audit-ready on your own domain. Hosted for you, or self-host the open source stack." category: "product" diff --git a/apps/web/src/content/pages/product/status-page.mdx b/apps/web/src/content/pages/product/status-page.mdx index 0f75f90c..e10e5963 100644 --- a/apps/web/src/content/pages/product/status-page.mdx +++ b/apps/web/src/content/pages/product/status-page.mdx @@ -5,6 +5,7 @@ seo: description: "Launch a branded status page on your own domain in minutes. Public or private, with password protection, IP allowlists, maintenance windows and RSS alerts." hero: "Status pages your users actually trust" publishedAt: "2025-11-10" +updatedAt: "2026-08-26" author: "Maximilian Kaske" description: "Create a public or private status page in minutes. Show real-time uptime, incidents and maintenance on a fully branded page with a custom domain." category: "Product" diff --git a/apps/web/src/content/pages/product/uptime-monitoring.mdx b/apps/web/src/content/pages/product/uptime-monitoring.mdx index 2b76f66f..e268b0a3 100644 --- a/apps/web/src/content/pages/product/uptime-monitoring.mdx +++ b/apps/web/src/content/pages/product/uptime-monitoring.mdx @@ -1,6 +1,7 @@ --- title: "Uptime Monitoring" publishedAt: "2025-11-10" +updatedAt: "2026-08-26" seo: title: "API & Service Uptime Monitoring for Developers" description: "Monitor API and service uptime from 28 regions. Instant alerts via Slack, Discord, PagerDuty and email. Monitoring as code with YAML, CLI and Terraform." diff --git a/apps/web/src/content/utils/schema.ts b/apps/web/src/content/utils/schema.ts index 88502a93..87e6275a 100644 --- a/apps/web/src/content/utils/schema.ts +++ b/apps/web/src/content/utils/schema.ts @@ -36,6 +36,9 @@ export const metadataSchema = z.object({ // from the canonical `title` used for SEO, breadcrumbs, search, and nav. hero: z.string().optional(), publishedAt: z.coerce.date(), + // Last meaningful content edit. Feeds og:article:modified_time and JSON-LD + // dateModified; falls back to `publishedAt` when absent. + updatedAt: z.coerce.date().optional(), description: z.string(), category: z.string(), author: z.string(), diff --git a/apps/web/src/lib/metadata/shared-metadata.ts b/apps/web/src/lib/metadata/shared-metadata.ts index b505c29e..3a04ffdd 100644 --- a/apps/web/src/lib/metadata/shared-metadata.ts +++ b/apps/web/src/lib/metadata/shared-metadata.ts @@ -83,7 +83,8 @@ export const getSocialMetadata = (args: { export const getPageMetadata = (page: MDXData, basePath?: string): Metadata => { const { slug, metadata } = page; - const { title, description, category, publishedAt, seo } = metadata; + const { title, description, category, publishedAt, updatedAt, seo } = + metadata; const url = basePath ? `${BASE_URL}/${basePath}/${slug}` @@ -112,6 +113,7 @@ export const getPageMetadata = (page: MDXData, basePath?: string): Metadata => { ...openGraph, type: "article", publishedTime: publishedAt.toISOString(), + modifiedTime: (updatedAt ?? publishedAt).toISOString(), }, twitter, }; diff --git a/apps/web/src/lib/metadata/structured-data.ts b/apps/web/src/lib/metadata/structured-data.ts index d7f7fb38..e00d6d0d 100644 --- a/apps/web/src/lib/metadata/structured-data.ts +++ b/apps/web/src/lib/metadata/structured-data.ts @@ -38,6 +38,10 @@ export const getJsonLDWebPage = ( "@type": "WebPage", name: `${input.metadata.title} | openstatus`, headline: input.metadata.description, + datePublished: input.metadata.publishedAt.toISOString(), + dateModified: ( + input.metadata.updatedAt ?? input.metadata.publishedAt + ).toISOString(), mainEntityOfPage: { "@type": "WebPage", "@id": BASE_URL, @@ -66,7 +70,9 @@ export const getJsonLDBlogPosting = ( "@type": "BlogPosting", headline: post.metadata.title, datePublished: post.metadata.publishedAt.toISOString(), - dateModified: post.metadata.publishedAt.toISOString(), + dateModified: ( + post.metadata.updatedAt ?? post.metadata.publishedAt + ).toISOString(), description: post.metadata.description, image: post.metadata.image ? `${BASE_URL}${post.metadata.image}` @@ -92,7 +98,9 @@ export const getJsonLDTechArticle = ( headline: doc.metadata.title, description: doc.metadata.description, datePublished: doc.metadata.publishedAt.toISOString(), - dateModified: doc.metadata.publishedAt.toISOString(), + dateModified: ( + doc.metadata.updatedAt ?? doc.metadata.publishedAt + ).toISOString(), url: `${BASE_URL}${doc.href}`, author: { "@type": "Organization", -- 2.51.2 From 2f05e0ca42a0300ab879a83370e6ac5ec223af41 Mon Sep 17 00:00:00 2001 From: Maria Zuheros Date: Thu, 27 Aug 2026 09:45:58 +0100 Subject: [PATCH 164/266] test(notifications): cover the formatTimestamp helper (#2499) --- .../base/src/utils/timestamp.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/notifications/base/src/utils/timestamp.test.ts diff --git a/packages/notifications/base/src/utils/timestamp.test.ts b/packages/notifications/base/src/utils/timestamp.test.ts new file mode 100644 index 00000000..08a0793f --- /dev/null +++ b/packages/notifications/base/src/utils/timestamp.test.ts @@ -0,0 +1,32 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { formatTimestamp } from "./timestamp"; + +describe("formatTimestamp", () => { + it("formats a valid epoch timestamp as an ISO string", () => { + expect(formatTimestamp(1700000000000)).toBe("2023-11-14T22:13:20.000Z"); + }); + + it("formats timestamps before the unix epoch", () => { + expect(formatTimestamp(-1000)).toBe("1969-12-31T23:59:59.000Z"); + }); + + it("returns Unknown for 0, since it is falsy", () => { + expect(formatTimestamp(0)).toBe("Unknown"); + }); + + it("returns Unknown for NaN", () => { + expect(formatTimestamp(Number.NaN)).toBe("Unknown"); + }); + + it("returns Unknown for non finite values", () => { + expect(formatTimestamp(Number.POSITIVE_INFINITY)).toBe("Unknown"); + expect(formatTimestamp(Number.NEGATIVE_INFINITY)).toBe("Unknown"); + }); + + it("returns Unknown when the timestamp is beyond the maximum valid date", () => { + // The largest date JS can represent is 8.64e15 ms; one past it is invalid. + expect(formatTimestamp(8.64e15 + 1)).toBe("Unknown"); + }); +}); -- 2.51.2 From bb3f2ee9e5bfdf4c6fcb626ac8cff08d3c033b81 Mon Sep 17 00:00:00 2001 From: Harsh Kumar <9u.harsh@gmail.com> Date: Thu, 27 Aug 2026 17:02:43 +0530 Subject: [PATCH 165/266] fix(validation): reject whitespace-only names and titles (#2606) --- .../dashboard/src/components/forms/monitor/form-general.tsx | 2 +- .../src/components/forms/status-page/form-general.tsx | 2 +- packages/db/src/schema/monitors/validation.ts | 1 + packages/db/src/schema/pages/validation.ts | 1 + packages/services/src/monitor/schemas.ts | 6 +++--- packages/services/src/page/schemas.ts | 4 ++-- 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/src/components/forms/monitor/form-general.tsx b/apps/dashboard/src/components/forms/monitor/form-general.tsx index 5233f2d6..c13353f3 100644 --- a/apps/dashboard/src/components/forms/monitor/form-general.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-general.tsx @@ -77,7 +77,7 @@ const HTTP_ASSERTION_TYPES = ["status", "header", "textBody"] as const; const DNS_ASSERTION_TYPES = dnsRecords; const schema = z.object({ - name: z.string().min(1, "Name is required"), + name: z.string().trim().min(1, "Name is required"), type: z.enum(TYPES), method: z.enum(monitorMethods), url: z.string().min(1, "URL is required"), diff --git a/apps/dashboard/src/components/forms/status-page/form-general.tsx b/apps/dashboard/src/components/forms/status-page/form-general.tsx index 11c18fc2..bc60ceb8 100644 --- a/apps/dashboard/src/components/forms/status-page/form-general.tsx +++ b/apps/dashboard/src/components/forms/status-page/form-general.tsx @@ -57,7 +57,7 @@ function formatSlug(title: string) { } const schema = z.object({ - title: z.string().min(1, "Title is required"), + title: z.string().trim().min(1, "Title is required"), slug: z .string() .min(3, "Slug is required") diff --git a/packages/db/src/schema/monitors/validation.ts b/packages/db/src/schema/monitors/validation.ts index a6774e94..36736ef1 100644 --- a/packages/db/src/schema/monitors/validation.ts +++ b/packages/db/src/schema/monitors/validation.ts @@ -63,6 +63,7 @@ const headersSchema = z export const insertMonitorSchema = createInsertSchema(monitor, { name: z .string() + .trim() .min(1, "Name must be at least 1 character long") .max(255, "Name must be at most 255 characters long"), periodicity: monitorPeriodicitySchema.prefault("10m"), diff --git a/packages/db/src/schema/pages/validation.ts b/packages/db/src/schema/pages/validation.ts index 0aa0511e..d424172b 100644 --- a/packages/db/src/schema/pages/validation.ts +++ b/packages/db/src/schema/pages/validation.ts @@ -68,6 +68,7 @@ export const customThemeWriteSchema = customThemeSchema }); export const insertPageSchema = createInsertSchema(page, { + title: z.string().trim().min(1, "Title must be at least 1 character long"), customDomain: customDomainSchema.prefault(""), accessType: z.enum(pageAccessTypes).prefault("public"), icon: z.string().optional(), diff --git a/packages/services/src/monitor/schemas.ts b/packages/services/src/monitor/schemas.ts index c95f7bd0..0ccf5eb1 100644 --- a/packages/services/src/monitor/schemas.ts +++ b/packages/services/src/monitor/schemas.ts @@ -37,7 +37,7 @@ const apiTimeoutMs = z.coerce.number().gte(0).lte(120_000); * + `30m`/`1m` respectively). */ export const CreateMonitorInput = z.object({ - name: z.string().min(1), + name: z.string().trim().min(1), jobType: z.enum(monitorJobTypes), url: z.string(), method: z.enum(monitorMethods), @@ -72,7 +72,7 @@ export type CreateMonitorInput = z.infer; */ export const UpdateMonitorConfigInput = z.object({ id: z.number().int(), - name: z.string().min(1).optional(), + name: z.string().trim().min(1).optional(), url: z.string().optional(), method: z.enum(monitorMethods).optional(), headers: z.array(headerPair).optional(), @@ -95,7 +95,7 @@ export type UpdateMonitorConfigInput = z.infer; /** Update the "general" monitor payload — name / endpoint / headers / assertions. */ export const UpdateMonitorGeneralInput = z.object({ id: z.number().int(), - name: z.string().min(1), + name: z.string().trim().min(1), jobType: z.enum(monitorJobTypes), url: z.string(), method: z.enum(monitorMethods), diff --git a/packages/services/src/page/schemas.ts b/packages/services/src/page/schemas.ts index 6016629a..30ee8c86 100644 --- a/packages/services/src/page/schemas.ts +++ b/packages/services/src/page/schemas.ts @@ -49,7 +49,7 @@ export type CreatePageInput = { /** Minimal create — the onboarding / `new` path with no monitors. */ export const NewPageInput = z.object({ - title: z.string(), + title: z.string().trim().min(1), // Canonical `slugSchema` from db validation — regex + min(3). // Plain `z.string().toLowerCase()` here let malformed slugs through // that `insertPageSchema` would reject, so `create` and `new` had @@ -81,7 +81,7 @@ export type GetSlugAvailableInput = z.infer; export const UpdatePageGeneralInput = z.object({ id: z.number().int(), - title: z.string(), + title: z.string().trim().min(1), slug: slugSchema, description: z.string().nullish(), icon: z.string().nullish(), -- 2.51.2 From 8d2a72553acaa694a19b303a40c2d8fc0f5a0081 Mon Sep 17 00:00:00 2001 From: "polylane[bot]" <277585245+polylane[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:28:41 +0200 Subject: [PATCH 166/266] =?UTF-8?q?Fix=20Build=20error=20=E2=80=93=20ENOEN?= =?UTF-8?q?T=20for=20atlassian.png=20in=20public=20assets=20directory=20(#?= =?UTF-8?q?2593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): remove broken atlassian.png reference from guide Co-authored-by: polylane[bot] <277585245+polylane[bot]@users.noreply.github.com> * fix: atlassian image --------- Co-authored-by: polylane[bot] <277585245+polylane[bot]@users.noreply.github.com> Co-authored-by: Maximilian Kaske --- .../atlassian.png | Bin 0 -> 400216 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/web/public/assets/posts/top-five-instatus-alternatives/atlassian.png diff --git a/apps/web/public/assets/posts/top-five-instatus-alternatives/atlassian.png b/apps/web/public/assets/posts/top-five-instatus-alternatives/atlassian.png new file mode 100644 index 0000000000000000000000000000000000000000..336f10e71e16cb3e422120ef35cf62da324991f3 GIT binary patch literal 400216 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

e1ysT0oZ1g(Jga+W`&%=_}; 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 F0RSLpc<%rJ literal 0 HcmV?d00001 -- 2.51.2 From fadd068943c2ff32e40f141a90143941600907cd Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:45:11 +0200 Subject: [PATCH 167/266] fix: turborepo build cache (#2615) --- turbo.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index deed2942..72795002 100644 --- a/turbo.json +++ b/turbo.json @@ -14,7 +14,13 @@ "env": [ "\\*", "RESEND_API_KEY", - "\\!NEXT_PUBLIC_GIT_\\*", + // Framework inference pulls every NEXT_PUBLIC_* into the hash of any + // package that depends on next; these Vercel system vars change on every + // deploy, so without the negations nothing ever cache-hits. + "!NEXT_PUBLIC_VERCEL_GIT_*", + "!NEXT_PUBLIC_VERCEL_URL", + "!NEXT_PUBLIC_VERCEL_BRANCH_URL", + "!NEXT_PUBLIC_VERCEL_DEPLOYMENT_ID", "DATABASE_URL", "DATABASE_AUTH_TOKEN", "TINY_BIRD_API_KEY", -- 2.51.2 From 247d4673e2e43e2e12e575d9570c1c3080b03dd2 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Thu, 27 Aug 2026 15:00:32 +0200 Subject: [PATCH 168/266] feat: gRPC health check monitors (#2613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: gRPC health check monitors Adds a fifth monitor type that calls grpc.health.v1.Health/Check and reports the serving status it answers with. The probing tier and the TypeScript tier move together, per apps/checker/AGENTS.md: the Go checker, the private-location agent, the proto contracts and the product surface are all in this change. Notable decisions: - Health protocol only. No reflection, no arbitrary unary calls. - Two additive monitor columns, grpc_service and grpc_tls, rather than encoding the configuration into the URI — a URI parser would have had to exist in Go twice and TypeScript four times. - Only transport failures are retried. A server that answers NOT_SERVING, SERVICE_UNKNOWN or UNIMPLEMENTED is recorded once, the way an HTTP assertion failure already is, so alerts are not delayed by the backoff window. - GRPCResult carries an explicit Completed flag. It cannot be derived from the error flag, because NOT_SERVING is a failed check that completed perfectly well. Completion drives retry, OTel branching, what latency and timing hold, and the latency-quantile filter. - Timings reuse HTTP's ten-field shape verbatim, so calculateTiming and the dashboard waterfall work with no new code. - UNIMPLEMENTED gets its own message: the server is reachable, it just never registered a health service, and reading that as "down" sends people hunting the wrong problem. - TLS failures return a fixed string. The raw error quotes the peer's certificate subject and chain, which must not reach the caller. The three defects the ICMP pipes shipped with are fixed in the gRPC copies rather than inherited: no SELECT * in a materialization, the regions parameter is actually used, and the 30d/90d latency endpoints honour the forwarded window. Latency quantiles additionally exclude checks whose RPC never completed, so a refused connection no longer drags the median toward zero. While adding the fifth ingest validator, the monitor-id, latency and timestamp checks the four existing ones duplicated were extracted into one helper. mapMonitors gained a logging default so an unsupported job type can no longer vanish without a row, a log or an error. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YDMfxo4TidoFQt93wcnsYm * fix pr * fix pr --------- Co-authored-by: Claude Opus 5 (1M context) --- apps/checker/checker/grpc.go | 365 ++ apps/checker/checker/grpc_internal_test.go | 109 + apps/checker/checker/grpc_test.go | 280 + apps/checker/checker/testserver_test.go | 123 + apps/checker/cmd/server/main.go | 7 +- apps/checker/go.mod | 3 +- apps/checker/go.sum | 2 - apps/checker/handlers/checker.go | 20 +- apps/checker/handlers/grpc.go | 428 ++ apps/checker/handlers/grpc_test.go | 355 ++ apps/checker/handlers/icmp.go | 24 +- apps/checker/handlers/otel_wiring_test.go | 30 + apps/checker/handlers/ping.go | 14 +- apps/checker/handlers/tcp.go | 24 +- apps/checker/pkg/job/grpc_job.go | 194 + apps/checker/pkg/job/grpc_job_test.go | 223 + apps/checker/pkg/job/job.go | 1 + apps/checker/pkg/otel/otel.go | 64 + apps/checker/pkg/otel/otel_test.go | 99 + apps/checker/pkg/scheduler/scheduler.go | 54 + apps/checker/pkg/scheduler/scheduler_test.go | 99 + .../private_location/v1/grpc_monitor.pb.go | 213 + .../v1/private_location.connect.go | 29 + .../v1/private_location.pb.go | 276 +- apps/checker/request/request.go | 20 + .../(dashboard)/monitors/[id]/logs/client.tsx | 14 +- .../(dashboard)/monitors/[id]/nav-actions.tsx | 25 +- .../monitors/[id]/overview/client.tsx | 13 +- .../app/(dashboard)/monitors/[id]/sidebar.tsx | 2 +- .../app/(dashboard)/monitors/create/page.tsx | 2 + .../components/chart/chart-area-latency.tsx | 2 +- .../chart/chart-bar-uptime-light.tsx | 2 +- .../src/components/chart/chart-bar-uptime.tsx | 2 +- .../response-logs/data-table-basics.tsx | 755 +-- .../response-logs/data-table-sheet-test.tsx | 25 +- .../components/forms/monitor/form-general.tsx | 166 +- .../src/components/forms/monitor/update.tsx | 6 +- .../metric/global-uptime/section.tsx | 2 +- apps/dashboard/src/data/monitors.client.ts | 6 + apps/private-location/README.md | 17 + .../internal/database/models.go | 3 + .../internal/server/db_testdata | 2 +- .../internal/server/ingest_grpc.go | 89 + .../internal/server/monitors.go | 45 +- .../internal/server/validation.go | 68 +- .../internal/server/validation_test.go | 315 +- .../internal/tinybird/client.go | 1 + .../private_location/v1/grpc_monitor.pb.go | 213 + .../v1/private_location.connect.go | 29 + .../v1/private_location.pb.go | 276 +- apps/server/src/libs/checker/utils.test.ts | 37 +- apps/server/src/libs/checker/utils.ts | 27 +- .../monitor/__tests__/monitor.test.ts | 230 + .../handlers/monitor/converters/defaults.ts | 1 + .../rpc/handlers/monitor/converters/enums.ts | 27 + .../rpc/handlers/monitor/converters/index.ts | 3 + .../handlers/monitor/converters/monitors.ts | 31 + .../src/routes/rpc/handlers/monitor/index.ts | 130 +- .../rpc/handlers/monitor/validators.test.ts | 50 + .../routes/rpc/handlers/monitor/validators.ts | 47 +- .../interceptors/__tests__/tracking.test.ts | 14 + .../src/routes/rpc/interceptors/tracking.ts | 12 +- .../src/routes/rpc/interceptors/validation.ts | 1 + apps/server/static/openapi.yaml | 259 +- apps/web/src/content/docs.config.ts | 1 + .../pages/changelog/grpc-monitoring.mdx | 16 + .../pages/docs/reference/grpc-monitor.mdx | 141 + .../content/pages/docs/reference/overview.mdx | 1 + apps/workflows/src/cron/checker.ts | 25 + apps/workflows/src/cron/uptime-freeze.ts | 1 + packages/api/src/router/checker.test.ts | 109 + packages/api/src/router/checker.ts | 157 + packages/api/src/router/monitor.ts | 27 +- .../api/src/router/statusPage.e2e.test.ts | 67 + packages/api/src/router/statusPage.ts | 87 +- packages/api/src/router/tinybird/index.ts | 37 +- packages/db/drizzle/0084_flaky_thundra.sql | 2 + packages/db/drizzle/meta/0084_snapshot.json | 4976 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/monitors/constants.ts | 2 + packages/db/src/schema/monitors/monitor.ts | 11 +- packages/db/src/schema/monitors/validation.ts | 9 +- .../openstatus/monitor/v1/grpc_monitor.proto | 116 + .../api/openstatus/monitor/v1/service.proto | 43 +- packages/proto/gen/openapi.yaml | 259 +- .../openstatus/monitor/v1/grpc_monitor_pb.ts | 195 + .../gen/ts/openstatus/monitor/v1/index.ts | 1 + .../ts/openstatus/monitor/v1/service_pb.ts | 194 +- .../private_location/v1/grpc_monitor.proto | 24 + .../v1/private_location.proto | 23 + .../__tests__/get-history.test.ts | 4 +- .../src/frozen-uptime/__tests__/run.test.ts | 1 + .../services/src/frozen-uptime/get-history.ts | 1 + packages/services/src/frozen-uptime/run.ts | 5 +- packages/services/src/import/phase-writers.ts | 1 + .../src/monitor/__tests__/monitor.test.ts | 76 + packages/services/src/monitor/create.ts | 2 + .../services/src/monitor/get-daily-summary.ts | 12 +- .../src/monitor/get-monitor-summary.ts | 10 +- packages/services/src/monitor/index.ts | 1 + packages/services/src/monitor/schemas.ts | 11 +- packages/services/src/monitor/update.ts | 6 + .../check_grpc_response__v0.datasource | 20 + .../datasources/grpc_response__v0.datasource | 21 + .../datasources/mv__grpc_14d__v0.datasource | 21 + .../datasources/mv__grpc_1d__v0.datasource | 21 + .../datasources/mv__grpc_30d__v0.datasource | 21 + .../datasources/mv__grpc_7d__v0.datasource | 21 + .../datasources/mv__grpc_90d__v0.datasource | 21 + .../mv__grpc_full_14d__v0.datasource | 25 + .../mv__grpc_full_30d__v0.datasource | 25 + .../mv__grpc_status_45d__v0.datasource | 14 + .../mv__grpc_status_7d__v0.datasource | 12 + .../mv__grpc_uptime_30d__v0.datasource | 13 + .../mv__grpc_uptime_7d__v0.datasource | 13 + .../mv__grpc_uptime_90d__v0.datasource | 13 + .../mv__grpc_workspace_30d__v0.datasource | 12 + .../endpoints/endpoint__grpc_get_14d__v0.pipe | 15 + .../endpoints/endpoint__grpc_get_30d__v0.pipe | 15 + .../endpoint__grpc_list_14d__v0.pipe | 19 + .../endpoints/endpoint__grpc_list_1d__v0.pipe | 19 + .../endpoints/endpoint__grpc_list_7d__v0.pipe | 19 + .../endpoint__grpc_metrics_14d__v0.pipe | 43 + .../endpoint__grpc_metrics_1d__v0.pipe | 43 + .../endpoint__grpc_metrics_30d__v0.pipe | 42 + .../endpoint__grpc_metrics_7d__v0.pipe | 43 + .../endpoint__grpc_metrics_90d__v0.pipe | 39 + ...int__grpc_metrics_by_interval_14d__v0.pipe | 32 + ...oint__grpc_metrics_by_interval_1d__v0.pipe | 32 + ...int__grpc_metrics_by_interval_30d__v0.pipe | 30 + ...oint__grpc_metrics_by_interval_7d__v0.pipe | 32 + ...int__grpc_metrics_by_interval_90d__v0.pipe | 30 + ...point__grpc_metrics_by_region_14d__v0.pipe | 22 + ...dpoint__grpc_metrics_by_region_1d__v0.pipe | 22 + ...dpoint__grpc_metrics_by_region_7d__v0.pipe | 22 + .../endpoint__grpc_metrics_global_1d__v0.pipe | 25 + ...endpoint__grpc_metrics_latency_1d__v0.pipe | 25 + ...nt__grpc_metrics_latency_1d_multi__v0.pipe | 26 + ...ndpoint__grpc_metrics_latency_30d__v0.pipe | 26 + ...endpoint__grpc_metrics_latency_7d__v0.pipe | 25 + ...ndpoint__grpc_metrics_latency_90d__v0.pipe | 26 + .../endpoint__grpc_status_45d__v0.pipe | 20 + .../endpoint__grpc_status_7d__v0.pipe | 20 + .../endpoint__grpc_uptime_30d__v0.pipe | 22 + .../endpoint__grpc_uptime_7d__v0.pipe | 22 + .../endpoint__grpc_uptime_90d__v0.pipe | 21 + .../endpoint__grpc_workspace_30d__v0.pipe | 16 + .../aggregate__grpc_full_30d__v0.pipe | 31 + .../aggregate__grpc_status_7d__v0.pipe | 17 + .../pipes/aggregate__grpc_14d__v0.pipe | 23 + .../pipes/aggregate__grpc_1d__v0.pipe | 23 + .../pipes/aggregate__grpc_30d__v0.pipe | 23 + .../pipes/aggregate__grpc_7d__v0.pipe | 23 + .../pipes/aggregate__grpc_90d__v0.pipe | 23 + .../pipes/aggregate__grpc_full_14d__v0.pipe | 31 + .../pipes/aggregate__grpc_status_45d__v0.pipe | 19 + .../pipes/aggregate__grpc_uptime_30d__v0.pipe | 13 + .../pipes/aggregate__grpc_uptime_7d__v0.pipe | 13 + .../pipes/aggregate__grpc_uptime_90d__v0.pipe | 13 + .../aggregate__grpc_workspace_30d__v0.pipe | 18 + packages/tinybird/src/client.ts | 515 ++ packages/tinybird/src/schema.ts | 10 +- packages/utils/src/constants.ts | 1 + packages/utils/src/index.ts | 3 + packages/utils/src/payloads.ts | 25 + 165 files changed, 13573 insertions(+), 983 deletions(-) create mode 100644 apps/checker/checker/grpc.go create mode 100644 apps/checker/checker/grpc_internal_test.go create mode 100644 apps/checker/checker/grpc_test.go create mode 100644 apps/checker/checker/testserver_test.go create mode 100644 apps/checker/handlers/grpc.go create mode 100644 apps/checker/handlers/grpc_test.go create mode 100644 apps/checker/pkg/job/grpc_job.go create mode 100644 apps/checker/pkg/job/grpc_job_test.go create mode 100644 apps/checker/proto/private_location/v1/grpc_monitor.pb.go create mode 100644 apps/private-location/internal/server/ingest_grpc.go create mode 100644 apps/private-location/proto/private_location/v1/grpc_monitor.pb.go create mode 100644 apps/web/src/content/pages/changelog/grpc-monitoring.mdx create mode 100644 apps/web/src/content/pages/docs/reference/grpc-monitor.mdx create mode 100644 packages/api/src/router/checker.test.ts create mode 100644 packages/db/drizzle/0084_flaky_thundra.sql create mode 100644 packages/db/drizzle/meta/0084_snapshot.json create mode 100644 packages/proto/api/openstatus/monitor/v1/grpc_monitor.proto create mode 100644 packages/proto/gen/ts/openstatus/monitor/v1/grpc_monitor_pb.ts create mode 100644 packages/proto/internal/private_location/v1/grpc_monitor.proto create mode 100644 packages/tinybird/datasources/check_grpc_response__v0.datasource create mode 100644 packages/tinybird/datasources/grpc_response__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_14d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_1d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_30d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_7d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_90d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_full_14d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_full_30d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_status_45d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_status_7d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_uptime_30d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_uptime_7d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_uptime_90d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_workspace_30d__v0.datasource create mode 100644 packages/tinybird/endpoints/endpoint__grpc_get_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_get_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_list_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_list_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_list_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d_multi__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_latency_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_latency_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_latency_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_status_45d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_status_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_uptime_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_uptime_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_uptime_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_workspace_30d__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__grpc_full_30d__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__grpc_status_7d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_14d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_1d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_30d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_7d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_90d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_full_14d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_status_45d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_uptime_30d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_uptime_7d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_uptime_90d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_workspace_30d__v0.pipe diff --git a/apps/checker/checker/grpc.go b/apps/checker/checker/grpc.go new file mode 100644 index 00000000..fbb13273 --- /dev/null +++ b/apps/checker/checker/grpc.go @@ -0,0 +1,365 @@ +package checker + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "strings" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/stats" + "google.golang.org/grpc/status" +) + +type GRPCTLSMode string + +const ( + GRPCTLSModeTLS GRPCTLSMode = "tls" + GRPCTLSModePlaintext GRPCTLSMode = "plaintext" + GRPCTLSModeTLSInsecure GRPCTLSMode = "tls_insecure" +) + +const ( + // Applied when a caller omits the timeout, matching the monitor column default. + grpcDefaultTimeout = 45_000 + + ServingStatusServing = "SERVING" + ServingStatusNotServing = "NOT_SERVING" + ServingStatusServiceUnknown = "SERVICE_UNKNOWN" + ServingStatusUnknown = "UNKNOWN" + // Not a grpc.health.v1 enum value: the server answered, it just has no + // health service. It still needs a status, because a NULL servingStatus is + // what the metrics pipes use to mean "never reached the server", and this + // check did — with a real latency worth counting. + ServingStatusUnimplemented = "UNIMPLEMENTED" +) + +// GRPCResponseTiming is HTTP's phase shape verbatim: gRPC is HTTP/2, and reusing +// it lets calculateTiming and the dashboard waterfall work with no new code. +type GRPCResponseTiming = Timing + +func ParseGRPCTLSMode(value string) GRPCTLSMode { + switch value { + case string(GRPCTLSModePlaintext): + return GRPCTLSModePlaintext + case string(GRPCTLSModeTLSInsecure): + return GRPCTLSModeTLSInsecure + default: + return GRPCTLSModeTLS + } +} + +type GRPCResult struct { + Timing GRPCResponseTiming + ServingStatus string + Message string + Latency int64 + GRPCCode int64 + // Completed reports that the server answered. It cannot be derived from the + // error flag: NOT_SERVING is a failed check that completed perfectly well. + Completed bool + Healthy bool +} + +type GRPCResponse struct { + Region string `json:"region"` + ErrorMessage string `json:"errorMessage"` + JobType string `json:"jobType"` + ServingStatus string `json:"servingStatus"` + Service string `json:"service"` + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + GRPCCode int64 `json:"grpcCode"` + Timing GRPCResponseTiming `json:"timing"` + Completed bool `json:"completed"` + Error uint8 `json:"error,omitempty"` +} + +func grpcNow() int64 { + return time.Now().UTC().UnixMilli() +} + +// grpcTimer guards the phase struct: grpc-go dials and reads on its own +// goroutines, so the probe goroutine cannot write it unsynchronised. +type grpcTimer struct { + mu sync.Mutex + timing GRPCResponseTiming +} + +func (g *grpcTimer) set(apply func(t *GRPCResponseTiming)) { + g.mu.Lock() + defer g.mu.Unlock() + apply(&g.timing) +} + +func (g *grpcTimer) snapshot() GRPCResponseTiming { + g.mu.Lock() + defer g.mu.Unlock() + return g.timing +} + +// CheckGRPC calls grpc.health.v1.Health/Check on target. A non-nil error means +// the RPC never completed; a completed call with a bad answer is reported +// through the result instead. +func CheckGRPC(timeoutMs int64, target, service string, mode GRPCTLSMode, md map[string]string) (GRPCResult, error) { + if timeoutMs <= 0 { + timeoutMs = grpcDefaultTimeout + } + + // Every failure path must set GRPCCode: its zero value is codes.OK, and + // callers persist it verbatim, so leaving it unset records a check that + // never reached the server under the code for success. Unavailable is what + // grpc-go itself answers for a malformed-but-parseable target, so the whole + // "never got on the wire" class stays one code; the error message is what + // separates a bad target from a refused connection. + host, _, err := net.SplitHostPort(target) + if err != nil { + return GRPCResult{GRPCCode: int64(codes.Unavailable)}, + fmt.Errorf("invalid target %q: expected host:port", target) + } + + // One deadline for resolve, dial, handshake and call. Splitting them would + // let a stalled DNS lookup run past the timeout the user configured. + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) + defer cancel() + + timer := &grpcTimer{} + + conn, err := grpc.NewClient(target, + grpc.WithTransportCredentials(grpcCredentials(mode, host, timer)), + grpc.WithContextDialer(grpcDialer(timer)), + grpc.WithStatsHandler(&grpcStatsHandler{timer: timer}), + ) + if err != nil { + return GRPCResult{ + Timing: timer.snapshot(), + GRPCCode: int64(codes.Unavailable), + }, fmt.Errorf("dial error: %w", err) + } + defer conn.Close() + + callCtx := ctx + if len(md) > 0 { + callCtx = metadata.NewOutgoingContext(ctx, metadata.New(md)) + } + + start := time.Now() + res, err := grpc_health_v1.NewHealthClient(conn).Check( + callCtx, + &grpc_health_v1.HealthCheckRequest{Service: service}, + grpc.WaitForReady(false), + ) + latency := time.Since(start).Milliseconds() + timing := timer.snapshot() + + if err != nil { + code := status.Code(err) + result := GRPCResult{ + Timing: timing, + Latency: latency, + GRPCCode: int64(code), + } + + switch code { + case codes.Unimplemented: + // The server is up and talking gRPC; it just has no health service. + // Reporting this as "down" sends people hunting the wrong problem. + result.Completed = true + result.ServingStatus = ServingStatusUnimplemented + result.Message = "server does not implement grpc.health.v1.Health" + return result, nil + case codes.NotFound: + // grpc-go's reference health server answers an unregistered service + // with NOT_FOUND rather than the SERVICE_UNKNOWN enum value. + result.Completed = true + result.ServingStatus = ServingStatusServiceUnknown + result.Message = grpcUnknownServiceMessage(service) + return result, nil + } + + result.Latency = 0 + return result, fmt.Errorf("%s", grpcTransportMessage(code, err, timeoutMs)) + } + + servingStatus := grpcServingStatusName(res.GetStatus()) + result := GRPCResult{ + Timing: timing, + Latency: latency, + ServingStatus: servingStatus, + GRPCCode: int64(codes.OK), + Completed: true, + Healthy: servingStatus == ServingStatusServing, + } + + switch servingStatus { + case ServingStatusServing: + result.Message = fmt.Sprintf("Health check passed for %s", target) + case ServingStatusNotServing: + result.Message = "service reports NOT_SERVING" + case ServingStatusServiceUnknown: + result.Message = grpcUnknownServiceMessage(service) + default: + result.Message = "service reports UNKNOWN" + } + + return result, nil +} + +func grpcUnknownServiceMessage(service string) string { + if service == "" { + return "server does not know the requested service" + } + return fmt.Sprintf("server does not know service %q", service) +} + +func grpcServingStatusName(s grpc_health_v1.HealthCheckResponse_ServingStatus) string { + switch s { + case grpc_health_v1.HealthCheckResponse_SERVING: + return ServingStatusServing + case grpc_health_v1.HealthCheckResponse_NOT_SERVING: + return ServingStatusNotServing + case grpc_health_v1.HealthCheckResponse_SERVICE_UNKNOWN: + return ServingStatusServiceUnknown + default: + return ServingStatusUnknown + } +} + +// grpcTransportMessage maps a failed dial onto a fixed string. The raw error is +// never returned: a certificate failure quotes the peer's subject and chain, +// which would hand an internal service's identity back to the caller. +func grpcTransportMessage(code codes.Code, err error, timeoutMs int64) string { + if code == codes.DeadlineExceeded { + return fmt.Sprintf("timeout after %d ms", timeoutMs) + } + + raw := err.Error() + switch { + case isTLSFailure(raw): + return "certificate verification failed" + case strings.Contains(raw, "connection refused"): + return "connection refused" + case strings.Contains(raw, "no such host"): + return "resolve error" + case code == codes.Unavailable: + return "dial error" + } + + return fmt.Sprintf("grpc check failed with code %s", code) +} + +func isTLSFailure(message string) bool { + return strings.Contains(message, "x509:") || + strings.Contains(message, "tls:") || + strings.Contains(message, "certificate") +} + +func grpcCredentials(mode GRPCTLSMode, host string, timer *grpcTimer) credentials.TransportCredentials { + switch mode { + case GRPCTLSModePlaintext: + return insecure.NewCredentials() + case GRPCTLSModeTLSInsecure: + return &timingCredentials{ + //nolint:gosec // the tls_insecure mode exists so operators can probe + // self-signed internal services; it is opt-in per monitor. + TransportCredentials: credentials.NewTLS(&tls.Config{InsecureSkipVerify: true}), + timer: timer, + } + default: + return &timingCredentials{ + TransportCredentials: credentials.NewTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}), + timer: timer, + } + } +} + +type timingCredentials struct { + credentials.TransportCredentials + timer *grpcTimer +} + +func (c *timingCredentials) ClientHandshake(ctx context.Context, authority string, raw net.Conn) (net.Conn, credentials.AuthInfo, error) { + c.timer.set(func(t *GRPCResponseTiming) { t.TlsHandshakeStart = grpcNow() }) + conn, info, err := c.TransportCredentials.ClientHandshake(ctx, authority, raw) + if err == nil { + c.timer.set(func(t *GRPCResponseTiming) { t.TlsHandshakeDone = grpcNow() }) + } + + return conn, info, err +} + +func (c *timingCredentials) Clone() credentials.TransportCredentials { + return &timingCredentials{TransportCredentials: c.TransportCredentials.Clone(), timer: c.timer} +} + +// grpcDialer resolves and connects by hand so the two phases can be timed +// apart, the way httptrace splits them for HTTP. +func grpcDialer(timer *grpcTimer) func(context.Context, string) (net.Conn, error) { + return func(ctx context.Context, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + timer.set(func(t *GRPCResponseTiming) { t.DnsStart = grpcNow() }) + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + if len(ips) == 0 { + return nil, fmt.Errorf("no such host %s", host) + } + timer.set(func(t *GRPCResponseTiming) { t.DnsDone = grpcNow() }) + + timer.set(func(t *GRPCResponseTiming) { t.ConnectStart = grpcNow() }) + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ips[0].String(), port)) + if err != nil { + return nil, err + } + timer.set(func(t *GRPCResponseTiming) { t.ConnectDone = grpcNow() }) + + return conn, nil + } +} + +// grpcStatsHandler times the call itself: headers back is the first byte, the +// message that follows is the transfer. +type grpcStatsHandler struct { + timer *grpcTimer +} + +func (h *grpcStatsHandler) TagRPC(ctx context.Context, _ *stats.RPCTagInfo) context.Context { + return ctx +} + +func (h *grpcStatsHandler) HandleRPC(_ context.Context, s stats.RPCStats) { + switch s.(type) { + case *stats.OutHeader: + h.timer.set(func(t *GRPCResponseTiming) { t.FirstByteStart = grpcNow() }) + case *stats.InHeader: + h.timer.set(func(t *GRPCResponseTiming) { + t.FirstByteDone = grpcNow() + t.TransferStart = grpcNow() + }) + case *stats.InPayload: + h.timer.set(func(t *GRPCResponseTiming) { t.TransferDone = grpcNow() }) + } +} + +func (h *grpcStatsHandler) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context { + return ctx +} + +func (h *grpcStatsHandler) HandleConn(context.Context, stats.ConnStats) {} diff --git a/apps/checker/checker/grpc_internal_test.go b/apps/checker/checker/grpc_internal_test.go new file mode 100644 index 00000000..4bf45b0e --- /dev/null +++ b/apps/checker/checker/grpc_internal_test.go @@ -0,0 +1,109 @@ +package checker + +import ( + "errors" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestParseGRPCTLSMode(t *testing.T) { + cases := map[string]GRPCTLSMode{ + "plaintext": GRPCTLSModePlaintext, + "tls_insecure": GRPCTLSModeTLSInsecure, + "tls": GRPCTLSModeTLS, + "": GRPCTLSModeTLS, + "nonsense": GRPCTLSModeTLS, + } + + for input, want := range cases { + if got := ParseGRPCTLSMode(input); got != want { + t.Errorf("ParseGRPCTLSMode(%q) = %q, want %q", input, got, want) + } + } +} + +func TestGRPCTransportMessageNeverEchoesTheRawError(t *testing.T) { + cases := []struct { + name string + code codes.Code + err error + want string + }{ + { + name: "certificate", + code: codes.Unavailable, + err: status.Error(codes.Unavailable, `x509: certificate signed by unknown authority (subject CN=internal-billing)`), + want: "certificate verification failed", + }, + { + name: "tls record", + code: codes.Unavailable, + err: status.Error(codes.Unavailable, `tls: first record does not look like a TLS handshake`), + want: "certificate verification failed", + }, + { + name: "refused", + code: codes.Unavailable, + err: status.Error(codes.Unavailable, "connection error: connection refused"), + want: "connection refused", + }, + { + name: "resolve", + code: codes.Unavailable, + err: status.Error(codes.Unavailable, `lookup nope.invalid: no such host`), + want: "resolve error", + }, + { + name: "deadline", + code: codes.DeadlineExceeded, + err: status.Error(codes.DeadlineExceeded, "context deadline exceeded"), + want: "timeout after 1500 ms", + }, + { + name: "other", + code: codes.Internal, + err: errors.New("boom"), + want: "grpc check failed with code Internal", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := grpcTransportMessage(tc.code, tc.err, 1500) + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + if got == tc.err.Error() { + t.Fatal("the raw error must never be returned verbatim") + } + }) + } +} + +func TestGRPCUnknownServiceMessage(t *testing.T) { + if got := grpcUnknownServiceMessage(""); got != "server does not know the requested service" { + t.Fatalf("unexpected message %q", got) + } + if got := grpcUnknownServiceMessage("pkg.Svc"); got != `server does not know service "pkg.Svc"` { + t.Fatalf("unexpected message %q", got) + } +} + +func TestGRPCTimerIsSafeForConcurrentPhases(t *testing.T) { + timer := &grpcTimer{} + + done := make(chan struct{}) + go func() { + timer.set(func(t *GRPCResponseTiming) { t.DnsStart = 1 }) + close(done) + }() + timer.set(func(t *GRPCResponseTiming) { t.ConnectStart = 2 }) + <-done + + snapshot := timer.snapshot() + if snapshot.DnsStart != 1 || snapshot.ConnectStart != 2 { + t.Fatalf("unexpected snapshot %+v", snapshot) + } +} diff --git a/apps/checker/checker/grpc_test.go b/apps/checker/checker/grpc_test.go new file mode 100644 index 00000000..53649410 --- /dev/null +++ b/apps/checker/checker/grpc_test.go @@ -0,0 +1,280 @@ +package checker_test + +import ( + "strings" + "testing" + "time" + + "github.com/openstatushq/openstatus/apps/checker/checker" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/health/grpc_health_v1" +) + +func TestCheckGRPCServing(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + }) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModePlaintext, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Completed || !res.Healthy { + t.Fatalf("expected a healthy completed check, got %+v", res) + } + if res.ServingStatus != checker.ServingStatusServing { + t.Fatalf("expected SERVING, got %q", res.ServingStatus) + } + if res.GRPCCode != int64(codes.OK) { + t.Fatalf("expected code OK, got %d", res.GRPCCode) + } + if res.Timing.FirstByteStart == 0 || res.Timing.FirstByteDone == 0 { + t.Fatalf("expected the call phase to be timed, got %+v", res.Timing) + } + if res.Timing.TlsHandshakeStart != 0 || res.Timing.TlsHandshakeDone != 0 { + t.Fatalf("plaintext must not record a TLS phase, got %+v", res.Timing) + } +} + +func TestCheckGRPCNamedService(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "checkout.v1.CheckoutService": grpc_health_v1.HealthCheckResponse_SERVING, + }, + }) + + res, err := checker.CheckGRPC(5000, target, "checkout.v1.CheckoutService", checker.GRPCTLSModePlaintext, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Healthy { + t.Fatalf("expected the named service to be healthy, got %+v", res) + } +} + +func TestCheckGRPCNotServing(t *testing.T) { + // A loopback call rounds to 0 ms, which cannot be told apart from a latency + // that was discarded. The delay makes the assertion mean something. + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_NOT_SERVING, + }, + delay: 30 * time.Millisecond, + }) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModePlaintext, nil) + if err != nil { + t.Fatalf("NOT_SERVING is an answer, not a transport failure: %v", err) + } + if !res.Completed { + t.Fatal("expected Completed to be true") + } + if res.Healthy { + t.Fatal("expected Healthy to be false") + } + if res.ServingStatus != checker.ServingStatusNotServing { + t.Fatalf("expected NOT_SERVING, got %q", res.ServingStatus) + } + if res.Latency < 20 { + t.Fatalf("a completed call must keep its measured latency, got %d", res.Latency) + } + if res.Timing.FirstByteDone == 0 { + t.Fatalf("a completed call must keep its timing, got %+v", res.Timing) + } + if res.Message != "service reports NOT_SERVING" { + t.Fatalf("unexpected message %q", res.Message) + } +} + +func TestCheckGRPCServiceUnknown(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + }) + + res, err := checker.CheckGRPC(5000, target, "missing.Service", checker.GRPCTLSModePlaintext, nil) + if err != nil { + t.Fatalf("an unknown service is an answer, not a transport failure: %v", err) + } + if !res.Completed || res.Healthy { + t.Fatalf("expected a completed unhealthy check, got %+v", res) + } + if res.ServingStatus != checker.ServingStatusServiceUnknown { + t.Fatalf("expected SERVICE_UNKNOWN, got %q", res.ServingStatus) + } + if !strings.Contains(res.Message, "missing.Service") { + t.Fatalf("expected the message to name the service, got %q", res.Message) + } +} + +func TestCheckGRPCUnimplemented(t *testing.T) { + target := newHealthServer(t, healthServerOptions{omitHealthService: true}) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModePlaintext, nil) + if err != nil { + t.Fatalf("a reachable server without the health service still completed: %v", err) + } + if !res.Completed || res.Healthy { + t.Fatalf("expected a completed unhealthy check, got %+v", res) + } + if res.GRPCCode != int64(codes.Unimplemented) { + t.Fatalf("expected UNIMPLEMENTED, got code %d", res.GRPCCode) + } + if res.Message != "server does not implement grpc.health.v1.Health" { + t.Fatalf("unexpected message %q", res.Message) + } + // Deliberately not empty. The metrics pipes read a NULL servingStatus as + // "never reached the server" and drop the row from every latency + // aggregate — but this check did reach the server and timed a real round + // trip, so it has to carry a status to stay in them. + if res.ServingStatus != checker.ServingStatusUnimplemented { + t.Fatalf( + "expected %q to keep the row in latency metrics, got %q", + checker.ServingStatusUnimplemented, res.ServingStatus, + ) + } +} + +func TestCheckGRPCConnectionRefused(t *testing.T) { + res, err := checker.CheckGRPC(5000, closedPort(t), "", checker.GRPCTLSModePlaintext, nil) + if err == nil { + t.Fatal("expected a transport failure") + } + if res.Completed { + t.Fatal("expected Completed to be false") + } + if res.Latency != 0 { + t.Fatalf("a call that never completed has no latency, got %d", res.Latency) + } + if err.Error() != "connection refused" { + t.Fatalf("unexpected message %q", err.Error()) + } + if res.GRPCCode != int64(codes.Unavailable) { + t.Fatalf("expected Unavailable, got %d", res.GRPCCode) + } +} + +func TestCheckGRPCTimeout(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + delay: 2 * time.Second, + }) + + res, err := checker.CheckGRPC(200, target, "", checker.GRPCTLSModePlaintext, nil) + if err == nil { + t.Fatal("expected a deadline failure") + } + if res.Completed { + t.Fatal("expected Completed to be false") + } + if !strings.HasPrefix(err.Error(), "timeout after") { + t.Fatalf("unexpected message %q", err.Error()) + } +} + +func TestCheckGRPCInvalidTarget(t *testing.T) { + res, err := checker.CheckGRPC(5000, "api.example.com", "", checker.GRPCTLSModeTLS, nil) + if err == nil { + t.Fatal("expected a portless target to be rejected") + } + // GRPCCode's zero value is codes.OK and callers persist it verbatim, so an + // unset code records a check that never ran as a successful one. + if res.GRPCCode != int64(codes.Unavailable) { + t.Fatalf("expected Unavailable, got %d", res.GRPCCode) + } +} + +// A target that clears net.SplitHostPort but that grpc-go cannot parse is the +// only route into the grpc.NewClient error branch: a merely unreachable target +// constructs fine and fails later, on the call. +func TestCheckGRPCUnparseableTarget(t *testing.T) { + res, err := checker.CheckGRPC(5000, "ho%zzst:443", "", checker.GRPCTLSModePlaintext, nil) + if err == nil { + t.Fatal("expected an unparseable target to be rejected") + } + if res.Completed { + t.Fatal("expected Completed to be false") + } + if res.GRPCCode != int64(codes.Unavailable) { + t.Fatalf("expected Unavailable, got %d", res.GRPCCode) + } +} + +func TestCheckGRPCTLSInsecureAcceptsSelfSigned(t *testing.T) { + cert := newSelfSignedCert(t) + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + cert: &cert, + }) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModeTLSInsecure, nil) + if err != nil { + t.Fatalf("tls_insecure must accept a self-signed certificate: %v", err) + } + if !res.Healthy { + t.Fatalf("expected SERVING, got %+v", res) + } + if res.Timing.TlsHandshakeStart == 0 || res.Timing.TlsHandshakeDone == 0 { + t.Fatalf("expected the TLS phase to be timed, got %+v", res.Timing) + } +} + +func TestCheckGRPCTLSRejectsSelfSigned(t *testing.T) { + cert := newSelfSignedCert(t) + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + cert: &cert, + }) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModeTLS, nil) + if err == nil { + t.Fatal("tls mode must reject a self-signed certificate") + } + if res.Completed { + t.Fatal("expected Completed to be false") + } + if err.Error() != "certificate verification failed" { + t.Fatalf("unexpected message %q", err.Error()) + } + if strings.Contains(err.Error(), "openstatus-test") || strings.Contains(err.Error(), "x509") { + t.Fatalf("the certificate subject must never reach the caller: %q", err.Error()) + } +} + +func TestCheckGRPCTLSAgainstPlaintextServer(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + }) + + if _, err := checker.CheckGRPC(2000, target, "", checker.GRPCTLSModeTLS, nil); err == nil { + t.Fatal("expected TLS against a plaintext server to fail") + } +} + +func TestCheckGRPCMetadataReachesTheServer(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + }) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModePlaintext, + map[string]string{"authorization": "Bearer token"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Healthy { + t.Fatalf("expected SERVING, got %+v", res) + } +} diff --git a/apps/checker/checker/testserver_test.go b/apps/checker/checker/testserver_test.go new file mode 100644 index 00000000..f765dd90 --- /dev/null +++ b/apps/checker/checker/testserver_test.go @@ -0,0 +1,123 @@ +package checker_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" +) + +// newSelfSignedCert mints a certificate for localhost. Nothing in the Go tier +// ships a TLS fixture, and the tls/tls_insecure modes cannot be exercised +// without a server presenting a certificate the system store will reject. +func newSelfSignedCert(t *testing.T) tls.Certificate { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "openstatus-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} + +type healthServerOptions struct { + // statuses registers a serving status per service name. The empty key is + // the overall server status. + statuses map[string]grpc_health_v1.HealthCheckResponse_ServingStatus + // omitHealthService starts a gRPC server with no health service registered, + // which is what a real server missing the registration looks like. + omitHealthService bool + cert *tls.Certificate + delay time.Duration +} + +// newHealthServer starts a health server on a loopback port and returns its +// host:port. The listener is closed when the test finishes. +func newHealthServer(t *testing.T, opts healthServerOptions) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + var serverOpts []grpc.ServerOption + if opts.cert != nil { + serverOpts = append(serverOpts, grpc.Creds(credentials.NewServerTLSFromCert(opts.cert))) + } + if opts.delay > 0 { + delay := opts.delay + serverOpts = append(serverOpts, grpc.UnaryInterceptor( + func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + return handler(ctx, req) + }, + )) + } + + server := grpc.NewServer(serverOpts...) + + if !opts.omitHealthService { + healthServer := health.NewServer() + for service, status := range opts.statuses { + healthServer.SetServingStatus(service, status) + } + grpc_health_v1.RegisterHealthServer(server, healthServer) + } + + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(server.Stop) + + return listener.Addr().String() +} + +// closedPort returns a host:port nothing is listening on. +func closedPort(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + return addr +} diff --git a/apps/checker/cmd/server/main.go b/apps/checker/cmd/server/main.go index ed17e4ac..60ef72dc 100644 --- a/apps/checker/cmd/server/main.go +++ b/apps/checker/cmd/server/main.go @@ -21,9 +21,9 @@ import ( "github.com/rs/zerolog/log" "go.opentelemetry.io/contrib/bridges/otelslog" // otelz "go.opentelemetry.io/contrib/bridges/otelzerolog" - "go.opentelemetry.io/otel/log/global" "go.opentelemetry.io/otel/attribute" otlploghttp "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" + "go.opentelemetry.io/otel/log/global" sdklog "go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/resource" semconv "go.opentelemetry.io/otel/semconv/v1.39.0" @@ -144,7 +144,7 @@ func Logger() gin.HandlerFunc { if shouldSample(event) { attrs := MapToAttrs(event) - slog.LogAttrs(c.Request.Context(),slog.LevelInfo, "request done", attrs...) + slog.LogAttrs(c.Request.Context(), slog.LevelInfo, "request done", attrs...) } log.Debug(). @@ -221,7 +221,6 @@ func main() { logProvider := sdklog.NewLoggerProvider( sdklog.WithResource(res), sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)), - ) defer logProvider.Shutdown(ctx) @@ -250,10 +249,12 @@ func main() { router.POST("/checker/tcp", h.TCPHandler) router.POST("/checker/dns", h.DNSHandler) router.POST("/checker/icmp", h.ICMPHandler) + router.POST("/checker/grpc", h.GRPCHandler) router.POST("/ping/:region", h.PingRegionHandler) router.POST("/tcp/:region", h.TCPHandlerRegion) router.POST("/dns/:region", h.DNSHandlerRegion) router.POST("/icmp/:region", h.ICMPHandlerRegion) + router.POST("/grpc/:region", h.GRPCHandlerRegion) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong", "region": region, "provider": cloudProvider}) diff --git a/apps/checker/go.mod b/apps/checker/go.mod index 46ca4305..deee0d1f 100644 --- a/apps/checker/go.mod +++ b/apps/checker/go.mod @@ -6,7 +6,6 @@ require ( cloud.google.com/go/auth v0.18.2 cloud.google.com/go/cloudtasks v1.13.7 connectrpc.com/connect v1.19.1 - github.com/cenkalti/backoff/v4 v4.3.0 github.com/cenkalti/backoff/v5 v5.0.3 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 @@ -24,6 +23,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.41.0 golang.org/x/net v0.51.0 google.golang.org/api v0.269.0 + google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 ) @@ -81,6 +81,5 @@ require ( google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/grpc v1.79.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/checker/go.sum b/apps/checker/go.sum index 07a5d081..bb6dff56 100644 --- a/apps/checker/go.sum +++ b/apps/checker/go.sum @@ -16,8 +16,6 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/apps/checker/handlers/checker.go b/apps/checker/handlers/checker.go index 5a22ecca..0c9c1e3f 100644 --- a/apps/checker/handlers/checker.go +++ b/apps/checker/handlers/checker.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/rs/zerolog/log" @@ -117,28 +117,28 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { retry = int(req.Retry) } - op := func() error { + op := func() (struct{}, error) { called++ res, err := checker.Http(ctx, requestClient, req) if err != nil { - return fmt.Errorf("unable to ping: %w", err) + return struct{}{}, fmt.Errorf("unable to ping: %w", err) } // In TB we need to store them as string timingAsString, err := json.Marshal(res.Timing) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) } headersAsString, err := json.Marshal(res.Headers) if err != nil { - return fmt.Errorf("error while parsing headers %s: %w", req.URL, err) + return struct{}{}, fmt.Errorf("error while parsing headers %s: %w", req.URL, err) } id, err := uuid.NewV7() if err != nil { - return fmt.Errorf("error while generating uuid %w", err) + return struct{}{}, fmt.Errorf("error while generating uuid %w", err) } var requestStatus = "" @@ -173,12 +173,12 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { var isSuccessfull bool = true isSuccessfull, err = EvaluateHTTPAssertions(req.RawAssertions, data, res) if err != nil { - return err + return struct{}{}, err } // let's retry at least once if the status code is not successful. if !isSuccessfull && called < retry { - return fmt.Errorf("unable to ping: %v with status %v", res, res.Status) + return struct{}{}, fmt.Errorf("unable to ping: %v with status %v", res, res.Status) } result = res @@ -271,10 +271,10 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { c.Set("event", t) } - return nil + return struct{}{}, nil } - if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), uint64(retry))); err != nil { + if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))); err != nil { id, e := uuid.NewV7() if e != nil { log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") diff --git a/apps/checker/handlers/grpc.go b/apps/checker/handlers/grpc.go new file mode 100644 index 00000000..a0f324d3 --- /dev/null +++ b/apps/checker/handlers/grpc.go @@ -0,0 +1,428 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/cenkalti/backoff/v5" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/openstatushq/openstatus/apps/checker/checker" + otelOS "github.com/openstatushq/openstatus/apps/checker/pkg/otel" + "github.com/openstatushq/openstatus/apps/checker/request" + "github.com/rs/zerolog/log" +) + +// Only used for Tinybird. +type GRPCData struct { + ID string `json:"id"` + Timing string `json:"timing"` + ErrorMessage string `json:"errorMessage"` + Region string `json:"region"` + Trigger string `json:"trigger"` + URI string `json:"uri"` + Service string `json:"service,omitempty"` + ServingStatus string `json:"servingStatus,omitempty"` + RequestStatus string `json:"requestStatus,omitempty"` + + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + CronTimestamp int64 `json:"cronTimestamp"` + GRPCCode int64 `json:"grpcCode"` + + Error uint8 `json:"error"` +} + +func grpcCheck(req request.GRPCCheckerRequest) (checker.GRPCResult, error) { + return checker.CheckGRPC( + req.Timeout, + req.URI, + req.Service, + checker.ParseGRPCTLSMode(req.TLS), + req.Headers, + ) +} + +func (h Handler) GRPCHandler(c *gin.Context) { + ctx := c.Request.Context() + dataSourceName := "grpc_response__v0" + + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + + return + } + + if h.CloudProvider == "fly" { + // if the request has been routed to a wrong region, we forward it to the correct one. + region := c.GetHeader("fly-prefer-region") + if region != "" && region != h.Region { + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) + c.String(http.StatusAccepted, "Forwarding request to %s", region) + + return + } + } + + var req request.GRPCCheckerRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + workspaceId, err := strconv.ParseInt(req.WorkspaceID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + monitorId, err := strconv.ParseInt(req.MonitorID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + var trigger = "cron" + if req.Trigger != "" { + trigger = req.Trigger + } + + e, f := c.Get("event") + if f { + t := e.(map[string]any) + t["checker"] = map[string]string{ + "uri": req.URI, + "workspace_id": req.WorkspaceID, + "monitor_id": req.MonitorID, + "trigger": trigger, + "type": "grpc", + } + c.Set("event", t) + } + + var response checker.GRPCResponse + + var retry int + if req.Retry != 0 { + retry = int(req.Retry) + } else { + retry = 3 + } + + // CheckGRPC hands back what it learned alongside its error: whatever phases + // completed, plus the gRPC status code. That is what separates a DNS failure + // from a connect or TLS one, and a timeout from a refused connection. op() + // drops `res` when it returns an error to the retry loop, so keep the last + // attempt's result. + var lastResult checker.GRPCResult + + op := func() (struct{}, error) { + res, err := grpcCheck(req) + lastResult = res + if err != nil { + // Only a call that never reached the server is worth repeating. A + // server answering NOT_SERVING will answer the same three more times. + return struct{}{}, fmt.Errorf("unable to check grpc %s", err) + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + } + + id, err := uuid.NewV7() + if err != nil { + return struct{}{}, fmt.Errorf("error while generating uuid %w", err) + } + + timestamp := time.Now().UTC().UnixMilli() + degraded := res.Healthy && req.DegradedAfter > 0 && res.Latency > req.DegradedAfter + + requestStatus := "success" + switch { + case !res.Healthy: + requestStatus = "error" + case degraded: + requestStatus = "degraded" + } + + errorFlag := uint8(0) + errorMessage := "" + if !res.Healthy { + errorFlag = 1 + errorMessage = res.Message + } + + data := GRPCData{ + ID: id.String(), + WorkspaceID: workspaceId, + Timestamp: timestamp, + Error: errorFlag, + ErrorMessage: errorMessage, + Region: h.Region, + MonitorID: monitorId, + Timing: string(timingAsString), + Latency: res.Latency, + GRPCCode: res.GRPCCode, + ServingStatus: res.ServingStatus, + Service: req.Service, + CronTimestamp: req.CronTimestamp, + Trigger: trigger, + URI: req.URI, + RequestStatus: requestStatus, + } + + response = checker.GRPCResponse{ + Timestamp: timestamp, + Timing: res.Timing, + Latency: res.Latency, + GRPCCode: res.GRPCCode, + ServingStatus: res.ServingStatus, + Service: req.Service, + ErrorMessage: errorMessage, + Completed: res.Completed, + Error: errorFlag, + Region: h.Region, + JobType: "grpc", + } + + switch { + case !res.Healthy && req.Status != "error": + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "error", + Message: res.Message, + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: res.Latency, + }) + case degraded && req.Status != "degraded": + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "degraded", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: res.Latency, + }) + case res.Healthy && !degraded && req.Status != "active": + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "active", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: res.Latency, + }) + } + + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + + return struct{}{}, nil + } + + if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))); err != nil { + id, e := uuid.NewV7() + if e != nil { + log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") + return + } + // A marshal failure leaves the column empty rather than dropping the row: + // the rest of the failure record is still worth ingesting. + timingAsString, timingErr := json.Marshal(lastResult.Timing) + if timingErr != nil { + log.Ctx(ctx).Error().Err(timingErr).Msg("error while parsing timing data") + } + + data := GRPCData{ + ID: id.String(), + WorkspaceID: workspaceId, + CronTimestamp: req.CronTimestamp, + Timestamp: time.Now().UTC().UnixMilli(), + ErrorMessage: err.Error(), + Region: h.Region, + MonitorID: monitorId, + Error: 1, + Trigger: trigger, + URI: req.URI, + Service: req.Service, + Timing: string(timingAsString), + GRPCCode: lastResult.GRPCCode, + RequestStatus: "error", + } + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "error", + Message: err.Error(), + Region: h.Region, + CronTimestamp: req.CronTimestamp, + }) + + // Only the success path inside op() fills these in, so a check that + // exhausted its retries would otherwise be returned under `?data=true` + // with an empty jobType and region — a shape no caller can parse. + response.JobType = "grpc" + response.Region = h.Region + response.ErrorMessage = err.Error() + response.Error = 1 + response.Timing = lastResult.Timing + response.GRPCCode = lastResult.GRPCCode + } + + if req.OtelConfig.Endpoint != "" { + otelOS.RecordGRPCMetrics(ctx, req, response, h.Region) + } + + returnData := c.Query("data") + if returnData == "true" { + c.JSON(http.StatusOK, response) + + return + } + + c.JSON(http.StatusOK, nil) +} + +func (h Handler) GRPCHandlerRegion(c *gin.Context) { + ctx := c.Request.Context() + dataSourceName := "check_grpc_response__v0" + + region := c.Param("region") + if region == "" { + c.String(http.StatusBadRequest, "region is required") + + return + } + + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + + return + } + + if h.CloudProvider == "fly" { + // if the request has been routed to a wrong region, we forward it to the correct one. + region := c.GetHeader("fly-prefer-region") + if region != "" && region != h.Region { + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) + c.String(http.StatusAccepted, "Forwarding request to %s", region) + + return + } + } + + var req request.GRPCCheckerRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + var response checker.GRPCResponse + + var retry int + if req.Retry != 0 { + retry = int(req.Retry) + } else { + retry = 3 + } + + // Same as GRPCHandler: op() discards `res` when it hands an error back to the + // retry loop, so keep what the last attempt learned. + var lastResult checker.GRPCResult + + op := func() (struct{}, error) { + timestamp := time.Now().UTC().UnixMilli() + res, err := grpcCheck(req) + lastResult = res + if err != nil { + return struct{}{}, fmt.Errorf("unable to check grpc %s", err) + } + + errorFlag := uint8(0) + errorMessage := "" + if !res.Healthy { + errorFlag = 1 + errorMessage = res.Message + } + + response = checker.GRPCResponse{ + Timestamp: timestamp, + Timing: res.Timing, + Latency: res.Latency, + GRPCCode: res.GRPCCode, + ServingStatus: res.ServingStatus, + Service: req.Service, + ErrorMessage: errorMessage, + Completed: res.Completed, + Error: errorFlag, + Region: h.Region, + JobType: "grpc", + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + } + + data := GRPCData{ + CronTimestamp: req.CronTimestamp, + Timestamp: timestamp, + Error: errorFlag, + ErrorMessage: errorMessage, + Region: h.Region, + Timing: string(timingAsString), + Latency: res.Latency, + GRPCCode: res.GRPCCode, + ServingStatus: res.ServingStatus, + Service: req.Service, + RequestId: req.RequestId, + Trigger: "api", + URI: req.URI, + } + + if req.RequestId != 0 { + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + } + + return struct{}{}, nil + } + + _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))) + if err != nil { + response.JobType = "grpc" + response.Region = h.Region + response.ErrorMessage = err.Error() + response.Error = 1 + response.Timing = lastResult.Timing + response.GRPCCode = lastResult.GRPCCode + } + + if req.OtelConfig.Endpoint != "" { + otelOS.RecordGRPCMetrics(ctx, req, response, region) + } + + if err != nil { + c.JSON(http.StatusOK, gin.H{"message": "uri not reachable"}) + + return + } + + c.JSON(http.StatusOK, response) +} diff --git a/apps/checker/handlers/grpc_test.go b/apps/checker/handlers/grpc_test.go new file mode 100644 index 00000000..7426f800 --- /dev/null +++ b/apps/checker/handlers/grpc_test.go @@ -0,0 +1,355 @@ +package handlers_test + +import ( + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/gin-gonic/gin" + "github.com/openstatushq/openstatus/apps/checker/checker" + "github.com/openstatushq/openstatus/apps/checker/handlers" + "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" +) + +// grpcTestServer starts a plaintext health server and returns its host:port. +func grpcTestServer(t *testing.T, status grpc_health_v1.HealthCheckResponse_ServingStatus) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + server := grpc.NewServer() + healthServer := health.NewServer() + healthServer.SetServingStatus("", status) + grpc_health_v1.RegisterHealthServer(server, healthServer) + + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(server.Stop) + + return listener.Addr().String() +} + +func TestGRPCHandler_RejectsUnauthorized(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc", strings.NewReader(`{"uri":"127.0.0.1:1"}`)) + r.Header.Set("Authorization", "Basic wrong") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestGRPCHandler_RejectsBadPayload(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc", strings.NewReader(`{not json`)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestGRPCHandlerRegion_RejectsUnauthorized(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/grpc/:region", h.GRPCHandlerRegion) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/grpc/local", strings.NewReader(`{"uri":"127.0.0.1:1"}`)) + r.Header.Set("Authorization", "Basic wrong") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestGRPCHandler_ServingResponse(t *testing.T) { + target := grpcTestServer(t, grpc_health_v1.HealthCheckResponse_SERVING) + + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + body := `{"uri":"` + target + `","tls":"plaintext","timeout":5000,"retry":1,"status":"active","workspaceId":"1","monitorId":"1"}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc?data=true", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + + var res map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + + assert.Equal(t, "grpc", res["jobType"]) + assert.Equal(t, "local", res["region"]) + assert.Equal(t, "SERVING", res["servingStatus"]) + assert.Equal(t, true, res["completed"]) + assert.NotContains(t, res, "error") +} + +// A NOT_SERVING answer is a failed check that completed. It must keep its +// serving status and its measured timing, and must not be retried. +func TestGRPCHandler_NotServingResponse(t *testing.T) { + target := grpcTestServer(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING) + + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + body := `{"uri":"` + target + `","tls":"plaintext","timeout":5000,"retry":1,"status":"error","workspaceId":"1","monitorId":"1"}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc?data=true", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + + var res map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + + assert.Equal(t, "grpc", res["jobType"]) + assert.Equal(t, "NOT_SERVING", res["servingStatus"]) + assert.Equal(t, true, res["completed"]) + assert.Equal(t, float64(1), res["error"]) + assert.Equal(t, "service reports NOT_SERVING", res["errorMessage"]) +} + +// A check that exhausts its retries is still returned under `?data=true`, so it +// has to carry the same identifying fields as a successful one — the success +// path sets them inside op(), which never runs when every attempt fails. +func TestGRPCHandler_FailureResponseKeepsJobTypeAndRegion(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + closed := listener.Addr().String() + require.NoError(t, listener.Close()) + + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + body := `{"uri":"` + closed + `","tls":"plaintext","timeout":500,"retry":1,"status":"error","workspaceId":"1","monitorId":"1"}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc?data=true", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + + var res map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + + assert.Equal(t, "grpc", res["jobType"], "jobType is the discriminator callers match on") + assert.Equal(t, "local", res["region"]) + assert.Equal(t, float64(1), res["error"]) + assert.Equal(t, false, res["completed"]) + + // A failed check is when the phase breakdown matters most: it is what + // separates "DNS never resolved" from "connected, TLS refused". + timing, ok := res["timing"].(map[string]any) + require.True(t, ok, "timing must be present on the failure response") + assert.Greater(t, timing["dnsStart"], float64(0), "resolution was attempted") + assert.Greater(t, timing["dnsDone"], float64(0), "127.0.0.1 resolves") + assert.Greater(t, timing["connectStart"], float64(0), "the dial was attempted") + assert.Equal(t, float64(0), timing["connectDone"], "the port is closed, so connect never completed") + + // OK(0) on a check that never reached the server would make a refused + // connection indistinguishable from a timeout. + assert.NotZero(t, res["grpcCode"], "a transport failure carries its status code, not OK") +} + +// capturingTinybird records the payloads the handler ships, so a test can +// assert on the ingested row rather than only the HTTP response. +func capturingTinybird(t *testing.T, sent *[][]byte) tinybird.Client { + t.Helper() + hclient := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response { + if req.Body != nil { + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + *sent = append(*sent, body) + } + + return &http.Response{ + StatusCode: http.StatusAccepted, + Body: io.NopCloser(strings.NewReader(`{}`)), + } + })} + + return tinybird.NewClient(hclient, "apiKey") +} + +func TestGRPCHandler_FailureEventCarriesTiming(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + closed := listener.Addr().String() + require.NoError(t, listener.Close()) + + var sent [][]byte + h := handlers.Handler{TbClient: capturingTinybird(t, &sent), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + body := `{"uri":"` + closed + `","tls":"plaintext","timeout":500,"retry":1,"status":"error","workspaceId":"1","monitorId":"1"}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + require.Len(t, sent, 1, "the exhausted-retries path ships exactly one row") + + var event struct { + Timing string `json:"timing"` + RequestStatus string `json:"requestStatus"` + GRPCCode int64 `json:"grpcCode"` + } + require.NoError(t, json.Unmarshal(sent[0], &event)) + assert.Equal(t, "error", event.RequestStatus) + assert.NotZero(t, event.GRPCCode, "a transport failure carries its status code, not OK") + + var timing checker.Timing + require.NoError(t, json.Unmarshal([]byte(event.Timing), &timing)) + assert.Greater(t, timing.DnsStart, int64(0), "resolution was attempted") + assert.Greater(t, timing.ConnectStart, int64(0), "the dial was attempted") + assert.Zero(t, timing.ConnectDone, "the port is closed, so connect never completed") +} + +func TestGRPCHandlerRegion_ReturnsResult(t *testing.T) { + target := grpcTestServer(t, grpc_health_v1.HealthCheckResponse_SERVING) + + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/grpc/:region", h.GRPCHandlerRegion) + + body := `{"uri":"` + target + `","tls":"plaintext","timeout":5000}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/grpc/local", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + + var res map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + + assert.Equal(t, "grpc", res["jobType"]) + assert.Equal(t, "SERVING", res["servingStatus"]) +} + +// `retry` counts attempts, not extra tries on top of the first: v5's +// WithMaxTries(n) runs op exactly n times, so the cron/API path and the +// private-location path in pkg/job probe a monitor the same number of times. +// Counting accepted connections is exact here because every attempt builds a +// fresh client, and a connection closed mid-handshake leaves grpc-go nothing +// to reuse. +func TestGRPCHandler_ProbesExactlyRetryTimes(t *testing.T) { + for _, retry := range []int{1, 2, 3} { + t.Run("retry="+strconv.Itoa(retry), func(t *testing.T) { + var accepts int64 + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, err := ln.Accept() + if err != nil { + return + } + atomic.AddInt64(&accepts, 1) + conn.Close() + } + }() + + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + body := `{"uri":"` + ln.Addr().String() + `","tls":"plaintext","timeout":500,"retry":` + + strconv.Itoa(retry) + `,"status":"error","workspaceId":"1","monitorId":"1"}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + require.NoError(t, ln.Close()) + <-done + + assert.Equal(t, int64(retry), atomic.LoadInt64(&accepts), + "retry:%d must probe %d times", retry, retry) + }) + } +} + +// grpcTestServerNoHealth starts a plaintext gRPC server with no services +// registered, so Health/Check answers UNIMPLEMENTED. +func grpcTestServerNoHealth(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + server := grpc.NewServer() + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(server.Stop) + + return listener.Addr().String() +} + +// A server that answers UNIMPLEMENTED reached the wire and timed a real round +// trip, so its row must carry a serving status. A NULL one means "never reached +// the server" to the metrics pipes, which would drop it from every latency +// quantile alongside genuine transport failures. +func TestGRPCHandler_UnimplementedRowCarriesServingStatus(t *testing.T) { + target := grpcTestServerNoHealth(t) + + var sent [][]byte + h := handlers.Handler{ + TbClient: capturingTinybird(t, &sent), + Secret: "test", + Region: "local", + } + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + // status:"error" matches the outcome, so the handler skips its status-change + // callback and the test makes no outbound request. + body := `{"uri":"` + target + `","tls":"plaintext","timeout":5000,"retry":1,"status":"error","workspaceId":"1","monitorId":"1"}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc?data=true", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + require.Len(t, sent, 1, "a completed check ships exactly one row") + + var event struct { + ServingStatus string `json:"servingStatus"` + RequestStatus string `json:"requestStatus"` + } + require.NoError(t, json.Unmarshal(sent[0], &event)) + assert.Equal(t, "UNIMPLEMENTED", event.ServingStatus) + // Still an unhealthy check — only its visibility to the pipes changed. + assert.Equal(t, "error", event.RequestStatus) +} diff --git a/apps/checker/handlers/icmp.go b/apps/checker/handlers/icmp.go index c50b90da..9efa0dac 100644 --- a/apps/checker/handlers/icmp.go +++ b/apps/checker/handlers/icmp.go @@ -7,7 +7,7 @@ import ( "strconv" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/openstatushq/openstatus/apps/checker/checker" @@ -111,15 +111,15 @@ func (h Handler) ICMPHandler(c *gin.Context) { retry = 3 } - op := func() error { + op := func() (struct{}, error) { res, err := checker.PingICMP(req.Timeout, req.URI) if err != nil { - return fmt.Errorf("unable to check icmp %s", err) + return struct{}{}, fmt.Errorf("unable to check icmp %s", err) } timingAsString, err := json.Marshal(res.Timing) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) } latency := res.Latency @@ -136,7 +136,7 @@ func (h Handler) ICMPHandler(c *gin.Context) { id, err := uuid.NewV7() if err != nil { - return fmt.Errorf("error while generating uuid %w", err) + return struct{}{}, fmt.Errorf("error while generating uuid %w", err) } timestamp := time.Now().UTC().UnixMilli() @@ -210,10 +210,10 @@ func (h Handler) ICMPHandler(c *gin.Context) { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } - return nil + return struct{}{}, nil } - if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), uint64(retry))); err != nil { + if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))); err != nil { id, e := uuid.NewV7() if e != nil { log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") @@ -302,11 +302,11 @@ func (h Handler) ICMPHandlerRegion(c *gin.Context) { var response checker.ICMPResponse - op := func() error { + op := func() (struct{}, error) { timestamp := time.Now().UTC().UnixMilli() res, err := checker.PingICMP(req.Timeout, req.URI) if err != nil { - return fmt.Errorf("unable to check icmp %s", err) + return struct{}{}, fmt.Errorf("unable to check icmp %s", err) } response = checker.ICMPResponse{ @@ -323,7 +323,7 @@ func (h Handler) ICMPHandlerRegion(c *gin.Context) { timingAsString, err := json.Marshal(res.Timing) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) } data := ICMPData{ @@ -349,10 +349,10 @@ func (h Handler) ICMPHandlerRegion(c *gin.Context) { } } - return nil + return struct{}{}, nil } - err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)) + _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(3)) if err != nil { response.Error = 1 } diff --git a/apps/checker/handlers/otel_wiring_test.go b/apps/checker/handlers/otel_wiring_test.go index 4bbbf03e..8c37dddf 100644 --- a/apps/checker/handlers/otel_wiring_test.go +++ b/apps/checker/handlers/otel_wiring_test.go @@ -198,3 +198,33 @@ func TestDNSHandlerRegion_ExportsOTLPOnFailure(t *testing.T) { assert.Eventually(t, func() bool { return atomic.LoadInt64(count) > 0 }, 10*time.Second, 50*time.Millisecond, "expected an OTLP export on DNS failure") } + +func TestGRPCHandlerRegion_ExportsOTLPOnFailure(t *testing.T) { + otlp, count := countingOTLPServer(t) + + h := handlers.Handler{ + TbClient: testTinybird(t), + Secret: "test", + Region: "local", + } + router := gin.New() + router.POST("/grpc/:region", h.GRPCHandlerRegion) + + req := request.GRPCCheckerRequest{ + URI: "127.0.0.1:1", // connection refused + TLS: "plaintext", + Status: "active", + Timeout: 1000, + } + req.OtelConfig.Endpoint = otlp.URL + body, _ := json.Marshal(req) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/grpc/local", strings.NewReader(string(body))) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Eventually(t, func() bool { return atomic.LoadInt64(count) > 0 }, 10*time.Second, 50*time.Millisecond, + "expected an OTLP export on gRPC failure") +} diff --git a/apps/checker/handlers/ping.go b/apps/checker/handlers/ping.go index ddc3032f..9283d481 100644 --- a/apps/checker/handlers/ping.go +++ b/apps/checker/handlers/ping.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/gin-gonic/gin" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/request" @@ -85,7 +85,7 @@ func (h Handler) PingRegionHandler(c *gin.Context) { var res checker.Response - op := func() error { + op := func() (struct{}, error) { headers := make([]struct { Key string `json:"key"` @@ -109,17 +109,17 @@ func (h Handler) PingRegionHandler(c *gin.Context) { r, err := checker.Http(c.Request.Context(), requestClient, input) if err != nil { - return fmt.Errorf("unable to ping: %w", err) + return struct{}{}, fmt.Errorf("unable to ping: %w", err) } timingAsString, err := json.Marshal(r.Timing) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) } headersAsString, err := json.Marshal(r.Headers) if err != nil { - return nil + return struct{}{}, nil } tbData := PingResponse{ @@ -143,9 +143,9 @@ func (h Handler) PingRegionHandler(c *gin.Context) { } } - return nil + return struct{}{}, nil } - if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { + if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(3)); err != nil { c.JSON(http.StatusOK, gin.H{"message": "url not reachable"}) return diff --git a/apps/checker/handlers/tcp.go b/apps/checker/handlers/tcp.go index 678277cb..12a06a8f 100644 --- a/apps/checker/handlers/tcp.go +++ b/apps/checker/handlers/tcp.go @@ -7,7 +7,7 @@ import ( "strconv" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/openstatushq/openstatus/apps/checker/checker" @@ -108,16 +108,16 @@ func (h Handler) TCPHandler(c *gin.Context) { retry = 3 } - op := func() error { + op := func() (struct{}, error) { res, err := checker.PingTCP(int(req.Timeout), req.URI) if err != nil { - return fmt.Errorf("unable to check tcp %s", err) + return struct{}{}, fmt.Errorf("unable to check tcp %s", err) } timingAsString, err := json.Marshal(res) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) } latency := res.TCPDone - res.TCPStart @@ -135,7 +135,7 @@ func (h Handler) TCPHandler(c *gin.Context) { id, err := uuid.NewV7() if err != nil { - return fmt.Errorf("error while generating uuid %w", err) + return struct{}{}, fmt.Errorf("error while generating uuid %w", err) } data := TCPData{ @@ -204,10 +204,10 @@ func (h Handler) TCPHandler(c *gin.Context) { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } - return nil + return struct{}{}, nil } - if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), uint64(retry))); err != nil { + if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))); err != nil { id, e := uuid.NewV7() if e != nil { @@ -295,13 +295,13 @@ func (h Handler) TCPHandlerRegion(c *gin.Context) { var response checker.TCPResponse - op := func() error { + op := func() (struct{}, error) { called++ timestamp := time.Now().UTC().UnixMilli() res, err := checker.PingTCP(int(req.Timeout), req.URI) if err != nil { - return fmt.Errorf("unable to check tcp %s", err) + return struct{}{}, fmt.Errorf("unable to check tcp %s", err) } response = checker.TCPResponse{ @@ -317,7 +317,7 @@ func (h Handler) TCPHandlerRegion(c *gin.Context) { timingAsString, err := json.Marshal(res) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) } latency := res.TCPDone - res.TCPStart @@ -341,10 +341,10 @@ func (h Handler) TCPHandlerRegion(c *gin.Context) { } } - return nil + return struct{}{}, nil } - err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)) + _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(3)) if err != nil { response.Error = 1 } diff --git a/apps/checker/pkg/job/grpc_job.go b/apps/checker/pkg/job/grpc_job.go new file mode 100644 index 00000000..ee592d99 --- /dev/null +++ b/apps/checker/pkg/job/grpc_job.go @@ -0,0 +1,194 @@ +package job + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/cenkalti/backoff/v5" + "github.com/google/uuid" + "github.com/openstatushq/openstatus/apps/checker/checker" + "github.com/openstatushq/openstatus/apps/checker/pkg/otel" + v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" + "github.com/openstatushq/openstatus/apps/checker/request" +) + +// GRPCPrivateRegionData represents the result of a gRPC monitor check +type GRPCPrivateRegionData struct { + ID string `json:"id"` + URI string `json:"uri"` + Service string `json:"service"` + ServingStatus string `json:"serving_status"` + RequestStatus string `json:"request_status"` + Message string `json:"message"` + Latency int64 `json:"latency"` + GRPCCode int64 `json:"grpc_code"` + Timestamp int64 `json:"timestamp"` + CronTimestamp int64 `json:"cron_timestamp"` + Error int `json:"error"` + Timing string `json:"timing"` +} + +func (jobRunner) GRPCJob(ctx context.Context, monitor *v1.GRPCMonitor, region string) (*GRPCPrivateRegionData, error) { + retry := monitor.Retry + if retry == 0 { + retry = 3 + } + + var degradedAfter int64 + if monitor.DegradedAt != nil { + degradedAfter = *monitor.DegradedAt + } + + req := grpcCheckerRequest(monitor) + + var called int + var lastResult checker.GRPCResponse + + op := func() (*GRPCPrivateRegionData, error) { + called++ + start := time.Now().UTC().UnixMilli() + + res, err := checker.CheckGRPC( + monitor.Timeout, + monitor.Uri, + monitor.Service, + checker.ParseGRPCTLSMode(monitor.TlsMode), + headersToMap(monitor.GetMetadata()), + ) + if err != nil { + // Only a call that never reached the server is worth repeating. + if called < int(retry) { + return nil, fmt.Errorf("gRPC check failed: %w", err) + } + + data, dataErr := newGRPCData(monitor.Uri, monitor.Service, start) + if dataErr != nil { + return nil, dataErr + } + + lastResult = checker.GRPCResponse{Error: 1, GRPCCode: res.GRPCCode} + + data.RequestStatus = "error" + data.Error = 1 + data.GRPCCode = res.GRPCCode + data.Message = err.Error() + data.Timing = marshalGRPCTiming(res.Timing) + + return data, nil + } + + // The server answered. Whatever it said, asking again cannot change it, + // so this path never returns an error to the retry loop. + errorFlag := 0 + if !res.Healthy { + errorFlag = 1 + } + + lastResult = checker.GRPCResponse{ + Latency: res.Latency, + Timing: res.Timing, + ServingStatus: res.ServingStatus, + GRPCCode: res.GRPCCode, + Completed: true, + Error: uint8(errorFlag), + } + + // "success", not "active": the Tinybird gRPC status and uptime pipes + // count `requestStatus = 'success'`, and the other jobs report it that way. + requestStatus := "success" + switch { + case !res.Healthy: + requestStatus = "error" + case degradedAfter > 0 && res.Latency > degradedAfter: + requestStatus = "degraded" + } + + data, err := newGRPCData(monitor.Uri, monitor.Service, start) + if err != nil { + return nil, err + } + + data.Latency = res.Latency + data.GRPCCode = res.GRPCCode + data.ServingStatus = res.ServingStatus + data.RequestStatus = requestStatus + data.Error = errorFlag + data.Message = res.Message + data.Timing = marshalGRPCTiming(res.Timing) + + return data, nil + } + + resp, err := backoff.Retry(ctx, op, + backoff.WithMaxTries(uint(retry)), + backoff.WithBackOff(backoff.NewExponentialBackOff()), + ) + + recordGRPCOtel(ctx, req, lastResult, region, err != nil) + + if err != nil { + return nil, fmt.Errorf("gRPC job failed after %d retries: %w", retry, err) + } + + return resp, nil +} + +// newGRPCData stamps the fields every result must carry regardless of outcome. +// `Timestamp`/`CronTimestamp` are required: ValidateIngestGRPCRequest rejects a +// non-positive timestamp, so a result missing them is dropped at ingest. +func newGRPCData(uri, service string, start int64) (*GRPCPrivateRegionData, error) { + id, err := uuid.NewV7() + if err != nil { + return nil, fmt.Errorf("failed to generate UUID: %w", err) + } + + return &GRPCPrivateRegionData{ + ID: id.String(), + URI: uri, + Service: service, + Timestamp: start, + CronTimestamp: start, + }, nil +} + +// marshalGRPCTiming keeps whatever phases completed. A transport failure still +// carries the ones it got through, which is what separates a DNS failure from a +// TLS one once the row is in Tinybird. +func marshalGRPCTiming(timing checker.GRPCResponseTiming) string { + encoded, err := json.Marshal(timing) + if err != nil { + return "" + } + + return string(encoded) +} + +func grpcCheckerRequest(monitor *v1.GRPCMonitor) request.GRPCCheckerRequest { + req := request.GRPCCheckerRequest{ + URI: monitor.Uri, + Service: monitor.Service, + TLS: monitor.TlsMode, + Headers: headersToMap(monitor.GetMetadata()), + } + if otelCfg := monitor.GetOtelConfig(); otelCfg.GetEndpoint() != "" { + req.OtelConfig.Endpoint = otelCfg.GetEndpoint() + req.OtelConfig.Headers = headersToMap(otelCfg.GetHeaders()) + } + + return req +} + +func recordGRPCOtel(ctx context.Context, req request.GRPCCheckerRequest, result checker.GRPCResponse, region string, failed bool) { + if req.OtelConfig.Endpoint == "" { + return + } + + if failed { + result.Error = 1 + result.Completed = false + } + + otel.RecordGRPCMetrics(ctx, req, result, region) +} diff --git a/apps/checker/pkg/job/grpc_job_test.go b/apps/checker/pkg/job/grpc_job_test.go new file mode 100644 index 00000000..88b19f54 --- /dev/null +++ b/apps/checker/pkg/job/grpc_job_test.go @@ -0,0 +1,223 @@ +package job_test + +import ( + "context" + "encoding/json" + "net" + "testing" + "time" + + "github.com/openstatushq/openstatus/apps/checker/pkg/job" + v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" +) + +func grpcJobServer(t *testing.T, status grpc_health_v1.HealthCheckResponse_ServingStatus, delay time.Duration) (string, func() int) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + calls := 0 + server := grpc.NewServer(grpc.UnaryInterceptor( + func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + calls++ + if delay > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return handler(ctx, req) + }, + )) + + healthServer := health.NewServer() + healthServer.SetServingStatus("", status) + grpc_health_v1.RegisterHealthServer(server, healthServer) + + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(server.Stop) + + return listener.Addr().String(), func() int { return calls } +} + +func closedGRPCPort(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + return addr +} + +func TestGRPCJobServing(t *testing.T) { + target, _ := grpcJobServer(t, grpc_health_v1.HealthCheckResponse_SERVING, 0) + + data, err := job.NewJobRunner().GRPCJob(context.Background(), &v1.GRPCMonitor{ + Id: "1", + Uri: target, + TlsMode: "plaintext", + Timeout: 5000, + Retry: 3, + }, "ams") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if data.RequestStatus != "success" { + t.Fatalf(`expected "success", got %q`, data.RequestStatus) + } + if data.ServingStatus != "SERVING" { + t.Fatalf("expected SERVING, got %q", data.ServingStatus) + } + if data.Error != 0 { + t.Fatalf("expected error 0, got %d", data.Error) + } + assertStamped(t, data) +} + +// ValidateIngestGRPCRequest rejects a non-positive timestamp, so a result that +// forgets to stamp them is silently dropped at ingest. +func assertStamped(t *testing.T, data *job.GRPCPrivateRegionData) { + t.Helper() + + if data.Timestamp <= 0 { + t.Fatalf("Timestamp must be stamped, got %d", data.Timestamp) + } + if data.CronTimestamp <= 0 { + t.Fatalf("CronTimestamp must be stamped, got %d", data.CronTimestamp) + } + if data.ID == "" { + t.Fatal("ID must be stamped") + } +} + +func TestGRPCJobNotServingIsProbedOnce(t *testing.T) { + target, calls := grpcJobServer(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING, 0) + + data, err := job.NewJobRunner().GRPCJob(context.Background(), &v1.GRPCMonitor{ + Id: "1", + Uri: target, + TlsMode: "plaintext", + Timeout: 5000, + Retry: 3, + }, "ams") + if err != nil { + t.Fatalf("NOT_SERVING is an answer, not a job failure: %v", err) + } + + if data.RequestStatus != "error" { + t.Fatalf(`expected "error", got %q`, data.RequestStatus) + } + if data.Error != 1 { + t.Fatalf("expected error 1, got %d", data.Error) + } + if got := calls(); got != 1 { + t.Fatalf("a definitive answer must be probed once, got %d probes", got) + } + assertStamped(t, data) +} + +func TestGRPCJobDegraded(t *testing.T) { + // Loopback rounds to 0 ms, so the threshold can only be crossed by a server + // that actually takes time to answer. + target, _ := grpcJobServer(t, grpc_health_v1.HealthCheckResponse_SERVING, 40*time.Millisecond) + + degradedAt := int64(10) + data, err := job.NewJobRunner().GRPCJob(context.Background(), &v1.GRPCMonitor{ + Id: "1", + Uri: target, + TlsMode: "plaintext", + Timeout: 5000, + Retry: 3, + DegradedAt: °radedAt, + }, "ams") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if data.RequestStatus != "degraded" { + t.Fatalf(`expected "degraded", got %q`, data.RequestStatus) + } + if data.Error != 0 { + t.Fatalf("a degraded check is not an error, got %d", data.Error) + } +} + +// A transport failure that exhausts its retries still has to produce a stamped +// row: returning (nil, err) would leave the scheduler nothing to forward. +func TestGRPCJobTransportFailureStillStamps(t *testing.T) { + data, err := job.NewJobRunner().GRPCJob(context.Background(), &v1.GRPCMonitor{ + Id: "1", + Uri: closedGRPCPort(t), + TlsMode: "plaintext", + Timeout: 300, + Retry: 1, + }, "ams") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if data.RequestStatus != "error" { + t.Fatalf(`expected "error", got %q`, data.RequestStatus) + } + if data.Error != 1 { + t.Fatalf("expected error 1, got %d", data.Error) + } + if data.Message == "" { + t.Fatal("a failed check must carry its diagnosis") + } + assertStamped(t, data) +} + +// The phases that completed before the failure are what separate a DNS problem +// from a TLS one once the row is in Tinybird. +func TestGRPCJobKeepsPartialTiming(t *testing.T) { + data, err := job.NewJobRunner().GRPCJob(context.Background(), &v1.GRPCMonitor{ + Id: "1", + Uri: closedGRPCPort(t), + TlsMode: "plaintext", + Timeout: 300, + Retry: 1, + }, "ams") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var timing map[string]int64 + if err := json.Unmarshal([]byte(data.Timing), &timing); err != nil { + t.Fatalf("timing must be valid json, got %q: %v", data.Timing, err) + } + if _, ok := timing["dnsStart"]; !ok { + t.Fatalf("expected the HTTP phase shape, got %v", timing) + } +} + +func TestGRPCJobRespectsContextCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + if _, err := job.NewJobRunner().GRPCJob(ctx, &v1.GRPCMonitor{ + Id: "1", + Uri: closedGRPCPort(t), + TlsMode: "plaintext", + Timeout: 5000, + Retry: 5, + }, "ams"); err == nil { + t.Fatal("expected the cancelled context to end the job") + } +} diff --git a/apps/checker/pkg/job/job.go b/apps/checker/pkg/job/job.go index 74518d41..f768bca6 100644 --- a/apps/checker/pkg/job/job.go +++ b/apps/checker/pkg/job/job.go @@ -32,6 +32,7 @@ type JobRunner interface { HTTPJob(ctx context.Context, monitor *v1.HTTPMonitor, region string) (*HttpPrivateRegionData, error) DNSJob(ctx context.Context, monitor *v1.DNSMonitor) (*DNSPrivateRegionData, error) ICMPJob(ctx context.Context, monitor *v1.ICMPMonitor, region string) (*ICMPPrivateRegionData, error) + GRPCJob(ctx context.Context, monitor *v1.GRPCMonitor, region string) (*GRPCPrivateRegionData, error) } type jobRunner struct{} diff --git a/apps/checker/pkg/otel/otel.go b/apps/checker/pkg/otel/otel.go index c4938d36..dfce446e 100644 --- a/apps/checker/pkg/otel/otel.go +++ b/apps/checker/pkg/otel/otel.go @@ -215,6 +215,70 @@ func recordICMPInstruments(ctx context.Context, meter metric.Meter, result check } } +func RecordGRPCMetrics(ctx context.Context, req request.GRPCCheckerRequest, result checker.GRPCResponse, region string) { + withMeter(ctx, req.OtelConfig.Endpoint, req.OtelConfig.Headers, func(meter metric.Meter) { + att := metric.WithAttributes( + attribute.String("openstatus.probes", region), + attribute.String("openstatus.target", req.URI), + ) + recordGRPCInstruments(ctx, meter, result, att) + }) +} + +// recordGRPCInstruments branches on whether the RPC completed, not on the error +// flag: a NOT_SERVING answer sets the flag but did complete, and gating the +// gauges on the flag would leave openstatus.grpc.serving_status able to emit +// only 1. Split out of RecordGRPCMetrics so tests can supply a collectable meter. +func recordGRPCInstruments(ctx context.Context, meter metric.Meter, result checker.GRPCResponse, att metric.MeasurementOption) { + if !result.Completed { + recordErrorCounter(ctx, meter, att) + return + } + + gauges := []struct { + name string + description string + value float64 + }{ + {"openstatus.grpc.request.duration", "Duration of the check", float64(result.Latency)}, + {"openstatus.grpc.dns.duration", "Duration of the DNS lookup", grpcPhase(result.Timing.DnsStart, result.Timing.DnsDone)}, + {"openstatus.grpc.connection.duration", "Duration of the connection", grpcPhase(result.Timing.ConnectStart, result.Timing.ConnectDone)}, + {"openstatus.grpc.tls.duration", "Duration of the TLS handshake", grpcPhase(result.Timing.TlsHandshakeStart, result.Timing.TlsHandshakeDone)}, + {"openstatus.grpc.ttfb.duration", "Duration of the TTFB", grpcPhase(result.Timing.FirstByteStart, result.Timing.FirstByteDone)}, + } + + for _, g := range gauges { + if err := recordGauge(ctx, meter, g.name, g.description, g.value, att); err != nil { + log.Ctx(ctx).Error().Err(err).Str("metric", g.name).Msg("Error creating gauge") + } + } + + serving := float64(0) + if result.ServingStatus == checker.ServingStatusServing { + serving = 1 + } + + if err := recordGauge(ctx, meter, "openstatus.grpc.serving_status", "Serving status of the target", serving, att); err != nil { + log.Ctx(ctx).Error().Err(err).Str("metric", "openstatus.grpc.serving_status").Msg("Error creating gauge") + } + + if serving == 1 { + recordStatusCounter(ctx, meter, att) + } else { + recordErrorCounter(ctx, meter, att) + } +} + +// grpcPhase mirrors calculateTiming: a phase whose hook never fired leaves a +// zero behind, and subtracting absolute epoch stamps would report a huge value. +func grpcPhase(start, done int64) float64 { + if start == 0 || done == 0 { + return 0 + } + + return float64(done - start) +} + func RecordTCPMetrics(ctx context.Context, req request.TCPCheckerRequest, result checker.TCPResponse, region string) { withMeter(ctx, req.OtelConfig.Endpoint, req.OtelConfig.Headers, func(meter metric.Meter) { att := metric.WithAttributes( diff --git a/apps/checker/pkg/otel/otel_test.go b/apps/checker/pkg/otel/otel_test.go index c3428119..f86bc91b 100644 --- a/apps/checker/pkg/otel/otel_test.go +++ b/apps/checker/pkg/otel/otel_test.go @@ -416,3 +416,102 @@ func TestRecordDNSMetrics_SetupFailure(t *testing.T) { // Must not panic — same nil pointer guard as HTTP. RecordDNSMetrics(context.Background(), req, 30, false, "us-east-1") } + +// grpcMetricValues collects every gauge and counter one recordGRPCInstruments +// call produced, keyed by metric name. +func grpcMetricValues(t *testing.T, reader *sdkMetrics.ManualReader) (map[string]float64, map[string]int64) { + t.Helper() + + gauges := map[string]float64{} + counters := map[string]int64{} + for _, sm := range collectMetrics(t, reader).ScopeMetrics { + for _, m := range sm.Metrics { + if gauge, ok := m.Data.(metricdata.Gauge[float64]); ok && len(gauge.DataPoints) == 1 { + gauges[m.Name] = gauge.DataPoints[0].Value + } + if sum, ok := m.Data.(metricdata.Sum[int64]); ok && len(sum.DataPoints) == 1 { + counters[m.Name] = sum.DataPoints[0].Value + } + } + } + + return gauges, counters +} + +func TestRecordGRPCInstrumentsServing(t *testing.T) { + meter, reader := newTestMeter(t) + att := metric.WithAttributes(attribute.String("openstatus.probes", "ams")) + + recordGRPCInstruments(context.Background(), meter, checker.GRPCResponse{ + Latency: 120, + Completed: true, + ServingStatus: checker.ServingStatusServing, + Timing: checker.GRPCResponseTiming{ + DnsStart: 100, DnsDone: 104, + ConnectStart: 104, ConnectDone: 114, + TlsHandshakeStart: 114, TlsHandshakeDone: 150, + FirstByteStart: 150, FirstByteDone: 220, + }, + }, att) + + gauges, counters := grpcMetricValues(t, reader) + + assert.Equal(t, float64(120), gauges["openstatus.grpc.request.duration"]) + assert.Equal(t, float64(4), gauges["openstatus.grpc.dns.duration"]) + assert.Equal(t, float64(10), gauges["openstatus.grpc.connection.duration"]) + assert.Equal(t, float64(36), gauges["openstatus.grpc.tls.duration"]) + assert.Equal(t, float64(70), gauges["openstatus.grpc.ttfb.duration"]) + assert.Equal(t, float64(1), gauges["openstatus.grpc.serving_status"]) + + assert.Equal(t, int64(1), counters["openstatus.status"]) + assert.NotContains(t, counters, "openstatus.error") +} + +// A NOT_SERVING check sets the error flag but completed, so it must still +// report its real durations and a serving_status of 0. Gating on the error flag +// the way the ICMP path does would leave this gauge only ever emitting 1. +func TestRecordGRPCInstrumentsNotServing(t *testing.T) { + meter, reader := newTestMeter(t) + att := metric.WithAttributes(attribute.String("openstatus.probes", "ams")) + + recordGRPCInstruments(context.Background(), meter, checker.GRPCResponse{ + Latency: 90, + Completed: true, + Error: 1, + ServingStatus: checker.ServingStatusNotServing, + Timing: checker.GRPCResponseTiming{ + FirstByteStart: 10, FirstByteDone: 100, + }, + }, att) + + gauges, counters := grpcMetricValues(t, reader) + + assert.Equal(t, float64(0), gauges["openstatus.grpc.serving_status"]) + assert.Equal(t, float64(90), gauges["openstatus.grpc.request.duration"]) + assert.Equal(t, float64(90), gauges["openstatus.grpc.ttfb.duration"]) + assert.Equal(t, int64(1), counters["openstatus.error"]) + assert.NotContains(t, counters, "openstatus.status") +} + +func TestRecordGRPCInstrumentsTransportFailure(t *testing.T) { + meter, reader := newTestMeter(t) + att := metric.WithAttributes(attribute.String("openstatus.probes", "ams")) + + recordGRPCInstruments(context.Background(), meter, checker.GRPCResponse{ + Completed: false, + Error: 1, + }, att) + + gauges, counters := grpcMetricValues(t, reader) + + assert.Equal(t, int64(1), counters["openstatus.error"]) + assert.Empty(t, gauges, "a call that never completed has no durations to report") +} + +// A phase whose hook never fired leaves a zero, and subtracting absolute epoch +// stamps from it would report a value in the trillions. +func TestGRPCPhaseIgnoresUnfiredHooks(t *testing.T) { + assert.Equal(t, float64(0), grpcPhase(0, 1761000000000)) + assert.Equal(t, float64(0), grpcPhase(1761000000000, 0)) + assert.Equal(t, float64(25), grpcPhase(100, 125)) +} diff --git a/apps/checker/pkg/scheduler/scheduler.go b/apps/checker/pkg/scheduler/scheduler.go index 87cab525..21ab0c02 100644 --- a/apps/checker/pkg/scheduler/scheduler.go +++ b/apps/checker/pkg/scheduler/scheduler.go @@ -298,6 +298,60 @@ func (mm *MonitorManager) UpdateMonitors(ctx context.Context) { } } + for _, m := range res.Msg.GrpcMonitors { + currentIDs[m.Id] = struct{}{} + if mm.shouldSchedule(m.Id, m) { + + interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second + task := tasks.Task{ + Interval: interval, + RunOnce: false, + RunSingleInstance: true, + FuncWithTaskContext: func(ctx tasks.TaskContext) error { + + monitor := m + c := context.Background() + log.Printf("Starting gRPC job for monitor %s (%s)", monitor.Id, monitor.Uri) + data, err := mm.JobRunner.GRPCJob(c, monitor, res.Msg.Region) + if err != nil { + log.Printf("gRPC monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) + return err + } + resp, ingestErr := mm.Client.IngestGRPC(c, &connect.Request[v1.IngestGRPCRequest]{ + Msg: &v1.IngestGRPCRequest{ + MonitorId: monitor.Id, + Id: data.ID, + Uri: monitor.Uri, + Service: data.Service, + ServingStatus: data.ServingStatus, + GrpcCode: data.GRPCCode, + Message: data.Message, + Latency: data.Latency, + RequestStatus: data.RequestStatus, + Error: int64(data.Error), + CronTimestamp: data.CronTimestamp, + Timestamp: data.Timestamp, + Timing: data.Timing, + }, + }) + if ingestErr != nil { + log.Printf("Failed to ingest gRPC result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) + return ingestErr + } + log.Printf("gRPC monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Uri, resp) + + return nil + }, + } + err := mm.Scheduler.AddWithID(m.Id, &task) + if err != nil { + log.Printf("Failed to add gRPC monitor job for %s (%s): %v", m.Id, m.Uri, err) + continue + } + log.Printf("Started gRPC monitoring job for %s (%s)", m.Id, m.Uri) + } + } + mm.mu.Lock() for id := range mm.Scheduler.Tasks() { if _, stillExists := currentIDs[id]; !stillExists { diff --git a/apps/checker/pkg/scheduler/scheduler_test.go b/apps/checker/pkg/scheduler/scheduler_test.go index ba1013a5..65afde2d 100644 --- a/apps/checker/pkg/scheduler/scheduler_test.go +++ b/apps/checker/pkg/scheduler/scheduler_test.go @@ -2,6 +2,7 @@ package scheduler_test import ( "context" + "errors" "sync/atomic" "sync" @@ -20,6 +21,8 @@ type mockJobRunner struct { HTTPJobCalled atomic.Bool TCPJobCalled atomic.Bool DNSJobCalled atomic.Bool + GRPCJobCalled atomic.Bool + GRPCJobErr error mu sync.Mutex httpRegion string tcpRegion string @@ -78,6 +81,14 @@ func (m *mockJobRunner) ICMPJob(ctx context.Context, monitor *v1.ICMPMonitor, re return &job.ICMPPrivateRegionData{}, nil } +func (m *mockJobRunner) GRPCJob(ctx context.Context, monitor *v1.GRPCMonitor, region string) (*job.GRPCPrivateRegionData, error) { + m.GRPCJobCalled.Store(true) + if m.GRPCJobErr != nil { + return nil, m.GRPCJobErr + } + return &job.GRPCPrivateRegionData{ID: "grpc-result", Timestamp: 1, CronTimestamp: 1}, nil +} + // mockClient implements v1.PrivateLocationServiceClient for testing type mockClient struct { MonitorsFunc func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) @@ -85,6 +96,7 @@ type mockClient struct { IngestTCPFunc func(ctx context.Context, req *connect.Request[v1.IngestTCPRequest]) (*connect.Response[v1.IngestTCPResponse], error) IngestDNSFunc func(ctx context.Context, req *connect.Request[v1.IngestDNSRequest]) (*connect.Response[v1.IngestDNSResponse], error) IngestICMPFunc func(ctx context.Context, req *connect.Request[v1.IngestICMPRequest]) (*connect.Response[v1.IngestICMPResponse], error) + IngestGRPCFunc func(ctx context.Context, req *connect.Request[v1.IngestGRPCRequest]) (*connect.Response[v1.IngestGRPCResponse], error) } func (m *mockClient) Monitors(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { @@ -102,6 +114,9 @@ func (m *mockClient) IngestDNS(ctx context.Context, req *connect.Request[v1.Inge func (m *mockClient) IngestICMP(ctx context.Context, req *connect.Request[v1.IngestICMPRequest]) (*connect.Response[v1.IngestICMPResponse], error) { return m.IngestICMPFunc(ctx, req) } +func (m *mockClient) IngestGRPC(ctx context.Context, req *connect.Request[v1.IngestGRPCRequest]) (*connect.Response[v1.IngestGRPCResponse], error) { + return m.IngestGRPCFunc(ctx, req) +} func TestMonitorManager_StartAndStopJobs_WithJobRunner(t *testing.T) { ctx := t.Context() @@ -318,3 +333,87 @@ func TestMonitorManager_IngestsDNSResult(t *testing.T) { t.Errorf("expected the A records to be forwarded, got %v", got) } } + +func TestMonitorManager_SchedulesGRPCMonitors(t *testing.T) { + ctx := t.Context() + + grpcMonitor := &v1.GRPCMonitor{Id: "grpc1", Uri: "api.example.com:443", Periodicity: "10s", TlsMode: "tls"} + + var ingested atomic.Bool + var ingestedID atomic.Value + + client := &mockClient{ + MonitorsFunc: func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { + return connect.NewResponse(&v1.MonitorsResponse{ + GrpcMonitors: []*v1.GRPCMonitor{grpcMonitor}, + Region: "frankfurt-dc1", + }), nil + }, + IngestGRPCFunc: func(ctx context.Context, req *connect.Request[v1.IngestGRPCRequest]) (*connect.Response[v1.IngestGRPCResponse], error) { + ingested.Store(true) + ingestedID.Store(req.Msg.Id) + return connect.NewResponse(&v1.IngestGRPCResponse{}), nil + }, + } + + jobRunner := &mockJobRunner{} + s := tasks.New() + defer s.Stop() + + mm := &scheduler.MonitorManager{Client: client, JobRunner: jobRunner, Scheduler: s} + mm.UpdateMonitors(ctx) + + runScheduledTask(t, mm.Scheduler, "grpc1") + + if !jobRunner.GRPCJobCalled.Load() { + t.Error("expected GRPCJob to be called") + } + if !ingested.Load() { + t.Error("expected IngestGRPC to be called") + } + if got := ingestedID.Load(); got != "grpc-result" { + t.Errorf("expected the job result to be forwarded, got %v", got) + } +} + +// GRPCJob returns (nil, err) when the retry loop itself fails. The task must +// stop there: reading data.ID after only logging the error would panic. +func TestMonitorManager_GRPCJobErrorSkipsIngest(t *testing.T) { + ctx := t.Context() + + grpcMonitor := &v1.GRPCMonitor{Id: "grpc-fail", Uri: "api.example.com:443", Periodicity: "10s"} + + var ingested atomic.Bool + + client := &mockClient{ + MonitorsFunc: func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { + return connect.NewResponse(&v1.MonitorsResponse{ + GrpcMonitors: []*v1.GRPCMonitor{grpcMonitor}, + Region: "frankfurt-dc1", + }), nil + }, + IngestGRPCFunc: func(ctx context.Context, req *connect.Request[v1.IngestGRPCRequest]) (*connect.Response[v1.IngestGRPCResponse], error) { + ingested.Store(true) + return connect.NewResponse(&v1.IngestGRPCResponse{}), nil + }, + } + + jobRunner := &mockJobRunner{GRPCJobErr: errors.New("job failed")} + s := tasks.New() + defer s.Stop() + + mm := &scheduler.MonitorManager{Client: client, JobRunner: jobRunner, Scheduler: s} + mm.UpdateMonitors(ctx) + + task, err := mm.Scheduler.Lookup("grpc-fail") + if err != nil { + t.Fatalf("expected a scheduled task: %v", err) + } + if err := task.FuncWithTaskContext(tasks.TaskContext{}); err == nil { + t.Error("expected the task to surface the job error") + } + + if ingested.Load() { + t.Error("a failed job must not be forwarded to IngestGRPC") + } +} diff --git a/apps/checker/proto/private_location/v1/grpc_monitor.pb.go b/apps/checker/proto/private_location/v1/grpc_monitor.pb.go new file mode 100644 index 00000000..6784cdc9 --- /dev/null +++ b/apps/checker/proto/private_location/v1/grpc_monitor.pb.go @@ -0,0 +1,213 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: private_location/v1/grpc_monitor.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GRPCMonitor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` + Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` + DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` + Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` + Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` + Service string `protobuf:"bytes,7,opt,name=service,proto3" json:"service,omitempty"` + TlsMode string `protobuf:"bytes,8,opt,name=tls_mode,json=tlsMode,proto3" json:"tls_mode,omitempty"` + Metadata []*Headers `protobuf:"bytes,10,rep,name=metadata,proto3" json:"metadata,omitempty"` + OtelConfig *OtelConfig `protobuf:"bytes,20,opt,name=otel_config,json=otelConfig,proto3" json:"otel_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GRPCMonitor) Reset() { + *x = GRPCMonitor{} + mi := &file_private_location_v1_grpc_monitor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GRPCMonitor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GRPCMonitor) ProtoMessage() {} + +func (x *GRPCMonitor) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_grpc_monitor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GRPCMonitor.ProtoReflect.Descriptor instead. +func (*GRPCMonitor) Descriptor() ([]byte, []int) { + return file_private_location_v1_grpc_monitor_proto_rawDescGZIP(), []int{0} +} + +func (x *GRPCMonitor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GRPCMonitor) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *GRPCMonitor) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *GRPCMonitor) GetDegradedAt() int64 { + if x != nil && x.DegradedAt != nil { + return *x.DegradedAt + } + return 0 +} + +func (x *GRPCMonitor) GetPeriodicity() string { + if x != nil { + return x.Periodicity + } + return "" +} + +func (x *GRPCMonitor) GetRetry() int64 { + if x != nil { + return x.Retry + } + return 0 +} + +func (x *GRPCMonitor) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *GRPCMonitor) GetTlsMode() string { + if x != nil { + return x.TlsMode + } + return "" +} + +func (x *GRPCMonitor) GetMetadata() []*Headers { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *GRPCMonitor) GetOtelConfig() *OtelConfig { + if x != nil { + return x.OtelConfig + } + return nil +} + +var File_private_location_v1_grpc_monitor_proto protoreflect.FileDescriptor + +const file_private_location_v1_grpc_monitor_proto_rawDesc = "" + + "\n" + + "&private_location/v1/grpc_monitor.proto\x12\x13private_location.v1\x1a\x1eprivate_location/v1/otel.proto\"\xe8\x02\n" + + "\vGRPCMonitor\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + + "degradedAt\x88\x01\x01\x12 \n" + + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + + "\x05retry\x18\x06 \x01(\x03R\x05retry\x12\x18\n" + + "\aservice\x18\a \x01(\tR\aservice\x12\x19\n" + + "\btls_mode\x18\b \x01(\tR\atlsMode\x128\n" + + "\bmetadata\x18\n" + + " \x03(\v2\x1c.private_location.v1.HeadersR\bmetadata\x12@\n" + + "\votel_config\x18\x14 \x01(\v2\x1f.private_location.v1.OtelConfigR\n" + + "otelConfigB\x0e\n" + + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + +var ( + file_private_location_v1_grpc_monitor_proto_rawDescOnce sync.Once + file_private_location_v1_grpc_monitor_proto_rawDescData []byte +) + +func file_private_location_v1_grpc_monitor_proto_rawDescGZIP() []byte { + file_private_location_v1_grpc_monitor_proto_rawDescOnce.Do(func() { + file_private_location_v1_grpc_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_grpc_monitor_proto_rawDesc), len(file_private_location_v1_grpc_monitor_proto_rawDesc))) + }) + return file_private_location_v1_grpc_monitor_proto_rawDescData +} + +var file_private_location_v1_grpc_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_private_location_v1_grpc_monitor_proto_goTypes = []any{ + (*GRPCMonitor)(nil), // 0: private_location.v1.GRPCMonitor + (*Headers)(nil), // 1: private_location.v1.Headers + (*OtelConfig)(nil), // 2: private_location.v1.OtelConfig +} +var file_private_location_v1_grpc_monitor_proto_depIdxs = []int32{ + 1, // 0: private_location.v1.GRPCMonitor.metadata:type_name -> private_location.v1.Headers + 2, // 1: private_location.v1.GRPCMonitor.otel_config:type_name -> private_location.v1.OtelConfig + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_private_location_v1_grpc_monitor_proto_init() } +func file_private_location_v1_grpc_monitor_proto_init() { + if File_private_location_v1_grpc_monitor_proto != nil { + return + } + file_private_location_v1_otel_proto_init() + file_private_location_v1_grpc_monitor_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_grpc_monitor_proto_rawDesc), len(file_private_location_v1_grpc_monitor_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_private_location_v1_grpc_monitor_proto_goTypes, + DependencyIndexes: file_private_location_v1_grpc_monitor_proto_depIdxs, + MessageInfos: file_private_location_v1_grpc_monitor_proto_msgTypes, + }.Build() + File_private_location_v1_grpc_monitor_proto = out.File + file_private_location_v1_grpc_monitor_proto_goTypes = nil + file_private_location_v1_grpc_monitor_proto_depIdxs = nil +} diff --git a/apps/checker/proto/private_location/v1/private_location.connect.go b/apps/checker/proto/private_location/v1/private_location.connect.go index 8138ab7c..e52f9503 100644 --- a/apps/checker/proto/private_location/v1/private_location.connect.go +++ b/apps/checker/proto/private_location/v1/private_location.connect.go @@ -47,6 +47,9 @@ const ( // PrivateLocationServiceIngestICMPProcedure is the fully-qualified name of the // PrivateLocationService's IngestICMP RPC. PrivateLocationServiceIngestICMPProcedure = "/private_location.v1.PrivateLocationService/IngestICMP" + // PrivateLocationServiceIngestGRPCProcedure is the fully-qualified name of the + // PrivateLocationService's IngestGRPC RPC. + PrivateLocationServiceIngestGRPCProcedure = "/private_location.v1.PrivateLocationService/IngestGRPC" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService @@ -57,6 +60,7 @@ type PrivateLocationServiceClient interface { IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) + IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) } // NewPrivateLocationServiceClient constructs a client for the @@ -100,6 +104,12 @@ func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL stri connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), connect.WithClientOptions(opts...), ), + ingestGRPC: connect.NewClient[IngestGRPCRequest, IngestGRPCResponse]( + httpClient, + baseURL+PrivateLocationServiceIngestGRPCProcedure, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestGRPC")), + connect.WithClientOptions(opts...), + ), } } @@ -110,6 +120,7 @@ type privateLocationServiceClient struct { ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] ingestICMP *connect.Client[IngestICMPRequest, IngestICMPResponse] + ingestGRPC *connect.Client[IngestGRPCRequest, IngestGRPCResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. @@ -137,6 +148,11 @@ func (c *privateLocationServiceClient) IngestICMP(ctx context.Context, req *conn return c.ingestICMP.CallUnary(ctx, req) } +// IngestGRPC calls private_location.v1.PrivateLocationService.IngestGRPC. +func (c *privateLocationServiceClient) IngestGRPC(ctx context.Context, req *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) { + return c.ingestGRPC.CallUnary(ctx, req) +} + // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { @@ -145,6 +161,7 @@ type PrivateLocationServiceHandler interface { IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) + IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It @@ -184,6 +201,12 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), connect.WithHandlerOptions(opts...), ) + privateLocationServiceIngestGRPCHandler := connect.NewUnaryHandler( + PrivateLocationServiceIngestGRPCProcedure, + svc.IngestGRPC, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestGRPC")), + connect.WithHandlerOptions(opts...), + ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: @@ -196,6 +219,8 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestICMPProcedure: privateLocationServiceIngestICMPHandler.ServeHTTP(w, r) + case PrivateLocationServiceIngestGRPCProcedure: + privateLocationServiceIngestGRPCHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -224,3 +249,7 @@ func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *co func (UnimplementedPrivateLocationServiceHandler) IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestICMP is not implemented")) } + +func (UnimplementedPrivateLocationServiceHandler) IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestGRPC is not implemented")) +} diff --git a/apps/checker/proto/private_location/v1/private_location.pb.go b/apps/checker/proto/private_location/v1/private_location.pb.go index a28112bc..6cded061 100644 --- a/apps/checker/proto/private_location/v1/private_location.pb.go +++ b/apps/checker/proto/private_location/v1/private_location.pb.go @@ -63,6 +63,7 @@ type MonitorsResponse struct { TcpMonitors []*TCPMonitor `protobuf:"bytes,2,rep,name=tcp_monitors,json=tcpMonitors,proto3" json:"tcp_monitors,omitempty"` DnsMonitors []*DNSMonitor `protobuf:"bytes,3,rep,name=dns_monitors,json=dnsMonitors,proto3" json:"dns_monitors,omitempty"` IcmpMonitors []*ICMPMonitor `protobuf:"bytes,5,rep,name=icmp_monitors,json=icmpMonitors,proto3" json:"icmp_monitors,omitempty"` + GrpcMonitors []*GRPCMonitor `protobuf:"bytes,6,rep,name=grpc_monitors,json=grpcMonitors,proto3" json:"grpc_monitors,omitempty"` Region string `protobuf:"bytes,4,opt,name=region,proto3" json:"region,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -126,6 +127,13 @@ func (x *MonitorsResponse) GetIcmpMonitors() []*ICMPMonitor { return nil } +func (x *MonitorsResponse) GetGrpcMonitors() []*GRPCMonitor { + if x != nil { + return x.GrpcMonitors + } + return nil +} + func (x *MonitorsResponse) GetRegion() string { if x != nil { return x.Region @@ -849,17 +857,194 @@ func (*IngestICMPResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{10} } +type IngestGRPCRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` + Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` + Uri string `protobuf:"bytes,6,opt,name=uri,proto3" json:"uri,omitempty"` + Service string `protobuf:"bytes,7,opt,name=service,proto3" json:"service,omitempty"` + ServingStatus string `protobuf:"bytes,8,opt,name=servingStatus,proto3" json:"servingStatus,omitempty"` + GrpcCode int64 `protobuf:"varint,9,opt,name=grpcCode,proto3" json:"grpcCode,omitempty"` + Message string `protobuf:"bytes,10,opt,name=message,proto3" json:"message,omitempty"` + RequestStatus string `protobuf:"bytes,11,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` + Error int64 `protobuf:"varint,12,opt,name=error,proto3" json:"error,omitempty"` + Timing string `protobuf:"bytes,13,opt,name=timing,proto3" json:"timing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestGRPCRequest) Reset() { + *x = IngestGRPCRequest{} + mi := &file_private_location_v1_private_location_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestGRPCRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestGRPCRequest) ProtoMessage() {} + +func (x *IngestGRPCRequest) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestGRPCRequest.ProtoReflect.Descriptor instead. +func (*IngestGRPCRequest) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{11} +} + +func (x *IngestGRPCRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *IngestGRPCRequest) GetMonitorId() string { + if x != nil { + return x.MonitorId + } + return "" +} + +func (x *IngestGRPCRequest) GetLatency() int64 { + if x != nil { + return x.Latency + } + return 0 +} + +func (x *IngestGRPCRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *IngestGRPCRequest) GetCronTimestamp() int64 { + if x != nil { + return x.CronTimestamp + } + return 0 +} + +func (x *IngestGRPCRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *IngestGRPCRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *IngestGRPCRequest) GetServingStatus() string { + if x != nil { + return x.ServingStatus + } + return "" +} + +func (x *IngestGRPCRequest) GetGrpcCode() int64 { + if x != nil { + return x.GrpcCode + } + return 0 +} + +func (x *IngestGRPCRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *IngestGRPCRequest) GetRequestStatus() string { + if x != nil { + return x.RequestStatus + } + return "" +} + +func (x *IngestGRPCRequest) GetError() int64 { + if x != nil { + return x.Error + } + return 0 +} + +func (x *IngestGRPCRequest) GetTiming() string { + if x != nil { + return x.Timing + } + return "" +} + +type IngestGRPCResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestGRPCResponse) Reset() { + *x = IngestGRPCResponse{} + mi := &file_private_location_v1_private_location_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestGRPCResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestGRPCResponse) ProtoMessage() {} + +func (x *IngestGRPCResponse) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestGRPCResponse.ProtoReflect.Descriptor instead. +func (*IngestGRPCResponse) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{12} +} + var File_private_location_v1_private_location_proto protoreflect.FileDescriptor const file_private_location_v1_private_location_proto_rawDesc = "" + "\n" + - "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a&private_location/v1/icmp_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + - "\x0fMonitorsRequest\"\xc0\x02\n" + + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/grpc_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a&private_location/v1/icmp_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + + "\x0fMonitorsRequest\"\x87\x03\n" + "\x10MonitorsResponse\x12E\n" + "\rhttp_monitors\x18\x01 \x03(\v2 .private_location.v1.HTTPMonitorR\fhttpMonitors\x12B\n" + "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\x12B\n" + "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\x12E\n" + - "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12\x16\n" + + "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12E\n" + + "\rgrpc_monitors\x18\x06 \x03(\v2 .private_location.v1.GRPCMonitorR\fgrpcMonitors\x12\x16\n" + "\x06region\x18\x04 \x01(\tR\x06region\"\x9e\x02\n" + "\x10IngestTCPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + @@ -931,7 +1116,23 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "\rrequestStatus\x18\f \x01(\tR\rrequestStatus\x12\x14\n" + "\x05error\x18\r \x01(\x03R\x05error\x12\x16\n" + "\x06timing\x18\x0e \x01(\tR\x06timing\"\x14\n" + - "\x12IngestICMPResponse2\xf1\x03\n" + + "\x12IngestICMPResponse\"\xfb\x02\n" + + "\x11IngestGRPCRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1c\n" + + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + + "\x03uri\x18\x06 \x01(\tR\x03uri\x12\x18\n" + + "\aservice\x18\a \x01(\tR\aservice\x12$\n" + + "\rservingStatus\x18\b \x01(\tR\rservingStatus\x12\x1a\n" + + "\bgrpcCode\x18\t \x01(\x03R\bgrpcCode\x12\x18\n" + + "\amessage\x18\n" + + " \x01(\tR\amessage\x12$\n" + + "\rrequestStatus\x18\v \x01(\tR\rrequestStatus\x12\x14\n" + + "\x05error\x18\f \x01(\x03R\x05error\x12\x16\n" + + "\x06timing\x18\r \x01(\tR\x06timing\"\x14\n" + + "\x12IngestGRPCResponse2\xd2\x04\n" + "\x16PrivateLocationService\x12Y\n" + "\bMonitors\x12$.private_location.v1.MonitorsRequest\x1a%.private_location.v1.MonitorsResponse\"\x00\x12\\\n" + "\tIngestTCP\x12%.private_location.v1.IngestTCPRequest\x1a&.private_location.v1.IngestTCPResponse\"\x00\x12_\n" + @@ -939,7 +1140,9 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00\x12\\\n" + "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00\x12_\n" + "\n" + - "IngestICMP\x12&.private_location.v1.IngestICMPRequest\x1a'.private_location.v1.IngestICMPResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + "IngestICMP\x12&.private_location.v1.IngestICMPRequest\x1a'.private_location.v1.IngestICMPResponse\"\x00\x12_\n" + + "\n" + + "IngestGRPC\x12&.private_location.v1.IngestGRPCRequest\x1a'.private_location.v1.IngestGRPCResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once @@ -953,7 +1156,7 @@ func file_private_location_v1_private_location_proto_rawDescGZIP() []byte { return file_private_location_v1_private_location_proto_rawDescData } -var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse @@ -966,34 +1169,40 @@ var file_private_location_v1_private_location_proto_goTypes = []any{ (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse (*IngestICMPRequest)(nil), // 9: private_location.v1.IngestICMPRequest (*IngestICMPResponse)(nil), // 10: private_location.v1.IngestICMPResponse - nil, // 11: private_location.v1.IngestDNSRequest.RecordsEntry - (*HTTPMonitor)(nil), // 12: private_location.v1.HTTPMonitor - (*TCPMonitor)(nil), // 13: private_location.v1.TCPMonitor - (*DNSMonitor)(nil), // 14: private_location.v1.DNSMonitor - (*ICMPMonitor)(nil), // 15: private_location.v1.ICMPMonitor + (*IngestGRPCRequest)(nil), // 11: private_location.v1.IngestGRPCRequest + (*IngestGRPCResponse)(nil), // 12: private_location.v1.IngestGRPCResponse + nil, // 13: private_location.v1.IngestDNSRequest.RecordsEntry + (*HTTPMonitor)(nil), // 14: private_location.v1.HTTPMonitor + (*TCPMonitor)(nil), // 15: private_location.v1.TCPMonitor + (*DNSMonitor)(nil), // 16: private_location.v1.DNSMonitor + (*ICMPMonitor)(nil), // 17: private_location.v1.ICMPMonitor + (*GRPCMonitor)(nil), // 18: private_location.v1.GRPCMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ - 12, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor - 13, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor - 14, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor - 15, // 3: private_location.v1.MonitorsResponse.icmp_monitors:type_name -> private_location.v1.ICMPMonitor - 11, // 4: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry - 6, // 5: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records - 0, // 6: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest - 2, // 7: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest - 4, // 8: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest - 7, // 9: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest - 9, // 10: private_location.v1.PrivateLocationService.IngestICMP:input_type -> private_location.v1.IngestICMPRequest - 1, // 11: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse - 3, // 12: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse - 5, // 13: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse - 8, // 14: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse - 10, // 15: private_location.v1.PrivateLocationService.IngestICMP:output_type -> private_location.v1.IngestICMPResponse - 11, // [11:16] is the sub-list for method output_type - 6, // [6:11] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 14, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor + 15, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor + 16, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor + 17, // 3: private_location.v1.MonitorsResponse.icmp_monitors:type_name -> private_location.v1.ICMPMonitor + 18, // 4: private_location.v1.MonitorsResponse.grpc_monitors:type_name -> private_location.v1.GRPCMonitor + 13, // 5: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry + 6, // 6: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records + 0, // 7: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest + 2, // 8: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest + 4, // 9: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest + 7, // 10: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest + 9, // 11: private_location.v1.PrivateLocationService.IngestICMP:input_type -> private_location.v1.IngestICMPRequest + 11, // 12: private_location.v1.PrivateLocationService.IngestGRPC:input_type -> private_location.v1.IngestGRPCRequest + 1, // 13: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse + 3, // 14: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse + 5, // 15: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse + 8, // 16: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse + 10, // 17: private_location.v1.PrivateLocationService.IngestICMP:output_type -> private_location.v1.IngestICMPResponse + 12, // 18: private_location.v1.PrivateLocationService.IngestGRPC:output_type -> private_location.v1.IngestGRPCResponse + 13, // [13:19] is the sub-list for method output_type + 7, // [7:13] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } @@ -1002,6 +1211,7 @@ func file_private_location_v1_private_location_proto_init() { return } file_private_location_v1_dns_monitor_proto_init() + file_private_location_v1_grpc_monitor_proto_init() file_private_location_v1_http_monitor_proto_init() file_private_location_v1_icmp_monitor_proto_init() file_private_location_v1_tcp_monitor_proto_init() @@ -1011,7 +1221,7 @@ func file_private_location_v1_private_location_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc)), NumEnums: 0, - NumMessages: 12, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/checker/request/request.go b/apps/checker/request/request.go index ac5b9626..290fdd0b 100644 --- a/apps/checker/request/request.go +++ b/apps/checker/request/request.go @@ -125,6 +125,26 @@ type ICMPCheckerRequest struct { } `json:"otelConfig"` } +type GRPCCheckerRequest struct { + Status string `json:"status"` + WorkspaceID string `json:"workspaceId"` + URI string `json:"uri"` + MonitorID string `json:"monitorId"` + Service string `json:"service,omitempty"` + TLS string `json:"tls,omitempty"` + Trigger string `json:"trigger,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + RequestId int64 `json:"requestId,omitempty"` + CronTimestamp int64 `json:"cronTimestamp"` + Timeout int64 `json:"timeout"` + DegradedAfter int64 `json:"degradedAfter,omitempty"` + Retry int64 `json:"retry,omitempty"` + OtelConfig struct { + Endpoint string `json:"endpoint"` + Headers map[string]string `json:"headers,omitempty"` + } `json:"otelConfig"` +} + type TCPRequest struct { WorkspaceID string `json:"workspaceId"` URL string `json:"url"` diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx index 26680f14..79df5d76 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx @@ -131,11 +131,15 @@ export function Client() { setPagination={setPagination} paginationComponent={DataTablePagination} defaultColumnVisibility={ - monitor.jobType === "tcp" || - monitor.jobType === "dns" || - monitor.jobType === "icmp" - ? { timing: false, statusCode: false } - : {} + // gRPC carries HTTP's phase timings, so only its status code + // column is meaningless. + monitor.jobType === "grpc" + ? { statusCode: false } + : monitor.jobType === "tcp" || + monitor.jobType === "dns" || + monitor.jobType === "icmp" + ? { timing: false, statusCode: false } + : {} } // NOTE: required to control the pagination autoResetPageIndex={false} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx index 7197d2c8..84ac57a4 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx @@ -27,11 +27,12 @@ type TestTCP = RouterOutputs["checker"]["testTcp"]; type TestHTTP = RouterOutputs["checker"]["testHttp"]; type TestDNS = RouterOutputs["checker"]["testDns"]; type TestICMP = RouterOutputs["checker"]["testIcmp"]; +type TestGRPC = RouterOutputs["checker"]["testGrpc"]; export function NavActions() { const { id } = useParams<{ id: string }>(); const [test, setTest] = useState< - TestTCP | TestHTTP | TestDNS | TestICMP | null + TestTCP | TestHTTP | TestDNS | TestICMP | TestGRPC | null >(null); const queryClient = useQueryClient(); const trpc = useTRPC(); @@ -70,6 +71,7 @@ export function NavActions() { const testTcpMutation = useMutation(trpc.checker.testTcp.mutationOptions()); const testDnsMutation = useMutation(trpc.checker.testDns.mutationOptions()); const testIcmpMutation = useMutation(trpc.checker.testIcmp.mutationOptions()); + const testGrpcMutation = useMutation(trpc.checker.testGrpc.mutationOptions()); // curl only speaks HTTP — the action is hidden for tcp/dns monitors const curlCommand = @@ -180,6 +182,27 @@ export function NavActions() { return "ICMP test failed"; }, }); + } else if (monitor?.jobType === "grpc") { + const promise = testGrpcMutation.mutateAsync({ + url: monitor.url, + service: monitor.grpcService ?? undefined, + tls: monitor.grpcTls ?? "tls", + headers: monitor.headers ?? [], + }); + + toast.promise(promise, { + loading: "Testing gRPC request...", + success: (data) => { + setTest(data); + return "gRPC test completed successfully"; + }, + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "gRPC test failed"; + }, + }); } } diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx index 35e9c6ad..eaecd0af 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx @@ -83,7 +83,12 @@ export function Client() { ...trpc.tinybird.metricsRegions.queryOptions({ monitorId: id, period: effectivePeriod, - type: (monitor?.jobType ?? "http") as "http" | "tcp" | "dns" | "icmp", + type: (monitor?.jobType ?? "http") as + | "http" + | "tcp" + | "dns" + | "icmp" + | "grpc", regions: selectedRegions, // bucket by period (daily at 30d/90d) to keep payload + chart readable interval: periodToInterval[effectivePeriod], @@ -144,7 +149,7 @@ export function Client() {

@@ -158,7 +163,7 @@ export function Client() { @@ -197,7 +202,7 @@ export function Client() { monitorId={id} percentile={percentile} degradedAfter={monitor.degradedAfter} - type={monitor.jobType as "http" | "tcp" | "dns" | "icmp"} + type={monitor.jobType as "http" | "tcp" | "dns" | "icmp" | "grpc"} period={effectivePeriod} regions={selectedRegions} /> diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx index 655f3b2b..97b7fdbd 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx @@ -57,7 +57,7 @@ export function Sidebar() { label: "Type", value: type ? ( - {type.label} + {type.label} ) : ( diff --git a/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx index 88240aa0..6d62bc99 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx @@ -104,6 +104,8 @@ export default function Page() { body: data.body, active: data.active, assertions: data.assertions, + grpcService: data.grpcService, + grpcTls: data.grpcTls, saveCheck: data.saveCheck, skipCheck: data.skipCheck, }); diff --git a/apps/dashboard/src/components/chart/chart-area-latency.tsx b/apps/dashboard/src/components/chart/chart-area-latency.tsx index b1dfc888..c76fe07a 100644 --- a/apps/dashboard/src/components/chart/chart-area-latency.tsx +++ b/apps/dashboard/src/components/chart/chart-area-latency.tsx @@ -51,7 +51,7 @@ export function ChartAreaLatency({ degradedAfter: number | null; percentile: (typeof PERCENTILES)[number]; period: (typeof PERIODS)[number]; - type: "http" | "tcp" | "dns" | "icmp"; + type: "http" | "tcp" | "dns" | "icmp" | "grpc"; regions: string[] | undefined; }) { const trpc = useTRPC(); diff --git a/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx b/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx index ac4672c7..eeba847d 100644 --- a/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx +++ b/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx @@ -36,7 +36,7 @@ export function ChartBarUptimeLight({ regions, }: { monitorId: string; - type: "http" | "tcp" | "dns" | "icmp"; + type: "http" | "tcp" | "dns" | "icmp" | "grpc"; regions?: Region[]; }) { const trpc = useTRPC(); diff --git a/apps/dashboard/src/components/chart/chart-bar-uptime.tsx b/apps/dashboard/src/components/chart/chart-bar-uptime.tsx index cfbb1f7e..a0d726a8 100644 --- a/apps/dashboard/src/components/chart/chart-bar-uptime.tsx +++ b/apps/dashboard/src/components/chart/chart-bar-uptime.tsx @@ -44,7 +44,7 @@ export function ChartBarUptime({ }: { monitorId: string; period: (typeof PERIODS)[number]; - type: "http" | "tcp" | "dns" | "icmp"; + type: "http" | "tcp" | "dns" | "icmp" | "grpc"; regions: string[] | undefined; }) { const isMobile = useIsMobile(); diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx index 41f23c05..6642dea2 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx @@ -55,6 +55,11 @@ export function DataTableBasics({ ); } + if (data.type === "grpc") { + return ( + + ); + } return null; } @@ -336,21 +341,18 @@ export function DataTableBasicsHTTP({ ); } -export function DataTableBasicsTCP({ - data, - privateLocations, -}: { - data: Extract & { - trigger?: "cron" | "api" | "test" | null; - }; - privateLocations?: PrivateLocation[]; -}) { - const privateLocataion = privateLocations?.find( - (location) => String(location.id) === String(data.region), - ); - const regionConfig = getRegionInfo(data.region, { - location: privateLocataion?.name, - }); +type BasicsRequestFields = { + id?: string | null; + requestStatus?: string | null; + cronTimestamp: number; +}; + +type BasicsLocationFields = { + region: string; + trigger?: "cron" | "api" | "test" | null; +}; + +function BasicsTable({ children }: { children: React.ReactNode }) { return ( @@ -361,278 +363,218 @@ export function DataTableBasicsTCP({ Request - - - Result - - {/* TODO: add colored square like list (see columns) */} - -
-
-
- {data?.requestStatus ?? "unknown"} -
-
- - - {data.id ? ( - - - ID - - - {data.id} - - - ) : null} - - - Timestamp - - - - - - - - URI - - - {data.uri} - - - - - Latency - - - - - - - - Region - - - {regionConfig?.flag} {regionConfig?.code}{" "} - - {regionConfig?.location} - - - - - - Cloud Provider - - - - - {regionConfig?.provider} - - - - {data.trigger ? ( - - - Trigger - - - {data?.trigger} - - - ) : null} - {data?.errorMessage ? ( - <> - - Error Message - - - -
-                  {data.errorMessage}
-                
-
-
- - ) : null} + {children}
); } -export function DataTableBasicsICMP({ +function BasicsRow({ + label, + children, + cellClassName, +}: { + label: string; + children: React.ReactNode; + cellClassName?: string; +}) { + return ( + + + {label} + + + {children} + + + ); +} + +function BasicsRequestRows({ data }: { data: BasicsRequestFields }) { + return ( + <> + {/* TODO: add colored square like list (see columns) */} + +
+
+
{data?.requestStatus ?? "unknown"}
+
+ + {data.id ? {data.id} : null} + + + + + ); +} + +function BasicsLocationRows({ data, privateLocations, }: { - data: Extract & { - trigger?: "cron" | "api" | "test" | null; - }; + data: BasicsLocationFields; privateLocations?: PrivateLocation[]; }) { - const privateLocataion = privateLocations?.find( + const privateLocation = privateLocations?.find( (location) => String(location.id) === String(data.region), ); const regionConfig = getRegionInfo(data.region, { - location: privateLocataion?.name, + location: privateLocation?.name, }); + + return ( + <> + + {regionConfig?.flag} {regionConfig?.code}{" "} + {regionConfig?.location} + + + + + {regionConfig?.provider} + + + {data.trigger ? ( + {data?.trigger} + ) : null} + + ); +} + +function BasicsErrorMessageRows({ + errorMessage, +}: { + errorMessage?: string | null; +}) { + if (!errorMessage) return null; + + return ( + <> + + Error Message + + + +
+            {errorMessage}
+          
+
+
+ + ); +} + +export function DataTableBasicsTCP({ + data, + privateLocations, +}: { + data: Extract & { + trigger?: "cron" | "api" | "test" | null; + }; + privateLocations?: PrivateLocation[]; +}) { + return ( + + + {data.uri} + + + + + + + ); +} + +export function DataTableBasicsICMP({ + data, + privateLocations, +}: { + data: Extract & { + trigger?: "cron" | "api" | "test" | null; + }; + privateLocations?: PrivateLocation[]; +}) { const packetLoss = data.packetsSent > 0 ? (data.packetsSent - data.packetsReceived) / data.packetsSent : 0; + return ( - - - - - - - - Request - - - - Result - - -
-
-
- {data?.requestStatus ?? "unknown"} -
-
- - - {data.id ? ( - - - ID - - - {data.id} - - - ) : null} - - - Timestamp - - - - - - - - Host - - - {data.uri} - - - - - Latency (avg) - - - - - - - - Latency (min / max) - - - {formatMilliseconds(data.latencyMin)} /{" "} - {formatMilliseconds(data.latencyMax)} - - - - - Packets - - - {data.packetsReceived} / {data.packetsSent} received - - - - - Packet Loss - - - {formatPercentage(packetLoss)} - - - - - Region - - - {regionConfig?.flag} {regionConfig?.code}{" "} - - {regionConfig?.location} - - - - - - Cloud Provider - - - - - {regionConfig?.provider} - - - - {data.trigger ? ( - - - Trigger - - - {data?.trigger} - - - ) : null} - {data?.errorMessage ? ( - <> - - Error Message - - - -
-                  {data.errorMessage}
-                
-
-
- - ) : null} - -
+ + + {data.uri} + + + + + {formatMilliseconds(data.latencyMin)} /{" "} + {formatMilliseconds(data.latencyMax)} + + + {data.packetsReceived} / {data.packetsSent} received + + {formatPercentage(packetLoss)} + + + + ); +} + +export function DataTableBasicsGRPC({ + data, + privateLocations, +}: { + data: Extract & { + trigger?: "cron" | "api" | "test" | null; + }; + privateLocations?: PrivateLocation[]; +}) { + return ( + + + {data.uri} + + {data.service ? ( + data.service + ) : ( + overall server health + )} + + + {data.servingStatus ?? ( + no answer + )} + + + {data.grpcCode ?? N/A} + + + + + + + ); } @@ -645,202 +587,99 @@ export function DataTableBasicsDNS({ }; privateLocations?: PrivateLocation[]; }) { - const privateLocataion = privateLocations?.find( - (location) => String(location.id) === String(data.region), - ); - const regionConfig = getRegionInfo(data.region, { - location: privateLocataion?.name, - }); return ( - - - - - - - - Request - - - - Result - - {/* TODO: add colored square like list (see columns) */} - -
-
-
- {data?.requestStatus ?? "unknown"} -
-
- - - {data.id ? ( - - - ID - - - {data.id} + + + {data.uri} + + + + + {data?.records ? ( + <> + + Records + + + + + + + + + + + + + +
+ + + + + + {Object.entries(data?.records ?? {}).map( + ([key, value]) => ( + + + {key.toUpperCase()} + + + {Array.isArray(value) ? value.join(", ") : value} + + + ), + )} + +
+ + +
+                    {JSON.stringify(data?.records, null, 2)}
+                  
+
+ - ) : null} - - - Timestamp - - - - - - - - URI - - - {data.uri} - - - - - Latency - - - - - - - - Region - - - {regionConfig?.flag} {regionConfig?.code}{" "} - - {regionConfig?.location} - - - - - - Cloud Provider - - - - - {regionConfig?.provider} - - - - {data.trigger ? ( - - - Trigger - - - {data?.trigger} + + ) : null} + {data?.errorMessage ? ( + <> + + Error Message + + + +
+                {data.errorMessage}
+              
- ) : null} - {data?.records ? ( - <> - - Records - - - - - - - - - - - - - - - - - - - - {Object.entries(data?.records ?? {}).map( - ([key, value]) => ( - - - {key.toUpperCase()} - - - {Array.isArray(value) - ? value.join(", ") - : value} - - - ), - )} - -
-
- -
-                      {JSON.stringify(data?.records, null, 2)}
-                    
-
-
-
-
- - ) : null} - {data?.errorMessage ? ( - <> - - Error Message - - - + + ) : null} + {data.assertions ? ( + <> + + Assertions + + + + {!data.assertions || data.assertions === "[]" ? ( +
+ No assertions +
+ ) : (
-                  {data.errorMessage}
+                  {JSON.stringify(data.assertions, null, 2)}
                 
-
-
- - ) : null} - {data.assertions ? ( - <> - - Assertions - - - - {!data.assertions || data.assertions === "[]" ? ( -
- No assertions -
- ) : ( -
-                    {JSON.stringify(data.assertions, null, 2)}
-                  
- )} -
-
- - ) : null} - - + )} +
+
+ + ) : null} + ); } diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx index 71ecdfdd..e65dcb39 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx @@ -16,6 +16,7 @@ type TestTCP = RouterOutputs["checker"]["testTcp"]; type TestHTTP = RouterOutputs["checker"]["testHttp"]; type TestDNS = RouterOutputs["checker"]["testDns"]; type TestICMP = RouterOutputs["checker"]["testIcmp"]; +type TestGRPC = RouterOutputs["checker"]["testGrpc"]; type Monitor = NonNullable; export function DataTableSheetTest({ @@ -23,7 +24,7 @@ export function DataTableSheetTest({ monitor, onClose, }: { - data: TestTCP | TestHTTP | TestDNS | TestICMP | null; + data: TestTCP | TestHTTP | TestDNS | TestICMP | TestGRPC | null; monitor: Monitor; onClose: () => void; }) { @@ -47,7 +48,7 @@ export function DataTableSheetTest({ } function mapping( - data: TestTCP | TestHTTP | TestDNS | TestICMP, + data: TestTCP | TestHTTP | TestDNS | TestICMP | TestGRPC, monitor: Monitor, ) { switch (data.type) { @@ -126,6 +127,26 @@ function mapping( errorMessage: null, assertions: null, } as const; + case "grpc": + return { + id: null, + trigger: null, + timestamp: data.timestamp, + cronTimestamp: data.timestamp, + region: data.region, + type: data.type, + requestStatus: data.servingStatus === "SERVING" ? "success" : "error", + error: data.servingStatus !== "SERVING", + latency: data.latency ?? 0, + servingStatus: data.servingStatus ?? null, + grpcCode: data.grpcCode ?? null, + service: data.service ?? null, + timing: calculateTiming(data.timing), + uri: monitor.url, + monitorId: String(monitor.id), + errorMessage: data.errorMessage ?? null, + assertions: null, + } as const; default: return null; } diff --git a/apps/dashboard/src/components/forms/monitor/form-general.tsx b/apps/dashboard/src/components/forms/monitor/form-general.tsx index c13353f3..519de499 100644 --- a/apps/dashboard/src/components/forms/monitor/form-general.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-general.tsx @@ -12,8 +12,19 @@ import { stringCompareDictionary, textBodyAssertion, } from "@openstatus/assertions"; -import { monitorMethods } from "@openstatus/db/src/schema/monitors/constants"; -import { Globe, Network, Add, Speed, Server, Close } from "@openstatus/icons"; +import { + grpcTlsModes, + monitorMethods, +} from "@openstatus/db/src/schema/monitors/constants"; +import { + Add, + Api, + Close, + Globe, + Network, + Server, + Speed, +} from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, @@ -72,7 +83,12 @@ import { FormCardTitle, } from "@/components/forms/form-card"; -const TYPES = ["http", "tcp", "dns", "icmp"] as const; +const TYPES = ["http", "tcp", "dns", "icmp", "grpc"] as const; +const GRPC_TLS_LABELS = { + tls: "TLS (verify certificate)", + tls_insecure: "TLS (skip verification)", + plaintext: "Plaintext (h2c)", +} as const; const HTTP_ASSERTION_TYPES = ["status", "header", "textBody"] as const; const DNS_ASSERTION_TYPES = dnsRecords; @@ -98,6 +114,8 @@ const schema = z.object({ ]), ), body: z.string().optional(), + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional().prefault("tls"), skipCheck: z.boolean().optional().prefault(false), saveCheck: z.boolean().optional().prefault(false), }); @@ -126,6 +144,8 @@ export function FormGeneral({ headers: [], body: "", assertions: [], + grpcService: "", + grpcTls: "tls", skipCheck: false, saveCheck: false, }, @@ -281,6 +301,7 @@ export function FormGeneral({ { value: "tcp", icon: Network, label: "TCP" }, { value: "dns", icon: Server, label: "DNS" }, { value: "icmp", icon: Speed, label: "ICMP" }, + { value: "grpc", icon: Api, label: "gRPC" }, ].map((type) => { return ( @@ -767,6 +788,145 @@ export function FormGeneral({
)} + {watchType === "grpc" && ( + + ( + + Host:Port + + + + + + The gRPC target. A port is required; bracket IPv6 + addresses. + + + )} + /> + ( + + TLS + + + + )} + /> + ( + + Service + + + + + + The service name passed to grpc.health.v1.Health/Check. + Leave empty to check overall server health. + + + )} + /> + ( + + Metadata + {field.value.map((header, index) => ( +
+ { + const newHeaders = [...field.value]; + newHeaders[index] = { + ...newHeaders[index], + key: e.target.value, + }; + field.onChange(newHeaders); + }} + /> + { + const newHeaders = [...field.value]; + newHeaders[index] = { + ...newHeaders[index], + value: e.target.value, + }; + field.onChange(newHeaders); + }} + /> + +
+ ))} +
+ +
+ + Sent with the health check request, commonly for + authentication. + +
+ )} + /> +
+ )} {watchType === "dns" && ( <> diff --git a/apps/dashboard/src/components/forms/monitor/update.tsx b/apps/dashboard/src/components/forms/monitor/update.tsx index 071607f4..0e2a08a3 100644 --- a/apps/dashboard/src/components/forms/monitor/update.tsx +++ b/apps/dashboard/src/components/forms/monitor/update.tsx @@ -119,7 +119,7 @@ export function FormMonitorUpdate() { a.schema) : [], + grpcService: monitor.grpcService ?? "", + grpcTls: monitor.grpcTls ?? "tls", skipCheck: false, saveCheck: false, }} @@ -143,6 +145,8 @@ export function FormMonitorUpdate() { headers: values.headers, body: values.body, assertions: values.assertions, + grpcService: values.grpcService, + grpcTls: values.grpcTls, skipCheck: values.skipCheck, saveCheck: values.saveCheck, active: values.active, diff --git a/apps/dashboard/src/components/metric/global-uptime/section.tsx b/apps/dashboard/src/components/metric/global-uptime/section.tsx index 807d7367..7c91eac6 100644 --- a/apps/dashboard/src/components/metric/global-uptime/section.tsx +++ b/apps/dashboard/src/components/metric/global-uptime/section.tsx @@ -37,7 +37,7 @@ export function GlobalUptimeSection({ regions, }: { monitorId: string; - jobType: "http" | "tcp" | "dns" | "icmp"; + jobType: "http" | "tcp" | "dns" | "icmp" | "grpc"; period: (typeof PERIODS)[number]; regions: string[] | undefined; }) { diff --git a/apps/dashboard/src/data/monitors.client.ts b/apps/dashboard/src/data/monitors.client.ts index e59e8dbb..970fcf03 100644 --- a/apps/dashboard/src/data/monitors.client.ts +++ b/apps/dashboard/src/data/monitors.client.ts @@ -1,4 +1,5 @@ import { + Api, Settings, Copy, Duplicate, @@ -31,6 +32,11 @@ export const monitorTypes = [ label: "ICMP", icon: Speed, }, + { + id: "grpc", + label: "gRPC", + icon: Api, + }, ] as const; export const actions = [ diff --git a/apps/private-location/README.md b/apps/private-location/README.md index 7c1eb9a4..3c0d578e 100644 --- a/apps/private-location/README.md +++ b/apps/private-location/README.md @@ -16,3 +16,20 @@ needs one of the following on the host running the agent: sockets are unavailable. Without either, ICMP checks fail to open a socket and are reported as errors. + +## gRPC monitors + +gRPC monitors call `grpc.health.v1.Health/Check` on the target. Unlike ICMP they +open an ordinary TCP connection, so the agent needs no elevated capabilities and +no `ping_group_range` change. + +The monitor's TLS mode decides how the connection is secured: + +- `plaintext` — h2c, for a service behind a mesh or load balancer that has + already terminated TLS. +- `tls` — verify the certificate against the host's trust store. +- `tls_insecure` — use TLS but skip certificate verification, for internal + services presenting a self-signed or mesh-issued certificate. + +A server that is reachable but has not registered the health service answers +`UNIMPLEMENTED`, which is reported with its own message rather than as "down". diff --git a/apps/private-location/internal/database/models.go b/apps/private-location/internal/database/models.go index 9334198c..6627ea15 100644 --- a/apps/private-location/internal/database/models.go +++ b/apps/private-location/internal/database/models.go @@ -11,6 +11,7 @@ const ( JobTypeHTTP JobType = "http" JobTypeDNS JobType = "dns" JobTypeICMP JobType = "icmp" + JobTypeGRPC JobType = "grpc" ) type Monitor struct { @@ -39,6 +40,8 @@ type Monitor struct { Regions string `db:"regions" json:"-"` Status string `db:"status" json:"-"` Public bool `db:"public" json:"-"` + GrpcService sql.NullString `db:"grpc_service" json:"-"` + GrpcTls sql.NullString `db:"grpc_tls" json:"-"` } type PrivateLocation struct { diff --git a/apps/private-location/internal/server/db_testdata b/apps/private-location/internal/server/db_testdata index 57366385..b174cc57 100644 --- a/apps/private-location/internal/server/db_testdata +++ b/apps/private-location/internal/server/db_testdata @@ -87,7 +87,7 @@ CREATE TABLE "monitor" ( `headers` text DEFAULT '', `body` text DEFAULT '', `method` text(5) DEFAULT 'GET', - `created_at` integer DEFAULT (strftime('%s', 'now')), `regions` text DEFAULT '' NOT NULL, `updated_at` integer, `status` text(2) DEFAULT 'active' NOT NULL, `assertions` text, `deleted_at` integer, `public` integer DEFAULT false, `timeout` integer DEFAULT 45000 NOT NULL, `degraded_after` integer, `otel_endpoint` text, `otel_headers` text, `retry` integer DEFAULT 3, `follow_redirects` integer DEFAULT true, + `created_at` integer DEFAULT (strftime('%s', 'now')), `regions` text DEFAULT '' NOT NULL, `updated_at` integer, `status` text(2) DEFAULT 'active' NOT NULL, `assertions` text, `deleted_at` integer, `public` integer DEFAULT false, `timeout` integer DEFAULT 45000 NOT NULL, `degraded_after` integer, `otel_endpoint` text, `otel_headers` text, `retry` integer DEFAULT 3, `follow_redirects` integer DEFAULT true, `grpc_service` text, `grpc_tls` text DEFAULT 'tls', FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action ); diff --git a/apps/private-location/internal/server/ingest_grpc.go b/apps/private-location/internal/server/ingest_grpc.go new file mode 100644 index 00000000..479436e3 --- /dev/null +++ b/apps/private-location/internal/server/ingest_grpc.go @@ -0,0 +1,89 @@ +package server + +import ( + "context" + "strconv" + + "connectrpc.com/connect" + "github.com/openstatushq/openstatus/apps/private-location/internal/tinybird" + private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" +) + +type GRPCData struct { + ID string `json:"id"` + Timing string `json:"timing"` + ErrorMessage string `json:"errorMessage"` + Region string `json:"region"` + Trigger string `json:"trigger"` + URI string `json:"uri"` + Service string `json:"service,omitempty"` + ServingStatus string `json:"servingStatus,omitempty"` + RequestStatus string `json:"requestStatus,omitempty"` + + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + CronTimestamp int64 `json:"cronTimestamp"` + GRPCCode int64 `json:"grpcCode"` + + Error uint8 `json:"error"` +} + +func (h *privateLocationHandler) IngestGRPC(ctx context.Context, req *connect.Request[private_locationv1.IngestGRPCRequest]) (*connect.Response[private_locationv1.IngestGRPCResponse], error) { + token := req.Header().Get("openstatus-token") + if token == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, ErrMissingToken) + } + + if err := ValidateIngestGRPCRequest(req.Msg); err != nil { + return nil, NewValidationError(err) + } + + ic, err := h.getIngestContext(ctx, token, req.Msg.MonitorId) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Enrich wide event with business context + if holder := GetEvent(ctx); holder != nil { + holder.Event["private_location"] = map[string]any{ + "monitor_id": req.Msg.MonitorId, + "workspace_id": ic.Monitor.WorkspaceID, + "region_id": ic.Region.ID, + "datasource": tinybird.DatasourceGRPC, + } + } + + data := GRPCData{ + ID: req.Msg.Id, + WorkspaceID: int64(ic.Monitor.WorkspaceID), + Timestamp: req.Msg.Timestamp, + Error: uint8(req.Msg.Error), + ErrorMessage: req.Msg.Message, + Region: strconv.Itoa(ic.Region.ID), + MonitorID: int64(ic.Monitor.ID), + Timing: req.Msg.Timing, + Latency: req.Msg.Latency, + GRPCCode: req.Msg.GrpcCode, + ServingStatus: req.Msg.ServingStatus, + Service: req.Msg.Service, + CronTimestamp: req.Msg.CronTimestamp, + Trigger: "cron", + URI: req.Msg.Uri, + RequestStatus: req.Msg.RequestStatus, + } + + h.sendEventAndUpdateLastSeen(ctx, data, tinybird.DatasourceGRPC, ic.Region.ID) + + h.forwardStatusUpdate(ctx, ic, statusUpdateInput{ + RequestStatus: data.RequestStatus, + Message: data.ErrorMessage, + Latency: data.Latency, + CronTimestamp: data.CronTimestamp, + ErrorFlag: data.Error, + }) + + return connect.NewResponse(&private_locationv1.IngestGRPCResponse{}), nil +} diff --git a/apps/private-location/internal/server/monitors.go b/apps/private-location/internal/server/monitors.go index 04cece34..29d4f47d 100644 --- a/apps/private-location/internal/server/monitors.go +++ b/apps/private-location/internal/server/monitors.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "strconv" "connectrpc.com/connect" @@ -191,11 +192,11 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ } var monitors []database.Monitor - err := h.db.Select(&monitors, "SELECT monitor.id, monitor.job_type, monitor.url, monitor.periodicity, monitor.method, monitor.body, monitor.timeout, monitor.degraded_after, monitor.follow_redirects, monitor.headers, monitor.assertions, monitor.workspace_id, monitor.retry, monitor.otel_endpoint, monitor.otel_headers FROM monitor JOIN private_location_to_monitor a ON monitor.id = a.monitor_id JOIN private_location b ON a.private_location_id = b.id WHERE b.token = ? AND monitor.deleted_at IS NULL and monitor.active = 1", token) + err := h.db.Select(&monitors, "SELECT monitor.id, monitor.job_type, monitor.url, monitor.periodicity, monitor.method, monitor.body, monitor.timeout, monitor.degraded_after, monitor.follow_redirects, monitor.headers, monitor.assertions, monitor.workspace_id, monitor.retry, monitor.otel_endpoint, monitor.otel_headers, monitor.grpc_service, monitor.grpc_tls FROM monitor JOIN private_location_to_monitor a ON monitor.id = a.monitor_id JOIN private_location b ON a.private_location_id = b.id WHERE b.token = ? AND monitor.deleted_at IS NULL and monitor.active = 1", token) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } - httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, workspaceId := mapMonitors(ctx, monitors) + httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, grpcMonitors, workspaceId := mapMonitors(ctx, monitors) // Enrich wide event with monitor counts if holder := GetEvent(ctx); holder != nil { @@ -205,6 +206,7 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ "tcp_monitors": len(tcpMonitors), "dns_monitors": len(dnsMonitors), "icmp_monitors": len(icmpMonitors), + "grpc_monitors": len(grpcMonitors), "total_monitors": len(monitors), } } @@ -214,6 +216,7 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ TcpMonitors: tcpMonitors, DnsMonitors: dnsMonitors, IcmpMonitors: icmpMonitors, + GrpcMonitors: grpcMonitors, Region: location.Name, }), nil } @@ -223,6 +226,7 @@ func mapMonitors(ctx context.Context, monitors []database.Monitor) ( []*private_locationv1.TCPMonitor, []*private_locationv1.DNSMonitor, []*private_locationv1.ICMPMonitor, + []*private_locationv1.GRPCMonitor, int, ) { var workspaceId int @@ -230,6 +234,7 @@ func mapMonitors(ctx context.Context, monitors []database.Monitor) ( var tcpMonitors []*private_locationv1.TCPMonitor var dnsMonitors []*private_locationv1.DNSMonitor var icmpMonitors []*private_locationv1.ICMPMonitor + var grpcMonitors []*private_locationv1.GRPCMonitor for _, monitor := range monitors { if workspaceId == 0 { workspaceId = monitor.WorkspaceID @@ -244,10 +249,16 @@ func mapMonitors(ctx context.Context, monitors []database.Monitor) ( dnsMonitors = append(dnsMonitors, toDNSMonitor(ctx, monitor)) case database.JobTypeICMP: icmpMonitors = append(icmpMonitors, toICMPMonitor(ctx, monitor)) + case database.JobTypeGRPC: + grpcMonitors = append(grpcMonitors, toGRPCMonitor(ctx, monitor)) + default: + // Without this a job type the checker does not know is dropped in + // silence: no row, no log, and a monitor that reads as "no data yet". + addParseError(ctx, "unsupported_job_type", fmt.Errorf("monitor %d has job type %q", monitor.ID, monitor.JobType)) } } - return httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, workspaceId + return httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, grpcMonitors, workspaceId } func toHTTPMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.HTTPMonitor { @@ -301,6 +312,34 @@ func toICMPMonitor(ctx context.Context, monitor database.Monitor) *private_locat } } +func toGRPCMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.GRPCMonitor { + var metadata []*private_locationv1.Headers + if monitor.Headers != "" { + if err := json.Unmarshal([]byte(monitor.Headers), &metadata); err != nil { + addParseError(ctx, "metadata_unmarshal", err) + metadata = nil + } + } + + tlsMode := monitor.GrpcTls.String + if tlsMode == "" { + tlsMode = "tls" + } + + return &private_locationv1.GRPCMonitor{ + Id: strconv.Itoa(monitor.ID), + Uri: monitor.URL, + Timeout: monitor.Timeout, + DegradedAt: &monitor.DegradedAfter.Int64, + Periodicity: monitor.Periodicity, + Retry: int64(monitor.Retry), + Service: monitor.GrpcService.String, + TlsMode: tlsMode, + Metadata: metadata, + OtelConfig: buildOtelConfig(ctx, monitor), + } +} + func toDNSMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.DNSMonitor { return &private_locationv1.DNSMonitor{ Id: strconv.Itoa(monitor.ID), diff --git a/apps/private-location/internal/server/validation.go b/apps/private-location/internal/server/validation.go index cf9f11f2..f6ef6914 100644 --- a/apps/private-location/internal/server/validation.go +++ b/apps/private-location/internal/server/validation.go @@ -10,64 +10,64 @@ import ( // Validation errors var ( - ErrEmptyMonitorID = errors.New("monitor_id is required") - ErrEmptyID = errors.New("id is required") - ErrInvalidLatency = errors.New("latency must be non-negative") + ErrEmptyMonitorID = errors.New("monitor_id is required") + ErrEmptyID = errors.New("id is required") + ErrInvalidLatency = errors.New("latency must be non-negative") ErrInvalidTimestamp = errors.New("timestamp must be positive") + ErrInvalidGRPCCode = errors.New("grpc_code must be a canonical gRPC status code") + ErrInvalidErrorFlag = errors.New("error must be 0 or 1") ) -// ValidateIngestHTTPRequest validates an HTTP ingest request -func ValidateIngestHTTPRequest(req *private_locationv1.IngestHTTPRequest) error { - if req.MonitorId == "" { +// maxGRPCStatusCode is UNAUTHENTICATED, the highest canonical code. +const maxGRPCStatusCode = 16 + +// validateIngestCommon holds the rules every ingest request shares. Keeping one +// copy stops the per-type validators drifting as the rules change. +func validateIngestCommon(monitorID string, latency, timestamp int64) error { + if monitorID == "" { return ErrEmptyMonitorID } - if req.Latency < 0 { + if latency < 0 { return ErrInvalidLatency } - if req.Timestamp <= 0 { + if timestamp <= 0 { return ErrInvalidTimestamp } return nil } +// ValidateIngestHTTPRequest validates an HTTP ingest request +func ValidateIngestHTTPRequest(req *private_locationv1.IngestHTTPRequest) error { + return validateIngestCommon(req.MonitorId, req.Latency, req.Timestamp) +} + // ValidateIngestTCPRequest validates a TCP ingest request func ValidateIngestTCPRequest(req *private_locationv1.IngestTCPRequest) error { - if req.MonitorId == "" { - return ErrEmptyMonitorID - } - if req.Latency < 0 { - return ErrInvalidLatency - } - if req.Timestamp <= 0 { - return ErrInvalidTimestamp - } - return nil + return validateIngestCommon(req.MonitorId, req.Latency, req.Timestamp) } // ValidateIngestDNSRequest validates a DNS ingest request func ValidateIngestDNSRequest(req *private_locationv1.IngestDNSRequest) error { - if req.MonitorId == "" { - return ErrEmptyMonitorID - } - if req.Latency < 0 { - return ErrInvalidLatency - } - if req.Timestamp <= 0 { - return ErrInvalidTimestamp - } - return nil + return validateIngestCommon(req.MonitorId, req.Latency, req.Timestamp) } // ValidateIngestICMPRequest validates an ICMP ingest request func ValidateIngestICMPRequest(req *private_locationv1.IngestICMPRequest) error { - if req.MonitorId == "" { - return ErrEmptyMonitorID + return validateIngestCommon(req.MonitorId, req.Latency, req.Timestamp) +} + +// ValidateIngestGRPCRequest validates a gRPC ingest request. The two numeric +// fields are narrowed on the way into the Tinybird row, so they are bounded +// here rather than wrapping silently. +func ValidateIngestGRPCRequest(req *private_locationv1.IngestGRPCRequest) error { + if err := validateIngestCommon(req.MonitorId, req.Latency, req.Timestamp); err != nil { + return err } - if req.Latency < 0 { - return ErrInvalidLatency + if req.GrpcCode < 0 || req.GrpcCode > maxGRPCStatusCode { + return ErrInvalidGRPCCode } - if req.Timestamp <= 0 { - return ErrInvalidTimestamp + if req.Error < 0 || req.Error > 1 { + return ErrInvalidErrorFlag } return nil } diff --git a/apps/private-location/internal/server/validation_test.go b/apps/private-location/internal/server/validation_test.go index d83f80ab..a4b19a16 100644 --- a/apps/private-location/internal/server/validation_test.go +++ b/apps/private-location/internal/server/validation_test.go @@ -1,271 +1,94 @@ -package server_test +package server import ( + "errors" "testing" - "connectrpc.com/connect" - "github.com/openstatushq/openstatus/apps/private-location/internal/server" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) -func TestValidateIngestHTTPRequest(t *testing.T) { - tests := []struct { - name string - req *private_locationv1.IngestHTTPRequest - wantErr error +// The four pre-existing validators now share one helper, so this covers the +// rules once rather than four times. +func TestValidateIngestCommon(t *testing.T) { + cases := []struct { + name string + monitorID string + latency int64 + timestamp int64 + want error }{ - { - name: "valid request", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "valid request with zero latency", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "monitor-123", - Latency: 0, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "empty monitor_id", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: server.ErrEmptyMonitorID, - }, - { - name: "negative latency", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "monitor-123", - Latency: -1, - Timestamp: 1234567890, - }, - wantErr: server.ErrInvalidLatency, - }, - { - name: "zero timestamp", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 0, - }, - wantErr: server.ErrInvalidTimestamp, - }, - { - name: "negative timestamp", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: -1, - }, - wantErr: server.ErrInvalidTimestamp, - }, + {name: "valid", monitorID: "1", latency: 10, timestamp: 1761000000000}, + {name: "zero latency is fine", monitorID: "1", latency: 0, timestamp: 1761000000000}, + {name: "missing monitor id", monitorID: "", latency: 10, timestamp: 1, want: ErrEmptyMonitorID}, + {name: "negative latency", monitorID: "1", latency: -1, timestamp: 1, want: ErrInvalidLatency}, + {name: "zero timestamp", monitorID: "1", latency: 10, timestamp: 0, want: ErrInvalidTimestamp}, + {name: "negative timestamp", monitorID: "1", latency: 10, timestamp: -5, want: ErrInvalidTimestamp}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := server.ValidateIngestHTTPRequest(tt.req) - if err != tt.wantErr { - t.Errorf("ValidateIngestHTTPRequest() error = %v, wantErr %v", err, tt.wantErr) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateIngestCommon(tc.monitorID, tc.latency, tc.timestamp) + if !errors.Is(err, tc.want) { + t.Fatalf("got %v, want %v", err, tc.want) } }) } } -func TestValidateIngestTCPRequest(t *testing.T) { - tests := []struct { - name string - req *private_locationv1.IngestTCPRequest - wantErr error - }{ - { - name: "valid request", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "valid request with zero latency", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "monitor-123", - Latency: 0, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "empty monitor_id", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: server.ErrEmptyMonitorID, - }, - { - name: "negative latency", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "monitor-123", - Latency: -1, - Timestamp: 1234567890, - }, - wantErr: server.ErrInvalidLatency, - }, - { - name: "zero timestamp", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 0, - }, - wantErr: server.ErrInvalidTimestamp, - }, - { - name: "negative timestamp", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: -1, - }, - wantErr: server.ErrInvalidTimestamp, - }, +func TestValidateIngestGRPCRequest(t *testing.T) { + valid := func() *private_locationv1.IngestGRPCRequest { + return &private_locationv1.IngestGRPCRequest{ + Id: "01", + MonitorId: "1", + Latency: 120, + Timestamp: 1761000000000, + CronTimestamp: 1761000000000, + GrpcCode: 0, + Error: 0, + } } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := server.ValidateIngestTCPRequest(tt.req) - if err != tt.wantErr { - t.Errorf("ValidateIngestTCPRequest() error = %v, wantErr %v", err, tt.wantErr) - } - }) + if err := ValidateIngestGRPCRequest(valid()); err != nil { + t.Fatalf("expected a valid request to pass, got %v", err) } -} -func TestValidateIngestDNSRequest(t *testing.T) { - tests := []struct { - name string - req *private_locationv1.IngestDNSRequest - wantErr error - }{ - { - name: "valid request", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "valid request with zero latency", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "monitor-123", - Latency: 0, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "empty monitor_id", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: server.ErrEmptyMonitorID, - }, - { - name: "negative latency", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "monitor-123", - Latency: -1, - Timestamp: 1234567890, - }, - wantErr: server.ErrInvalidLatency, - }, - { - name: "zero timestamp", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 0, - }, - wantErr: server.ErrInvalidTimestamp, - }, - { - name: "negative timestamp", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: -1, - }, - wantErr: server.ErrInvalidTimestamp, - }, - } + t.Run("inherits the shared rules", func(t *testing.T) { + req := valid() + req.Timestamp = 0 + if err := ValidateIngestGRPCRequest(req); !errors.Is(err, ErrInvalidTimestamp) { + t.Fatalf("got %v, want %v", err, ErrInvalidTimestamp) + } + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := server.ValidateIngestDNSRequest(tt.req) - if err != tt.wantErr { - t.Errorf("ValidateIngestDNSRequest() error = %v, wantErr %v", err, tt.wantErr) + // Both fields are narrowed on the way into the Tinybird row, so an + // out-of-range value would wrap into a plausible-looking one. + t.Run("rejects an out of range status code", func(t *testing.T) { + for _, code := range []int64{-1, 17, 300} { + req := valid() + req.GrpcCode = code + if err := ValidateIngestGRPCRequest(req); !errors.Is(err, ErrInvalidGRPCCode) { + t.Fatalf("code %d: got %v, want %v", code, err, ErrInvalidGRPCCode) } - }) - } -} - -func TestNewValidationError(t *testing.T) { - tests := []struct { - name string - err error - wantCode connect.Code - wantContains string - }{ - { - name: "empty monitor id error", - err: server.ErrEmptyMonitorID, - wantCode: connect.CodeInvalidArgument, - wantContains: "monitor_id is required", - }, - { - name: "empty id error", - err: server.ErrEmptyID, - wantCode: connect.CodeInvalidArgument, - wantContains: "id is required", - }, - { - name: "invalid latency error", - err: server.ErrInvalidLatency, - wantCode: connect.CodeInvalidArgument, - wantContains: "latency must be non-negative", - }, - { - name: "invalid timestamp error", - err: server.ErrInvalidTimestamp, - wantCode: connect.CodeInvalidArgument, - wantContains: "timestamp must be positive", - }, - } + } + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - connErr := server.NewValidationError(tt.err) - if connErr.Code() != tt.wantCode { - t.Errorf("NewValidationError() code = %v, want %v", connErr.Code(), tt.wantCode) + t.Run("accepts every canonical status code", func(t *testing.T) { + for code := int64(0); code <= maxGRPCStatusCode; code++ { + req := valid() + req.GrpcCode = code + if err := ValidateIngestGRPCRequest(req); err != nil { + t.Fatalf("code %d should be valid, got %v", code, err) } - if connErr.Message() == "" { - t.Error("NewValidationError() message should not be empty") + } + }) + + t.Run("rejects an out of range error flag", func(t *testing.T) { + for _, flag := range []int64{-1, 2, 256} { + req := valid() + req.Error = flag + if err := ValidateIngestGRPCRequest(req); !errors.Is(err, ErrInvalidErrorFlag) { + t.Fatalf("flag %d: got %v, want %v", flag, err, ErrInvalidErrorFlag) } - }) - } + } + }) } diff --git a/apps/private-location/internal/tinybird/client.go b/apps/private-location/internal/tinybird/client.go index b50fd538..27382b7f 100644 --- a/apps/private-location/internal/tinybird/client.go +++ b/apps/private-location/internal/tinybird/client.go @@ -16,6 +16,7 @@ const ( DatasourceTCP = "tcp_response__v0" DatasourceDNS = "dns_response__v0" DatasourceICMP = "icmp_response__v0" + DatasourceGRPC = "grpc_response__v0" ) func getBaseURL() string { diff --git a/apps/private-location/proto/private_location/v1/grpc_monitor.pb.go b/apps/private-location/proto/private_location/v1/grpc_monitor.pb.go new file mode 100644 index 00000000..6784cdc9 --- /dev/null +++ b/apps/private-location/proto/private_location/v1/grpc_monitor.pb.go @@ -0,0 +1,213 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: private_location/v1/grpc_monitor.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GRPCMonitor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` + Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` + DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` + Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` + Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` + Service string `protobuf:"bytes,7,opt,name=service,proto3" json:"service,omitempty"` + TlsMode string `protobuf:"bytes,8,opt,name=tls_mode,json=tlsMode,proto3" json:"tls_mode,omitempty"` + Metadata []*Headers `protobuf:"bytes,10,rep,name=metadata,proto3" json:"metadata,omitempty"` + OtelConfig *OtelConfig `protobuf:"bytes,20,opt,name=otel_config,json=otelConfig,proto3" json:"otel_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GRPCMonitor) Reset() { + *x = GRPCMonitor{} + mi := &file_private_location_v1_grpc_monitor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GRPCMonitor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GRPCMonitor) ProtoMessage() {} + +func (x *GRPCMonitor) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_grpc_monitor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GRPCMonitor.ProtoReflect.Descriptor instead. +func (*GRPCMonitor) Descriptor() ([]byte, []int) { + return file_private_location_v1_grpc_monitor_proto_rawDescGZIP(), []int{0} +} + +func (x *GRPCMonitor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GRPCMonitor) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *GRPCMonitor) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *GRPCMonitor) GetDegradedAt() int64 { + if x != nil && x.DegradedAt != nil { + return *x.DegradedAt + } + return 0 +} + +func (x *GRPCMonitor) GetPeriodicity() string { + if x != nil { + return x.Periodicity + } + return "" +} + +func (x *GRPCMonitor) GetRetry() int64 { + if x != nil { + return x.Retry + } + return 0 +} + +func (x *GRPCMonitor) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *GRPCMonitor) GetTlsMode() string { + if x != nil { + return x.TlsMode + } + return "" +} + +func (x *GRPCMonitor) GetMetadata() []*Headers { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *GRPCMonitor) GetOtelConfig() *OtelConfig { + if x != nil { + return x.OtelConfig + } + return nil +} + +var File_private_location_v1_grpc_monitor_proto protoreflect.FileDescriptor + +const file_private_location_v1_grpc_monitor_proto_rawDesc = "" + + "\n" + + "&private_location/v1/grpc_monitor.proto\x12\x13private_location.v1\x1a\x1eprivate_location/v1/otel.proto\"\xe8\x02\n" + + "\vGRPCMonitor\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + + "degradedAt\x88\x01\x01\x12 \n" + + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + + "\x05retry\x18\x06 \x01(\x03R\x05retry\x12\x18\n" + + "\aservice\x18\a \x01(\tR\aservice\x12\x19\n" + + "\btls_mode\x18\b \x01(\tR\atlsMode\x128\n" + + "\bmetadata\x18\n" + + " \x03(\v2\x1c.private_location.v1.HeadersR\bmetadata\x12@\n" + + "\votel_config\x18\x14 \x01(\v2\x1f.private_location.v1.OtelConfigR\n" + + "otelConfigB\x0e\n" + + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + +var ( + file_private_location_v1_grpc_monitor_proto_rawDescOnce sync.Once + file_private_location_v1_grpc_monitor_proto_rawDescData []byte +) + +func file_private_location_v1_grpc_monitor_proto_rawDescGZIP() []byte { + file_private_location_v1_grpc_monitor_proto_rawDescOnce.Do(func() { + file_private_location_v1_grpc_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_grpc_monitor_proto_rawDesc), len(file_private_location_v1_grpc_monitor_proto_rawDesc))) + }) + return file_private_location_v1_grpc_monitor_proto_rawDescData +} + +var file_private_location_v1_grpc_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_private_location_v1_grpc_monitor_proto_goTypes = []any{ + (*GRPCMonitor)(nil), // 0: private_location.v1.GRPCMonitor + (*Headers)(nil), // 1: private_location.v1.Headers + (*OtelConfig)(nil), // 2: private_location.v1.OtelConfig +} +var file_private_location_v1_grpc_monitor_proto_depIdxs = []int32{ + 1, // 0: private_location.v1.GRPCMonitor.metadata:type_name -> private_location.v1.Headers + 2, // 1: private_location.v1.GRPCMonitor.otel_config:type_name -> private_location.v1.OtelConfig + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_private_location_v1_grpc_monitor_proto_init() } +func file_private_location_v1_grpc_monitor_proto_init() { + if File_private_location_v1_grpc_monitor_proto != nil { + return + } + file_private_location_v1_otel_proto_init() + file_private_location_v1_grpc_monitor_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_grpc_monitor_proto_rawDesc), len(file_private_location_v1_grpc_monitor_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_private_location_v1_grpc_monitor_proto_goTypes, + DependencyIndexes: file_private_location_v1_grpc_monitor_proto_depIdxs, + MessageInfos: file_private_location_v1_grpc_monitor_proto_msgTypes, + }.Build() + File_private_location_v1_grpc_monitor_proto = out.File + file_private_location_v1_grpc_monitor_proto_goTypes = nil + file_private_location_v1_grpc_monitor_proto_depIdxs = nil +} diff --git a/apps/private-location/proto/private_location/v1/private_location.connect.go b/apps/private-location/proto/private_location/v1/private_location.connect.go index 8138ab7c..e52f9503 100644 --- a/apps/private-location/proto/private_location/v1/private_location.connect.go +++ b/apps/private-location/proto/private_location/v1/private_location.connect.go @@ -47,6 +47,9 @@ const ( // PrivateLocationServiceIngestICMPProcedure is the fully-qualified name of the // PrivateLocationService's IngestICMP RPC. PrivateLocationServiceIngestICMPProcedure = "/private_location.v1.PrivateLocationService/IngestICMP" + // PrivateLocationServiceIngestGRPCProcedure is the fully-qualified name of the + // PrivateLocationService's IngestGRPC RPC. + PrivateLocationServiceIngestGRPCProcedure = "/private_location.v1.PrivateLocationService/IngestGRPC" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService @@ -57,6 +60,7 @@ type PrivateLocationServiceClient interface { IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) + IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) } // NewPrivateLocationServiceClient constructs a client for the @@ -100,6 +104,12 @@ func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL stri connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), connect.WithClientOptions(opts...), ), + ingestGRPC: connect.NewClient[IngestGRPCRequest, IngestGRPCResponse]( + httpClient, + baseURL+PrivateLocationServiceIngestGRPCProcedure, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestGRPC")), + connect.WithClientOptions(opts...), + ), } } @@ -110,6 +120,7 @@ type privateLocationServiceClient struct { ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] ingestICMP *connect.Client[IngestICMPRequest, IngestICMPResponse] + ingestGRPC *connect.Client[IngestGRPCRequest, IngestGRPCResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. @@ -137,6 +148,11 @@ func (c *privateLocationServiceClient) IngestICMP(ctx context.Context, req *conn return c.ingestICMP.CallUnary(ctx, req) } +// IngestGRPC calls private_location.v1.PrivateLocationService.IngestGRPC. +func (c *privateLocationServiceClient) IngestGRPC(ctx context.Context, req *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) { + return c.ingestGRPC.CallUnary(ctx, req) +} + // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { @@ -145,6 +161,7 @@ type PrivateLocationServiceHandler interface { IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) + IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It @@ -184,6 +201,12 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), connect.WithHandlerOptions(opts...), ) + privateLocationServiceIngestGRPCHandler := connect.NewUnaryHandler( + PrivateLocationServiceIngestGRPCProcedure, + svc.IngestGRPC, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestGRPC")), + connect.WithHandlerOptions(opts...), + ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: @@ -196,6 +219,8 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestICMPProcedure: privateLocationServiceIngestICMPHandler.ServeHTTP(w, r) + case PrivateLocationServiceIngestGRPCProcedure: + privateLocationServiceIngestGRPCHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -224,3 +249,7 @@ func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *co func (UnimplementedPrivateLocationServiceHandler) IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestICMP is not implemented")) } + +func (UnimplementedPrivateLocationServiceHandler) IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestGRPC is not implemented")) +} diff --git a/apps/private-location/proto/private_location/v1/private_location.pb.go b/apps/private-location/proto/private_location/v1/private_location.pb.go index a28112bc..6cded061 100644 --- a/apps/private-location/proto/private_location/v1/private_location.pb.go +++ b/apps/private-location/proto/private_location/v1/private_location.pb.go @@ -63,6 +63,7 @@ type MonitorsResponse struct { TcpMonitors []*TCPMonitor `protobuf:"bytes,2,rep,name=tcp_monitors,json=tcpMonitors,proto3" json:"tcp_monitors,omitempty"` DnsMonitors []*DNSMonitor `protobuf:"bytes,3,rep,name=dns_monitors,json=dnsMonitors,proto3" json:"dns_monitors,omitempty"` IcmpMonitors []*ICMPMonitor `protobuf:"bytes,5,rep,name=icmp_monitors,json=icmpMonitors,proto3" json:"icmp_monitors,omitempty"` + GrpcMonitors []*GRPCMonitor `protobuf:"bytes,6,rep,name=grpc_monitors,json=grpcMonitors,proto3" json:"grpc_monitors,omitempty"` Region string `protobuf:"bytes,4,opt,name=region,proto3" json:"region,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -126,6 +127,13 @@ func (x *MonitorsResponse) GetIcmpMonitors() []*ICMPMonitor { return nil } +func (x *MonitorsResponse) GetGrpcMonitors() []*GRPCMonitor { + if x != nil { + return x.GrpcMonitors + } + return nil +} + func (x *MonitorsResponse) GetRegion() string { if x != nil { return x.Region @@ -849,17 +857,194 @@ func (*IngestICMPResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{10} } +type IngestGRPCRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` + Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` + Uri string `protobuf:"bytes,6,opt,name=uri,proto3" json:"uri,omitempty"` + Service string `protobuf:"bytes,7,opt,name=service,proto3" json:"service,omitempty"` + ServingStatus string `protobuf:"bytes,8,opt,name=servingStatus,proto3" json:"servingStatus,omitempty"` + GrpcCode int64 `protobuf:"varint,9,opt,name=grpcCode,proto3" json:"grpcCode,omitempty"` + Message string `protobuf:"bytes,10,opt,name=message,proto3" json:"message,omitempty"` + RequestStatus string `protobuf:"bytes,11,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` + Error int64 `protobuf:"varint,12,opt,name=error,proto3" json:"error,omitempty"` + Timing string `protobuf:"bytes,13,opt,name=timing,proto3" json:"timing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestGRPCRequest) Reset() { + *x = IngestGRPCRequest{} + mi := &file_private_location_v1_private_location_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestGRPCRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestGRPCRequest) ProtoMessage() {} + +func (x *IngestGRPCRequest) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestGRPCRequest.ProtoReflect.Descriptor instead. +func (*IngestGRPCRequest) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{11} +} + +func (x *IngestGRPCRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *IngestGRPCRequest) GetMonitorId() string { + if x != nil { + return x.MonitorId + } + return "" +} + +func (x *IngestGRPCRequest) GetLatency() int64 { + if x != nil { + return x.Latency + } + return 0 +} + +func (x *IngestGRPCRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *IngestGRPCRequest) GetCronTimestamp() int64 { + if x != nil { + return x.CronTimestamp + } + return 0 +} + +func (x *IngestGRPCRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *IngestGRPCRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *IngestGRPCRequest) GetServingStatus() string { + if x != nil { + return x.ServingStatus + } + return "" +} + +func (x *IngestGRPCRequest) GetGrpcCode() int64 { + if x != nil { + return x.GrpcCode + } + return 0 +} + +func (x *IngestGRPCRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *IngestGRPCRequest) GetRequestStatus() string { + if x != nil { + return x.RequestStatus + } + return "" +} + +func (x *IngestGRPCRequest) GetError() int64 { + if x != nil { + return x.Error + } + return 0 +} + +func (x *IngestGRPCRequest) GetTiming() string { + if x != nil { + return x.Timing + } + return "" +} + +type IngestGRPCResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestGRPCResponse) Reset() { + *x = IngestGRPCResponse{} + mi := &file_private_location_v1_private_location_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestGRPCResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestGRPCResponse) ProtoMessage() {} + +func (x *IngestGRPCResponse) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestGRPCResponse.ProtoReflect.Descriptor instead. +func (*IngestGRPCResponse) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{12} +} + var File_private_location_v1_private_location_proto protoreflect.FileDescriptor const file_private_location_v1_private_location_proto_rawDesc = "" + "\n" + - "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a&private_location/v1/icmp_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + - "\x0fMonitorsRequest\"\xc0\x02\n" + + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/grpc_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a&private_location/v1/icmp_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + + "\x0fMonitorsRequest\"\x87\x03\n" + "\x10MonitorsResponse\x12E\n" + "\rhttp_monitors\x18\x01 \x03(\v2 .private_location.v1.HTTPMonitorR\fhttpMonitors\x12B\n" + "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\x12B\n" + "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\x12E\n" + - "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12\x16\n" + + "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12E\n" + + "\rgrpc_monitors\x18\x06 \x03(\v2 .private_location.v1.GRPCMonitorR\fgrpcMonitors\x12\x16\n" + "\x06region\x18\x04 \x01(\tR\x06region\"\x9e\x02\n" + "\x10IngestTCPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + @@ -931,7 +1116,23 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "\rrequestStatus\x18\f \x01(\tR\rrequestStatus\x12\x14\n" + "\x05error\x18\r \x01(\x03R\x05error\x12\x16\n" + "\x06timing\x18\x0e \x01(\tR\x06timing\"\x14\n" + - "\x12IngestICMPResponse2\xf1\x03\n" + + "\x12IngestICMPResponse\"\xfb\x02\n" + + "\x11IngestGRPCRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1c\n" + + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + + "\x03uri\x18\x06 \x01(\tR\x03uri\x12\x18\n" + + "\aservice\x18\a \x01(\tR\aservice\x12$\n" + + "\rservingStatus\x18\b \x01(\tR\rservingStatus\x12\x1a\n" + + "\bgrpcCode\x18\t \x01(\x03R\bgrpcCode\x12\x18\n" + + "\amessage\x18\n" + + " \x01(\tR\amessage\x12$\n" + + "\rrequestStatus\x18\v \x01(\tR\rrequestStatus\x12\x14\n" + + "\x05error\x18\f \x01(\x03R\x05error\x12\x16\n" + + "\x06timing\x18\r \x01(\tR\x06timing\"\x14\n" + + "\x12IngestGRPCResponse2\xd2\x04\n" + "\x16PrivateLocationService\x12Y\n" + "\bMonitors\x12$.private_location.v1.MonitorsRequest\x1a%.private_location.v1.MonitorsResponse\"\x00\x12\\\n" + "\tIngestTCP\x12%.private_location.v1.IngestTCPRequest\x1a&.private_location.v1.IngestTCPResponse\"\x00\x12_\n" + @@ -939,7 +1140,9 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00\x12\\\n" + "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00\x12_\n" + "\n" + - "IngestICMP\x12&.private_location.v1.IngestICMPRequest\x1a'.private_location.v1.IngestICMPResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + "IngestICMP\x12&.private_location.v1.IngestICMPRequest\x1a'.private_location.v1.IngestICMPResponse\"\x00\x12_\n" + + "\n" + + "IngestGRPC\x12&.private_location.v1.IngestGRPCRequest\x1a'.private_location.v1.IngestGRPCResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once @@ -953,7 +1156,7 @@ func file_private_location_v1_private_location_proto_rawDescGZIP() []byte { return file_private_location_v1_private_location_proto_rawDescData } -var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse @@ -966,34 +1169,40 @@ var file_private_location_v1_private_location_proto_goTypes = []any{ (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse (*IngestICMPRequest)(nil), // 9: private_location.v1.IngestICMPRequest (*IngestICMPResponse)(nil), // 10: private_location.v1.IngestICMPResponse - nil, // 11: private_location.v1.IngestDNSRequest.RecordsEntry - (*HTTPMonitor)(nil), // 12: private_location.v1.HTTPMonitor - (*TCPMonitor)(nil), // 13: private_location.v1.TCPMonitor - (*DNSMonitor)(nil), // 14: private_location.v1.DNSMonitor - (*ICMPMonitor)(nil), // 15: private_location.v1.ICMPMonitor + (*IngestGRPCRequest)(nil), // 11: private_location.v1.IngestGRPCRequest + (*IngestGRPCResponse)(nil), // 12: private_location.v1.IngestGRPCResponse + nil, // 13: private_location.v1.IngestDNSRequest.RecordsEntry + (*HTTPMonitor)(nil), // 14: private_location.v1.HTTPMonitor + (*TCPMonitor)(nil), // 15: private_location.v1.TCPMonitor + (*DNSMonitor)(nil), // 16: private_location.v1.DNSMonitor + (*ICMPMonitor)(nil), // 17: private_location.v1.ICMPMonitor + (*GRPCMonitor)(nil), // 18: private_location.v1.GRPCMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ - 12, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor - 13, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor - 14, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor - 15, // 3: private_location.v1.MonitorsResponse.icmp_monitors:type_name -> private_location.v1.ICMPMonitor - 11, // 4: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry - 6, // 5: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records - 0, // 6: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest - 2, // 7: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest - 4, // 8: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest - 7, // 9: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest - 9, // 10: private_location.v1.PrivateLocationService.IngestICMP:input_type -> private_location.v1.IngestICMPRequest - 1, // 11: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse - 3, // 12: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse - 5, // 13: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse - 8, // 14: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse - 10, // 15: private_location.v1.PrivateLocationService.IngestICMP:output_type -> private_location.v1.IngestICMPResponse - 11, // [11:16] is the sub-list for method output_type - 6, // [6:11] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 14, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor + 15, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor + 16, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor + 17, // 3: private_location.v1.MonitorsResponse.icmp_monitors:type_name -> private_location.v1.ICMPMonitor + 18, // 4: private_location.v1.MonitorsResponse.grpc_monitors:type_name -> private_location.v1.GRPCMonitor + 13, // 5: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry + 6, // 6: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records + 0, // 7: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest + 2, // 8: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest + 4, // 9: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest + 7, // 10: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest + 9, // 11: private_location.v1.PrivateLocationService.IngestICMP:input_type -> private_location.v1.IngestICMPRequest + 11, // 12: private_location.v1.PrivateLocationService.IngestGRPC:input_type -> private_location.v1.IngestGRPCRequest + 1, // 13: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse + 3, // 14: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse + 5, // 15: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse + 8, // 16: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse + 10, // 17: private_location.v1.PrivateLocationService.IngestICMP:output_type -> private_location.v1.IngestICMPResponse + 12, // 18: private_location.v1.PrivateLocationService.IngestGRPC:output_type -> private_location.v1.IngestGRPCResponse + 13, // [13:19] is the sub-list for method output_type + 7, // [7:13] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } @@ -1002,6 +1211,7 @@ func file_private_location_v1_private_location_proto_init() { return } file_private_location_v1_dns_monitor_proto_init() + file_private_location_v1_grpc_monitor_proto_init() file_private_location_v1_http_monitor_proto_init() file_private_location_v1_icmp_monitor_proto_init() file_private_location_v1_tcp_monitor_proto_init() @@ -1011,7 +1221,7 @@ func file_private_location_v1_private_location_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc)), NumEnums: 0, - NumMessages: 12, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/server/src/libs/checker/utils.test.ts b/apps/server/src/libs/checker/utils.test.ts index aa41d39e..c1417407 100644 --- a/apps/server/src/libs/checker/utils.test.ts +++ b/apps/server/src/libs/checker/utils.test.ts @@ -39,7 +39,7 @@ function buildMonitor(overrides: Partial = {}): Monitor { } test("getCheckerUrl routes each job type to its own checker endpoint", () => { - for (const jobType of ["http", "tcp", "dns", "icmp"] as const) { + for (const jobType of ["http", "tcp", "dns", "icmp", "grpc"] as const) { const url = getCheckerUrl(buildMonitor({ jobType })); expect(url).toContain(`/checker/${jobType}?`); expect(url).toContain("monitor_id=1"); @@ -77,6 +77,41 @@ test("getCheckerPayload builds an ICMP payload without assertions", () => { expect("url" in payload).toBe(false); }); +test("getCheckerPayload builds a gRPC payload with its target configuration", () => { + const payload = getCheckerPayload( + buildMonitor({ + jobType: "grpc", + url: "api.example.com:443", + grpcService: "checkout.v1.CheckoutService", + grpcTls: "tls_insecure", + headers: [{ key: "authorization", value: "Bearer token" }], + assertions: + '[{"version":"v1","type":"status","compare":"eq","target":200}]', + }), + "active", + ); + + expect(payload).toMatchObject({ + uri: "api.example.com:443", + service: "checkout.v1.CheckoutService", + tls: "tls_insecure", + headers: { authorization: "Bearer token" }, + trigger: "api", + }); + expect("assertions" in payload).toBe(false); +}); + +// The column is nullable and only defaults on insert, so a row that predates +// the default must still dial with verification on. +test("getCheckerPayload defaults a gRPC monitor with no TLS mode to tls", () => { + const payload = getCheckerPayload( + buildMonitor({ jobType: "grpc", url: "api.example.com:443" }), + "active", + ); + + expect(payload).toMatchObject({ tls: "tls" }); +}); + test("getCheckerPayload builds a DNS payload with assertions", () => { const payload = getCheckerPayload( buildMonitor({ diff --git a/apps/server/src/libs/checker/utils.ts b/apps/server/src/libs/checker/utils.ts index 5e411a20..32194e8b 100644 --- a/apps/server/src/libs/checker/utils.ts +++ b/apps/server/src/libs/checker/utils.ts @@ -3,6 +3,7 @@ import type { selectMonitorSchema } from "@openstatus/db/src/schema"; import { type DNSPayloadSchema, type httpPayloadSchema, + type grpcPayloadSchema, type icmpPayloadSchema, type tpcPayloadSchema, transformHeaders, @@ -17,7 +18,8 @@ export function getCheckerPayload( | z.infer | z.infer | z.infer - | z.infer { + | z.infer + | z.infer { const timestamp = new Date().getTime(); switch (monitor.jobType) { case "http": @@ -101,6 +103,28 @@ export function getCheckerPayload( : undefined, retry: monitor.retry ?? 0, }; + case "grpc": + // No assertions: a gRPC health check only reports the serving status. + return { + workspaceId: String(monitor.workspaceId), + monitorId: String(monitor.id), + uri: monitor.url, + service: monitor.grpcService ?? undefined, + tls: monitor.grpcTls ?? "tls", + headers: transformHeaders(monitor.headers), + status: status, + cronTimestamp: timestamp, + degradedAfter: monitor.degradedAfter, + timeout: monitor.timeout, + trigger: "api", + otelConfig: monitor.otelEndpoint + ? { + endpoint: monitor.otelEndpoint, + headers: transformHeaders(monitor.otelHeaders), + } + : undefined, + retry: monitor.retry ?? 0, + }; default: throw new OpenStatusApiError({ code: "BAD_REQUEST", @@ -129,6 +153,7 @@ export function getCheckerUrl( case "tcp": case "dns": case "icmp": + case "grpc": return `https://openstatus-checker.fly.dev/checker/${monitor.jobType}?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; default: throw new OpenStatusApiError({ diff --git a/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts b/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts index 833685d7..d6acd4a1 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts @@ -50,6 +50,7 @@ let testHttpMonitorId: number; let testTcpMonitorId: number; let testDnsMonitorId: number; let testIcmpMonitorId: number; +let testGrpcMonitorId: number; let testMonitorToDeleteId: number; let testMonitorWithStatusId: number; @@ -210,6 +211,24 @@ beforeAll(async () => { .get(); testIcmpMonitorId = icmpMon.id; + const grpcMon = await db + .insert(monitor) + .values({ + workspaceId: 1, + name: `${TEST_PREFIX}-grpc`, + url: "api.example.com:443", + periodicity: "10m", + active: true, + regions: "ams", + jobType: "grpc", + timeout: 5000, + grpcService: "checkout.v1.CheckoutService", + grpcTls: "tls", + }) + .returning() + .get(); + testGrpcMonitorId = grpcMon.id; + // Create monitor to be deleted const deleteMon = await db .insert(monitor) @@ -933,6 +952,103 @@ describe("MonitorService.CreateICMPMonitor", () => { }); }); +describe("MonitorService.CreateGRPCMonitor", () => { + test("successfully creates gRPC monitor", async () => { + const res = await connectRequest( + "CreateGRPCMonitor", + { + monitor: { + name: "test-create-grpc", + uri: "api.example.com:443", + periodicity: "PERIODICITY_5M", + timeout: "5000", + service: "checkout.v1.CheckoutService", + tlsMode: "GRPC_TLS_MODE_TLS_INSECURE", + metadata: [{ key: "authorization", value: "Bearer token" }], + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor).toBeDefined(); + expect(data.monitor.uri).toBe("api.example.com:443"); + expect(data.monitor.service).toBe("checkout.v1.CheckoutService"); + expect(data.monitor.tlsMode).toBe("GRPC_TLS_MODE_TLS_INSECURE"); + + const row = await db + .select() + .from(monitor) + .where(eq(monitor.id, Number(data.monitor.id))) + .get(); + expect(row?.jobType).toBe("grpc"); + expect(row?.grpcService).toBe("checkout.v1.CheckoutService"); + expect(row?.grpcTls).toBe("tls_insecure"); + + if (data.monitor.id) { + await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); + } + }); + + // The column default only applies on insert, and an omitted enum arrives as + // UNSPECIFIED — it must resolve to verified TLS, not to plaintext. + test("defaults the TLS mode to tls when omitted", async () => { + const res = await connectRequest( + "CreateGRPCMonitor", + { + monitor: { + name: "test-create-grpc-default-tls", + uri: "api.example.com:443", + periodicity: "PERIODICITY_5M", + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + const row = await db + .select() + .from(monitor) + .where(eq(monitor.id, Number(data.monitor.id))) + .get(); + expect(row?.grpcTls).toBe("tls"); + + if (data.monitor.id) { + await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); + } + }); + + test("rejects a target that is not host:port", async () => { + const res = await connectRequest( + "CreateGRPCMonitor", + { + monitor: { + name: "test-create-grpc-bad-target", + uri: "api.example.com", + periodicity: "PERIODICITY_5M", + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + }); + + test("returns error when monitor is missing", async () => { + const res = await connectRequest( + "CreateGRPCMonitor", + {}, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + }); +}); + describe("MonitorService.UpdateHTTPMonitor", () => { test("successfully updates HTTP monitor with partial data", async () => { const res = await connectRequest( @@ -1640,6 +1756,120 @@ describe("MonitorService.UpdateICMPMonitor", () => { }); }); +describe("MonitorService.UpdateGRPCMonitor", () => { + test("successfully updates gRPC monitor with partial data", async () => { + const res = await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { name: "updated-grpc-name" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor.name).toBe("updated-grpc-name"); + expect(data.monitor.uri).toBe("api.example.com:443"); + + await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { name: `${TEST_PREFIX}-grpc` }, + }, + { "x-openstatus-key": "1" }, + ); + }); + + // `tlsMode` has explicit presence, so an omitted one means "leave as-is". + // Resolving UNSPECIFIED to TLS here would silently break a plaintext monitor. + test("leaves the TLS mode untouched when the patch omits it", async () => { + await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { tlsMode: "GRPC_TLS_MODE_PLAINTEXT" }, + }, + { "x-openstatus-key": "1" }, + ); + + await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { name: `${TEST_PREFIX}-grpc` }, + }, + { "x-openstatus-key": "1" }, + ); + + const row = await db + .select() + .from(monitor) + .where(eq(monitor.id, testGrpcMonitorId)) + .get(); + expect(row?.grpcTls).toBe("plaintext"); + + await db + .update(monitor) + .set({ grpcTls: "tls" }) + .where(eq(monitor.id, testGrpcMonitorId)); + }); + + // An empty service is a real value: it means "check overall server health". + test("clears the service name when the patch sends an empty string", async () => { + const res = await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { service: "" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const row = await db + .select() + .from(monitor) + .where(eq(monitor.id, testGrpcMonitorId)) + .get(); + expect(row?.grpcService).toBe(""); + + await db + .update(monitor) + .set({ grpcService: "checkout.v1.CheckoutService" }) + .where(eq(monitor.id, testGrpcMonitorId)); + }); + + test("rejects a target that is not host:port", async () => { + const res = await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { uri: "api.example.com" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + }); + + test("returns error for a monitor of another job type", async () => { + const res = await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testIcmpMonitorId), + monitor: { name: "nope" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + }); +}); + describe("MonitorService - private and internal URLs", () => { // Valid URIs, so protovalidate passes them through to the service guard. const BLOCKED = [ diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/defaults.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/defaults.ts index 3b6642c2..69166785 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/defaults.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/defaults.ts @@ -8,4 +8,5 @@ export const MONITOR_DEFAULTS = { active: false, public: false, description: "", + grpcTls: "tls", } as const; diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/enums.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/enums.ts index 82ac27c3..04664b94 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/enums.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/enums.ts @@ -1,5 +1,7 @@ import type { monitorPeriodicitySchema } from "@openstatus/db/src/schema/constants"; +import type { GrpcTlsMode } from "@openstatus/db/src/schema/monitors/validation"; import { + GRPCTlsMode, HTTPMethod, MonitorStatus, Periodicity, @@ -95,6 +97,31 @@ export function stringToMonitorStatus(value: string): MonitorStatus { return DB_TO_MONITOR_STATUS[value] ?? MonitorStatus.UNSPECIFIED; } +// ============================================================ +// gRPC TLS Mode Conversions +// ============================================================ + +const DB_TO_GRPC_TLS_MODE: Record = { + plaintext: GRPCTlsMode.GRPC_TLS_MODE_PLAINTEXT, + tls: GRPCTlsMode.GRPC_TLS_MODE_TLS, + tls_insecure: GRPCTlsMode.GRPC_TLS_MODE_TLS_INSECURE, +}; + +const GRPC_TLS_MODE_TO_DB: Record = { + [GRPCTlsMode.GRPC_TLS_MODE_PLAINTEXT]: "plaintext", + [GRPCTlsMode.GRPC_TLS_MODE_TLS]: "tls", + [GRPCTlsMode.GRPC_TLS_MODE_TLS_INSECURE]: "tls_insecure", + [GRPCTlsMode.GRPC_TLS_MODE_UNSPECIFIED]: "tls", +}; + +export function stringToGrpcTlsMode(value: string | null): GRPCTlsMode { + return DB_TO_GRPC_TLS_MODE[value ?? ""] ?? GRPCTlsMode.GRPC_TLS_MODE_TLS; +} + +export function grpcTlsModeToString(value: GRPCTlsMode): GrpcTlsMode { + return GRPC_TLS_MODE_TO_DB[value] ?? "tls"; +} + // ============================================================ // Time Range Conversions // ============================================================ diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts index be77f8d6..1073bb7a 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts @@ -30,6 +30,8 @@ export { stringToHttpMethod, httpMethodToString, stringToMonitorStatus, + stringToGrpcTlsMode, + grpcTlsModeToString, timeRangeToKey, type TimeRangeKey, } from "./enums"; @@ -47,6 +49,7 @@ export { dbMonitorToHttpProto, dbMonitorToTcpProto, dbMonitorToDnsProto, + dbMonitorToGrpcProto, dbMonitorToIcmpProto, } from "./monitors"; diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts index b8aba3d0..785f22d1 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts @@ -1,6 +1,7 @@ import type { Monitor } from "@openstatus/db/src/schema/monitors/validation"; import type { DNSMonitor, + GRPCMonitor, HTTPMonitor, ICMPMonitor, TCPMonitor, @@ -9,6 +10,7 @@ import type { import { parseDnsAssertions, parseHttpAssertions } from "./assertions"; import { MONITOR_DEFAULTS } from "./defaults"; import { + stringToGrpcTlsMode, stringToHttpMethod, stringToMonitorStatus, stringToPeriodicity, @@ -103,6 +105,35 @@ export function dbMonitorToIcmpProto( }; } +/** + * Transform database gRPC monitor to proto GRPCMonitor. + */ +export function dbMonitorToGrpcProto( + dbMon: Monitor, + privateLocationIds: string[] = [], +): GRPCMonitor { + return { + $typeName: "openstatus.monitor.v1.GRPCMonitor", + id: String(dbMon.id), + name: dbMon.name, + uri: dbMon.url, + periodicity: stringToPeriodicity(dbMon.periodicity), + timeout: BigInt(dbMon.timeout), + degradedAt: dbMon.degradedAfter ? BigInt(dbMon.degradedAfter) : undefined, + retry: BigInt(dbMon.retry ?? MONITOR_DEFAULTS.retry), + description: dbMon.description, + active: dbMon.active ?? MONITOR_DEFAULTS.active, + public: dbMon.public ?? MONITOR_DEFAULTS.public, + regions: stringsToRegions(dbMon.regions), + openTelemetry: parseOpenTelemetry(dbMon.otelEndpoint, dbMon.otelHeaders), + status: stringToMonitorStatus(dbMon.status), + privateLocationIds, + service: dbMon.grpcService ?? undefined, + tlsMode: stringToGrpcTlsMode(dbMon.grpcTls), + metadata: toProtoHeaders(dbMon.headers), + }; +} + /** * Transform database DNS monitor to proto DNSMonitor. */ diff --git a/apps/server/src/routes/rpc/handlers/monitor/index.ts b/apps/server/src/routes/rpc/handlers/monitor/index.ts index 5967421b..4716537b 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/index.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/index.ts @@ -8,6 +8,7 @@ import type { GetMonitorSummaryResponse, HTTPMonitor, HTTPResponseLogPagination, + GRPCMonitor, ICMPMonitor, ListMonitorHTTPResponseLogsResponse, MonitorConfig, @@ -15,7 +16,7 @@ import type { RegionStatus, TCPMonitor, } from "@openstatus/proto/monitor/v1"; -import { TimeRange } from "@openstatus/proto/monitor/v1"; +import { GRPCTlsMode, TimeRange } from "@openstatus/proto/monitor/v1"; import { ForbiddenError, LimitExceededError, @@ -48,7 +49,9 @@ import { MONITOR_DEFAULTS, dbMonitorToDnsProto, dbMonitorToHttpProto, + dbMonitorToGrpcProto, dbMonitorToIcmpProto, + grpcTlsModeToString, dbMonitorToTcpProto, protoDnsAssertionsToService, protoHeadersToService, @@ -110,7 +113,7 @@ type DBMonitor = NonNullable>>; async function validateAndGetMonitor( id: string | undefined, workspaceId: number, - expectedJobType: "http" | "tcp" | "dns" | "icmp", + expectedJobType: "http" | "tcp" | "dns" | "icmp" | "grpc", ): Promise { if (!id || id.trim() === "") { throw monitorIdRequiredError(); @@ -303,6 +306,46 @@ export const monitorServiceImpl: ServiceImpl = { } }, + async createGRPCMonitor(req, ctx) { + const rpcCtx = getRpcContext(ctx); + const workspaceId = rpcCtx.workspace.id; + const limits = rpcCtx.workspace.limits; + + if (!req.monitor) { + throw monitorRequiredError(); + } + + const mon = req.monitor; + + // Validate required fields (proto validation handles name, uri, periodicity) + validateCommonMonitorFields(mon); + + // Check workspace limits + await checkMonitorLimits(workspaceId, limits, mon.periodicity, mon.regions); + + try { + const created = await createMonitor({ + ctx: toServiceCtx(rpcCtx), + input: { + ...getCommonCreateInput(mon), + jobType: "grpc", + url: mon.uri, + method: "GET", + headers: protoHeadersToService(mon.metadata) ?? [], + assertions: [], + grpcService: mon.service, + grpcTls: grpcTlsModeToString( + mon.tlsMode ?? GRPCTlsMode.GRPC_TLS_MODE_UNSPECIFIED, + ), + }, + }); + + return { monitor: dbMonitorToGrpcProto(created) }; + } catch (err) { + toConnectError(err); + } + }, + async updateHTTPMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; @@ -536,6 +579,73 @@ export const monitorServiceImpl: ServiceImpl = { ); }, + async updateGRPCMonitor(req, ctx) { + const rpcCtx = getRpcContext(ctx); + const workspaceId = rpcCtx.workspace.id; + const limits = rpcCtx.workspace.limits; + + const dbMon = await validateAndGetMonitor(req.id, workspaceId, "grpc"); + + const plMap = await getPrivateLocationIdsByMonitor({ + ctx: toServiceCtx(rpcCtx), + input: { monitorIds: [dbMon.id] }, + }); + const privateLocationIds = plMap.get(dbMon.id) ?? []; + + // If no monitor data provided, return current monitor + if (!req.monitor) { + const parsed = selectMonitorSchema.safeParse(dbMon); + if (!parsed.success) { + throw monitorParseFailedError(req.id); + } + return { + monitor: dbMonitorToGrpcProto(parsed.data, privateLocationIds), + }; + } + + const mon = req.monitor; + + // Validate regions if provided + validateCommonMonitorFields(mon); + // This method skips the protovalidate interceptor (see SKIP_VALIDATION_METHODS), + // so the message's own bounds have to be applied here. + validateMonitorPatchBounds(mon, { jobType: "grpc" }); + + // Check workspace limits if periodicity or regions are changing + checkMonitorConfigLimits( + limits, + mon.periodicity || undefined, + mon.regions && mon.regions.length > 0 ? mon.regions : undefined, + ); + + // Build update values - only include fields that are provided + const updateValues = getCommonUpdateInput(mon); + + // Handle gRPC-specific fields + if (mon.uri !== undefined && mon.uri !== "") { + updateValues.url = mon.uri; + } + + // `service` and `tlsMode` carry explicit presence, so `undefined` means + // omitted: an empty service clears it back to overall server health, and an + // omitted tlsMode leaves a plaintext monitor plaintext. + if (mon.service !== undefined) { + updateValues.grpcService = mon.service; + } + + if (mon.tlsMode !== undefined) { + updateValues.grpcTls = grpcTlsModeToString(mon.tlsMode); + } + + if (mon.metadata !== undefined && mon.metadata.length > 0) { + updateValues.headers = protoHeadersToService(mon.metadata) ?? []; + } + + return applyUpdate(rpcCtx, dbMon.id, updateValues, (data) => + dbMonitorToGrpcProto(data, privateLocationIds), + ); + }, + async triggerMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const limits = rpcCtx.workspace.limits; @@ -656,6 +766,7 @@ export const monitorServiceImpl: ServiceImpl = { const tcpMonitors: TCPMonitor[] = []; const dnsMonitors: DNSMonitor[] = []; const icmpMonitors: ICMPMonitor[] = []; + const grpcMonitors: GRPCMonitor[] = []; for (const data of parsedMonitors) { const privateLocationIds = plMap.get(data.id) ?? []; @@ -672,6 +783,9 @@ export const monitorServiceImpl: ServiceImpl = { case "icmp": icmpMonitors.push(dbMonitorToIcmpProto(data, privateLocationIds)); break; + case "grpc": + grpcMonitors.push(dbMonitorToGrpcProto(data, privateLocationIds)); + break; } } @@ -680,6 +794,7 @@ export const monitorServiceImpl: ServiceImpl = { tcpMonitors, dnsMonitors, icmpMonitors, + grpcMonitors, totalSize: totalCount, }; }, @@ -769,10 +884,19 @@ export const monitorServiceImpl: ServiceImpl = { }, }; break; + case "grpc": + monitorConfig = { + $typeName: "openstatus.monitor.v1.MonitorConfig", + config: { + case: "grpc", + value: dbMonitorToGrpcProto(monitorData, privateLocationIds), + }, + }; + break; default: throw monitorTypeMismatchError( req.id, - "http, tcp, dns, or icmp", + "http, tcp, dns, icmp, or grpc", monitorData.jobType, ); } diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts index 216e7720..ae876638 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts @@ -247,4 +247,54 @@ describe("validateMonitorPatchBounds", () => { ConnectError, ); }); + + test("rejects a gRPC service name over the proto's limit", () => { + expect(() => + validateMonitorPatchBounds({ service: "s".repeat(513) }), + ).toThrow(ConnectError); + validateMonitorPatchBounds({ service: "s".repeat(512) }); + }); + + test("accepts the host:port shapes a gRPC target can take", () => { + for (const uri of [ + "api.example.com:443", + "10.0.0.5:50051", + "[2001:db8::1]:50051", + "svc.internal:8443", + ]) { + validateMonitorPatchBounds({ uri }, { jobType: "grpc" }); + } + }); + + test("rejects a gRPC target with no port or with a scheme", () => { + for (const uri of [ + "api.example.com", + "grpc://api.example.com:443", + "api.example.com:", + ]) { + expect(() => + validateMonitorPatchBounds({ uri }, { jobType: "grpc" }), + ).toThrow(ConnectError); + } + }); + + // The format check keys off the job type, not off `service` being present, + // so a patch that changes only the target is still validated. + test("checks a gRPC target even when the patch omits the service", () => { + expect(() => + validateMonitorPatchBounds( + { uri: "api.example.com" }, + { jobType: "grpc" }, + ), + ).toThrow(ConnectError); + }); + + test("leaves the other job types' targets unconstrained", () => { + validateMonitorPatchBounds({ uri: "openstatus.dev" }); + validateMonitorPatchBounds({ uri: "openstatus.dev" }, { jobType: "dns" }); + validateMonitorPatchBounds( + { url: "https://openstatus.dev" }, + { jobType: "http" }, + ); + }); }); diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.ts index 23aeb2c8..11c90fc6 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.ts @@ -74,6 +74,10 @@ const MONITOR_BOUNDS = { degradedAtMaxMs: 120_000, retryMax: 10, regionsMaxItems: 28, + serviceMaxLen: 512, + // gRPC dials a host:port; a portless target or one carrying a scheme cannot + // be dialled and would surface as a bare UNAVAILABLE. + hostPortPattern: /^(\[[0-9a-fA-F:]+\]|[^:/\s]+):[0-9]{1,5}$/, } as const; function invalidArgument(message: string): never { @@ -85,16 +89,20 @@ function invalidArgument(message: string): never { * tests mirror `getCommonUpdateInput` exactly: a field this skips is a field * that never reaches the database. */ -export function validateMonitorPatchBounds(mon: { - name?: string; - uri?: string; - url?: string; - timeout?: bigint; - degradedAt?: bigint; - retry?: bigint; - description?: string; - regions?: Region[]; -}): void { +export function validateMonitorPatchBounds( + mon: { + name?: string; + uri?: string; + url?: string; + timeout?: bigint; + degradedAt?: bigint; + retry?: bigint; + description?: string; + regions?: Region[]; + service?: string; + }, + opts: { jobType?: "http" | "tcp" | "dns" | "icmp" | "grpc" } = {}, +): void { if (mon.name !== undefined && mon.name !== "") { if (mon.name.length > MONITOR_BOUNDS.nameMaxLen) { invalidArgument( @@ -113,6 +121,25 @@ export function validateMonitorPatchBounds(mon: { } } + if (mon.service !== undefined) { + if (mon.service.length > MONITOR_BOUNDS.serviceMaxLen) { + invalidArgument( + `monitor.service: must be at most ${MONITOR_BOUNDS.serviceMaxLen} characters [string.max_len]`, + ); + } + } + + // gRPC only: the other types accept a bare hostname or a URL. Checked on the + // job type rather than on `service` being present, so a patch that changes + // only the target is still validated. + if (opts.jobType === "grpc" && target !== undefined && target !== "") { + if (!MONITOR_BOUNDS.hostPortPattern.test(target)) { + invalidArgument( + "monitor.uri: must match the host:port format [string.pattern]", + ); + } + } + if (mon.description !== undefined) { if (mon.description.length > MONITOR_BOUNDS.descriptionMaxLen) { invalidArgument( diff --git a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts index 6ebe54be..e9bc2068 100644 --- a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts +++ b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts @@ -275,6 +275,20 @@ describe("RPC_EVENT_MAP", () => { expect(untracked).toEqual([]); }); + test("gRPC monitor mutations are tracked like the other monitor types", () => { + const create = + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/CreateGRPCMonitor"]; + const update = + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/UpdateGRPCMonitor"]; + + expect(create?.event).toEqual(Events.CreateMonitor); + expect(update?.event).toEqual(Events.UpdateMonitor); + expect(create?.eventProps).toEqual( + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/CreateDNSMonitor"] + ?.eventProps, + ); + }); + test("ICMP monitor mutations are tracked like the other monitor types", () => { const create = RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/CreateICMPMonitor"]; diff --git a/apps/server/src/routes/rpc/interceptors/tracking.ts b/apps/server/src/routes/rpc/interceptors/tracking.ts index 4ae4ff35..57cdae60 100644 --- a/apps/server/src/routes/rpc/interceptors/tracking.ts +++ b/apps/server/src/routes/rpc/interceptors/tracking.ts @@ -18,8 +18,8 @@ type RpcEventMapping = { }; // Create*Monitor requests nest the config under `monitor`, so top-level -// extraction yields nothing; ICMP names its target `uri` and none of them -// carries jobType on the wire. +// extraction yields nothing; ICMP and gRPC name their target `uri` and none of +// them carries jobType on the wire. function monitorCreateInput(jobType: string) { return (message: unknown): Record => { if (typeof message !== "object" || message === null) return {}; @@ -58,6 +58,11 @@ export const RPC_EVENT_MAP: Record = { eventProps: ["url", "jobType"], normalizeInput: monitorCreateInput("icmp"), }, + "openstatus.monitor.v1.MonitorService/CreateGRPCMonitor": { + event: Events.CreateMonitor, + eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("grpc"), + }, "openstatus.monitor.v1.MonitorService/UpdateHTTPMonitor": { event: Events.UpdateMonitor, }, @@ -70,6 +75,9 @@ export const RPC_EVENT_MAP: Record = { "openstatus.monitor.v1.MonitorService/UpdateICMPMonitor": { event: Events.UpdateMonitor, }, + "openstatus.monitor.v1.MonitorService/UpdateGRPCMonitor": { + event: Events.UpdateMonitor, + }, "openstatus.monitor.v1.MonitorService/DeleteMonitor": { event: Events.DeleteMonitor, }, diff --git a/apps/server/src/routes/rpc/interceptors/validation.ts b/apps/server/src/routes/rpc/interceptors/validation.ts index 84611c3e..4c90ce31 100644 --- a/apps/server/src/routes/rpc/interceptors/validation.ts +++ b/apps/server/src/routes/rpc/interceptors/validation.ts @@ -8,6 +8,7 @@ const SKIP_VALIDATION_METHODS = new Set([ "UpdateTCPMonitor", "UpdateDNSMonitor", "UpdateICMPMonitor", + "UpdateGRPCMonitor", ]); // protovalidate >=1.2 dropped the "value " prefix from the string.pattern diff --git a/apps/server/static/openapi.yaml b/apps/server/static/openapi.yaml index 532ee048..ed2d5f69 100644 --- a/apps/server/static/openapi.yaml +++ b/apps/server/static/openapi.yaml @@ -448,6 +448,28 @@ components: title: CreateDNSMonitorResponse additionalProperties: false description: CreateDNSMonitorResponse is the response after creating a DNS monitor. + openstatus.monitor.v1.CreateGRPCMonitorRequest: + type: object + properties: + monitor: + title: monitor + description: Monitor configuration (required). + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: CreateGRPCMonitorRequest + required: + - monitor + additionalProperties: false + description: CreateGRPCMonitorRequest is the request to create a new gRPC monitor. + openstatus.monitor.v1.CreateGRPCMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The created monitor with assigned ID. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: CreateGRPCMonitorResponse + additionalProperties: false + description: CreateGRPCMonitorResponse is the response after creating a gRPC monitor. openstatus.monitor.v1.CreateHTTPMonitorRequest: type: object properties: @@ -645,6 +667,143 @@ components: title: DeleteMonitorResponse additionalProperties: false description: DeleteMonitorResponse is the response after deleting a monitor. + openstatus.monitor.v1.GRPCMonitor: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the monitor (output only for create requests). + name: + type: string + examples: + - Checkout gRPC + title: name + maxLength: 256 + minLength: 1 + description: Name of the monitor (required, max 256 characters). + uri: + type: string + examples: + - api.example.com:443 + title: uri + maxLength: 2048 + minLength: 1 + pattern: ^(\[[0-9a-fA-F:]+\]|[^:/\s]+):[0-9]{1,5}$ + description: Target in "host:port" form. IPv6 addresses must be bracketed. + periodicity: + not: + enum: + - PERIODICITY_UNSPECIFIED + title: periodicity + description: Check periodicity (required). + $ref: '#/components/schemas/openstatus.monitor.v1.Periodicity' + timeout: + type: + - integer + - string + title: timeout + maximum: 120000 + minimum: 0 + format: int64 + description: Timeout in milliseconds (0-120000, defaults to 45000). + degradedAt: + type: + - integer + - string + - "null" + title: degraded_at + maximum: 120000 + minimum: 0 + format: int64 + description: Latency threshold for degraded status in milliseconds (optional, 0-120000). + retry: + type: + - integer + - string + title: retry + maximum: 10 + minimum: 0 + format: int64 + description: Number of retry attempts (0-10, defaults to 3). + description: + type: + - string + - "null" + title: description + maxLength: 1024 + description: Description of the monitor (optional). + active: + type: + - boolean + - "null" + title: active + description: Whether the monitor is active (defaults to false). + public: + type: + - boolean + - "null" + title: public + description: Whether the monitor is publicly visible (defaults to false). + regions: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.Region' + title: regions + maxItems: 28 + description: Geographic regions to run checks from. + openTelemetry: + title: open_telemetry + description: OpenTelemetry configuration for exporting metrics. + $ref: '#/components/schemas/openstatus.monitor.v1.OpenTelemetryConfig' + status: + title: status + description: Current operational status of the monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.MonitorStatus' + privateLocationIds: + type: array + items: + type: string + readOnly: true + title: private_location_ids + description: IDs of private locations that run this monitor. Read-only. + readOnly: true + service: + type: + - string + - "null" + examples: + - checkout.v1.CheckoutService + title: service + maxLength: 512 + description: Service name passed to Health/Check. Empty means overall server health. + tlsMode: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.GRPCTlsMode' + - type: "null" + title: tls_mode + description: How the connection to the target is secured. Defaults to TLS. + metadata: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.Headers' + title: metadata + maxItems: 20 + description: Metadata sent with the health check request. + title: GRPCMonitor + additionalProperties: false + description: |- + GRPCMonitor defines the configuration for a gRPC health check monitor. + The probe calls grpc.health.v1.Health/Check on the target. + openstatus.monitor.v1.GRPCTlsMode: + type: string + title: GRPCTlsMode + enum: + - GRPC_TLS_MODE_UNSPECIFIED + - GRPC_TLS_MODE_TLS + - GRPC_TLS_MODE_PLAINTEXT + - GRPC_TLS_MODE_TLS_INSECURE + description: GRPCTlsMode selects how the probe secures its connection to the target. openstatus.monitor.v1.GetMonitorHTTPResponseLogRequest: type: object properties: @@ -687,7 +846,7 @@ components: properties: monitor: title: monitor - description: The monitor configuration (one of HTTP, TCP, DNS, or ICMP). + description: The monitor configuration (one of HTTP, TCP, DNS, ICMP, or gRPC). $ref: '#/components/schemas/openstatus.monitor.v1.MonitorConfig' title: GetMonitorResponse additionalProperties: false @@ -1424,6 +1583,12 @@ components: $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' title: icmp_monitors description: ICMP monitors in the workspace. + grpcMonitors: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: grpc_monitors + description: gRPC monitors in the workspace. totalSize: type: integer title: total_size @@ -1444,6 +1609,15 @@ components: title: dns required: - dns + - type: object + properties: + grpc: + title: grpc + description: gRPC monitor configuration. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: grpc + required: + - grpc - type: object properties: http: @@ -1808,6 +1982,33 @@ components: title: UpdateDNSMonitorResponse additionalProperties: false description: UpdateDNSMonitorResponse is the response after updating a DNS monitor. + openstatus.monitor.v1.UpdateGRPCMonitorRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: Monitor ID to update (required). + monitor: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + - type: "null" + title: monitor + description: Updated monitor configuration (all fields optional for partial updates). + title: UpdateGRPCMonitorRequest + additionalProperties: false + description: UpdateGRPCMonitorRequest is the request to update an existing gRPC monitor. + openstatus.monitor.v1.UpdateGRPCMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The updated monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: UpdateGRPCMonitorResponse + additionalProperties: false + description: UpdateGRPCMonitorResponse is the response after updating a gRPC monitor. openstatus.monitor.v1.UpdateHTTPMonitorRequest: type: object properties: @@ -5187,6 +5388,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateDNSMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/CreateGRPCMonitor: + post: + tags: + - MonitorService + summary: CreateGRPCMonitor + description: CreateGRPCMonitor creates a new gRPC health check monitor. + operationId: MonitorService_CreateGRPCMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateGRPCMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateGRPCMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateHTTPMonitor: post: tags: @@ -5298,7 +5525,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor.get parameters: - name: message @@ -5326,7 +5553,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor requestBody: content: @@ -5659,6 +5886,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateDNSMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/UpdateGRPCMonitor: + post: + tags: + - MonitorService + summary: UpdateGRPCMonitor + description: UpdateGRPCMonitor updates an existing gRPC monitor. + operationId: MonitorService_UpdateGRPCMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateGRPCMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateGRPCMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateHTTPMonitor: post: tags: diff --git a/apps/web/src/content/docs.config.ts b/apps/web/src/content/docs.config.ts index 85573dfd..7f55327c 100644 --- a/apps/web/src/content/docs.config.ts +++ b/apps/web/src/content/docs.config.ts @@ -242,6 +242,7 @@ export const docsNav: DocsNavSection[] = [ { slug: "reference/cli-reference", label: "CLI Reference" }, { slug: "reference/mcp-server", label: "MCP Server" }, { slug: "reference/dns-monitor", label: "DNS Monitor Reference" }, + { slug: "reference/grpc-monitor", label: "gRPC Monitor Reference" }, { slug: "reference/http-monitor", label: "HTTP Monitor Reference" }, { slug: "reference/icmp-monitor", label: "ICMP Monitor Reference" }, { slug: "reference/incident", label: "Incident Reference" }, diff --git a/apps/web/src/content/pages/changelog/grpc-monitoring.mdx b/apps/web/src/content/pages/changelog/grpc-monitoring.mdx new file mode 100644 index 00000000..b755fe62 --- /dev/null +++ b/apps/web/src/content/pages/changelog/grpc-monitoring.mdx @@ -0,0 +1,16 @@ +--- +title: "gRPC Monitoring" +description: "Monitor your gRPC services with health checks from openstatus." +publishedAt: "2026-08-26" +author: "openstatus" +category: "monitoring" +--- + +gRPC monitoring is now available in openstatus. Point a monitor at any service that implements the [gRPC Health Checking Protocol](https://grpc.io/docs/guides/health-checking/) and we'll call `grpc.health.v1.Health/Check` on the schedule you choose — the same check Kubernetes and Envoy already make, so most services need no new endpoint. + +You can check the server's overall health or a single named service, connect over TLS, TLS without certificate verification, or plaintext h2c, and send metadata with the request when your health endpoint sits behind an authenticating proxy. + +Because a gRPC monitor speaks HTTP/2, every check reports the same phase breakdown as an HTTP monitor: DNS, connection, TLS handshake and time to first byte. A service that reports `NOT_SERVING` keeps its measured latency, so you can see it slow down before it drains. + +#### Get Started 🚀 +Add a new monitor, select "gRPC" as your monitor type, and enter the target as `host:port`. diff --git a/apps/web/src/content/pages/docs/reference/grpc-monitor.mdx b/apps/web/src/content/pages/docs/reference/grpc-monitor.mdx new file mode 100644 index 00000000..39eed154 --- /dev/null +++ b/apps/web/src/content/pages/docs/reference/grpc-monitor.mdx @@ -0,0 +1,141 @@ +--- +category: Reference +title: gRPC Monitor Reference +description: Complete technical specification for gRPC health check monitoring. +--- + +A gRPC monitor calls the [gRPC Health Checking Protocol](https://grpc.io/docs/guides/health-checking/) — a unary `grpc.health.v1.Health/Check` request — against your service and reports the serving status it answers with. This is the check Kubernetes, Envoy and `grpcurl` all speak, so a service that is already health-checked in your cluster needs no new endpoint. + +Each check opens a fresh connection, so the reported timings cover DNS resolution, TCP connect, the TLS handshake and the call itself. + +**Use cases:** + +- Verifying a gRPC service is not just reachable but reports itself as serving. +- Catching a service that has drained (`NOT_SERVING`) before traffic reaches it. +- Tracking connection and TLS handshake latency to an internal service from a private location. + +## Outcomes + +| Server answer | Monitor result | Notes | +| --- | --- | --- | +| `SERVING` | Up | Degraded if latency exceeds your threshold | +| `NOT_SERVING` | Down | The service is up but reports itself as not serving | +| `SERVICE_UNKNOWN` | Down | The server does not know the service name you configured | +| `UNIMPLEMENTED` | Down | The server is reachable but has not registered `grpc.health.v1.Health` | +| `DEADLINE_EXCEEDED` | Down | No answer within the timeout | +| `UNAVAILABLE` | Down | Connection refused, DNS failure, or TLS failure | + +`UNIMPLEMENTED` is reported with its own message rather than a generic failure: it means the server is healthy enough to answer, it just has no health service registered. Register one with your gRPC library's health package and the check starts working. + +A check that reaches the server keeps its measured latency and phase timings even when the answer is `NOT_SERVING`, so a service that degrades before it drains is visible in the latency chart. + +## Configuration + +### Host:Port + +**Type:** String (required) +**Format:** `host:port` — a port is required + +The gRPC target. IPv6 addresses must be bracketed. A scheme (`grpc://`, `https://`) is not accepted. + +**Examples:** +- `api.example.com:443` +- `10.0.0.5:50051` +- `[2001:db8::1]:50051` + +### Service + +**Type:** String (optional) +**Default:** empty + +The service name sent in the health check request. Leave it empty to check the server's overall health, which is what most deployments register. + +**Example:** `checkout.v1.CheckoutService` + +### TLS + +**Type:** Enum (optional) +**Default:** `TLS (verify certificate)` + +How the probe secures its connection: + +- **TLS (verify certificate)** — verify the server certificate against the public trust store. Use this for any internet-facing endpoint. +- **TLS (skip verification)** — negotiate TLS but accept any certificate. For internal services presenting a self-signed or mesh-issued certificate, typically behind a private location. +- **Plaintext (h2c)** — no TLS. For a service behind a mesh or load balancer that has already terminated TLS. + +A certificate that fails verification is reported as `certificate verification failed`; the certificate's own details are never returned. + +### Metadata + +**Type:** Key-value pairs (optional) + +Metadata sent with the health check request. gRPC metadata is carried as HTTP/2 headers, so this is where an authorization token goes when your health endpoint sits behind an authenticating proxy. + +**Common example:** +``` +authorization: Bearer +``` + +### Regions + +**Type:** Array of strings (required) +**Format:** Region identifiers (e.g., `iad`, `jnb`) + +The geographical regions the check runs from. See the [Location Reference](/docs/reference/location) for the full list of regions and the IPs to allowlist. + +### Frequency + +**Type:** String (required) +**Format:** Duration string (e.g., `30s`, `1m`, `1h`) + +How often the health check runs. Supported frequencies: +- `30 seconds` +- `1 minute` +- `5 minutes` +- `10 minutes` +- `30 minutes` +- `1 hour` + +### Response time thresholds + +#### Timeout + +**Type:** Duration (optional) +**Default:** `45 seconds` + +The budget for the whole check — name resolution, connection, TLS handshake and the call. Exceeding it reports `DEADLINE_EXCEEDED`. + +#### Degraded + +**Type:** Duration (optional) + +The latency above which a `SERVING` response is recorded as degraded rather than healthy. + +### Retry + +**Type:** Integer (optional) +**Default:** `3` + +How many times a check is retried before reporting a definitive error. Only connection failures are retried: once the server has answered — including `NOT_SERVING` or `UNIMPLEMENTED` — the result is recorded immediately, because asking again cannot change it. + +### OpenTelemetry + +Configures the export of monitoring metrics to an OpenTelemetry-compatible observability platform. + +#### OTLP endpoint + +**Type:** String (optional) +**Protocol:** HTTP only + +The OTLP endpoint URL where metrics are exported. gRPC monitors export the total request duration, the DNS, connection, TLS and time-to-first-byte phase durations, and a serving-status gauge that is `1` while the service reports `SERVING` and `0` otherwise — so a drained service can be alerted on separately from an unreachable one. + +#### OTLP headers + +**Type:** Key-value pairs (optional) + +Custom headers to include when sending metrics to your OTLP endpoint, commonly used for authentication or tenant identification. + +## Related resources + +- **[Create your first monitor](/docs/tutorial/create-your-first-monitor)** — step-by-step tutorial on setting up a monitor. +- **[CLI reference](/docs/reference/cli-reference)** — manage monitors programmatically from the command line. diff --git a/apps/web/src/content/pages/docs/reference/overview.mdx b/apps/web/src/content/pages/docs/reference/overview.mdx index ec6a3103..a3413978 100644 --- a/apps/web/src/content/pages/docs/reference/overview.mdx +++ b/apps/web/src/content/pages/docs/reference/overview.mdx @@ -13,6 +13,7 @@ How each monitor type runs checks and what you can configure on it. - **[HTTP monitor](/docs/reference/http-monitor)** — URL, method, headers, body, assertions, regions. - **[TCP monitor](/docs/reference/tcp-monitor)** — host:port checks for non-HTTP services. - **[ICMP monitor](/docs/reference/icmp-monitor)** — ping checks for host reachability, latency, and packet loss. +- **[gRPC monitor](/docs/reference/grpc-monitor)** — health checks against the gRPC Health Checking Protocol. - **[DNS monitor](/docs/reference/dns-monitor)** — record-type assertions for A, AAAA, CNAME, MX, NS, TXT. ## Status pages diff --git a/apps/workflows/src/cron/checker.ts b/apps/workflows/src/cron/checker.ts index e39d950b..30e226cd 100644 --- a/apps/workflows/src/cron/checker.ts +++ b/apps/workflows/src/cron/checker.ts @@ -28,6 +28,7 @@ import { regionDict } from "@openstatus/regions"; import { type DNSPayloadSchema, type httpPayloadSchema, + type grpcPayloadSchema, type icmpPayloadSchema, type tpcPayloadSchema, transformHeaders, @@ -287,6 +288,7 @@ const createCronTask = async ( | z.infer | z.infer | z.infer + | z.infer | null = null; // @@ -376,6 +378,29 @@ const createCronTask = async ( }; } + if (row.jobType === "grpc") { + payload = { + workspaceId: String(row.workspaceId), + monitorId: String(row.id), + uri: row.url, + service: row.grpcService ?? undefined, + tls: row.grpcTls ?? "tls", + headers: transformHeaders(row.headers), + cronTimestamp: timestamp, + status: status, + degradedAfter: row.degradedAfter, + timeout: row.timeout, + trigger: "cron", + otelConfig: row.otelEndpoint + ? { + endpoint: row.otelEndpoint, + headers: transformHeaders(row.otelHeaders), + } + : undefined, + retry: row.retry || 3, + }; + } + if (!payload) { throw new Error("Invalid jobType"); } diff --git a/apps/workflows/src/cron/uptime-freeze.ts b/apps/workflows/src/cron/uptime-freeze.ts index 706bb21e..11a142ed 100644 --- a/apps/workflows/src/cron/uptime-freeze.ts +++ b/apps/workflows/src/cron/uptime-freeze.ts @@ -22,6 +22,7 @@ const pipes: UptimeFreezePipes = { tcp: tb.tcpStatus45d, dns: tb.dnsStatus45d, icmp: tb.icmpStatus45d, + grpc: tb.grpcStatus45d, }; export async function handleUptimeFreezeCron(c: Context) { diff --git a/packages/api/src/router/checker.test.ts b/packages/api/src/router/checker.test.ts new file mode 100644 index 00000000..5df2fccd --- /dev/null +++ b/packages/api/src/router/checker.test.ts @@ -0,0 +1,109 @@ +import { expect } from "@std/expect"; +import { afterEach, describe, test } from "@std/testing/bdd"; + +import { testGrpc } from "./checker"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** Stub the checker with one canned JSON body. */ +function stubChecker(body: unknown) { + globalThis.fetch = (() => + Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + )) as typeof globalThis.fetch; +} + +/** + * The shape GRPCHandlerRegion returns for a completed RPC. `state` is absent — + * the handler never sends it — so grpcOutput prefaults it to "success". + */ +function completedResponse( + overrides: Record, +): Record { + return { + jobType: "grpc", + region: "ams", + timestamp: 1_700_000_000_000, + timing: { + dnsStart: 1, + dnsDone: 2, + connectStart: 2, + connectDone: 3, + tlsHandshakeStart: 3, + tlsHandshakeDone: 4, + firstByteStart: 4, + firstByteDone: 5, + transferStart: 5, + transferDone: 6, + }, + latency: 5, + completed: true, + ...overrides, + }; +} + +describe("testGrpc", () => { + test("accepts a SERVING target", async () => { + // `error` is omitempty in Go, so a healthy check omits it entirely. + stubChecker(completedResponse({ servingStatus: "SERVING" })); + + const result = await testGrpc({ + url: "api.example.com:443", + region: "ams", + }); + expect(result.state).toBe("success"); + }); + + test("rejects a completed check that answered NOT_SERVING", async () => { + stubChecker( + completedResponse({ + servingStatus: "NOT_SERVING", + error: 1, + errorMessage: "service reports NOT_SERVING", + }), + ); + + await expect( + testGrpc({ url: "api.example.com:443", region: "ams" }), + ).rejects.toThrow("service reports NOT_SERVING"); + }); + + test("rejects a server with no health service", async () => { + stubChecker( + completedResponse({ + error: 1, + errorMessage: "server does not implement grpc.health.v1.Health", + }), + ); + + await expect( + testGrpc({ url: "api.example.com:443", region: "ams" }), + ).rejects.toThrow("does not implement"); + }); + + test("falls back to the serving status when no message is sent", async () => { + stubChecker( + completedResponse({ servingStatus: "SERVICE_UNKNOWN", error: 1 }), + ); + + await expect( + testGrpc({ url: "api.example.com:443", region: "ams" }), + ).rejects.toThrow("SERVICE_UNKNOWN"); + }); + + test("still rejects a transport failure", async () => { + // The only shape that carries `state` explicitly. + stubChecker({ message: "uri not reachable" }); + + await expect( + testGrpc({ url: "api.example.com:443", region: "ams" }), + ).rejects.toThrow("uri not reachable"); + }); +}); diff --git a/packages/api/src/router/checker.ts b/packages/api/src/router/checker.ts index 0e011912..b16ca45e 100644 --- a/packages/api/src/router/checker.ts +++ b/packages/api/src/router/checker.ts @@ -13,7 +13,9 @@ import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; import { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; import { type httpPayloadSchema, + type grpcPayloadSchema, type icmpPayloadSchema, + GRPC_TLS_MODES, safeUrlSchema, type tpcPayloadSchema, transformHeaders, @@ -32,6 +34,9 @@ const ABORT_TIMEOUT = 10000; // answers before the fetch above gives up. const ICMP_TEST_TIMEOUT = 5000; +// Kept under ABORT_TIMEOUT so the checker answers before the fetch gives up. +const GRPC_TEST_TIMEOUT = 5000; + // Input schemas const httpTestInput = z.object({ url: safeUrlSchema, @@ -90,6 +95,51 @@ const icmpTestInput = z.object({ region: monitorRegionSchema.optional().prefault("ams"), }); +const grpcTestInput = z.object({ + url: z.string(), + service: z.string().optional(), + tls: z.enum(GRPC_TLS_MODES).optional().prefault("tls"), + headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(), + region: monitorRegionSchema.optional().prefault("ams"), +}); + +export const grpcOutput = z + .object({ + state: z.literal("success").prefault("success"), + type: z.literal("grpc").prefault("grpc"), + jobType: z.literal("grpc").optional(), + requestId: z.number().optional(), + workspaceId: z.number().optional(), + monitorId: z.number().optional(), + timestamp: z.number(), + timing: z.object({ + dnsStart: z.number(), + dnsDone: z.number(), + connectStart: z.number(), + connectDone: z.number(), + tlsHandshakeStart: z.number(), + tlsHandshakeDone: z.number(), + firstByteStart: z.number(), + firstByteDone: z.number(), + transferStart: z.number(), + transferDone: z.number(), + }), + latency: z.number().optional(), + servingStatus: z.string().optional(), + service: z.string().optional(), + grpcCode: z.number().optional(), + completed: z.boolean().optional(), + errorMessage: z.string().optional(), + error: z.number().optional(), + region: monitorRegionSchema, + }) + .or( + z.object({ + state: z.literal("error").prefault("error"), + message: z.string(), + }), + ); + export const icmpOutput = z .object({ state: z.literal("success").prefault("success"), @@ -462,6 +512,81 @@ export async function testIcmp(input: z.infer) { } } +export async function testGrpc(input: z.infer) { + try { + const res = await fetch( + `https://openstatus-checker.fly.dev/grpc/${input.region}`, + { + method: "POST", + headers: { + Authorization: `Basic ${env.CRON_SECRET}`, + "Content-Type": "application/json", + "fly-prefer-region": input.region, + }, + body: JSON.stringify({ + uri: input.url, + service: input.service, + tls: input.tls, + headers: transformHeaders(input.headers ?? []), + timeout: GRPC_TEST_TIMEOUT, + }), + signal: AbortSignal.timeout(ABORT_TIMEOUT), + }, + ); + + const json = await res.json(); + const result = grpcOutput.safeParse(json); + + if (!result.success) { + console.error( + `Checker gRPC test failed for ${input.url}:`, + result.error.message, + ); + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Checker response is not valid. Please try again. If the problem persists, please contact support. ${result.error.message}`, + }); + } + + if (result.data.state === "error") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: result.data.message, + }); + } + + // Only a transport failure comes back as `state: "error"`. An RPC that + // completed but answered NOT_SERVING / SERVICE_UNKNOWN — or a server with no + // health service at all — returns the full response, where `state` is absent + // and prefaults to "success". `error` is omitempty, so it is present only + // when the check failed. Mirrors testHttp rejecting a non-2XX status: the + // target is reachable, but saving it would create a monitor that is already + // down. + if (result.data.error === 1) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + result.data.errorMessage || + `The health check did not report SERVING${ + result.data.servingStatus ? `: ${result.data.servingStatus}` : "" + }`, + }); + } + + return result.data; + } catch (error) { + console.error("Checker gRPC test failed", error); + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "gRPC check failed", + }); + } +} + export async function triggerChecker( input: z.infer, ) { @@ -469,6 +594,7 @@ export async function triggerChecker( | z.infer | z.infer | z.infer + | z.infer | null = null; if (process.env.NODE_ENV !== "production") { @@ -562,6 +688,28 @@ export async function triggerChecker( : undefined, }; } + if (input.jobType === "grpc") { + payload = { + workspaceId: String(input.workspaceId), + monitorId: String(input.id), + uri: input.url, + service: input.grpcService ?? undefined, + tls: input.grpcTls ?? "tls", + headers: transformHeaders(input.headers), + status: "active", + cronTimestamp: timestamp, + degradedAfter: input.degradedAfter, + timeout: input.timeout, + trigger: "cron", + retry: input.retry || 3, + otelConfig: input.otelEndpoint + ? { + endpoint: input.otelEndpoint, + headers: transformHeaders(input.otelHeaders), + } + : undefined, + }; + } const allResult = []; for (const region of input.regions) { @@ -591,6 +739,8 @@ function generateUrl({ row }: { row: z.infer }) { return `https://openstatus-checker.fly.dev/checker/dns?monitor_id=${row.id}`; case "icmp": return `https://openstatus-checker.fly.dev/checker/icmp?monitor_id=${row.id}`; + case "grpc": + return `https://openstatus-checker.fly.dev/checker/grpc?monitor_id=${row.id}`; default: throw new Error("Invalid jobType"); } @@ -623,6 +773,13 @@ export const checkerRouter = createTRPCRouter({ return testIcmp(input); }), + testGrpc: protectedProcedure + .meta({ track: Events.TestMonitor }) + .input(grpcTestInput) + .mutation(async ({ input }) => { + return testGrpc(input); + }), + triggerChecker: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async (opts) => { diff --git a/packages/api/src/router/monitor.ts b/packages/api/src/router/monitor.ts index a1a5b080..63923240 100644 --- a/packages/api/src/router/monitor.ts +++ b/packages/api/src/router/monitor.ts @@ -16,6 +16,7 @@ import { deleteMonitor, deleteMonitors, getMonitor, + grpcTlsModes, listMonitors, monitorJobTypes, monitorMethods, @@ -35,7 +36,7 @@ import { z } from "zod"; import { env } from "../env"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { testDns, testHttp, testIcmp, testTcp } from "./checker"; +import { testDns, testGrpc, testHttp, testIcmp, testTcp } from "./checker"; // self-host has no access to the openstatus checker fleet, so the pre-save // endpoint test can never succeed — skip it entirely. @@ -63,6 +64,8 @@ const newMonitorTRPCInput = z.object({ active: z.boolean().prefault(false), saveCheck: z.boolean().prefault(false), skipCheck: z.boolean().prefault(false), + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional(), }); const updateGeneralTRPCInput = z.object({ @@ -77,6 +80,8 @@ const updateGeneralTRPCInput = z.object({ active: z.boolean().prefault(true), skipCheck: z.boolean().prefault(true), saveCheck: z.boolean().prefault(false), + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional(), }); export const monitorRouter = createTRPCRouter({ @@ -344,6 +349,14 @@ export const monitorRouter = createTRPCRouter({ }); } else if (input.jobType === "icmp") { await testIcmp({ url: input.url, region: "ams" }); + } else if (input.jobType === "grpc") { + await testGrpc({ + url: input.url, + service: input.grpcService, + tls: input.grpcTls ?? "tls", + headers: input.headers, + region: "ams", + }); } } @@ -357,6 +370,8 @@ export const monitorRouter = createTRPCRouter({ body: input.body, assertions: input.assertions, active: input.active, + grpcService: input.grpcService, + grpcTls: input.grpcTls, }; await updateMonitorGeneral({ ctx: toServiceCtx(ctx), @@ -410,6 +425,14 @@ export const monitorRouter = createTRPCRouter({ }); } else if (input.jobType === "icmp") { await testIcmp({ url: input.url, region: "ams" }); + } else if (input.jobType === "grpc") { + await testGrpc({ + url: input.url, + service: input.grpcService, + tls: input.grpcTls ?? "tls", + headers: input.headers, + region: "ams", + }); } } @@ -422,6 +445,8 @@ export const monitorRouter = createTRPCRouter({ body: input.body, assertions: input.assertions, active: input.active, + grpcService: input.grpcService, + grpcTls: input.grpcTls, }; return await createMonitor({ ctx: toServiceCtx(ctx), diff --git a/packages/api/src/router/statusPage.e2e.test.ts b/packages/api/src/router/statusPage.e2e.test.ts index f65f079c..a474b731 100644 --- a/packages/api/src/router/statusPage.e2e.test.ts +++ b/packages/api/src/router/statusPage.e2e.test.ts @@ -1067,6 +1067,8 @@ describe("statusPage exposes page component names, not internal monitor names", let noDescriptionComponentId: number; let clearedDescriptionMonitorId: number; let clearedDescriptionComponentId: number; + let grpcMonitorId: number; + let grpcComponentId: number; const internalName = "Internal Monitor Name"; const internalDescription = "Internal monitor description"; @@ -1195,6 +1197,37 @@ describe("statusPage exposes page component names, not internal monitor names", .returning() .get(); clearedDescriptionComponentId = clearedDescriptionComponent.id; + + // getMonitor dispatches its Tinybird reads on jobType; a gRPC monitor must + // find a matching entry like every other supported type. + const grpcMonitor = await db + .insert(monitor) + .values({ + workspaceId: 1, + name: "gRPC monitor", + jobType: "grpc", + periodicity: "1m", + url: "api.example.com:443", + active: true, + public: true, + }) + .returning() + .get(); + grpcMonitorId = grpcMonitor.id; + + const grpcComponent = await db + .insert(pageComponent) + .values({ + workspaceId: 1, + pageId: publicNamePageId, + type: "monitor", + monitorId: grpcMonitorId, + name: "gRPC component", + order: 3, + }) + .returning() + .get(); + grpcComponentId = grpcComponent.id; }); afterAll(async () => { @@ -1207,6 +1240,8 @@ describe("statusPage exposes page component names, not internal monitor names", await db .delete(pageComponent) .where(eq(pageComponent.id, clearedDescriptionComponentId)); + await db.delete(pageComponent).where(eq(pageComponent.id, grpcComponentId)); + await db.delete(monitor).where(eq(monitor.id, grpcMonitorId)); await db.delete(monitor).where(eq(monitor.id, publicNameMonitorId)); await db.delete(monitor).where(eq(monitor.id, noDescriptionMonitorId)); await db.delete(monitor).where(eq(monitor.id, clearedDescriptionMonitorId)); @@ -1310,6 +1345,38 @@ describe("statusPage exposes page component names, not internal monitor names", expect(component?.monitor?.description).toBe(componentDescription); }); + test("getMonitor resolves metrics procedures for a gRPC monitor", async () => { + const caller = await createCaller(); + + // Tinybird is noop under test, so a resolved dispatch and a missing one both + // end up with empty chart data. What separates them is the TypeError that + // indexing proceduresByType with an absent job type throws — which + // withTinybirdFallback catches and files as a Tinybird outage rather than + // surfacing. Assert it never happens. + const logged: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + logged.push(args.map(String).join(" ")); + }; + + let result: Awaited>; + try { + result = await caller.statusPage.getMonitor({ + slug: publicNameSlug, + id: grpcMonitorId, + }); + } finally { + console.error = originalError; + } + + expect(result?.name).toBe("gRPC component"); + expect( + logged.filter((line) => + line.includes("Cannot read properties of undefined"), + ), + ).toEqual([]); + }); + test("getMonitor returns the page component name and description", async () => { const caller = await createCaller(); const result = await caller.statusPage.getMonitor({ diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index c25b4fc2..4b16bf9d 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -705,6 +705,7 @@ export const statusPageRouter = createTRPCRouter({ tcp: monitors.filter((c) => c.monitor.jobType === "tcp"), dns: monitors.filter((c) => c.monitor.jobType === "dns"), icmp: monitors.filter((c) => c.monitor.jobType === "icmp"), + grpc: monitors.filter((c) => c.monitor.jobType === "grpc"), }; const proceduresByType = { @@ -712,6 +713,7 @@ export const statusPageRouter = createTRPCRouter({ tcp: getStatusProcedure("45d", "tcp"), dns: getStatusProcedure("45d", "dns"), icmp: getStatusProcedure("45d", "icmp"), + grpc: getStatusProcedure("45d", "grpc"), }; // Manual mode never touches Tinybird. Otherwise race the reads against @@ -719,7 +721,7 @@ export const statusPageRouter = createTRPCRouter({ // whole page to manual mode so bars still render from DB events. const tinybird = await withTinybirdFallback(() => input.barType === "manual" - ? Promise.resolve([null, null, null, null]) + ? Promise.resolve([null, null, null, null, null]) : Promise.all( Object.entries(proceduresByType).map(([type, procedure]) => { const monitorIds = monitorsByType[ @@ -732,12 +734,8 @@ export const statusPageRouter = createTRPCRouter({ ); const tinybirdUnhealthy = !tinybird.ok; - const [statusHttp, statusTcp, statusDns, statusIcmp] = tinybird.data ?? [ - null, - null, - null, - null, - ]; + const [statusHttp, statusTcp, statusDns, statusIcmp, statusGrpc] = + tinybird.data ?? [null, null, null, null, null]; const statusDataByMonitorId = new Map< string, @@ -745,6 +743,7 @@ export const statusPageRouter = createTRPCRouter({ | Awaited>["data"] | Awaited>["data"] | Awaited>["data"] + | Awaited>["data"] >(); // Consolidate status data from all monitor types into the map @@ -753,6 +752,7 @@ export const statusPageRouter = createTRPCRouter({ statusTcp, statusDns, statusIcmp, + statusGrpc, ]) { if (statusResult?.data) { statusResult.data.forEach((status) => { @@ -1020,6 +1020,7 @@ export const statusPageRouter = createTRPCRouter({ tcp: publicMonitors.filter((c) => c.monitor.jobType === "tcp"), dns: publicMonitors.filter((c) => c.monitor.jobType === "dns"), icmp: publicMonitors.filter((c) => c.monitor.jobType === "icmp"), + grpc: publicMonitors.filter((c) => c.monitor.jobType === "grpc"), }; const proceduresByType = { @@ -1027,6 +1028,7 @@ export const statusPageRouter = createTRPCRouter({ tcp: getMetricsLatencyMultiProcedure("1d", "tcp"), dns: getMetricsLatencyMultiProcedure("1d", "dns"), icmp: getMetricsLatencyMultiProcedure("1d", "icmp"), + grpc: getMetricsLatencyMultiProcedure("1d", "grpc"), }; // Slow/erroring Tinybird → empty latency data so the page still renders. @@ -1047,7 +1049,8 @@ export const statusPageRouter = createTRPCRouter({ metricsLatencyMultiTcp, metricsLatencyMultiDns, metricsLatencyMultiIcmp, - ] = metrics.data ?? [null, null, null, null]; + metricsLatencyMultiGrpc, + ] = metrics.data ?? [null, null, null, null, null]; const metricsDataByMonitorId = new Map< string, @@ -1055,6 +1058,7 @@ export const statusPageRouter = createTRPCRouter({ | Awaited>["data"] | Awaited>["data"] | Awaited>["data"] + | Awaited>["data"] >(); if (metricsLatencyMultiHttp?.data) { @@ -1097,6 +1101,16 @@ export const statusPageRouter = createTRPCRouter({ }); } + if (metricsLatencyMultiGrpc?.data) { + metricsLatencyMultiGrpc.data.forEach((metric) => { + const monitorId = metric.monitorId; + if (!metricsDataByMonitorId.has(monitorId)) { + metricsDataByMonitorId.set(monitorId, []); + } + metricsDataByMonitorId.get(monitorId)?.push(metric); + }); + } + return publicMonitors.map((c) => { const monitorId = c.monitor.id.toString(); const data = metricsDataByMonitorId.get(monitorId) || []; @@ -1141,8 +1155,6 @@ export const statusPageRouter = createTRPCRouter({ if (!_monitor.public) return null; if (_monitor.deletedAt) return null; - const type = _monitor.jobType as "http" | "tcp" | "dns" | "icmp"; - const proceduresByType = { http: { latency: getMetricsLatencyProcedure("7d", "http"), @@ -1164,32 +1176,47 @@ export const statusPageRouter = createTRPCRouter({ regions: getMetricsRegionsProcedure("7d", "icmp"), uptime: getUptimeProcedure("7d", "icmp"), }, + grpc: { + latency: getMetricsLatencyProcedure("7d", "grpc"), + regions: getMetricsRegionsProcedure("7d", "grpc"), + uptime: getUptimeProcedure("7d", "grpc"), + }, }; + // `udp` and `ssl` are monitor job types with no Tinybird pipes. Looking the + // key up instead of asserting the type means such a monitor renders with + // empty charts — the same shape a Tinybird outage produces — rather than + // throwing on a missing key. + const procedures = + proceduresByType[_monitor.jobType as keyof typeof proceduresByType] ?? + null; + const fromDate = startOfDay(subDays(new Date(), 7)).toISOString(); const toDate = endOfDay(new Date()).toISOString(); // Slow/erroring Tinybird → empty chart data so the page still renders. - const metrics = await withTinybirdFallback(() => - Promise.all([ - proceduresByType[type].latency({ - monitorId: _monitor.id.toString(), - fromDate, - toDate, - }), - proceduresByType[type].regions({ - monitorId: _monitor.id.toString(), - fromDate, - toDate, - }), - proceduresByType[type].uptime({ - monitorId: _monitor.id.toString(), - interval: 240, - fromDate, - toDate, - }), - ]), - ); + const metrics = !procedures + ? { ok: false as const, data: null } + : await withTinybirdFallback(() => + Promise.all([ + procedures.latency({ + monitorId: _monitor.id.toString(), + fromDate, + toDate, + }), + procedures.regions({ + monitorId: _monitor.id.toString(), + fromDate, + toDate, + }), + procedures.uptime({ + monitorId: _monitor.id.toString(), + interval: 240, + fromDate, + toDate, + }), + ]), + ); const [latency, regions, uptime] = metrics.data ?? [ { data: [] }, diff --git a/packages/api/src/router/tinybird/index.ts b/packages/api/src/router/tinybird/index.ts index 2e38de87..b9043e3d 100644 --- a/packages/api/src/router/tinybird/index.ts +++ b/packages/api/src/router/tinybird/index.ts @@ -9,7 +9,7 @@ import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { calculatePeriod } from "./utils"; const periods = ["1d", "7d", "14d", "30d", "90d"] as const; -const types = ["http", "tcp", "dns", "icmp"] as const; +const types = ["http", "tcp", "dns", "icmp", "grpc"] as const; type Period = (typeof periods)[number]; type Type = (typeof types)[number]; @@ -44,6 +44,7 @@ function clampInterval(period: Period, interval?: number) { export function getWorkspace30dProcedure(type: Type) { if (type === "http") return tb.httpWorkspace30d; if (type === "icmp") return tb.icmpWorkspace30d; + if (type === "grpc") return tb.grpcWorkspace30d; return tb.tcpWorkspace30d; } // Helper functions to get the right procedure based on period and type @@ -54,24 +55,28 @@ export function getListProcedure(period: Period, type: Type) { if (type === "tcp") return tb.tcpListDaily; if (type === "dns") return tb.dnsListBiweekly; if (type === "icmp") return tb.icmpListDaily; + if (type === "grpc") return tb.grpcListDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "http") return tb.httpListWeekly; if (type === "tcp") return tb.tcpListWeekly; if (type === "dns") return tb.dnsListBiweekly; if (type === "icmp") return tb.icmpListWeekly; + if (type === "grpc") return tb.grpcListWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "14d": if (type === "http") return tb.httpListBiweekly; if (type === "tcp") return tb.tcpListBiweekly; if (type === "dns") return tb.dnsListBiweekly; if (type === "icmp") return tb.icmpListBiweekly; + if (type === "grpc") return tb.grpcListBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "http") return tb.httpListDaily; if (type === "tcp") return tb.tcpListDaily; if (type === "dns") return tb.dnsListBiweekly; if (type === "icmp") return tb.icmpListDaily; + if (type === "grpc") return tb.grpcListDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -83,36 +88,42 @@ export function getMetricsProcedure(period: Period, type: Type) { if (type === "http") return tb.httpMetricsDaily; if (type === "tcp") return tb.tcpMetricsDaily; if (type === "icmp") return tb.icmpMetricsDaily; + if (type === "grpc") return tb.grpcMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "dns") return tb.dnsMetricsWeekly; if (type === "http") return tb.httpMetricsWeekly; if (type === "tcp") return tb.tcpMetricsWeekly; if (type === "icmp") return tb.icmpMetricsWeekly; + if (type === "grpc") return tb.grpcMetricsWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "14d": if (type === "dns") return tb.dnsMetricsBiweekly; if (type === "http") return tb.httpMetricsBiweekly; if (type === "tcp") return tb.tcpMetricsBiweekly; if (type === "icmp") return tb.icmpMetricsBiweekly; + if (type === "grpc") return tb.grpcMetricsBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "30d": if (type === "dns") return tb.dnsMetrics30d; if (type === "http") return tb.httpMetrics30d; if (type === "tcp") return tb.tcpMetrics30d; if (type === "icmp") return tb.icmpMetrics30d; + if (type === "grpc") return tb.grpcMetrics30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsMetrics90d; if (type === "http") return tb.httpMetrics90d; if (type === "tcp") return tb.tcpMetrics90d; if (type === "icmp") return tb.icmpMetrics90d; + if (type === "grpc") return tb.grpcMetrics90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsMetricsDaily; if (type === "http") return tb.httpMetricsDaily; if (type === "tcp") return tb.tcpMetricsDaily; if (type === "icmp") return tb.icmpMetricsDaily; + if (type === "grpc") return tb.grpcMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -125,36 +136,42 @@ export function getMetricsRegionsProcedure(period: Period, type: Type) { if (type === "http") return tb.httpMetricsRegionsDaily; if (type === "tcp") return tb.tcpMetricsByIntervalDaily; if (type === "icmp") return tb.icmpMetricsByIntervalDaily; + if (type === "grpc") return tb.grpcMetricsByIntervalDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsWeekly; if (type === "tcp") return tb.tcpMetricsByIntervalWeekly; if (type === "icmp") return tb.icmpMetricsByIntervalWeekly; + if (type === "grpc") return tb.grpcMetricsByIntervalWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "14d": if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsBiweekly; if (type === "tcp") return tb.tcpMetricsByIntervalBiweekly; if (type === "icmp") return tb.icmpMetricsByIntervalBiweekly; + if (type === "grpc") return tb.grpcMetricsByIntervalBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "30d": if (type === "dns") return tb.dnsMetricsRegions30d; if (type === "http") return tb.httpMetricsRegions30d; if (type === "tcp") return tb.tcpMetricsByInterval30d; if (type === "icmp") return tb.icmpMetricsByInterval30d; + if (type === "grpc") return tb.grpcMetricsByInterval30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsMetricsRegions90d; if (type === "http") return tb.httpMetricsRegions90d; if (type === "tcp") return tb.tcpMetricsByInterval90d; if (type === "icmp") return tb.icmpMetricsByInterval90d; + if (type === "grpc") return tb.grpcMetricsByInterval90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsDaily; if (type === "tcp") return tb.tcpMetricsByIntervalDaily; if (type === "icmp") return tb.icmpMetricsByIntervalDaily; + if (type === "grpc") return tb.grpcMetricsByIntervalDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -164,6 +181,7 @@ export function getStatusProcedure(_period: "45d", type: Type) { if (type === "http") return tb.httpStatus45d; if (type === "tcp") return tb.tcpStatus45d; if (type === "icmp") return tb.icmpStatus45d; + if (type === "grpc") return tb.grpcStatus45d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -174,12 +192,14 @@ export function getGetProcedure(period: "14d", type: Type) { if (type === "tcp") return tb.tcpGetBiweekly; if (type === "dns") return tb.dnsGetBiweekly; if (type === "icmp") return tb.icmpGetBiweekly; + if (type === "grpc") return tb.grpcGetBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "http") return tb.httpGetBiweekly; if (type === "tcp") return tb.tcpGetBiweekly; if (type === "dns") return tb.dnsGetBiweekly; if (type === "icmp") return tb.icmpGetBiweekly; + if (type === "grpc") return tb.grpcGetBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -189,6 +209,7 @@ export function getGlobalMetricsProcedure(type: Type) { if (type === "tcp") return tb.tcpGlobalMetricsDaily; if (type === "dns") return tb.dnsGlobalMetricsDaily; if (type === "icmp") return tb.icmpGlobalMetricsDaily; + if (type === "grpc") return tb.grpcGlobalMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -200,24 +221,28 @@ export function getUptimeProcedure(period: "7d" | "30d" | "90d", type: Type) { if (type === "http") return tb.httpUptimeWeekly; if (type === "tcp") return tb.tcpUptimeWeekly; if (type === "icmp") return tb.icmpUptimeWeekly; + if (type === "grpc") return tb.grpcUptimeWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "30d": if (type === "dns") return tb.dnsUptime30d; if (type === "http") return tb.httpUptime30d; if (type === "tcp") return tb.tcpUptime30d; if (type === "icmp") return tb.icmpUptime30d; + if (type === "grpc") return tb.grpcUptime30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsUptime90d; if (type === "http") return tb.httpUptime90d; if (type === "tcp") return tb.tcpUptime90d; if (type === "icmp") return tb.icmpUptime90d; + if (type === "grpc") return tb.grpcUptime90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsUptime30d; if (type === "http") return tb.httpUptime30d; if (type === "tcp") return tb.tcpUptime30d; if (type === "icmp") return tb.icmpUptime30d; + if (type === "grpc") return tb.grpcUptime30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -230,12 +255,14 @@ export function getMetricsLatencyProcedure(_period: Period, type: Type) { if (type === "http") return tb.httpMetricsLatency1d; if (type === "tcp") return tb.tcpMetricsLatency1d; if (type === "icmp") return tb.icmpMetricsLatency1d; + if (type === "grpc") return tb.grpcMetricsLatency1d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "dns") return tb.dnsMetricsLatency7d; if (type === "http") return tb.httpMetricsLatency7d; if (type === "tcp") return tb.tcpMetricsLatency7d; if (type === "icmp") return tb.icmpMetricsLatency7d; + if (type === "grpc") return tb.grpcMetricsLatency7d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); // no dedicated 14d latency pipe; 30d MV is the smallest window covering 14d case "14d": @@ -244,18 +271,21 @@ export function getMetricsLatencyProcedure(_period: Period, type: Type) { if (type === "http") return tb.httpMetricsLatency30d; if (type === "tcp") return tb.tcpMetricsLatency30d; if (type === "icmp") return tb.icmpMetricsLatency30d; + if (type === "grpc") return tb.grpcMetricsLatency30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsMetricsLatency90d; if (type === "http") return tb.httpMetricsLatency90d; if (type === "tcp") return tb.tcpMetricsLatency90d; if (type === "icmp") return tb.icmpMetricsLatency90d; + if (type === "grpc") return tb.grpcMetricsLatency90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsMetricsLatency7d; if (type === "http") return tb.httpMetricsLatency1d; if (type === "tcp") return tb.tcpMetricsLatency1d; if (type === "icmp") return tb.icmpMetricsLatency1d; + if (type === "grpc") return tb.grpcMetricsLatency1d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -265,6 +295,7 @@ export function getMetricsLatencyMultiProcedure(_period: Period, type: Type) { if (type === "http") return tb.httpMetricsLatency1dMulti; if (type === "tcp") return tb.tcpMetricsLatency1dMulti; if (type === "icmp") return tb.icmpMetricsLatency1dMulti; + if (type === "grpc") return tb.grpcMetricsLatency1dMulti; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -312,7 +343,7 @@ export const tinybirdRouter = createTRPCRouter({ const procedure = getListProcedure( period, - _monitor.jobType as "http" | "tcp" | "dns" | "icmp", + _monitor.jobType as "http" | "tcp" | "dns" | "icmp" | "grpc", ); return await procedure({ ...opts.input, @@ -505,7 +536,7 @@ export const tinybirdRouter = createTRPCRouter({ const procedure = getGetProcedure( opts.input.period, - _monitor.jobType as "http" | "tcp" | "dns" | "icmp", + _monitor.jobType as "http" | "tcp" | "dns" | "icmp" | "grpc", ); return await procedure(opts.input); }), diff --git a/packages/db/drizzle/0084_flaky_thundra.sql b/packages/db/drizzle/0084_flaky_thundra.sql new file mode 100644 index 00000000..e1a05e67 --- /dev/null +++ b/packages/db/drizzle/0084_flaky_thundra.sql @@ -0,0 +1,2 @@ +ALTER TABLE `monitor` ADD `grpc_service` text;--> statement-breakpoint +ALTER TABLE `monitor` ADD `grpc_tls` text DEFAULT 'tls'; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0084_snapshot.json b/packages/db/drizzle/meta/0084_snapshot.json new file mode 100644 index 00000000..cb484e37 --- /dev/null +++ b/packages/db/drizzle/meta/0084_snapshot.json @@ -0,0 +1,4976 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b39ead3f-410d-45d6-8d9d-b169d00d0039", + "prevId": "6d3f5d66-7abe-46a6-bb0e-8db6c9f3b401", + "tables": { + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_until": { + "name": "paid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "limits": { + "name": "limits", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "workos_organization_id": { + "name": "workos_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_enabled": { + "name": "sso_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "workspace_stripe_id_unique": { + "name": "workspace_stripe_id_unique", + "columns": [ + "stripe_id" + ], + "isUnique": true + }, + "workspace_workos_organization_id_unique": { + "name": "workspace_workos_organization_id_unique", + "columns": [ + "workos_organization_id" + ], + "isUnique": true + }, + "workspace_id_dsn_unique": { + "name": "workspace_id_dsn_unique", + "columns": [ + "id", + "dsn" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sso_domain": { + "name": "workspace_sso_domain", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "workspace_sso_domain_domain_unique": { + "name": "workspace_sso_domain_domain_unique", + "columns": [ + "domain" + ], + "isUnique": true + }, + "workspace_sso_domain_workspace_id_idx": { + "name": "workspace_sso_domain_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sso_domain_workspace_id_workspace_id_fk": { + "name": "workspace_sso_domain_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sso_domain", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": [ + "provider", + "provider_account_id" + ], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_tenant_id_unique": { + "name": "user_tenant_id_unique", + "columns": [ + "tenant_id" + ], + "isUnique": true + }, + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_to_workspaces": { + "name": "users_to_workspaces", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "users_to_workspaces_workspace_id_idx": { + "name": "users_to_workspaces_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_workspaces_user_id_user_id_fk": { + "name": "users_to_workspaces_user_id_user_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_to_workspaces_workspace_id_workspace_id_fk": { + "name": "users_to_workspaces_workspace_id_workspace_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_workspaces_user_id_workspace_id_pk": { + "columns": [ + "user_id", + "workspace_id" + ], + "name": "users_to_workspaces_user_id_workspace_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_token": { + "name": "verification_token", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verification_token_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report": { + "name": "status_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_workspace_created_idx": { + "name": "status_report_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "status_report_page_id_idx": { + "name": "status_report_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_workspace_id_workspace_id_fk": { + "name": "status_report_workspace_id_workspace_id_fk", + "tableFrom": "status_report", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_report_page_id_page_id_fk": { + "name": "status_report_page_id_page_id_fk", + "tableFrom": "status_report", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_update": { + "name": "status_report_update", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_status_report_id_idx": { + "name": "status_report_update_status_report_id_idx", + "columns": [ + "status_report_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_status_report_id_status_report_id_fk": { + "name": "status_report_update_status_report_id_status_report_id_fk", + "tableFrom": "status_report_update", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential": { + "name": "credential", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_workspace_id_idx": { + "name": "integration_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_workspace_id_workspace_id_fk": { + "name": "integration_workspace_id_workspace_id_fk", + "tableFrom": "integration", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page": { + "name": "page", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "slug": { + "name": "slug", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "force_theme": { + "name": "force_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "custom_theme": { + "name": "custom_theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_protected": { + "name": "password_protected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'public'" + }, + "auth_email_domains": { + "name": "auth_email_domains", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ip_ranges": { + "name": "allowed_ip_ranges", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homepage_url": { + "name": "homepage_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_url": { + "name": "contact_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "locales": { + "name": "locales", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_page": { + "name": "legacy_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_index": { + "name": "allow_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_monitor_values": { + "name": "show_monitor_values", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_slug_unique": { + "name": "page_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "page_lower_slug_idx": { + "name": "page_lower_slug_idx", + "columns": [ + "LOWER(\"slug\")" + ], + "isUnique": false + }, + "page_lower_custom_domain_idx": { + "name": "page_lower_custom_domain_idx", + "columns": [ + "LOWER(\"custom_domain\")" + ], + "isUnique": false + }, + "page_workspace_id_idx": { + "name": "page_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_workspace_id_workspace_id_fk": { + "name": "page_workspace_id_workspace_id_fk", + "tableFrom": "page", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor": { + "name": "monitor", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "periodicity": { + "name": "periodicity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "external_name": { + "name": "external_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 45000 + }, + "degraded_after": { + "name": "degraded_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assertions": { + "name": "assertions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_endpoint": { + "name": "otel_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_headers": { + "name": "otel_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "retry": { + "name": "retry", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "follow_redirects": { + "name": "follow_redirects", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "grpc_service": { + "name": "grpc_service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grpc_tls": { + "name": "grpc_tls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'tls'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_workspace_id_active_idx": { + "name": "monitor_workspace_id_active_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false, + "where": "\"monitor\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "monitor_workspace_id_workspace_id_fk": { + "name": "monitor_workspace_id_workspace_id_fk", + "tableFrom": "monitor", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_subscriber": { + "name": "page_subscriber", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_type": { + "name": "channel_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_config": { + "name": "channel_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'self_signup'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_subscriber_page_id_idx": { + "name": "page_subscriber_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "idx_page_subscriber_email_page_active": { + "name": "idx_page_subscriber_email_page_active", + "columns": [ + "LOWER(\"email\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'email'" + }, + "idx_page_subscriber_webhook_page_active": { + "name": "idx_page_subscriber_webhook_page_active", + "columns": [ + "LOWER(\"webhook_url\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'webhook'" + }, + "idx_page_subscriber_slack_channel_page_active": { + "name": "idx_page_subscriber_slack_channel_page_active", + "columns": [ + "slack_channel_id", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'slack'" + } + }, + "foreignKeys": { + "page_subscriber_page_id_page_id_fk": { + "name": "page_subscriber_page_id_page_id_fk", + "tableFrom": "page_subscriber", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_subscriber_channel_check": { + "name": "page_subscriber_channel_check", + "value": "(\"page_subscriber\".\"channel_type\" = 'email' AND \"page_subscriber\".\"email\" IS NOT NULL AND \"page_subscriber\".\"webhook_url\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'webhook' AND \"page_subscriber\".\"webhook_url\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'slack' AND \"page_subscriber\".\"slack_channel_id\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL AND \"page_subscriber\".\"webhook_url\" IS NULL)" + } + } + }, + "page_subscriber_to_page_component": { + "name": "page_subscriber_to_page_component", + "columns": { + "page_subscriber_id": { + "name": "page_subscriber_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk": { + "name": "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_subscriber", + "columnsFrom": [ + "page_subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_subscriber_to_page_component_page_component_id_page_component_id_fk": { + "name": "page_subscriber_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk": { + "columns": [ + "page_subscriber_id", + "page_component_id" + ], + "name": "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notification_workspace_id_idx": { + "name": "notification_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_workspace_id_workspace_id_fk": { + "name": "notification_workspace_id_workspace_id_fk", + "tableFrom": "notification", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_trigger": { + "name": "notification_trigger", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_id_monitor_id_crontimestampe": { + "name": "notification_id_monitor_id_crontimestampe", + "columns": [ + "notification_id", + "monitor_id", + "cron_timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_trigger_monitor_id_monitor_id_fk": { + "name": "notification_trigger_monitor_id_monitor_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_trigger_notification_id_notification_id_fk": { + "name": "notification_trigger_notification_id_notification_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications_to_monitors": { + "name": "notifications_to_monitors", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notifications_to_monitors_notification_id_idx": { + "name": "notifications_to_monitors_notification_id_idx", + "columns": [ + "notification_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_to_monitors_monitor_id_monitor_id_fk": { + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_to_monitors_notification_id_notification_id_fk": { + "name": "notifications_to_monitors_notification_id_notification_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notifications_to_monitors_monitor_id_notification_id_pk": { + "columns": [ + "monitor_id", + "notification_id" + ], + "name": "notifications_to_monitors_monitor_id_notification_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_status": { + "name": "monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_status_idx": { + "name": "monitor_status_idx", + "columns": [ + "monitor_id", + "region" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_status_monitor_id_monitor_id_fk": { + "name": "monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_status_monitor_id_region_pk": { + "columns": [ + "monitor_id", + "region" + ], + "name": "monitor_status_monitor_id_region_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invitation_workspace_id_idx": { + "name": "invitation_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incident": { + "name": "incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'triage'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "incident_screenshot_url": { + "name": "incident_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recovery_screenshot_url": { + "name": "recovery_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_resolved": { + "name": "auto_resolved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "incident_workspace_id_started_at_idx": { + "name": "incident_workspace_id_started_at_idx", + "columns": [ + "workspace_id", + "started_at" + ], + "isUnique": false + }, + "incident_open_idx": { + "name": "incident_open_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false, + "where": "\"incident\".\"resolved_at\" IS NULL" + }, + "incident_monitor_id_started_at_unique": { + "name": "incident_monitor_id_started_at_unique", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "incident_monitor_id_monitor_id_fk": { + "name": "incident_monitor_id_monitor_id_fk", + "tableFrom": "incident", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set default", + "onUpdate": "no action" + }, + "incident_workspace_id_workspace_id_fk": { + "name": "incident_workspace_id_workspace_id_fk", + "tableFrom": "incident", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_acknowledged_by_user_id_fk": { + "name": "incident_acknowledged_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "acknowledged_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_resolved_by_user_id_fk": { + "name": "incident_resolved_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag": { + "name": "monitor_tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_workspace_id_idx": { + "name": "monitor_tag_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_workspace_id_workspace_id_fk": { + "name": "monitor_tag_workspace_id_workspace_id_fk", + "tableFrom": "monitor_tag", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag_to_monitor": { + "name": "monitor_tag_to_monitor", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_tag_id": { + "name": "monitor_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_to_monitor_monitor_tag_id_idx": { + "name": "monitor_tag_to_monitor_monitor_tag_id_idx", + "columns": [ + "monitor_tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor_tag", + "columnsFrom": [ + "monitor_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { + "columns": [ + "monitor_id", + "monitor_tag_id" + ], + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "application": { + "name": "application", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "application_dsn_unique": { + "name": "application_dsn_unique", + "columns": [ + "dsn" + ], + "isUnique": true + }, + "application_workspace_id_idx": { + "name": "application_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "application_workspace_id_workspace_id_fk": { + "name": "application_workspace_id_workspace_id_fk", + "tableFrom": "application", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance": { + "name": "maintenance", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_page_id_idx": { + "name": "maintenance_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "maintenance_workspace_id_idx": { + "name": "maintenance_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_workspace_id_workspace_id_fk": { + "name": "maintenance_workspace_id_workspace_id_fk", + "tableFrom": "maintenance", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "maintenance_page_id_page_id_fk": { + "name": "maintenance_page_id_page_id_fk", + "tableFrom": "maintenance", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "check": { + "name": "check", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "count_requests": { + "name": "count_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "check_workspace_id_idx": { + "name": "check_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "check_workspace_id_workspace_id_fk": { + "name": "check_workspace_id_workspace_id_fk", + "tableFrom": "check", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_run": { + "name": "monitor_run", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runned_at": { + "name": "runned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_run_workspace_id_created_at_idx": { + "name": "monitor_run_workspace_id_created_at_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "monitor_run_monitor_id_idx": { + "name": "monitor_run_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_run_workspace_id_workspace_id_fk": { + "name": "monitor_run_workspace_id_workspace_id_fk", + "tableFrom": "monitor_run", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "monitor_run_monitor_id_monitor_id_fk": { + "name": "monitor_run_monitor_id_monitor_id_fk", + "tableFrom": "monitor_run", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_monitor_status": { + "name": "private_location_monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_monitor_status_pl_id_idx": { + "name": "private_location_monitor_status_pl_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_monitor_status_monitor_id_monitor_id_fk": { + "name": "private_location_monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_monitor_status_private_location_id_private_location_id_fk": { + "name": "private_location_monitor_status_private_location_id_private_location_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "private_location_monitor_status_monitor_id_private_location_id_pk": { + "columns": [ + "monitor_id", + "private_location_id" + ], + "name": "private_location_monitor_status_monitor_id_private_location_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location": { + "name": "private_location", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'error'" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_workspace_id_idx": { + "name": "private_location_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_workspace_id_workspace_id_fk": { + "name": "private_location_workspace_id_workspace_id_fk", + "tableFrom": "private_location", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_to_monitor": { + "name": "private_location_to_monitor", + "columns": { + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "private_location_to_monitor_private_location_id_idx": { + "name": "private_location_to_monitor_private_location_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + }, + "private_location_to_monitor_monitor_id_idx": { + "name": "private_location_to_monitor_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_to_monitor_private_location_id_private_location_id_fk": { + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_to_monitor_monitor_id_monitor_id_fk": { + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_group": { + "name": "monitor_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_group_workspace_id_idx": { + "name": "monitor_group_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_group_page_id_idx": { + "name": "monitor_group_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_group_workspace_id_workspace_id_fk": { + "name": "monitor_group_workspace_id_workspace_id_fk", + "tableFrom": "monitor_group", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_group_page_id_page_id_fk": { + "name": "monitor_group_page_id_page_id_fk", + "tableFrom": "monitor_group", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer": { + "name": "viewer", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "viewer_email_unique": { + "name": "viewer_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_accounts": { + "name": "viewer_accounts", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_accounts_user_id_viewer_id_fk": { + "name": "viewer_accounts_user_id_viewer_id_fk", + "tableFrom": "viewer_accounts", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "viewer_accounts_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "viewer_accounts_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_session": { + "name": "viewer_session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_session_user_id_viewer_id_fk": { + "name": "viewer_session_user_id_viewer_id_fk", + "tableFrom": "viewer_session", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hashed_token": { + "name": "hashed_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"write\"]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_key_prefix_unique": { + "name": "api_key_prefix_unique", + "columns": [ + "prefix" + ], + "isUnique": true + }, + "api_key_hashed_token_unique": { + "name": "api_key_hashed_token_unique", + "columns": [ + "hashed_token" + ], + "isUnique": true + }, + "api_key_prefix_idx": { + "name": "api_key_prefix_idx", + "columns": [ + "prefix" + ], + "isUnique": false + }, + "api_key_workspace_id_idx": { + "name": "api_key_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_id_user_id_fk": { + "name": "api_key_created_by_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_to_page_component": { + "name": "maintenance_to_page_component", + "columns": { + "maintenance_id": { + "name": "maintenance_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_to_page_component_page_component_id_idx": { + "name": "maintenance_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_to_page_component_maintenance_id_maintenance_id_fk": { + "name": "maintenance_to_page_component_maintenance_id_maintenance_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "maintenance", + "columnsFrom": [ + "maintenance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_to_page_component_page_component_id_page_component_id_fk": { + "name": "maintenance_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "maintenance_to_page_component_maintenance_id_page_component_id_pk": { + "columns": [ + "maintenance_id", + "page_component_id" + ], + "name": "maintenance_to_page_component_maintenance_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component": { + "name": "page_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monitor'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_order": { + "name": "group_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_workspace_id_idx": { + "name": "page_component_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "page_component_page_id_monitor_id_unique": { + "name": "page_component_page_id_monitor_id_unique", + "columns": [ + "page_id", + "monitor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "page_component_workspace_id_workspace_id_fk": { + "name": "page_component_workspace_id_workspace_id_fk", + "tableFrom": "page_component", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_page_id_page_id_fk": { + "name": "page_component_page_id_page_id_fk", + "tableFrom": "page_component", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_monitor_id_monitor_id_fk": { + "name": "page_component_monitor_id_monitor_id_fk", + "tableFrom": "page_component", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_group_id_page_component_groups_id_fk": { + "name": "page_component_group_id_page_component_groups_id_fk", + "tableFrom": "page_component", + "tableTo": "page_component_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_component_type_check": { + "name": "page_component_type_check", + "value": "\"page_component\".\"type\" = 'monitor' AND \"page_component\".\"monitor_id\" IS NOT NULL OR \"page_component\".\"type\" = 'static' AND \"page_component\".\"monitor_id\" IS NULL" + } + } + }, + "status_report_update_to_page_component": { + "name": "status_report_update_to_page_component", + "columns": { + "status_report_update_id": { + "name": "status_report_update_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_to_page_component_page_component_id_idx": { + "name": "status_report_update_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk": { + "name": "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "status_report_update", + "columnsFrom": [ + "status_report_update_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_update_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_update_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_update_to_page_component_status_report_update_id_page_component_id_pk": { + "columns": [ + "status_report_update_id", + "page_component_id" + ], + "name": "status_report_update_to_page_component_status_report_update_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_to_page_component": { + "name": "status_report_to_page_component", + "columns": { + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_to_page_component_page_component_id_idx": { + "name": "status_report_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_to_page_component_status_report_id_status_report_id_fk": { + "name": "status_report_to_page_component_status_report_id_status_report_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_to_page_component_status_report_id_page_component_id_pk": { + "columns": [ + "status_report_id", + "page_component_id" + ], + "name": "status_report_to_page_component_status_report_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component_groups": { + "name": "page_component_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_open": { + "name": "default_open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_groups_page_id_idx": { + "name": "page_component_groups_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "page_component_groups_workspace_id_idx": { + "name": "page_component_groups_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_component_groups_workspace_id_workspace_id_fk": { + "name": "page_component_groups_workspace_id_workspace_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_groups_page_id_page_id_fk": { + "name": "page_component_groups_page_id_page_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocker": { + "name": "blocker", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "feedback_workspace_id_idx": { + "name": "feedback_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "feedback_workspace_id_workspace_id_fk": { + "name": "feedback_workspace_id_workspace_id_fk", + "tableFrom": "feedback", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_user_id_user_id_fk": { + "name": "feedback_user_id_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_fields": { + "name": "changed_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + "workspace_id", + "entity_type", + "entity_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service": { + "name": "external_service", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_page_url": { + "name": "status_page_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_config": { + "name": "api_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_slug_unique": { + "name": "external_service_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "external_service_deleted_at_idx": { + "name": "external_service_deleted_at_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_component": { + "name": "external_service_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_component_id": { + "name": "upstream_component_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "indicator": { + "name": "indicator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_component_unique_idx": { + "name": "external_service_component_unique_idx", + "columns": [ + "external_service_id", + "upstream_component_id" + ], + "isUnique": true + }, + "external_service_component_slug_unique_idx": { + "name": "external_service_component_slug_unique_idx", + "columns": [ + "external_service_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_service_component_external_service_id_external_service_id_fk": { + "name": "external_service_component_external_service_id_external_service_id_fk", + "tableFrom": "external_service_component", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_incident": { + "name": "external_service_incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_incident_id": { + "name": "provider_incident_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortlink": { + "name": "shortlink", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affected_component_ids": { + "name": "affected_component_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload_purged_at": { + "name": "raw_payload_purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_incident_unique_idx": { + "name": "external_service_incident_unique_idx", + "columns": [ + "external_service_id", + "provider_incident_id" + ], + "isUnique": true + }, + "external_service_incident_started_at_idx": { + "name": "external_service_incident_started_at_idx", + "columns": [ + "external_service_id", + "started_at" + ], + "isUnique": false + }, + "external_service_incident_resolved_at_idx": { + "name": "external_service_incident_resolved_at_idx", + "columns": [ + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_incident_external_service_id_external_service_id_fk": { + "name": "external_service_incident_external_service_id_external_service_id_fk", + "tableFrom": "external_service_incident", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_report": { + "name": "external_service_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_service_component_id": { + "name": "external_service_component_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_hash": { + "name": "reporter_hash", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text(2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_report_service_idx": { + "name": "external_service_report_service_idx", + "columns": [ + "external_service_id", + "created_at" + ], + "isUnique": false + }, + "external_service_report_component_idx": { + "name": "external_service_report_component_idx", + "columns": [ + "external_service_component_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_report_external_service_id_external_service_id_fk": { + "name": "external_service_report_external_service_id_external_service_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_service_report_external_service_component_id_external_service_component_id_fk": { + "name": "external_service_report_external_service_component_id_external_service_component_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service_component", + "columnsFrom": [ + "external_service_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_session": { + "name": "chat_session", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_session_workspace_user_updated_idx": { + "name": "chat_session_workspace_user_updated_idx", + "columns": [ + "workspace_id", + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_session_workspace_id_workspace_id_fk": { + "name": "chat_session_workspace_id_workspace_id_fk", + "tableFrom": "chat_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_session_user_id_user_id_fk": { + "name": "chat_session_user_id_user_id_fk", + "tableFrom": "chat_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "frozen_monitor_uptime": { + "name": "frozen_monitor_uptime", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "days": { + "name": "days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "frozen_monitor_uptime_workspace_id_idx": { + "name": "frozen_monitor_uptime_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "frozen_monitor_uptime_monitor_id_month_unique": { + "name": "frozen_monitor_uptime_monitor_id_month_unique", + "columns": [ + "monitor_id", + "month" + ], + "isUnique": true + } + }, + "foreignKeys": { + "frozen_monitor_uptime_workspace_id_workspace_id_fk": { + "name": "frozen_monitor_uptime_workspace_id_workspace_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frozen_monitor_uptime_monitor_id_monitor_id_fk": { + "name": "frozen_monitor_uptime_monitor_id_monitor_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "page_lower_slug_idx": { + "columns": { + "LOWER(\"slug\")": { + "isExpression": true + } + } + }, + "page_lower_custom_domain_idx": { + "columns": { + "LOWER(\"custom_domain\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_email_page_active": { + "columns": { + "LOWER(\"email\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_webhook_page_active": { + "columns": { + "LOWER(\"webhook_url\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index e586f117..02b53c50 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -589,6 +589,13 @@ "when": 1785227235369, "tag": "0083_wide_otto_octavius", "breakpoints": true + }, + { + "idx": 84, + "version": "6", + "when": 1787773726456, + "tag": "0084_flaky_thundra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/monitors/constants.ts b/packages/db/src/schema/monitors/constants.ts index 9c86c6c8..528c4006 100644 --- a/packages/db/src/schema/monitors/constants.ts +++ b/packages/db/src/schema/monitors/constants.ts @@ -1,4 +1,5 @@ import { + GRPC_TLS_MODES, MONITOR_JOB_TYPES, MONITOR_METHODS, MONITOR_STATUSES, @@ -7,3 +8,4 @@ import { export const monitorMethods = MONITOR_METHODS; export const monitorStatus = MONITOR_STATUSES; export const monitorJobTypes = MONITOR_JOB_TYPES; +export const grpcTlsModes = GRPC_TLS_MODES; diff --git a/packages/db/src/schema/monitors/monitor.ts b/packages/db/src/schema/monitors/monitor.ts index 9c15405a..eae65c16 100644 --- a/packages/db/src/schema/monitors/monitor.ts +++ b/packages/db/src/schema/monitors/monitor.ts @@ -8,7 +8,12 @@ import { monitorTagsToMonitors } from "../monitor_tags"; import { notificationsToMonitors } from "../notifications"; import { privateLocationToMonitors } from "../private_locations"; import { workspace } from "../workspaces/workspace"; -import { monitorJobTypes, monitorMethods, monitorStatus } from "./constants"; +import { + grpcTlsModes, + monitorJobTypes, + monitorMethods, + monitorStatus, +} from "./constants"; export const monitor = sqliteTable( "monitor", @@ -56,6 +61,10 @@ export const monitor = sqliteTable( true, ), + grpcService: text("grpc_service"), + + grpcTls: text("grpc_tls", { enum: grpcTlsModes }).default("tls"), + createdAt: integer("created_at", { mode: "timestamp" }).default( sql`(strftime('%s', 'now'))`, ), diff --git a/packages/db/src/schema/monitors/validation.ts b/packages/db/src/schema/monitors/validation.ts index 36736ef1..a4ed875f 100644 --- a/packages/db/src/schema/monitors/validation.ts +++ b/packages/db/src/schema/monitors/validation.ts @@ -3,12 +3,18 @@ import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { z } from "zod"; import { monitorPeriodicitySchema, monitorRegionSchema } from "../constants"; -import { monitorJobTypes, monitorMethods, monitorStatus } from "./constants"; +import { + grpcTlsModes, + monitorJobTypes, + monitorMethods, + monitorStatus, +} from "./constants"; import { monitor } from "./monitor"; export const monitorMethodsSchema = z.enum(monitorMethods); export const monitorStatusSchema = z.enum(monitorStatus); export const monitorJobTypesSchema = z.enum(monitorJobTypes); +export const grpcTlsModesSchema = z.enum(grpcTlsModes); // TODO: shared function // oxlint-disable-next-line eslint/no-unused-vars @@ -91,3 +97,4 @@ export type MonitorPeriodicity = z.infer; export type MonitorMethod = z.infer; export type MonitorRegion = z.infer; export type MonitorJobType = z.infer; +export type GrpcTlsMode = z.infer; diff --git a/packages/proto/api/openstatus/monitor/v1/grpc_monitor.proto b/packages/proto/api/openstatus/monitor/v1/grpc_monitor.proto new file mode 100644 index 00000000..18048422 --- /dev/null +++ b/packages/proto/api/openstatus/monitor/v1/grpc_monitor.proto @@ -0,0 +1,116 @@ +syntax = "proto3"; + +package openstatus.monitor.v1; + +import "buf/validate/validate.proto"; +import "gnostic/openapi/v3/annotations.proto"; +import "openstatus/monitor/v1/http_monitor.proto"; +import "openstatus/monitor/v1/monitor.proto"; + +option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/monitor/v1;monitorv1"; + +// GRPCTlsMode selects how the probe secures its connection to the target. +enum GRPCTlsMode { + // Unspecified. Treated as TLS on create; ignored on update. + GRPC_TLS_MODE_UNSPECIFIED = 0; + + // Verify the server certificate against the system trust store. + GRPC_TLS_MODE_TLS = 1; + + // Connect without TLS (h2c). + GRPC_TLS_MODE_PLAINTEXT = 2; + + // Use TLS but do not verify the server certificate. + GRPC_TLS_MODE_TLS_INSECURE = 3; +} + +// GRPCMonitor defines the configuration for a gRPC health check monitor. +// The probe calls grpc.health.v1.Health/Check on the target. +message GRPCMonitor { + // Unique identifier for the monitor (output only for create requests). + string id = 1; + + // Name of the monitor (required, max 256 characters). + string name = 2 [ + (buf.validate.field).string = { + min_len: 1 + max_len: 256 + }, + (gnostic.openapi.v3.property) = {example: {yaml: "Checkout gRPC"}} + ]; + + // Target in "host:port" form. IPv6 addresses must be bracketed. + string uri = 3 [ + (buf.validate.field).string = { + min_len: 1 + max_len: 2048 + pattern: "^(\\[[0-9a-fA-F:]+\\]|[^:/\\s]+):[0-9]{1,5}$" + }, + (gnostic.openapi.v3.property) = {example: {yaml: "api.example.com:443"}} + ]; + + // Check periodicity (required). + Periodicity periodicity = 4 [(buf.validate.field).enum = { + not_in: [0] + }]; + + // Timeout in milliseconds (0-120000, defaults to 45000). + int64 timeout = 5 [(buf.validate.field).int64 = { + gte: 0 + lte: 120000 + }]; + + // Latency threshold for degraded status in milliseconds (optional, 0-120000). + optional int64 degraded_at = 6 [(buf.validate.field).int64 = { + gte: 0 + lte: 120000 + }]; + + // Number of retry attempts (0-10, defaults to 3). + int64 retry = 7 [(buf.validate.field).int64 = { + gte: 0 + lte: 10 + }]; + + // Description of the monitor (optional). + optional string description = 8 [(buf.validate.field).string.max_len = 1024]; + + // Whether the monitor is active (defaults to false). + optional bool active = 9; + + // Whether the monitor is publicly visible (defaults to false). + optional bool public = 10; + + // Geographic regions to run checks from. + repeated Region regions = 11 [(buf.validate.field).repeated = { + max_items: 28 + items: { + enum: { + not_in: [0] + } + } + }]; + + // OpenTelemetry configuration for exporting metrics. + OpenTelemetryConfig open_telemetry = 12; + + // Current operational status of the monitor. + MonitorStatus status = 13; + + // IDs of private locations that run this monitor. Read-only. + repeated string private_location_ids = 14 [ + (gnostic.openapi.v3.property) = {read_only: true} + ]; + + // Service name passed to Health/Check. Empty means overall server health. + optional string service = 15 [ + (buf.validate.field).string.max_len = 512, + (gnostic.openapi.v3.property) = {example: {yaml: "checkout.v1.CheckoutService"}} + ]; + + // How the connection to the target is secured. Defaults to TLS. + optional GRPCTlsMode tls_mode = 16; + + // Metadata sent with the health check request. + repeated Headers metadata = 17 [(buf.validate.field).repeated.max_items = 20]; +} diff --git a/packages/proto/api/openstatus/monitor/v1/service.proto b/packages/proto/api/openstatus/monitor/v1/service.proto index 6ebb9780..1d9fbbdf 100644 --- a/packages/proto/api/openstatus/monitor/v1/service.proto +++ b/packages/proto/api/openstatus/monitor/v1/service.proto @@ -5,6 +5,7 @@ package openstatus.monitor.v1; import "buf/validate/validate.proto"; import "gnostic/openapi/v3/annotations.proto"; import "openstatus/monitor/v1/dns_monitor.proto"; +import "openstatus/monitor/v1/grpc_monitor.proto"; import "openstatus/monitor/v1/http_monitor.proto"; import "openstatus/monitor/v1/icmp_monitor.proto"; import "openstatus/monitor/v1/monitor.proto"; @@ -42,6 +43,9 @@ service MonitorService { // CreateICMPMonitor creates a new ICMP monitor. rpc CreateICMPMonitor(CreateICMPMonitorRequest) returns (CreateICMPMonitorResponse); + // CreateGRPCMonitor creates a new gRPC health check monitor. + rpc CreateGRPCMonitor(CreateGRPCMonitorRequest) returns (CreateGRPCMonitorResponse); + // UpdateHTTPMonitor updates an existing HTTP monitor. rpc UpdateHTTPMonitor(UpdateHTTPMonitorRequest) returns (UpdateHTTPMonitorResponse); @@ -54,6 +58,9 @@ service MonitorService { // UpdateICMPMonitor updates an existing ICMP monitor. rpc UpdateICMPMonitor(UpdateICMPMonitorRequest) returns (UpdateICMPMonitorResponse); + // UpdateGRPCMonitor updates an existing gRPC monitor. + rpc UpdateGRPCMonitor(UpdateGRPCMonitorRequest) returns (UpdateGRPCMonitorResponse); + // TriggerMonitor initiates an immediate check for a monitor across all configured regions. rpc TriggerMonitor(TriggerMonitorRequest) returns (TriggerMonitorResponse) { option (gnostic.openapi.v3.operation) = { @@ -83,7 +90,7 @@ service MonitorService { } // GetMonitor returns a single monitor by ID within the authenticated workspace. - // Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + // Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. rpc GetMonitor(GetMonitorRequest) returns (GetMonitorResponse) { option idempotency_level = NO_SIDE_EFFECTS; } @@ -147,6 +154,18 @@ message CreateICMPMonitorResponse { ICMPMonitor monitor = 1; } +// CreateGRPCMonitorRequest is the request to create a new gRPC monitor. +message CreateGRPCMonitorRequest { + // Monitor configuration (required). + GRPCMonitor monitor = 1 [(buf.validate.field).required = true]; +} + +// CreateGRPCMonitorResponse is the response after creating a gRPC monitor. +message CreateGRPCMonitorResponse { + // The created monitor with assigned ID. + GRPCMonitor monitor = 1; +} + // UpdateHTTPMonitorRequest is the request to update an existing HTTP monitor. message UpdateHTTPMonitorRequest { // Monitor ID to update (required). @@ -207,6 +226,21 @@ message UpdateICMPMonitorResponse { ICMPMonitor monitor = 1; } +// UpdateGRPCMonitorRequest is the request to update an existing gRPC monitor. +message UpdateGRPCMonitorRequest { + // Monitor ID to update (required). + string id = 1 [(buf.validate.field).string.min_len = 1]; + + // Updated monitor configuration (all fields optional for partial updates). + optional GRPCMonitor monitor = 2; +} + +// UpdateGRPCMonitorResponse is the response after updating a gRPC monitor. +message UpdateGRPCMonitorResponse { + // The updated monitor. + GRPCMonitor monitor = 1; +} + // TriggerMonitorRequest is the request to trigger a monitor check. message TriggerMonitorRequest { // Monitor ID to trigger (required). @@ -257,6 +291,9 @@ message ListMonitorsResponse { // ICMP monitors in the workspace. repeated ICMPMonitor icmp_monitors = 5; + // gRPC monitors in the workspace. + repeated GRPCMonitor grpc_monitors = 6; + // Total number of monitors across all types. int32 total_size = 4; } @@ -296,6 +333,8 @@ message MonitorConfig { DNSMonitor dns = 3; // ICMP monitor configuration. ICMPMonitor icmp = 4; + // gRPC monitor configuration. + GRPCMonitor grpc = 5; } } @@ -358,7 +397,7 @@ message GetMonitorRequest { // GetMonitorResponse is the response containing the monitor. message GetMonitorResponse { - // The monitor configuration (one of HTTP, TCP, DNS, or ICMP). + // The monitor configuration (one of HTTP, TCP, DNS, ICMP, or gRPC). MonitorConfig monitor = 1; } diff --git a/packages/proto/gen/openapi.yaml b/packages/proto/gen/openapi.yaml index 532ee048..ed2d5f69 100644 --- a/packages/proto/gen/openapi.yaml +++ b/packages/proto/gen/openapi.yaml @@ -448,6 +448,28 @@ components: title: CreateDNSMonitorResponse additionalProperties: false description: CreateDNSMonitorResponse is the response after creating a DNS monitor. + openstatus.monitor.v1.CreateGRPCMonitorRequest: + type: object + properties: + monitor: + title: monitor + description: Monitor configuration (required). + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: CreateGRPCMonitorRequest + required: + - monitor + additionalProperties: false + description: CreateGRPCMonitorRequest is the request to create a new gRPC monitor. + openstatus.monitor.v1.CreateGRPCMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The created monitor with assigned ID. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: CreateGRPCMonitorResponse + additionalProperties: false + description: CreateGRPCMonitorResponse is the response after creating a gRPC monitor. openstatus.monitor.v1.CreateHTTPMonitorRequest: type: object properties: @@ -645,6 +667,143 @@ components: title: DeleteMonitorResponse additionalProperties: false description: DeleteMonitorResponse is the response after deleting a monitor. + openstatus.monitor.v1.GRPCMonitor: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the monitor (output only for create requests). + name: + type: string + examples: + - Checkout gRPC + title: name + maxLength: 256 + minLength: 1 + description: Name of the monitor (required, max 256 characters). + uri: + type: string + examples: + - api.example.com:443 + title: uri + maxLength: 2048 + minLength: 1 + pattern: ^(\[[0-9a-fA-F:]+\]|[^:/\s]+):[0-9]{1,5}$ + description: Target in "host:port" form. IPv6 addresses must be bracketed. + periodicity: + not: + enum: + - PERIODICITY_UNSPECIFIED + title: periodicity + description: Check periodicity (required). + $ref: '#/components/schemas/openstatus.monitor.v1.Periodicity' + timeout: + type: + - integer + - string + title: timeout + maximum: 120000 + minimum: 0 + format: int64 + description: Timeout in milliseconds (0-120000, defaults to 45000). + degradedAt: + type: + - integer + - string + - "null" + title: degraded_at + maximum: 120000 + minimum: 0 + format: int64 + description: Latency threshold for degraded status in milliseconds (optional, 0-120000). + retry: + type: + - integer + - string + title: retry + maximum: 10 + minimum: 0 + format: int64 + description: Number of retry attempts (0-10, defaults to 3). + description: + type: + - string + - "null" + title: description + maxLength: 1024 + description: Description of the monitor (optional). + active: + type: + - boolean + - "null" + title: active + description: Whether the monitor is active (defaults to false). + public: + type: + - boolean + - "null" + title: public + description: Whether the monitor is publicly visible (defaults to false). + regions: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.Region' + title: regions + maxItems: 28 + description: Geographic regions to run checks from. + openTelemetry: + title: open_telemetry + description: OpenTelemetry configuration for exporting metrics. + $ref: '#/components/schemas/openstatus.monitor.v1.OpenTelemetryConfig' + status: + title: status + description: Current operational status of the monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.MonitorStatus' + privateLocationIds: + type: array + items: + type: string + readOnly: true + title: private_location_ids + description: IDs of private locations that run this monitor. Read-only. + readOnly: true + service: + type: + - string + - "null" + examples: + - checkout.v1.CheckoutService + title: service + maxLength: 512 + description: Service name passed to Health/Check. Empty means overall server health. + tlsMode: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.GRPCTlsMode' + - type: "null" + title: tls_mode + description: How the connection to the target is secured. Defaults to TLS. + metadata: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.Headers' + title: metadata + maxItems: 20 + description: Metadata sent with the health check request. + title: GRPCMonitor + additionalProperties: false + description: |- + GRPCMonitor defines the configuration for a gRPC health check monitor. + The probe calls grpc.health.v1.Health/Check on the target. + openstatus.monitor.v1.GRPCTlsMode: + type: string + title: GRPCTlsMode + enum: + - GRPC_TLS_MODE_UNSPECIFIED + - GRPC_TLS_MODE_TLS + - GRPC_TLS_MODE_PLAINTEXT + - GRPC_TLS_MODE_TLS_INSECURE + description: GRPCTlsMode selects how the probe secures its connection to the target. openstatus.monitor.v1.GetMonitorHTTPResponseLogRequest: type: object properties: @@ -687,7 +846,7 @@ components: properties: monitor: title: monitor - description: The monitor configuration (one of HTTP, TCP, DNS, or ICMP). + description: The monitor configuration (one of HTTP, TCP, DNS, ICMP, or gRPC). $ref: '#/components/schemas/openstatus.monitor.v1.MonitorConfig' title: GetMonitorResponse additionalProperties: false @@ -1424,6 +1583,12 @@ components: $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' title: icmp_monitors description: ICMP monitors in the workspace. + grpcMonitors: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: grpc_monitors + description: gRPC monitors in the workspace. totalSize: type: integer title: total_size @@ -1444,6 +1609,15 @@ components: title: dns required: - dns + - type: object + properties: + grpc: + title: grpc + description: gRPC monitor configuration. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: grpc + required: + - grpc - type: object properties: http: @@ -1808,6 +1982,33 @@ components: title: UpdateDNSMonitorResponse additionalProperties: false description: UpdateDNSMonitorResponse is the response after updating a DNS monitor. + openstatus.monitor.v1.UpdateGRPCMonitorRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: Monitor ID to update (required). + monitor: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + - type: "null" + title: monitor + description: Updated monitor configuration (all fields optional for partial updates). + title: UpdateGRPCMonitorRequest + additionalProperties: false + description: UpdateGRPCMonitorRequest is the request to update an existing gRPC monitor. + openstatus.monitor.v1.UpdateGRPCMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The updated monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: UpdateGRPCMonitorResponse + additionalProperties: false + description: UpdateGRPCMonitorResponse is the response after updating a gRPC monitor. openstatus.monitor.v1.UpdateHTTPMonitorRequest: type: object properties: @@ -5187,6 +5388,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateDNSMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/CreateGRPCMonitor: + post: + tags: + - MonitorService + summary: CreateGRPCMonitor + description: CreateGRPCMonitor creates a new gRPC health check monitor. + operationId: MonitorService_CreateGRPCMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateGRPCMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateGRPCMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateHTTPMonitor: post: tags: @@ -5298,7 +5525,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor.get parameters: - name: message @@ -5326,7 +5553,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor requestBody: content: @@ -5659,6 +5886,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateDNSMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/UpdateGRPCMonitor: + post: + tags: + - MonitorService + summary: UpdateGRPCMonitor + description: UpdateGRPCMonitor updates an existing gRPC monitor. + operationId: MonitorService_UpdateGRPCMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateGRPCMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateGRPCMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateHTTPMonitor: post: tags: diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/grpc_monitor_pb.ts b/packages/proto/gen/ts/openstatus/monitor/v1/grpc_monitor_pb.ts new file mode 100644 index 00000000..ed04312a --- /dev/null +++ b/packages/proto/gen/ts/openstatus/monitor/v1/grpc_monitor_pb.ts @@ -0,0 +1,195 @@ +// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=.ts" +// @generated from file openstatus/monitor/v1/grpc_monitor.proto (package openstatus.monitor.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb.ts"; +import { file_gnostic_openapi_v3_annotations } from "../../../gnostic/openapi/v3/annotations_pb.ts"; +import type { Headers, OpenTelemetryConfig } from "./http_monitor_pb.ts"; +import { file_openstatus_monitor_v1_http_monitor } from "./http_monitor_pb.ts"; +import type { MonitorStatus, Periodicity, Region } from "./monitor_pb.ts"; +import { file_openstatus_monitor_v1_monitor } from "./monitor_pb.ts"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file openstatus/monitor/v1/grpc_monitor.proto. + */ +export const file_openstatus_monitor_v1_grpc_monitor: GenFile = /*@__PURE__*/ + fileDesc("CihvcGVuc3RhdHVzL21vbml0b3IvdjEvZ3JwY19tb25pdG9yLnByb3RvEhVvcGVuc3RhdHVzLm1vbml0b3IudjEi/QYKC0dSUENNb25pdG9yEgoKAmlkGAEgASgJEiwKBG5hbWUYAiABKAlCHrpHEToPEg1DaGVja291dCBnUlBDukgHcgUQARiAAhJcCgN1cmkYAyABKAlCT7pHFzoVEhNhcGkuZXhhbXBsZS5jb206NDQzukgycjAQARiAEDIpXihcW1swLTlhLWZBLUY6XStcXXxbXjovXHNdKyk6WzAtOV17MSw1fSQSQQoLcGVyaW9kaWNpdHkYBCABKA4yIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuUGVyaW9kaWNpdHlCCLpIBYIBAiAAEhwKB3RpbWVvdXQYBSABKANCC7pICCIGGMCpBygAEiUKC2RlZ3JhZGVkX2F0GAYgASgDQgu6SAgiBhjAqQcoAEgAiAEBEhgKBXJldHJ5GAcgASgDQgm6SAYiBBgKKAASIgoLZGVzY3JpcHRpb24YCCABKAlCCLpIBXIDGIAISAGIAQESEwoGYWN0aXZlGAkgASgISAKIAQESEwoGcHVibGljGAogASgISAOIAQESPwoHcmVnaW9ucxgLIAMoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb25CD7pIDJIBCRAcIgWCAQIgABJCCg5vcGVuX3RlbGVtZXRyeRgMIAEoCzIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5PcGVuVGVsZW1ldHJ5Q29uZmlnEjQKBnN0YXR1cxgNIAEoDjIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yU3RhdHVzEiMKFHByaXZhdGVfbG9jYXRpb25faWRzGA4gAygJQgW6RwIYARJACgdzZXJ2aWNlGA8gASgJQiq6Rx86HRIbY2hlY2tvdXQudjEuQ2hlY2tvdXRTZXJ2aWNlukgFcgMYgARIBIgBARI5Cgh0bHNfbW9kZRgQIAEoDjIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5HUlBDVGxzTW9kZUgFiAEBEjoKCG1ldGFkYXRhGBEgAygLMh4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhlYWRlcnNCCLpIBZIBAhAUQg4KDF9kZWdyYWRlZF9hdEIOCgxfZGVzY3JpcHRpb25CCQoHX2FjdGl2ZUIJCgdfcHVibGljQgoKCF9zZXJ2aWNlQgsKCV90bHNfbW9kZSqAAQoLR1JQQ1Rsc01vZGUSHQoZR1JQQ19UTFNfTU9ERV9VTlNQRUNJRklFRBAAEhUKEUdSUENfVExTX01PREVfVExTEAESGwoXR1JQQ19UTFNfTU9ERV9QTEFJTlRFWFQQAhIeChpHUlBDX1RMU19NT0RFX1RMU19JTlNFQ1VSRRADQlNaUWdpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9tb25pdG9yL3YxO21vbml0b3J2MWIGcHJvdG8z", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_monitor]); + +/** + * GRPCMonitor defines the configuration for a gRPC health check monitor. + * The probe calls grpc.health.v1.Health/Check on the target. + * + * @generated from message openstatus.monitor.v1.GRPCMonitor + */ +export type GRPCMonitor = Message<"openstatus.monitor.v1.GRPCMonitor"> & { + /** + * Unique identifier for the monitor (output only for create requests). + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * Name of the monitor (required, max 256 characters). + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Target in "host:port" form. IPv6 addresses must be bracketed. + * + * @generated from field: string uri = 3; + */ + uri: string; + + /** + * Check periodicity (required). + * + * @generated from field: openstatus.monitor.v1.Periodicity periodicity = 4; + */ + periodicity: Periodicity; + + /** + * Timeout in milliseconds (0-120000, defaults to 45000). + * + * @generated from field: int64 timeout = 5; + */ + timeout: bigint; + + /** + * Latency threshold for degraded status in milliseconds (optional, 0-120000). + * + * @generated from field: optional int64 degraded_at = 6; + */ + degradedAt?: bigint | undefined; + + /** + * Number of retry attempts (0-10, defaults to 3). + * + * @generated from field: int64 retry = 7; + */ + retry: bigint; + + /** + * Description of the monitor (optional). + * + * @generated from field: optional string description = 8; + */ + description?: string | undefined; + + /** + * Whether the monitor is active (defaults to false). + * + * @generated from field: optional bool active = 9; + */ + active?: boolean | undefined; + + /** + * Whether the monitor is publicly visible (defaults to false). + * + * @generated from field: optional bool public = 10; + */ + public?: boolean | undefined; + + /** + * Geographic regions to run checks from. + * + * @generated from field: repeated openstatus.monitor.v1.Region regions = 11; + */ + regions: Region[]; + + /** + * OpenTelemetry configuration for exporting metrics. + * + * @generated from field: openstatus.monitor.v1.OpenTelemetryConfig open_telemetry = 12; + */ + openTelemetry?: OpenTelemetryConfig | undefined; + + /** + * Current operational status of the monitor. + * + * @generated from field: openstatus.monitor.v1.MonitorStatus status = 13; + */ + status: MonitorStatus; + + /** + * IDs of private locations that run this monitor. Read-only. + * + * @generated from field: repeated string private_location_ids = 14; + */ + privateLocationIds: string[]; + + /** + * Service name passed to Health/Check. Empty means overall server health. + * + * @generated from field: optional string service = 15; + */ + service?: string | undefined; + + /** + * How the connection to the target is secured. Defaults to TLS. + * + * @generated from field: optional openstatus.monitor.v1.GRPCTlsMode tls_mode = 16; + */ + tlsMode?: GRPCTlsMode | undefined; + + /** + * Metadata sent with the health check request. + * + * @generated from field: repeated openstatus.monitor.v1.Headers metadata = 17; + */ + metadata: Headers[]; +}; + +/** + * Describes the message openstatus.monitor.v1.GRPCMonitor. + * Use `create(GRPCMonitorSchema)` to create a new message. + */ +export const GRPCMonitorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_grpc_monitor, 0); + +/** + * GRPCTlsMode selects how the probe secures its connection to the target. + * + * @generated from enum openstatus.monitor.v1.GRPCTlsMode + */ +export enum GRPCTlsMode { + /** + * Unspecified. Treated as TLS on create; ignored on update. + * + * @generated from enum value: GRPC_TLS_MODE_UNSPECIFIED = 0; + */ + GRPC_TLS_MODE_UNSPECIFIED = 0, + + /** + * Verify the server certificate against the system trust store. + * + * @generated from enum value: GRPC_TLS_MODE_TLS = 1; + */ + GRPC_TLS_MODE_TLS = 1, + + /** + * Connect without TLS (h2c). + * + * @generated from enum value: GRPC_TLS_MODE_PLAINTEXT = 2; + */ + GRPC_TLS_MODE_PLAINTEXT = 2, + + /** + * Use TLS but do not verify the server certificate. + * + * @generated from enum value: GRPC_TLS_MODE_TLS_INSECURE = 3; + */ + GRPC_TLS_MODE_TLS_INSECURE = 3, +} + +/** + * Describes the enum openstatus.monitor.v1.GRPCTlsMode. + */ +export const GRPCTlsModeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_openstatus_monitor_v1_grpc_monitor, 0); + diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/index.ts b/packages/proto/gen/ts/openstatus/monitor/v1/index.ts index e068ed2e..edb1e9ee 100644 --- a/packages/proto/gen/ts/openstatus/monitor/v1/index.ts +++ b/packages/proto/gen/ts/openstatus/monitor/v1/index.ts @@ -2,6 +2,7 @@ export * from "./assertions_pb.js"; export * from "./dns_monitor_pb.js"; export * from "./http_monitor_pb.js"; +export * from "./grpc_monitor_pb.js"; export * from "./icmp_monitor_pb.js"; export * from "./tcp_monitor_pb.js"; export * from "./monitor_pb.js"; diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts b/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts index 9de06bc9..2e0f6450 100644 --- a/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts +++ b/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts @@ -8,6 +8,8 @@ import { file_buf_validate_validate } from "../../../buf/validate/validate_pb.ts import { file_gnostic_openapi_v3_annotations } from "../../../gnostic/openapi/v3/annotations_pb.ts"; import type { DNSMonitor } from "./dns_monitor_pb.ts"; import { file_openstatus_monitor_v1_dns_monitor } from "./dns_monitor_pb.ts"; +import type { GRPCMonitor } from "./grpc_monitor_pb.ts"; +import { file_openstatus_monitor_v1_grpc_monitor } from "./grpc_monitor_pb.ts"; import type { HTTPMonitor } from "./http_monitor_pb.ts"; import { file_openstatus_monitor_v1_http_monitor } from "./http_monitor_pb.ts"; import type { ICMPMonitor } from "./icmp_monitor_pb.ts"; @@ -22,7 +24,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file openstatus/monitor/v1/service.proto. */ export const file_openstatus_monitor_v1_service: GenFile = /*@__PURE__*/ - fileDesc("CiNvcGVuc3RhdHVzL21vbml0b3IvdjEvc2VydmljZS5wcm90bxIVb3BlbnN0YXR1cy5tb25pdG9yLnYxIlcKGENyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBI7Cgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yQga6SAPIAQEiUAoZQ3JlYXRlSFRUUE1vbml0b3JSZXNwb25zZRIzCgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yIlUKF0NyZWF0ZVRDUE1vbml0b3JSZXF1ZXN0EjoKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvckIGukgDyAEBIk4KGENyZWF0ZVRDUE1vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3IiVQoXQ3JlYXRlRE5TTW9uaXRvclJlcXVlc3QSOgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5ETlNNb25pdG9yQga6SAPIAQEiTgoYQ3JlYXRlRE5TTW9uaXRvclJlc3BvbnNlEjIKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvciJXChhDcmVhdGVJQ01QTW9uaXRvclJlcXVlc3QSOwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvckIGukgDyAEBIlAKGUNyZWF0ZUlDTVBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvciJ1ChhVcGRhdGVIVFRQTW9uaXRvclJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESOAoHbW9uaXRvchgCIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIlAKGVVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvciJzChdVcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARI3Cgdtb25pdG9yGAIgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJOChhVcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2USMgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5UQ1BNb25pdG9yInMKF1VwZGF0ZUROU01vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjcKB21vbml0b3IYAiABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIk4KGFVwZGF0ZUROU01vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLkROU01vbml0b3IidQoYVXBkYXRlSUNNUE1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjgKB21vbml0b3IYAiABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSUNNUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJQChlVcGRhdGVJQ01QTW9uaXRvclJlc3BvbnNlEjMKB21vbml0b3IYASABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSUNNUE1vbml0b3IiLAoVVHJpZ2dlck1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIikKFlRyaWdnZXJNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCIrChREZWxldGVNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIoChVEZWxldGVNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCJnChNMaXN0TW9uaXRvcnNSZXF1ZXN0Eh0KBWxpbWl0GAEgASgFQgm6SAYaBBhkKAFIAIgBARIcCgZvZmZzZXQYAiABKAVCB7pIBBoCKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCKSAgoUTGlzdE1vbml0b3JzUmVzcG9uc2USOQoNaHR0cF9tb25pdG9ycxgBIAMoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvchI3Cgx0Y3BfbW9uaXRvcnMYAiADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvchI3CgxkbnNfbW9uaXRvcnMYAyADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvchI5Cg1pY21wX21vbml0b3JzGAUgAygLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLklDTVBNb25pdG9yEhIKCnRvdGFsX3NpemUYBCABKAUiLgoXR2V0TW9uaXRvclN0YXR1c1JlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEicwoMUmVnaW9uU3RhdHVzEi0KBnJlZ2lvbhgBIAEoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb24SNAoGc3RhdHVzGAIgASgOMiQub3BlbnN0YXR1cy5tb25pdG9yLnYxLk1vbml0b3JTdGF0dXMiXAoYR2V0TW9uaXRvclN0YXR1c1Jlc3BvbnNlEgoKAmlkGAEgASgJEjQKB3JlZ2lvbnMYAiADKAsyIy5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uU3RhdHVzIuUBCg1Nb25pdG9yQ29uZmlnEjIKBGh0dHAYASABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUE1vbml0b3JIABIwCgN0Y3AYAiABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvckgAEjAKA2RucxgDIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5ETlNNb25pdG9ySAASMgoEaWNtcBgEIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvckgAQggKBmNvbmZpZyKfAQoYR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjQKCnRpbWVfcmFuZ2UYAiABKA4yIC5vcGVuc3RhdHVzLm1vbml0b3IudjEuVGltZVJhbmdlEjgKB3JlZ2lvbnMYAyADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQgi6SAWSAQIQHCKsAgoZR2V0TW9uaXRvclN1bW1hcnlSZXNwb25zZRIKCgJpZBgBIAEoCRIUCgxsYXN0X3BpbmdfYXQYAiABKAkSGAoQdG90YWxfc3VjY2Vzc2Z1bBgDIAEoAxIWCg50b3RhbF9kZWdyYWRlZBgEIAEoAxIUCgx0b3RhbF9mYWlsZWQYBSABKAMSCwoDcDUwGAYgASgDEgsKA3A3NRgHIAEoAxILCgNwOTAYCCABKAMSCwoDcDk1GAkgASgDEgsKA3A5ORgKIAEoAxI0Cgp0aW1lX3JhbmdlGAsgASgOMiAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRpbWVSYW5nZRIuCgdyZWdpb25zGAwgAygOMh0ub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvbiIoChFHZXRNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJLChJHZXRNb25pdG9yUmVzcG9uc2USNQoHbW9uaXRvchgBIAEoCzIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yQ29uZmlnImIKFUhUVFBSZXNwb25zZUxvZ1RpbWluZxILCgNkbnMYASABKAUSDwoHY29ubmVjdBgCIAEoBRILCgN0bHMYAyABKAUSDAoEdHRmYhgEIAEoBRIQCgh0cmFuc2ZlchgFIAEoBSK1AwoXSFRUUFJlc3BvbnNlTG9nTGlzdEl0ZW0SDwoCaWQYASABKAlIAIgBARIPCgdsYXRlbmN5GAIgASgFEhgKC3N0YXR1c19jb2RlGAMgASgFSAGIAQESEgoKbW9uaXRvcl9pZBgEIAEoCRJLCg5yZXF1ZXN0X3N0YXR1cxgFIAEoDjIzLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEi0KBnJlZ2lvbhgGIAEoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb24SFgoOY3Jvbl90aW1lc3RhbXAYByABKAMSPgoHdHJpZ2dlchgIIAEoDjItLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dUcmlnZ2VyEhEKCXRpbWVzdGFtcBgJIAEoAxJBCgZ0aW1pbmcYCiABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nVGltaW5nSAKIAQFCBQoDX2lkQg4KDF9zdGF0dXNfY29kZUIJCgdfdGltaW5nIrYCChVIVFRQUmVzcG9uc2VMb2dEZXRhaWwSOwoDbG9nGAEgASgLMi4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ0xpc3RJdGVtEgsKA3VybBgCIAEoCRINCgVlcnJvchgDIAEoCBIUCgdtZXNzYWdlGAQgASgJSACIAQESSgoHaGVhZGVycxgFIAMoCzI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dEZXRhaWwuSGVhZGVyc0VudHJ5EhcKCmFzc2VydGlvbnMYBiABKAlIAYgBARouCgxIZWFkZXJzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIKCghfbWVzc2FnZUINCgtfYXNzZXJ0aW9ucyLnAQoiTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIbCg5mcm9tX3RpbWVzdGFtcBgCIAEoA0gAiAEBEhkKDHRvX3RpbWVzdGFtcBgDIAEoA0gBiAEBEh0KBWxpbWl0GAQgASgFQgm6SAYaBBhkKAFIAogBARIcCgZvZmZzZXQYBSABKAVCB7pIBBoCKABIA4gBAUIRCg9fZnJvbV90aW1lc3RhbXBCDwoNX3RvX3RpbWVzdGFtcEIICgZfbGltaXRCCQoHX29mZnNldCJ2ChlIVFRQUmVzcG9uc2VMb2dQYWdpbmF0aW9uEg0KBWxpbWl0GAEgASgFEg4KBm9mZnNldBgCIAEoBRIQCghoYXNfbW9yZRgDIAEoCBIYCgtuZXh0X29mZnNldBgEIAEoBUgAiAEBQg4KDF9uZXh0X29mZnNldCKpAQojTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVzcG9uc2USPAoEbG9ncxgBIAMoCzIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dMaXN0SXRlbRJECgpwYWdpbmF0aW9uGAIgASgLMjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ1BhZ2luYXRpb24iUAogR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESFwoGbG9nX2lkGAIgASgJQge6SARyAhABIl4KIUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2dSZXNwb25zZRI5CgNsb2cYASABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nRGV0YWlsKmEKCVRpbWVSYW5nZRIaChZUSU1FX1JBTkdFX1VOU1BFQ0lGSUVEEAASEQoNVElNRV9SQU5HRV8xRBABEhEKDVRJTUVfUkFOR0VfN0QQAhISCg5USU1FX1JBTkdFXzE0RBADKtkBChxIVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEjAKLEhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX1VOU1BFQ0lGSUVEEAASLAooSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfU1VDQ0VTUxABEioKJkhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX0VSUk9SEAISLQopSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfREVHUkFERUQQAyqKAQoWSFRUUFJlc3BvbnNlTG9nVHJpZ2dlchIpCiVIVFRQX1JFU1BPTlNFX0xPR19UUklHR0VSX1VOU1BFQ0lGSUVEEAASIgoeSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9DUk9OEAESIQodSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9BUEkQAjKLFgoOTW9uaXRvclNlcnZpY2USuQMKEUNyZWF0ZUhUVFBNb25pdG9yEi8ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBowLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVIVFRQTW9uaXRvclJlc3BvbnNlIsACuke8Ahq5AkNyZWF0ZXMgYSBuZXcgSFRUUCBtb25pdG9yIGluIHRoZSBhdXRoZW50aWNhdGVkIHdvcmtzcGFjZS4gQ29uZmlndXJlIHRoZSB0YXJnZXQgVVJMLCBIVFRQIG1ldGhvZCwgcmVxdWVzdCBoZWFkZXJzIGFuZCBib2R5LCByZXNwb25zZSBhc3NlcnRpb25zIChzdGF0dXMgY29kZSwgYm9keSBjb250ZW50LCBoZWFkZXJzKSwgY2hlY2sgcGVyaW9kaWNpdHksIGdlb2dyYXBoaWMgcmVnaW9ucywgYW5kIG9wdGlvbmFsIE9wZW5UZWxlbWV0cnkgZXhwb3J0LiBUaGUgbW9uaXRvciBzdGFydHMgY2hlY2tpbmcgaW1tZWRpYXRlbHkgaWYgc2V0IHRvIGFjdGl2ZS4ScwoQQ3JlYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQQ3JlYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRQ3JlYXRlSUNNUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuQ3JlYXRlSUNNUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUlDTVBNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSFRUUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSFRUUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSUNNUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSUNNUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUlDTVBNb25pdG9yUmVzcG9uc2US6QIKDlRyaWdnZXJNb25pdG9yEiwub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRyaWdnZXJNb25pdG9yUmVxdWVzdBotLm9wZW5zdGF0dXMubW9uaXRvci52MS5UcmlnZ2VyTW9uaXRvclJlc3BvbnNlIvkBukf1ARryAU1hbnVhbGx5IHRyaWdnZXJzIGFuIGltbWVkaWF0ZSBjaGVjayBmb3IgdGhlIHNwZWNpZmllZCBtb25pdG9yIGFjcm9zcyBhbGwgY29uZmlndXJlZCByZWdpb25zLiBUaGlzIG9wZXJhdGlvbiBpcyByYXRlLWxpbWl0ZWQgdW5kZXIgdGhlIHN5bnRoZXRpYy1jaGVja3MgcXVvdGEuIEEgbW9uaXRvciBydW4gcmVjb3JkIGlzIGNyZWF0ZWQgYW5kIHRoZSBjaGVjayBpcyBkaXNwYXRjaGVkIHRvIHRoZSBjaGVja2VyIHNlcnZpY2UuEmoKDURlbGV0ZU1vbml0b3ISKy5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlcXVlc3QaLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlc3BvbnNlEmwKDExpc3RNb25pdG9ycxIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvcnNSZXF1ZXN0Gisub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9yc1Jlc3BvbnNlIgOQAgESeAoQR2V0TW9uaXRvclN0YXR1cxIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVzcG9uc2UiA5ACARKmAwoRR2V0TW9uaXRvclN1bW1hcnkSLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkdldE1vbml0b3JTdW1tYXJ5UmVzcG9uc2UirQKQAgG6R6YCGqMCUmV0dXJucyBhZ2dyZWdhdGVkIG1ldHJpY3MgZm9yIGEgbW9uaXRvciBpbmNsdWRpbmcgbGF0ZW5jeSBwZXJjZW50aWxlcyAocDUwLCBwNzUsIHA5MCwgcDk1LCBwOTkpLCByZXF1ZXN0IGNvdW50cyBieSBzdGF0dXMgKHN1Y2Nlc3NmdWwsIGRlZ3JhZGVkLCBmYWlsZWQpLCBhbmQgdGhlIHRpbWVzdGFtcCBvZiB0aGUgbGFzdCBjaGVjay4gTWV0cmljcyBjYW4gYmUgc2NvcGVkIHRvIGEgdGltZSByYW5nZSAoMSBkYXksIDcgZGF5cywgb3IgMTQgZGF5cykgYW5kIGZpbHRlcmVkIGJ5IHNwZWNpZmljIHJlZ2lvbnMuEmYKCkdldE1vbml0b3ISKC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlcXVlc3QaKS5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlc3BvbnNlIgOQAgESmQEKG0xpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9ncxI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvckhUVFBSZXNwb25zZUxvZ3NSZXF1ZXN0Gjoub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9nc1Jlc3BvbnNlIgOQAgESkwEKGUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2cSNy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QaOC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1Jlc3BvbnNlIgOQAgFCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_dns_monitor, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_icmp_monitor, file_openstatus_monitor_v1_monitor, file_openstatus_monitor_v1_tcp_monitor]); + fileDesc("CiNvcGVuc3RhdHVzL21vbml0b3IvdjEvc2VydmljZS5wcm90bxIVb3BlbnN0YXR1cy5tb25pdG9yLnYxIlcKGENyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBI7Cgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yQga6SAPIAQEiUAoZQ3JlYXRlSFRUUE1vbml0b3JSZXNwb25zZRIzCgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yIlUKF0NyZWF0ZVRDUE1vbml0b3JSZXF1ZXN0EjoKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvckIGukgDyAEBIk4KGENyZWF0ZVRDUE1vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3IiVQoXQ3JlYXRlRE5TTW9uaXRvclJlcXVlc3QSOgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5ETlNNb25pdG9yQga6SAPIAQEiTgoYQ3JlYXRlRE5TTW9uaXRvclJlc3BvbnNlEjIKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvciJXChhDcmVhdGVJQ01QTW9uaXRvclJlcXVlc3QSOwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvckIGukgDyAEBIlAKGUNyZWF0ZUlDTVBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvciJXChhDcmVhdGVHUlBDTW9uaXRvclJlcXVlc3QSOwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5HUlBDTW9uaXRvckIGukgDyAEBIlAKGUNyZWF0ZUdSUENNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5HUlBDTW9uaXRvciJ1ChhVcGRhdGVIVFRQTW9uaXRvclJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESOAoHbW9uaXRvchgCIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIlAKGVVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvciJzChdVcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARI3Cgdtb25pdG9yGAIgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJOChhVcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2USMgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5UQ1BNb25pdG9yInMKF1VwZGF0ZUROU01vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjcKB21vbml0b3IYAiABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIk4KGFVwZGF0ZUROU01vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLkROU01vbml0b3IidQoYVXBkYXRlSUNNUE1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjgKB21vbml0b3IYAiABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSUNNUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJQChlVcGRhdGVJQ01QTW9uaXRvclJlc3BvbnNlEjMKB21vbml0b3IYASABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSUNNUE1vbml0b3IidQoYVXBkYXRlR1JQQ01vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjgKB21vbml0b3IYAiABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuR1JQQ01vbml0b3JIAIgBAUIKCghfbW9uaXRvciJQChlVcGRhdGVHUlBDTW9uaXRvclJlc3BvbnNlEjMKB21vbml0b3IYASABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuR1JQQ01vbml0b3IiLAoVVHJpZ2dlck1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIikKFlRyaWdnZXJNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCIrChREZWxldGVNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIoChVEZWxldGVNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCJnChNMaXN0TW9uaXRvcnNSZXF1ZXN0Eh0KBWxpbWl0GAEgASgFQgm6SAYaBBhkKAFIAIgBARIcCgZvZmZzZXQYAiABKAVCB7pIBBoCKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCLNAgoUTGlzdE1vbml0b3JzUmVzcG9uc2USOQoNaHR0cF9tb25pdG9ycxgBIAMoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvchI3Cgx0Y3BfbW9uaXRvcnMYAiADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvchI3CgxkbnNfbW9uaXRvcnMYAyADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvchI5Cg1pY21wX21vbml0b3JzGAUgAygLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLklDTVBNb25pdG9yEjkKDWdycGNfbW9uaXRvcnMYBiADKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuR1JQQ01vbml0b3ISEgoKdG90YWxfc2l6ZRgEIAEoBSIuChdHZXRNb25pdG9yU3RhdHVzUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJzCgxSZWdpb25TdGF0dXMSLQoGcmVnaW9uGAEgASgOMh0ub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvbhI0CgZzdGF0dXMYAiABKA4yJC5vcGVuc3RhdHVzLm1vbml0b3IudjEuTW9uaXRvclN0YXR1cyJcChhHZXRNb25pdG9yU3RhdHVzUmVzcG9uc2USCgoCaWQYASABKAkSNAoHcmVnaW9ucxgCIAMoCzIjLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb25TdGF0dXMimQIKDU1vbml0b3JDb25maWcSMgoEaHR0cBgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvckgAEjAKA3RjcBgCIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5UQ1BNb25pdG9ySAASMAoDZG5zGAMgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLkROU01vbml0b3JIABIyCgRpY21wGAQgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLklDTVBNb25pdG9ySAASMgoEZ3JwYxgFIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5HUlBDTW9uaXRvckgAQggKBmNvbmZpZyKfAQoYR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjQKCnRpbWVfcmFuZ2UYAiABKA4yIC5vcGVuc3RhdHVzLm1vbml0b3IudjEuVGltZVJhbmdlEjgKB3JlZ2lvbnMYAyADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQgi6SAWSAQIQHCKsAgoZR2V0TW9uaXRvclN1bW1hcnlSZXNwb25zZRIKCgJpZBgBIAEoCRIUCgxsYXN0X3BpbmdfYXQYAiABKAkSGAoQdG90YWxfc3VjY2Vzc2Z1bBgDIAEoAxIWCg50b3RhbF9kZWdyYWRlZBgEIAEoAxIUCgx0b3RhbF9mYWlsZWQYBSABKAMSCwoDcDUwGAYgASgDEgsKA3A3NRgHIAEoAxILCgNwOTAYCCABKAMSCwoDcDk1GAkgASgDEgsKA3A5ORgKIAEoAxI0Cgp0aW1lX3JhbmdlGAsgASgOMiAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRpbWVSYW5nZRIuCgdyZWdpb25zGAwgAygOMh0ub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvbiIoChFHZXRNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJLChJHZXRNb25pdG9yUmVzcG9uc2USNQoHbW9uaXRvchgBIAEoCzIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yQ29uZmlnImIKFUhUVFBSZXNwb25zZUxvZ1RpbWluZxILCgNkbnMYASABKAUSDwoHY29ubmVjdBgCIAEoBRILCgN0bHMYAyABKAUSDAoEdHRmYhgEIAEoBRIQCgh0cmFuc2ZlchgFIAEoBSK1AwoXSFRUUFJlc3BvbnNlTG9nTGlzdEl0ZW0SDwoCaWQYASABKAlIAIgBARIPCgdsYXRlbmN5GAIgASgFEhgKC3N0YXR1c19jb2RlGAMgASgFSAGIAQESEgoKbW9uaXRvcl9pZBgEIAEoCRJLCg5yZXF1ZXN0X3N0YXR1cxgFIAEoDjIzLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEi0KBnJlZ2lvbhgGIAEoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb24SFgoOY3Jvbl90aW1lc3RhbXAYByABKAMSPgoHdHJpZ2dlchgIIAEoDjItLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dUcmlnZ2VyEhEKCXRpbWVzdGFtcBgJIAEoAxJBCgZ0aW1pbmcYCiABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nVGltaW5nSAKIAQFCBQoDX2lkQg4KDF9zdGF0dXNfY29kZUIJCgdfdGltaW5nIrYCChVIVFRQUmVzcG9uc2VMb2dEZXRhaWwSOwoDbG9nGAEgASgLMi4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ0xpc3RJdGVtEgsKA3VybBgCIAEoCRINCgVlcnJvchgDIAEoCBIUCgdtZXNzYWdlGAQgASgJSACIAQESSgoHaGVhZGVycxgFIAMoCzI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dEZXRhaWwuSGVhZGVyc0VudHJ5EhcKCmFzc2VydGlvbnMYBiABKAlIAYgBARouCgxIZWFkZXJzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIKCghfbWVzc2FnZUINCgtfYXNzZXJ0aW9ucyLnAQoiTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIbCg5mcm9tX3RpbWVzdGFtcBgCIAEoA0gAiAEBEhkKDHRvX3RpbWVzdGFtcBgDIAEoA0gBiAEBEh0KBWxpbWl0GAQgASgFQgm6SAYaBBhkKAFIAogBARIcCgZvZmZzZXQYBSABKAVCB7pIBBoCKABIA4gBAUIRCg9fZnJvbV90aW1lc3RhbXBCDwoNX3RvX3RpbWVzdGFtcEIICgZfbGltaXRCCQoHX29mZnNldCJ2ChlIVFRQUmVzcG9uc2VMb2dQYWdpbmF0aW9uEg0KBWxpbWl0GAEgASgFEg4KBm9mZnNldBgCIAEoBRIQCghoYXNfbW9yZRgDIAEoCBIYCgtuZXh0X29mZnNldBgEIAEoBUgAiAEBQg4KDF9uZXh0X29mZnNldCKpAQojTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVzcG9uc2USPAoEbG9ncxgBIAMoCzIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dMaXN0SXRlbRJECgpwYWdpbmF0aW9uGAIgASgLMjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ1BhZ2luYXRpb24iUAogR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESFwoGbG9nX2lkGAIgASgJQge6SARyAhABIl4KIUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2dSZXNwb25zZRI5CgNsb2cYASABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nRGV0YWlsKmEKCVRpbWVSYW5nZRIaChZUSU1FX1JBTkdFX1VOU1BFQ0lGSUVEEAASEQoNVElNRV9SQU5HRV8xRBABEhEKDVRJTUVfUkFOR0VfN0QQAhISCg5USU1FX1JBTkdFXzE0RBADKtkBChxIVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEjAKLEhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX1VOU1BFQ0lGSUVEEAASLAooSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfU1VDQ0VTUxABEioKJkhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX0VSUk9SEAISLQopSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfREVHUkFERUQQAyqKAQoWSFRUUFJlc3BvbnNlTG9nVHJpZ2dlchIpCiVIVFRQX1JFU1BPTlNFX0xPR19UUklHR0VSX1VOU1BFQ0lGSUVEEAASIgoeSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9DUk9OEAESIQodSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9BUEkQAjL7FwoOTW9uaXRvclNlcnZpY2USuQMKEUNyZWF0ZUhUVFBNb25pdG9yEi8ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBowLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVIVFRQTW9uaXRvclJlc3BvbnNlIsACuke8Ahq5AkNyZWF0ZXMgYSBuZXcgSFRUUCBtb25pdG9yIGluIHRoZSBhdXRoZW50aWNhdGVkIHdvcmtzcGFjZS4gQ29uZmlndXJlIHRoZSB0YXJnZXQgVVJMLCBIVFRQIG1ldGhvZCwgcmVxdWVzdCBoZWFkZXJzIGFuZCBib2R5LCByZXNwb25zZSBhc3NlcnRpb25zIChzdGF0dXMgY29kZSwgYm9keSBjb250ZW50LCBoZWFkZXJzKSwgY2hlY2sgcGVyaW9kaWNpdHksIGdlb2dyYXBoaWMgcmVnaW9ucywgYW5kIG9wdGlvbmFsIE9wZW5UZWxlbWV0cnkgZXhwb3J0LiBUaGUgbW9uaXRvciBzdGFydHMgY2hlY2tpbmcgaW1tZWRpYXRlbHkgaWYgc2V0IHRvIGFjdGl2ZS4ScwoQQ3JlYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQQ3JlYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRQ3JlYXRlSUNNUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuQ3JlYXRlSUNNUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUlDTVBNb25pdG9yUmVzcG9uc2USdgoRQ3JlYXRlR1JQQ01vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuQ3JlYXRlR1JQQ01vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUdSUENNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSFRUUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSFRUUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSUNNUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSUNNUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUlDTVBNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlR1JQQ01vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlR1JQQ01vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUdSUENNb25pdG9yUmVzcG9uc2US6QIKDlRyaWdnZXJNb25pdG9yEiwub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRyaWdnZXJNb25pdG9yUmVxdWVzdBotLm9wZW5zdGF0dXMubW9uaXRvci52MS5UcmlnZ2VyTW9uaXRvclJlc3BvbnNlIvkBukf1ARryAU1hbnVhbGx5IHRyaWdnZXJzIGFuIGltbWVkaWF0ZSBjaGVjayBmb3IgdGhlIHNwZWNpZmllZCBtb25pdG9yIGFjcm9zcyBhbGwgY29uZmlndXJlZCByZWdpb25zLiBUaGlzIG9wZXJhdGlvbiBpcyByYXRlLWxpbWl0ZWQgdW5kZXIgdGhlIHN5bnRoZXRpYy1jaGVja3MgcXVvdGEuIEEgbW9uaXRvciBydW4gcmVjb3JkIGlzIGNyZWF0ZWQgYW5kIHRoZSBjaGVjayBpcyBkaXNwYXRjaGVkIHRvIHRoZSBjaGVja2VyIHNlcnZpY2UuEmoKDURlbGV0ZU1vbml0b3ISKy5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlcXVlc3QaLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlc3BvbnNlEmwKDExpc3RNb25pdG9ycxIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvcnNSZXF1ZXN0Gisub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9yc1Jlc3BvbnNlIgOQAgESeAoQR2V0TW9uaXRvclN0YXR1cxIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVzcG9uc2UiA5ACARKmAwoRR2V0TW9uaXRvclN1bW1hcnkSLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkdldE1vbml0b3JTdW1tYXJ5UmVzcG9uc2UirQKQAgG6R6YCGqMCUmV0dXJucyBhZ2dyZWdhdGVkIG1ldHJpY3MgZm9yIGEgbW9uaXRvciBpbmNsdWRpbmcgbGF0ZW5jeSBwZXJjZW50aWxlcyAocDUwLCBwNzUsIHA5MCwgcDk1LCBwOTkpLCByZXF1ZXN0IGNvdW50cyBieSBzdGF0dXMgKHN1Y2Nlc3NmdWwsIGRlZ3JhZGVkLCBmYWlsZWQpLCBhbmQgdGhlIHRpbWVzdGFtcCBvZiB0aGUgbGFzdCBjaGVjay4gTWV0cmljcyBjYW4gYmUgc2NvcGVkIHRvIGEgdGltZSByYW5nZSAoMSBkYXksIDcgZGF5cywgb3IgMTQgZGF5cykgYW5kIGZpbHRlcmVkIGJ5IHNwZWNpZmljIHJlZ2lvbnMuEmYKCkdldE1vbml0b3ISKC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlcXVlc3QaKS5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlc3BvbnNlIgOQAgESmQEKG0xpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9ncxI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvckhUVFBSZXNwb25zZUxvZ3NSZXF1ZXN0Gjoub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9nc1Jlc3BvbnNlIgOQAgESkwEKGUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2cSNy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QaOC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1Jlc3BvbnNlIgOQAgFCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_dns_monitor, file_openstatus_monitor_v1_grpc_monitor, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_icmp_monitor, file_openstatus_monitor_v1_monitor, file_openstatus_monitor_v1_tcp_monitor]); /** * CreateHTTPMonitorRequest is the request to create a new HTTP monitor. @@ -192,6 +194,48 @@ export type CreateICMPMonitorResponse = Message<"openstatus.monitor.v1.CreateICM export const CreateICMPMonitorResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_openstatus_monitor_v1_service, 7); +/** + * CreateGRPCMonitorRequest is the request to create a new gRPC monitor. + * + * @generated from message openstatus.monitor.v1.CreateGRPCMonitorRequest + */ +export type CreateGRPCMonitorRequest = Message<"openstatus.monitor.v1.CreateGRPCMonitorRequest"> & { + /** + * Monitor configuration (required). + * + * @generated from field: openstatus.monitor.v1.GRPCMonitor monitor = 1; + */ + monitor?: GRPCMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.CreateGRPCMonitorRequest. + * Use `create(CreateGRPCMonitorRequestSchema)` to create a new message. + */ +export const CreateGRPCMonitorRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 8); + +/** + * CreateGRPCMonitorResponse is the response after creating a gRPC monitor. + * + * @generated from message openstatus.monitor.v1.CreateGRPCMonitorResponse + */ +export type CreateGRPCMonitorResponse = Message<"openstatus.monitor.v1.CreateGRPCMonitorResponse"> & { + /** + * The created monitor with assigned ID. + * + * @generated from field: openstatus.monitor.v1.GRPCMonitor monitor = 1; + */ + monitor?: GRPCMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.CreateGRPCMonitorResponse. + * Use `create(CreateGRPCMonitorResponseSchema)` to create a new message. + */ +export const CreateGRPCMonitorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 9); + /** * UpdateHTTPMonitorRequest is the request to update an existing HTTP monitor. * @@ -218,7 +262,7 @@ export type UpdateHTTPMonitorRequest = Message<"openstatus.monitor.v1.UpdateHTTP * Use `create(UpdateHTTPMonitorRequestSchema)` to create a new message. */ export const UpdateHTTPMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 8); + messageDesc(file_openstatus_monitor_v1_service, 10); /** * UpdateHTTPMonitorResponse is the response after updating an HTTP monitor. @@ -239,7 +283,7 @@ export type UpdateHTTPMonitorResponse = Message<"openstatus.monitor.v1.UpdateHTT * Use `create(UpdateHTTPMonitorResponseSchema)` to create a new message. */ export const UpdateHTTPMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 9); + messageDesc(file_openstatus_monitor_v1_service, 11); /** * UpdateTCPMonitorRequest is the request to update an existing TCP monitor. @@ -267,7 +311,7 @@ export type UpdateTCPMonitorRequest = Message<"openstatus.monitor.v1.UpdateTCPMo * Use `create(UpdateTCPMonitorRequestSchema)` to create a new message. */ export const UpdateTCPMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 10); + messageDesc(file_openstatus_monitor_v1_service, 12); /** * UpdateTCPMonitorResponse is the response after updating a TCP monitor. @@ -288,7 +332,7 @@ export type UpdateTCPMonitorResponse = Message<"openstatus.monitor.v1.UpdateTCPM * Use `create(UpdateTCPMonitorResponseSchema)` to create a new message. */ export const UpdateTCPMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 11); + messageDesc(file_openstatus_monitor_v1_service, 13); /** * UpdateDNSMonitorRequest is the request to update an existing DNS monitor. @@ -316,7 +360,7 @@ export type UpdateDNSMonitorRequest = Message<"openstatus.monitor.v1.UpdateDNSMo * Use `create(UpdateDNSMonitorRequestSchema)` to create a new message. */ export const UpdateDNSMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 12); + messageDesc(file_openstatus_monitor_v1_service, 14); /** * UpdateDNSMonitorResponse is the response after updating a DNS monitor. @@ -337,7 +381,7 @@ export type UpdateDNSMonitorResponse = Message<"openstatus.monitor.v1.UpdateDNSM * Use `create(UpdateDNSMonitorResponseSchema)` to create a new message. */ export const UpdateDNSMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 13); + messageDesc(file_openstatus_monitor_v1_service, 15); /** * UpdateICMPMonitorRequest is the request to update an existing ICMP monitor. @@ -365,7 +409,7 @@ export type UpdateICMPMonitorRequest = Message<"openstatus.monitor.v1.UpdateICMP * Use `create(UpdateICMPMonitorRequestSchema)` to create a new message. */ export const UpdateICMPMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 14); + messageDesc(file_openstatus_monitor_v1_service, 16); /** * UpdateICMPMonitorResponse is the response after updating an ICMP monitor. @@ -386,7 +430,56 @@ export type UpdateICMPMonitorResponse = Message<"openstatus.monitor.v1.UpdateICM * Use `create(UpdateICMPMonitorResponseSchema)` to create a new message. */ export const UpdateICMPMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 15); + messageDesc(file_openstatus_monitor_v1_service, 17); + +/** + * UpdateGRPCMonitorRequest is the request to update an existing gRPC monitor. + * + * @generated from message openstatus.monitor.v1.UpdateGRPCMonitorRequest + */ +export type UpdateGRPCMonitorRequest = Message<"openstatus.monitor.v1.UpdateGRPCMonitorRequest"> & { + /** + * Monitor ID to update (required). + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * Updated monitor configuration (all fields optional for partial updates). + * + * @generated from field: optional openstatus.monitor.v1.GRPCMonitor monitor = 2; + */ + monitor?: GRPCMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.UpdateGRPCMonitorRequest. + * Use `create(UpdateGRPCMonitorRequestSchema)` to create a new message. + */ +export const UpdateGRPCMonitorRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 18); + +/** + * UpdateGRPCMonitorResponse is the response after updating a gRPC monitor. + * + * @generated from message openstatus.monitor.v1.UpdateGRPCMonitorResponse + */ +export type UpdateGRPCMonitorResponse = Message<"openstatus.monitor.v1.UpdateGRPCMonitorResponse"> & { + /** + * The updated monitor. + * + * @generated from field: openstatus.monitor.v1.GRPCMonitor monitor = 1; + */ + monitor?: GRPCMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.UpdateGRPCMonitorResponse. + * Use `create(UpdateGRPCMonitorResponseSchema)` to create a new message. + */ +export const UpdateGRPCMonitorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 19); /** * TriggerMonitorRequest is the request to trigger a monitor check. @@ -407,7 +500,7 @@ export type TriggerMonitorRequest = Message<"openstatus.monitor.v1.TriggerMonito * Use `create(TriggerMonitorRequestSchema)` to create a new message. */ export const TriggerMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 16); + messageDesc(file_openstatus_monitor_v1_service, 20); /** * TriggerMonitorResponse is the response after triggering a monitor. @@ -428,7 +521,7 @@ export type TriggerMonitorResponse = Message<"openstatus.monitor.v1.TriggerMonit * Use `create(TriggerMonitorResponseSchema)` to create a new message. */ export const TriggerMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 17); + messageDesc(file_openstatus_monitor_v1_service, 21); /** * DeleteMonitorRequest is the request to delete a monitor. @@ -449,7 +542,7 @@ export type DeleteMonitorRequest = Message<"openstatus.monitor.v1.DeleteMonitorR * Use `create(DeleteMonitorRequestSchema)` to create a new message. */ export const DeleteMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 18); + messageDesc(file_openstatus_monitor_v1_service, 22); /** * DeleteMonitorResponse is the response after deleting a monitor. @@ -470,7 +563,7 @@ export type DeleteMonitorResponse = Message<"openstatus.monitor.v1.DeleteMonitor * Use `create(DeleteMonitorResponseSchema)` to create a new message. */ export const DeleteMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 19); + messageDesc(file_openstatus_monitor_v1_service, 23); /** * ListMonitorsRequest is the request to list monitors. @@ -498,7 +591,7 @@ export type ListMonitorsRequest = Message<"openstatus.monitor.v1.ListMonitorsReq * Use `create(ListMonitorsRequestSchema)` to create a new message. */ export const ListMonitorsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 20); + messageDesc(file_openstatus_monitor_v1_service, 24); /** * ListMonitorsResponse is the response containing a list of monitors. @@ -534,6 +627,13 @@ export type ListMonitorsResponse = Message<"openstatus.monitor.v1.ListMonitorsRe */ icmpMonitors: ICMPMonitor[]; + /** + * gRPC monitors in the workspace. + * + * @generated from field: repeated openstatus.monitor.v1.GRPCMonitor grpc_monitors = 6; + */ + grpcMonitors: GRPCMonitor[]; + /** * Total number of monitors across all types. * @@ -547,7 +647,7 @@ export type ListMonitorsResponse = Message<"openstatus.monitor.v1.ListMonitorsRe * Use `create(ListMonitorsResponseSchema)` to create a new message. */ export const ListMonitorsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 21); + messageDesc(file_openstatus_monitor_v1_service, 25); /** * GetMonitorStatusRequest is the request to get the status of all regions for a monitor. @@ -568,7 +668,7 @@ export type GetMonitorStatusRequest = Message<"openstatus.monitor.v1.GetMonitorS * Use `create(GetMonitorStatusRequestSchema)` to create a new message. */ export const GetMonitorStatusRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 22); + messageDesc(file_openstatus_monitor_v1_service, 26); /** * RegionStatus represents the status of a monitor in a specific region. @@ -596,7 +696,7 @@ export type RegionStatus = Message<"openstatus.monitor.v1.RegionStatus"> & { * Use `create(RegionStatusSchema)` to create a new message. */ export const RegionStatusSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 23); + messageDesc(file_openstatus_monitor_v1_service, 27); /** * GetMonitorStatusResponse is the response containing the status of all regions for a monitor. @@ -624,7 +724,7 @@ export type GetMonitorStatusResponse = Message<"openstatus.monitor.v1.GetMonitor * Use `create(GetMonitorStatusResponseSchema)` to create a new message. */ export const GetMonitorStatusResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 24); + messageDesc(file_openstatus_monitor_v1_service, 28); /** * MonitorConfig represents the type-specific configuration for a monitor. @@ -667,6 +767,14 @@ export type MonitorConfig = Message<"openstatus.monitor.v1.MonitorConfig"> & { */ value: ICMPMonitor; case: "icmp"; + } | { + /** + * gRPC monitor configuration. + * + * @generated from field: openstatus.monitor.v1.GRPCMonitor grpc = 5; + */ + value: GRPCMonitor; + case: "grpc"; } | { case: undefined; value?: undefined }; }; @@ -675,7 +783,7 @@ export type MonitorConfig = Message<"openstatus.monitor.v1.MonitorConfig"> & { * Use `create(MonitorConfigSchema)` to create a new message. */ export const MonitorConfigSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 25); + messageDesc(file_openstatus_monitor_v1_service, 29); /** * GetMonitorSummaryRequest is the request to get aggregated metrics for a monitor. @@ -710,7 +818,7 @@ export type GetMonitorSummaryRequest = Message<"openstatus.monitor.v1.GetMonitor * Use `create(GetMonitorSummaryRequestSchema)` to create a new message. */ export const GetMonitorSummaryRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 26); + messageDesc(file_openstatus_monitor_v1_service, 30); /** * GetMonitorSummaryResponse is the response containing aggregated metrics for a monitor. @@ -808,7 +916,7 @@ export type GetMonitorSummaryResponse = Message<"openstatus.monitor.v1.GetMonito * Use `create(GetMonitorSummaryResponseSchema)` to create a new message. */ export const GetMonitorSummaryResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 27); + messageDesc(file_openstatus_monitor_v1_service, 31); /** * GetMonitorRequest is the request to get a single monitor by ID. @@ -829,7 +937,7 @@ export type GetMonitorRequest = Message<"openstatus.monitor.v1.GetMonitorRequest * Use `create(GetMonitorRequestSchema)` to create a new message. */ export const GetMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 28); + messageDesc(file_openstatus_monitor_v1_service, 32); /** * GetMonitorResponse is the response containing the monitor. @@ -838,7 +946,7 @@ export const GetMonitorRequestSchema: GenMessage = /*@__PURE_ */ export type GetMonitorResponse = Message<"openstatus.monitor.v1.GetMonitorResponse"> & { /** - * The monitor configuration (one of HTTP, TCP, DNS, or ICMP). + * The monitor configuration (one of HTTP, TCP, DNS, ICMP, or gRPC). * * @generated from field: openstatus.monitor.v1.MonitorConfig monitor = 1; */ @@ -850,7 +958,7 @@ export type GetMonitorResponse = Message<"openstatus.monitor.v1.GetMonitorRespon * Use `create(GetMonitorResponseSchema)` to create a new message. */ export const GetMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 29); + messageDesc(file_openstatus_monitor_v1_service, 33); /** * HTTPResponseLogTiming contains calculated timing phases in milliseconds. @@ -899,7 +1007,7 @@ export type HTTPResponseLogTiming = Message<"openstatus.monitor.v1.HTTPResponseL * Use `create(HTTPResponseLogTimingSchema)` to create a new message. */ export const HTTPResponseLogTimingSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 30); + messageDesc(file_openstatus_monitor_v1_service, 34); /** * HTTPResponseLogListItem is a compact response log entry. @@ -983,7 +1091,7 @@ export type HTTPResponseLogListItem = Message<"openstatus.monitor.v1.HTTPRespons * Use `create(HTTPResponseLogListItemSchema)` to create a new message. */ export const HTTPResponseLogListItemSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 31); + messageDesc(file_openstatus_monitor_v1_service, 35); /** * HTTPResponseLogDetail contains full response log debugging data. @@ -1039,7 +1147,7 @@ export type HTTPResponseLogDetail = Message<"openstatus.monitor.v1.HTTPResponseL * Use `create(HTTPResponseLogDetailSchema)` to create a new message. */ export const HTTPResponseLogDetailSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 32); + messageDesc(file_openstatus_monitor_v1_service, 36); /** * ListMonitorHTTPResponseLogsRequest is the request to list response logs within the 14-day HTTP response-log window. @@ -1088,7 +1196,7 @@ export type ListMonitorHTTPResponseLogsRequest = Message<"openstatus.monitor.v1. * Use `create(ListMonitorHTTPResponseLogsRequestSchema)` to create a new message. */ export const ListMonitorHTTPResponseLogsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 33); + messageDesc(file_openstatus_monitor_v1_service, 37); /** * HTTPResponseLogPagination contains offset pagination metadata. @@ -1130,7 +1238,7 @@ export type HTTPResponseLogPagination = Message<"openstatus.monitor.v1.HTTPRespo * Use `create(HTTPResponseLogPaginationSchema)` to create a new message. */ export const HTTPResponseLogPaginationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 34); + messageDesc(file_openstatus_monitor_v1_service, 38); /** * ListMonitorHTTPResponseLogsResponse is the response containing response logs. @@ -1158,7 +1266,7 @@ export type ListMonitorHTTPResponseLogsResponse = Message<"openstatus.monitor.v1 * Use `create(ListMonitorHTTPResponseLogsResponseSchema)` to create a new message. */ export const ListMonitorHTTPResponseLogsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 35); + messageDesc(file_openstatus_monitor_v1_service, 39); /** * GetMonitorHTTPResponseLogRequest is the request to get one response log. @@ -1186,7 +1294,7 @@ export type GetMonitorHTTPResponseLogRequest = Message<"openstatus.monitor.v1.Ge * Use `create(GetMonitorHTTPResponseLogRequestSchema)` to create a new message. */ export const GetMonitorHTTPResponseLogRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 36); + messageDesc(file_openstatus_monitor_v1_service, 40); /** * GetMonitorHTTPResponseLogResponse is the response containing one response log. @@ -1207,7 +1315,7 @@ export type GetMonitorHTTPResponseLogResponse = Message<"openstatus.monitor.v1.G * Use `create(GetMonitorHTTPResponseLogResponseSchema)` to create a new message. */ export const GetMonitorHTTPResponseLogResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 37); + messageDesc(file_openstatus_monitor_v1_service, 41); /** * TimeRange represents the time period for metrics aggregation. @@ -1371,6 +1479,16 @@ export const MonitorService: GenService<{ input: typeof CreateICMPMonitorRequestSchema; output: typeof CreateICMPMonitorResponseSchema; }, + /** + * CreateGRPCMonitor creates a new gRPC health check monitor. + * + * @generated from rpc openstatus.monitor.v1.MonitorService.CreateGRPCMonitor + */ + createGRPCMonitor: { + methodKind: "unary"; + input: typeof CreateGRPCMonitorRequestSchema; + output: typeof CreateGRPCMonitorResponseSchema; + }, /** * UpdateHTTPMonitor updates an existing HTTP monitor. * @@ -1411,6 +1529,16 @@ export const MonitorService: GenService<{ input: typeof UpdateICMPMonitorRequestSchema; output: typeof UpdateICMPMonitorResponseSchema; }, + /** + * UpdateGRPCMonitor updates an existing gRPC monitor. + * + * @generated from rpc openstatus.monitor.v1.MonitorService.UpdateGRPCMonitor + */ + updateGRPCMonitor: { + methodKind: "unary"; + input: typeof UpdateGRPCMonitorRequestSchema; + output: typeof UpdateGRPCMonitorResponseSchema; + }, /** * TriggerMonitor initiates an immediate check for a monitor across all configured regions. * @@ -1463,7 +1591,7 @@ export const MonitorService: GenService<{ }, /** * GetMonitor returns a single monitor by ID within the authenticated workspace. - * Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + * Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. * * @generated from rpc openstatus.monitor.v1.MonitorService.GetMonitor */ diff --git a/packages/proto/internal/private_location/v1/grpc_monitor.proto b/packages/proto/internal/private_location/v1/grpc_monitor.proto new file mode 100644 index 00000000..0112e9cb --- /dev/null +++ b/packages/proto/internal/private_location/v1/grpc_monitor.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package private_location.v1; + +import "private_location/v1/otel.proto"; + +option go_package = "github.com/openstatushq/openstatus/packages/proto/private_location/v1;v1"; + + +message GRPCMonitor { + string id = 1; + string uri = 2; + int64 timeout = 3; + optional int64 degraded_at = 4; + string periodicity = 5; + int64 retry = 6; + string service = 7; + string tls_mode = 8; + + repeated Headers metadata = 10; + + OtelConfig otel_config = 20; + +} diff --git a/packages/proto/internal/private_location/v1/private_location.proto b/packages/proto/internal/private_location/v1/private_location.proto index b8633c7d..d4a55bff 100644 --- a/packages/proto/internal/private_location/v1/private_location.proto +++ b/packages/proto/internal/private_location/v1/private_location.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package private_location.v1; import "private_location/v1/dns_monitor.proto"; +import "private_location/v1/grpc_monitor.proto"; import "private_location/v1/http_monitor.proto"; import "private_location/v1/icmp_monitor.proto"; import "private_location/v1/tcp_monitor.proto"; @@ -16,6 +17,7 @@ service PrivateLocationService { rpc IngestHTTP(IngestHTTPRequest) returns (IngestHTTPResponse) {} rpc IngestDNS(IngestDNSRequest) returns (IngestDNSResponse) {} rpc IngestICMP(IngestICMPRequest) returns (IngestICMPResponse) {} + rpc IngestGRPC(IngestGRPCRequest) returns (IngestGRPCResponse) {} } @@ -26,6 +28,7 @@ message MonitorsResponse { repeated TCPMonitor tcp_monitors = 2; repeated DNSMonitor dns_monitors = 3; repeated ICMPMonitor icmp_monitors = 5; + repeated GRPCMonitor grpc_monitors = 6; string region = 4; } @@ -109,3 +112,23 @@ message IngestICMPRequest { message IngestICMPResponse { } + +message IngestGRPCRequest { + string id = 1; + string monitorId = 2; + int64 latency = 3; + int64 timestamp = 4; + int64 cronTimestamp = 5; + string uri = 6; + string service = 7; + string servingStatus = 8; + int64 grpcCode = 9; + string message = 10; + string requestStatus = 11; + int64 error = 12; + string timing = 13; +} + +message IngestGRPCResponse { + +} diff --git a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts index 5335f78e..52594603 100644 --- a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts +++ b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts @@ -77,12 +77,12 @@ function fullMonth( function makePipes(rows: ComputeCountRow[]): UptimeFreezePipes { const pipe = () => Promise.resolve({ data: rows }); - return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe }; + return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe, grpc: pipe }; } function failingPipes(): UptimeFreezePipes { const pipe = () => Promise.reject(new Error("tinybird down")); - return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe }; + return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe, grpc: pipe }; } type Tx = Parameters[0]>[0]; diff --git a/packages/services/src/frozen-uptime/__tests__/run.test.ts b/packages/services/src/frozen-uptime/__tests__/run.test.ts index 7c6a3df7..dcb7d486 100644 --- a/packages/services/src/frozen-uptime/__tests__/run.test.ts +++ b/packages/services/src/frozen-uptime/__tests__/run.test.ts @@ -33,6 +33,7 @@ function makePipes( tcp: overrides.tcp ?? fallback, dns: overrides.dns ?? fallback, icmp: overrides.icmp ?? fallback, + grpc: overrides.grpc ?? fallback, }; } diff --git a/packages/services/src/frozen-uptime/get-history.ts b/packages/services/src/frozen-uptime/get-history.ts index ec1e3fa5..2effc4a0 100644 --- a/packages/services/src/frozen-uptime/get-history.ts +++ b/packages/services/src/frozen-uptime/get-history.ts @@ -242,6 +242,7 @@ export async function getUptimeHistory(args: { tcp: defaultTb.tcpStatus45d, dns: defaultTb.dnsStatus45d, icmp: defaultTb.icmpStatus45d, + grpc: defaultTb.grpcStatus45d, }; return fetchFreezeCounts({ monitorIdsByJobType, diff --git a/packages/services/src/frozen-uptime/run.ts b/packages/services/src/frozen-uptime/run.ts index ba66b6a1..fb133030 100644 --- a/packages/services/src/frozen-uptime/run.ts +++ b/packages/services/src/frozen-uptime/run.ts @@ -22,7 +22,7 @@ export type StatusPipeFn = (params: { // only these job types have a 45d status pipe; others (udp/ssl) have no // counts on the live status page either and are skipped export type UptimeFreezePipes = Record< - "http" | "tcp" | "dns" | "icmp", + "http" | "tcp" | "dns" | "icmp" | "grpc", StatusPipeFn >; @@ -60,7 +60,8 @@ function hasStatusPipe( jobType === "http" || jobType === "tcp" || jobType === "dns" || - jobType === "icmp" + jobType === "icmp" || + jobType === "grpc" ); } diff --git a/packages/services/src/import/phase-writers.ts b/packages/services/src/import/phase-writers.ts index 8bdc6655..5a33ec01 100644 --- a/packages/services/src/import/phase-writers.ts +++ b/packages/services/src/import/phase-writers.ts @@ -754,6 +754,7 @@ export async function writeMonitorsPhase( | "http" | "tcp" | "icmp" + | "grpc" | "udp" | "dns" | "ssl", diff --git a/packages/services/src/monitor/__tests__/monitor.test.ts b/packages/services/src/monitor/__tests__/monitor.test.ts index fb654dfc..25fc75c6 100644 --- a/packages/services/src/monitor/__tests__/monitor.test.ts +++ b/packages/services/src/monitor/__tests__/monitor.test.ts @@ -705,6 +705,82 @@ describe("updateMonitorGeneral", () => { }); }); }); + + test("persists the gRPC service / TLS mode", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await createMonitor({ + ctx, + input: { + name: `${TEST_PREFIX}-gen-grpc`, + jobType: "grpc", + url: "api.example.com:443", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + expect(row.grpcService).toBe(null); + expect(row.grpcTls).toBe("tls"); + + const updated = await updateMonitorGeneral({ + ctx, + input: { + id: row.id, + name: `${TEST_PREFIX}-gen-grpc`, + jobType: "grpc", + url: "api.example.com:443", + method: "GET", + headers: [], + assertions: [], + active: true, + grpcService: "checkout.v1.CheckoutService", + grpcTls: "plaintext", + }, + }); + + expect(updated.grpcService).toBe("checkout.v1.CheckoutService"); + expect(updated.grpcTls).toBe("plaintext"); + }); + }); + + test("omitting the gRPC fields leaves them untouched", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await createMonitor({ + ctx, + input: { + name: `${TEST_PREFIX}-gen-grpc-keep`, + jobType: "grpc", + url: "api.example.com:443", + method: "GET", + headers: [], + assertions: [], + active: false, + grpcService: "checkout.v1.CheckoutService", + grpcTls: "plaintext", + }, + }); + + const updated = await updateMonitorGeneral({ + ctx, + input: { + id: row.id, + name: `${TEST_PREFIX}-gen-grpc-keep-2`, + jobType: "grpc", + url: "api.example.com:443", + method: "GET", + headers: [], + assertions: [], + active: true, + }, + }); + + expect(updated.grpcService).toBe("checkout.v1.CheckoutService"); + expect(updated.grpcTls).toBe("plaintext"); + }); + }); }); describe("bulkUpdateMonitors", () => { diff --git a/packages/services/src/monitor/create.ts b/packages/services/src/monitor/create.ts index c1bd5aca..8bc94be7 100644 --- a/packages/services/src/monitor/create.ts +++ b/packages/services/src/monitor/create.ts @@ -54,6 +54,8 @@ export async function createMonitor(args: { degradedAfter: input.degradedAfter, retry: input.retry, followRedirects: input.followRedirects, + grpcService: input.grpcService, + grpcTls: input.grpcTls, otelEndpoint: input.otelEndpoint, otelHeaders: headersToDbJson(input.otelHeaders), updatedAt: new Date(), diff --git a/packages/services/src/monitor/get-daily-summary.ts b/packages/services/src/monitor/get-daily-summary.ts index 00e5eefb..5719b11b 100644 --- a/packages/services/src/monitor/get-daily-summary.ts +++ b/packages/services/src/monitor/get-daily-summary.ts @@ -5,7 +5,7 @@ import type { OSTinybird } from "@openstatus/tinybird"; import type { DB } from "../context"; import type { StatusData } from "../status-timeline"; -type SupportedJobType = "http" | "tcp" | "dns" | "icmp"; +type SupportedJobType = "http" | "tcp" | "dns" | "icmp" | "grpc"; /** * Raw daily status buckets (one row per monitor per day) from the 45d Tinybird @@ -39,20 +39,22 @@ export async function fetchMonitorDailyStats(args: { tcp: [], dns: [], icmp: [], + grpc: [], }; for (const row of rows) { if ( row.jobType === "http" || row.jobType === "tcp" || row.jobType === "dns" || - row.jobType === "icmp" + row.jobType === "icmp" || + row.jobType === "grpc" ) { idsByJobType[row.jobType].push(String(row.id)); } } const results = await Promise.all( - (["http", "tcp", "dns", "icmp"] as const) + (["http", "tcp", "dns", "icmp", "grpc"] as const) .filter((jobType) => idsByJobType[jobType].length > 0) .map((jobType) => { const monitorIds = idsByJobType[jobType]; @@ -63,7 +65,9 @@ export async function fetchMonitorDailyStats(args: { ? args.tb.tcpStatus45d : jobType === "dns" ? args.tb.dnsStatus45d - : args.tb.icmpStatus45d; + : jobType === "icmp" + ? args.tb.icmpStatus45d + : args.tb.grpcStatus45d; return pipe({ monitorIds }); }), ); diff --git a/packages/services/src/monitor/get-monitor-summary.ts b/packages/services/src/monitor/get-monitor-summary.ts index 5345242d..1c43f914 100644 --- a/packages/services/src/monitor/get-monitor-summary.ts +++ b/packages/services/src/monitor/get-monitor-summary.ts @@ -34,7 +34,7 @@ type MetricsRow = { lastTimestamp: number | null; }; -type SupportedJobType = "http" | "tcp" | "dns" | "icmp"; +type SupportedJobType = "http" | "tcp" | "dns" | "icmp" | "grpc"; function fetchMetrics( tb: NonNullable, @@ -63,6 +63,11 @@ function fetchMetrics( "7d": tb.icmpMetricsWeekly, "14d": tb.icmpMetricsBiweekly, }, + grpc: { + "1d": tb.grpcMetricsDaily, + "7d": tb.grpcMetricsWeekly, + "14d": tb.grpcMetricsBiweekly, + }, }[jobType][timeRange]; return pipe(params); } @@ -86,7 +91,8 @@ export async function getMonitorSummary(args: { parsed.jobType !== "http" && parsed.jobType !== "tcp" && parsed.jobType !== "dns" && - parsed.jobType !== "icmp" + parsed.jobType !== "icmp" && + parsed.jobType !== "grpc" ) { throw new ValidationError( `getMonitorSummary does not support jobType '${parsed.jobType}'`, diff --git a/packages/services/src/monitor/index.ts b/packages/services/src/monitor/index.ts index 87ce2d9e..17f2ecd5 100644 --- a/packages/services/src/monitor/index.ts +++ b/packages/services/src/monitor/index.ts @@ -62,6 +62,7 @@ export { GetMonitorSummaryInput, GetPrivateLocationIdsByMonitorInput, GetResponseLogInput, + grpcTlsModes, ListMonitorsInput, ListResponseLogsInput, monitorJobTypes, diff --git a/packages/services/src/monitor/schemas.ts b/packages/services/src/monitor/schemas.ts index 0ccf5eb1..f6f653d5 100644 --- a/packages/services/src/monitor/schemas.ts +++ b/packages/services/src/monitor/schemas.ts @@ -7,12 +7,13 @@ import { } from "@openstatus/assertions"; import { monitorPeriodicity } from "@openstatus/db/src/schema/constants"; import { + grpcTlsModes, monitorJobTypes, monitorMethods, } from "@openstatus/db/src/schema/monitors/constants"; import { z } from "zod"; -export { monitorJobTypes, monitorMethods, monitorPeriodicity }; +export { grpcTlsModes, monitorJobTypes, monitorMethods, monitorPeriodicity }; const headerPair = z.object({ key: z.string(), value: z.string() }); const assertion = z.discriminatedUnion("type", [ @@ -56,6 +57,8 @@ export const CreateMonitorInput = z.object({ degradedAfter: apiTimeoutMs.nullish(), retry: z.number().int().min(0).optional(), followRedirects: z.boolean().optional(), + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional(), otelEndpoint: z.string().optional(), otelHeaders: z.array(headerPair).optional(), }); @@ -87,6 +90,8 @@ export const UpdateMonitorConfigInput = z.object({ degradedAfter: apiTimeoutMs.nullish(), retry: z.number().int().min(0).optional(), followRedirects: z.boolean().optional(), + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional(), otelEndpoint: z.string().optional(), otelHeaders: z.array(headerPair).optional(), }); @@ -103,6 +108,10 @@ export const UpdateMonitorGeneralInput = z.object({ body: z.string().optional(), assertions: z.array(assertion).default([]), active: z.boolean().default(true), + // gRPC-only fields. `undefined` leaves the stored value untouched so a + // caller that omits them (e.g. an HTTP monitor) never clears the column. + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional(), }); export type UpdateMonitorGeneralInput = z.infer< typeof UpdateMonitorGeneralInput diff --git a/packages/services/src/monitor/update.ts b/packages/services/src/monitor/update.ts index c4e8c916..a66e5ead 100644 --- a/packages/services/src/monitor/update.ts +++ b/packages/services/src/monitor/update.ts @@ -70,6 +70,8 @@ export async function updateMonitorConfig(args: { if (input.followRedirects !== undefined) { values.followRedirects = input.followRedirects; } + if (input.grpcService !== undefined) values.grpcService = input.grpcService; + if (input.grpcTls !== undefined) values.grpcTls = input.grpcTls; if (input.otelEndpoint !== undefined) { values.otelEndpoint = input.otelEndpoint; } @@ -129,6 +131,10 @@ export async function updateMonitorGeneral(args: { body: input.body, active: input.active, assertions: serialiseAssertions(input.assertions), + ...(input.grpcService !== undefined + ? { grpcService: input.grpcService } + : {}), + ...(input.grpcTls !== undefined ? { grpcTls: input.grpcTls } : {}), updatedAt: new Date(), }) .where(eq(monitor.id, existing.id)) diff --git a/packages/tinybird/datasources/check_grpc_response__v0.datasource b/packages/tinybird/datasources/check_grpc_response__v0.datasource new file mode 100644 index 00000000..9989d0b8 --- /dev/null +++ b/packages/tinybird/datasources/check_grpc_response__v0.datasource @@ -0,0 +1,20 @@ +SCHEMA > + `monitorId` Int32 `json:$.monitorId`, + `region` String `json:$.region`, + `timestamp` Int64 `json:$.timestamp`, + `cronTimestamp` Int64 `json:$.cronTimestamp`, + `timing` String `json:$.timing`, + `latency` Int64 `json:$.latency`, + `servingStatus` Nullable(String) `json:$.servingStatus`, + `grpcCode` Nullable(Int16) `json:$.grpcCode`, + `service` Nullable(String) `json:$.service`, + `errorMessage` Nullable(String) `json:$.errorMessage`, + `error` Int16 `json:$.error`, + `trigger` Nullable(String) `json:$.trigger`, + `uri` Nullable(String) `json:$.uri`, + `id` Nullable(String) `json:$.id`, + `requestStatus` Nullable(String) `json:$.requestStatus`, + `requestId` Int64 `json:$.requestId` + +ENGINE "MergeTree" +ENGINE_SORTING_KEY "monitorId, requestId, timestamp" diff --git a/packages/tinybird/datasources/grpc_response__v0.datasource b/packages/tinybird/datasources/grpc_response__v0.datasource new file mode 100644 index 00000000..43a694f1 --- /dev/null +++ b/packages/tinybird/datasources/grpc_response__v0.datasource @@ -0,0 +1,21 @@ +SCHEMA > + `monitorId` Int32 `json:$.monitorId`, + `region` String `json:$.region`, + `timestamp` Int64 `json:$.timestamp`, + `cronTimestamp` Int64 `json:$.cronTimestamp`, + `timing` String `json:$.timing`, + `workspaceId` Int32 `json:$.workspaceId`, + `latency` Int64 `json:$.latency`, + `servingStatus` Nullable(String) `json:$.servingStatus`, + `grpcCode` Nullable(Int16) `json:$.grpcCode`, + `service` Nullable(String) `json:$.service`, + `errorMessage` Nullable(String) `json:$.errorMessage`, + `error` Int16 `json:$.error`, + `trigger` Nullable(String) `json:$.trigger`, + `uri` Nullable(String) `json:$.uri`, + `id` Nullable(String) `json:$.id`, + `requestStatus` Nullable(String) `json:$.requestStatus` + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(fromUnixTimestamp64Milli(cronTimestamp))" +ENGINE_SORTING_KEY "monitorId, cronTimestamp" diff --git a/packages/tinybird/datasources/mv__grpc_14d__v0.datasource b/packages/tinybird/datasources/mv__grpc_14d__v0.datasource new file mode 100644 index 00000000..3e27e66e --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_14d__v0.datasource @@ -0,0 +1,21 @@ +# Data Source created from Pipe 'aggregate__grpc_14d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(14)" diff --git a/packages/tinybird/datasources/mv__grpc_1d__v0.datasource b/packages/tinybird/datasources/mv__grpc_1d__v0.datasource new file mode 100644 index 00000000..2e3ef49d --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_1d__v0.datasource @@ -0,0 +1,21 @@ +# Data Source created from Pipe 'aggregate__grpc_1d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(1)" diff --git a/packages/tinybird/datasources/mv__grpc_30d__v0.datasource b/packages/tinybird/datasources/mv__grpc_30d__v0.datasource new file mode 100644 index 00000000..f6f73666 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_30d__v0.datasource @@ -0,0 +1,21 @@ +# Data Source created from Pipe 'aggregate__grpc_30d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/datasources/mv__grpc_7d__v0.datasource b/packages/tinybird/datasources/mv__grpc_7d__v0.datasource new file mode 100644 index 00000000..f7250815 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_7d__v0.datasource @@ -0,0 +1,21 @@ +# Data Source created from Pipe 'aggregate__grpc_7d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(7)" diff --git a/packages/tinybird/datasources/mv__grpc_90d__v0.datasource b/packages/tinybird/datasources/mv__grpc_90d__v0.datasource new file mode 100644 index 00000000..e4b59871 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_90d__v0.datasource @@ -0,0 +1,21 @@ +# Data Source created from Pipe 'aggregate__grpc_90d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(90)" diff --git a/packages/tinybird/datasources/mv__grpc_full_14d__v0.datasource b/packages/tinybird/datasources/mv__grpc_full_14d__v0.datasource new file mode 100644 index 00000000..b1e350e7 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_full_14d__v0.datasource @@ -0,0 +1,25 @@ +# Data Source created from Pipe 'aggregate__grpc_full_14d__v0' + +SCHEMA > + `time` DateTime, + `monitorId` Int32, + `region` String, + `timestamp` Int64, + `cronTimestamp` Int64, + `timing` String, + `workspaceId` Int32, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `errorMessage` Nullable(String), + `error` Int16, + `trigger` Nullable(String), + `uri` Nullable(String), + `id` Nullable(String), + `requestStatus` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(14)" diff --git a/packages/tinybird/datasources/mv__grpc_full_30d__v0.datasource b/packages/tinybird/datasources/mv__grpc_full_30d__v0.datasource new file mode 100644 index 00000000..2d5c0d86 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_full_30d__v0.datasource @@ -0,0 +1,25 @@ +# Data Source created from Pipe 'aggregate__grpc_full_30d__v0' + +SCHEMA > + `time` DateTime, + `monitorId` Int32, + `region` String, + `timestamp` Int64, + `cronTimestamp` Int64, + `timing` String, + `workspaceId` Int32, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `errorMessage` Nullable(String), + `error` Int16, + `trigger` Nullable(String), + `uri` Nullable(String), + `id` Nullable(String), + `requestStatus` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/datasources/mv__grpc_status_45d__v0.datasource b/packages/tinybird/datasources/mv__grpc_status_45d__v0.datasource new file mode 100644 index 00000000..62eeec90 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_status_45d__v0.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__grpc_status_45d__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(46)" diff --git a/packages/tinybird/datasources/mv__grpc_status_7d__v0.datasource b/packages/tinybird/datasources/mv__grpc_status_7d__v0.datasource new file mode 100644 index 00000000..b64b3c6e --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_status_7d__v0.datasource @@ -0,0 +1,12 @@ +# Data Source created from Pipe 'aggregate__grpc_status_7d__v0' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` Int32, + `count` AggregateFunction(count), + `ok` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(7)" diff --git a/packages/tinybird/datasources/mv__grpc_uptime_30d__v0.datasource b/packages/tinybird/datasources/mv__grpc_uptime_30d__v0.datasource new file mode 100644 index 00000000..ca2ee107 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_uptime_30d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__grpc_uptime_30d__v0' + +SCHEMA > + `time` DateTime, + `region` String, + `requestStatus` Nullable(String), + `monitorId` Int32, + `workspaceId` Int32 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/datasources/mv__grpc_uptime_7d__v0.datasource b/packages/tinybird/datasources/mv__grpc_uptime_7d__v0.datasource new file mode 100644 index 00000000..56f45b8d --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_uptime_7d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__grpc_uptime_7d__v0' + +SCHEMA > + `time` DateTime, + `region` String, + `requestStatus` Nullable(String), + `monitorId` Int32, + `workspaceId` Int32 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(7)" diff --git a/packages/tinybird/datasources/mv__grpc_uptime_90d__v0.datasource b/packages/tinybird/datasources/mv__grpc_uptime_90d__v0.datasource new file mode 100644 index 00000000..500fae25 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_uptime_90d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__grpc_uptime_90d__v0' + +SCHEMA > + `time` DateTime, + `region` String, + `requestStatus` Nullable(String), + `monitorId` Int32, + `workspaceId` Int32 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(90)" diff --git a/packages/tinybird/datasources/mv__grpc_workspace_30d__v0.datasource b/packages/tinybird/datasources/mv__grpc_workspace_30d__v0.datasource new file mode 100644 index 00000000..99593ab0 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_workspace_30d__v0.datasource @@ -0,0 +1,12 @@ +# Data Source created from Pipe 'aggregate__grpc_workspace_30d__v0' + +SCHEMA > + `time` DateTime('UTC'), + `workspaceId` Int32, + `trigger` String, + `count_state` AggregateFunction(count) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "workspaceId, time, trigger" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/endpoints/endpoint__grpc_get_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_get_14d__v0.pipe new file mode 100644 index 00000000..5514d081 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_get_14d__v0.pipe @@ -0,0 +1,15 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT * + FROM mv__grpc_full_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND id = {{ String(id, '', required=True) }} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_get_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_get_30d__v0.pipe new file mode 100644 index 00000000..d1f3687f --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_get_30d__v0.pipe @@ -0,0 +1,15 @@ +TAGS "grpc" + +NODE endpoint +SQL > + +% + SELECT * + FROM mv__grpc_full_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND cronTimestamp = {{ Int64(cronTimestamp, 1709477432205, required=True) }} + AND region = {{ String(region, 'ams', required=True) }} + ORDER BY cronTimestamp DESC + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__grpc_list_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_list_14d__v0.pipe new file mode 100644 index 00000000..9925bb64 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_list_14d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__grpc_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_list_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_list_1d__v0.pipe new file mode 100644 index 00000000..7ef93d74 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_list_1d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__grpc_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_list_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_list_7d__v0.pipe new file mode 100644 index 00000000..7587e512 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_list_7d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__grpc_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_14d__v0.pipe new file mode 100644 index 00000000..63d8204a --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_14d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__grpc_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) AS p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) AS p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) AS p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) AS p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__grpc_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 28 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 14 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_1d__v0.pipe new file mode 100644 index 00000000..2abd6112 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_1d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__grpc_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 1 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) AS p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) AS p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) AS p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) AS p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__grpc_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 2 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 1 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_30d__v0.pipe new file mode 100644 index 00000000..d6aac75e --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_30d__v0.pipe @@ -0,0 +1,42 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__grpc_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 30 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) AS p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) AS p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) AS p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) AS p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp + FROM mv__grpc_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 60 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 30 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_7d__v0.pipe new file mode 100644 index 00000000..7e4a97f8 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_7d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__grpc_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 7 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) AS p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) AS p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) AS p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) AS p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__grpc_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 7 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_90d__v0.pipe new file mode 100644 index 00000000..00df748e --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_90d__v0.pipe @@ -0,0 +1,39 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__grpc_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 90 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + -- the previous 90d window (90-180d ago) is past the 90d MV TTL, so there's no + -- real comparison data. emit an empty row to keep the 2-row contract; count 0 + -- makes the client suppress the trend badge (NaN). + SELECT + 0 AS p50Latency, + 0 AS p75Latency, + 0 AS p90Latency, + 0 AS p95Latency, + 0 AS p99Latency, + 0 AS count, + 0 AS success, + 0 AS degraded, + 0 AS error, + NULL AS lastTimestamp + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe new file mode 100644 index 00000000..1279b786 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe @@ -0,0 +1,32 @@ +VERSION 0 + +TAGS grpc + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), + INTERVAL {{ Int64(interval, 30) }} MINUTE -- use 2880 (2d) in case you want the 1d summary for the regions + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__grpc_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + -- A transport failure resets latency to 0; including those rows drags + -- every quantile toward zero. Only completed RPCs carry a servingStatus. + AND servingStatus IS NOT NULL + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe new file mode 100644 index 00000000..d27424ec --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe @@ -0,0 +1,32 @@ +VERSION 0 + +TAGS grpc + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), + INTERVAL {{ Int64(interval, 30) }} MINUTE -- use 2880 (2d) in case you want the 1d summary for the regions + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__grpc_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + -- A transport failure resets latency to 0; including those rows drags + -- every quantile toward zero. Only completed RPCs carry a servingStatus. + AND servingStatus IS NOT NULL + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe new file mode 100644 index 00000000..2d10ebcd --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe @@ -0,0 +1,30 @@ +VERSION 0 + +TAGS grpc + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__grpc_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + -- A transport failure resets latency to 0; including those rows drags + -- every quantile toward zero. Only completed RPCs carry a servingStatus. + AND servingStatus IS NOT NULL + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY h, region + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe new file mode 100644 index 00000000..b5221535 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe @@ -0,0 +1,32 @@ +VERSION 0 + +TAGS grpc + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), + INTERVAL {{ Int64(interval, 30) }} MINUTE -- use 2880 (2d) in case you want the 1d summary for the regions + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__grpc_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + -- A transport failure resets latency to 0; including those rows drags + -- every quantile toward zero. Only completed RPCs carry a servingStatus. + AND servingStatus IS NOT NULL + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe new file mode 100644 index 00000000..77ffa31e --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe @@ -0,0 +1,30 @@ +VERSION 0 + +TAGS grpc + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__grpc_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + -- A transport failure resets latency to 0; including those rows drags + -- every quantile toward zero. Only completed RPCs carry a servingStatus. + AND servingStatus IS NOT NULL + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY h, region + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_14d__v0.pipe new file mode 100644 index 00000000..6b8aed5d --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_14d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "grpc" + +NODE endpoint +SQL > + +% + SELECT + region, + round(quantileIf(0.5)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.9)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) as p99Latency, + count() as count, + count(if(error = 0, 1, NULL)) AS ok + FROM mv__grpc_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY region + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_1d__v0.pipe new file mode 100644 index 00000000..39fde077 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_1d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "grpc" + +NODE endpoint +SQL > + +% + SELECT + region, + round(quantileIf(0.5)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.9)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) as p99Latency, + count() as count, + count(if(error = 0, 1, NULL)) AS ok + FROM mv__grpc_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY region + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_7d__v0.pipe new file mode 100644 index 00000000..8b123742 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_7d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "grpc" + +NODE endpoint +SQL > + +% + SELECT + region, + round(quantileIf(0.5)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.9)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) as p99Latency, + count() as count, + count(if(error = 0, 1, NULL)) AS ok + FROM mv__grpc_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY region + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe new file mode 100644 index 00000000..b12112fe --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe @@ -0,0 +1,25 @@ +VERSION 0 + +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(minIf(latency, servingStatus IS NOT NULL), 0) as minLatency, + round(maxIf(latency, servingStatus IS NOT NULL), 0) as maxLatency, + round(quantileIf(0.5)(latency, servingStatus IS NOT NULL), 0) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL), 0) as p75Latency, + round(quantileIf(0.9)(latency, servingStatus IS NOT NULL), 0) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL), 0) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL), 0) as p99Latency, + max(cronTimestamp) as lastTimestamp, + count() as count, + monitorId + FROM mv__grpc_1d__v0 + WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY monitorId + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d__v0.pipe new file mode 100644 index 00000000..1a37d8ed --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d__v0.pipe @@ -0,0 +1,25 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__grpc_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND servingStatus IS NOT NULL + GROUP BY h + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d_multi__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d_multi__v0.pipe new file mode 100644 index 00000000..fa2f6bbd --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d_multi__v0.pipe @@ -0,0 +1,26 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + monitorId, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__grpc_1d__v0 + WHERE + monitorId IN {{ Array(monitorIds, 'String', '4433') }} + AND servingStatus IS NOT NULL + GROUP BY h, monitorId + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_30d__v0.pipe new file mode 100644 index 00000000..aa158359 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_30d__v0.pipe @@ -0,0 +1,26 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__grpc_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND servingStatus IS NOT NULL + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + GROUP BY h + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_7d__v0.pipe new file mode 100644 index 00000000..35a051de --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_7d__v0.pipe @@ -0,0 +1,25 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__grpc_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND servingStatus IS NOT NULL + GROUP BY h + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_90d__v0.pipe new file mode 100644 index 00000000..59015abe --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_90d__v0.pipe @@ -0,0 +1,26 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__grpc_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND servingStatus IS NOT NULL + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + GROUP BY h + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_status_45d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_status_45d__v0.pipe new file mode 100644 index 00000000..543cc6d4 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_status_45d__v0.pipe @@ -0,0 +1,20 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + time as day, + monitorId, + countMerge(count) as count, + countMerge(success) as ok, + countMerge(error) as error, + countMerge(degraded) as degraded + FROM mv__grpc_status_45d__v0 + WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY day, monitorId + ORDER BY day DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_status_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_status_7d__v0.pipe new file mode 100644 index 00000000..2a6c04ac --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_status_7d__v0.pipe @@ -0,0 +1,20 @@ +TAGS "grpc" + +NODE endpoint +SQL > + +% + SELECT time as day, countMerge(count) as count, countMerge(ok) as ok + FROM mv__grpc_status_7d__v0 + WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + GROUP BY day + ORDER BY day DESC + WITH FILL + FROM + toStartOfDay(toStartOfDay(toTimeZone(now(), 'UTC'))) + TO toStartOfDay( + date_sub(DAY, 7, now()) + ) STEP INTERVAL -1 DAY + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__grpc_uptime_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_uptime_30d__v0.pipe new file mode 100644 index 00000000..b1a2bf65 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_uptime_30d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error + FROM mv__grpc_uptime_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY interval + ORDER BY interval DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_uptime_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_uptime_7d__v0.pipe new file mode 100644 index 00000000..ee3d5507 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_uptime_7d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error + FROM mv__grpc_uptime_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY interval + ORDER BY interval DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_uptime_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_uptime_90d__v0.pipe new file mode 100644 index 00000000..3663451f --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_uptime_90d__v0.pipe @@ -0,0 +1,21 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '1440', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error + FROM mv__grpc_uptime_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY interval + ORDER BY interval DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_workspace_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_workspace_30d__v0.pipe new file mode 100644 index 00000000..f1a63a2d --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_workspace_30d__v0.pipe @@ -0,0 +1,16 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + time as day, + countMerge(count_state) as count + FROM mv__grpc_workspace_30d__v0 + WHERE workspaceId = {{ Int32(workspaceId, 1, required=True) }} + GROUP BY day + ORDER BY day DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/materializations/aggregate__grpc_full_30d__v0.pipe b/packages/tinybird/materializations/aggregate__grpc_full_30d__v0.pipe new file mode 100644 index 00000000..5add6c93 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__grpc_full_30d__v0.pipe @@ -0,0 +1,31 @@ +DESCRIPTION > + Stores all the data from the grpc_response table for the last 30 days, mainly used for accessing the data details. + + +TAGS "grpc, full" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + monitorId, + region, + timestamp, + cronTimestamp, + timing, + workspaceId, + latency, + servingStatus, + grpcCode, + service, + errorMessage, + error, + trigger, + uri, + id, + requestStatus + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_full_30d__v0 diff --git a/packages/tinybird/materializations/aggregate__grpc_status_7d__v0.pipe b/packages/tinybird/materializations/aggregate__grpc_status_7d__v0.pipe new file mode 100644 index 00000000..380aae7f --- /dev/null +++ b/packages/tinybird/materializations/aggregate__grpc_status_7d__v0.pipe @@ -0,0 +1,17 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(error = 0, 1, NULL)) AS ok + FROM grpc_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__grpc_status_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_14d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_14d__v0.pipe new file mode 100644 index 00000000..03065f20 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_14d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + servingStatus, + grpcCode, + service, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_14d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_1d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_1d__v0.pipe new file mode 100644 index 00000000..d592fa9b --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_1d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + servingStatus, + grpcCode, + service, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_1d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_30d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_30d__v0.pipe new file mode 100644 index 00000000..ac3feb88 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_30d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + servingStatus, + grpcCode, + service, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_30d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_7d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_7d__v0.pipe new file mode 100644 index 00000000..e376511c --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_7d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + servingStatus, + grpcCode, + service, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_90d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_90d__v0.pipe new file mode 100644 index 00000000..b661e6d7 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_90d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + servingStatus, + grpcCode, + service, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_90d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_full_14d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_full_14d__v0.pipe new file mode 100644 index 00000000..a6eda35b --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_full_14d__v0.pipe @@ -0,0 +1,31 @@ +DESCRIPTION > + Stores all the data from the grpc_response table, mainly used for accessing the data details. + + +TAGS "grpc, full" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + monitorId, + region, + timestamp, + cronTimestamp, + timing, + workspaceId, + latency, + servingStatus, + grpcCode, + service, + errorMessage, + error, + trigger, + uri, + id, + requestStatus + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_full_14d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_status_45d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_status_45d__v0.pipe new file mode 100644 index 00000000..4d62f91e --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_status_45d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "grpc, statuspage" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(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_45d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_uptime_30d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_uptime_30d__v0.pipe new file mode 100644 index 00000000..04f664eb --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_uptime_30d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_uptime_30d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_uptime_7d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_uptime_7d__v0.pipe new file mode 100644 index 00000000..e8eebbe5 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_uptime_7d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_uptime_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_uptime_90d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_uptime_90d__v0.pipe new file mode 100644 index 00000000..be85cb91 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_uptime_90d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_uptime_90d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_workspace_30d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_workspace_30d__v0.pipe new file mode 100644 index 00000000..e629d712 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_workspace_30d__v0.pipe @@ -0,0 +1,18 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + workspaceId, + ifNull(trigger, 'cron') AS trigger, + countState() AS count_state + FROM grpc_response__v0 + GROUP BY + time, + workspaceId, + trigger + +TYPE materialized +DATASOURCE mv__grpc_workspace_30d__v0 diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts index a6f43412..d1487969 100644 --- a/packages/tinybird/src/client.ts +++ b/packages/tinybird/src/client.ts @@ -91,6 +91,75 @@ const icmpUptimeShape = z.object({ error: z.int(), }); +const grpcMetricsShape = z.object({ + p50Latency: z.number().nullable().prefault(0), + p75Latency: z.number().nullable().prefault(0), + p90Latency: z.number().nullable().prefault(0), + p95Latency: z.number().nullable().prefault(0), + p99Latency: z.number().nullable().prefault(0), + count: z.int().prefault(0), + success: z.int().prefault(0), + degraded: z.int().prefault(0), + error: z.int().prefault(0), + lastTimestamp: z.int().nullable(), +}); + +const grpcMetricsByIntervalParameters = z.object({ + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + interval: z.int().optional(), + monitorId: z.string(), +}); + +const grpcMetricsByIntervalShape = z.object({ + region: z.enum(monitorRegions).or(z.string()), + timestamp: z.int(), + p50Latency: z.number().nullable().prefault(0), + p75Latency: z.number().nullable().prefault(0), + p90Latency: z.number().nullable().prefault(0), + p95Latency: z.number().nullable().prefault(0), + p99Latency: z.number().nullable().prefault(0), +}); + +const grpcMetricsByRegionParameters = z.object({ + monitorId: z.string(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), +}); + +const grpcMetricsByRegionShape = z.object({ + region: z.enum(monitorRegions).or(z.string()), + count: z.int(), + ok: z.int(), + p50Latency: z.number().nullable().prefault(0), + p75Latency: z.number().nullable().prefault(0), + p90Latency: z.number().nullable().prefault(0), + p95Latency: z.number().nullable().prefault(0), + p99Latency: z.number().nullable().prefault(0), +}); + +const grpcMetricsLatencyShape = z.object({ + timestamp: z.int(), + p50Latency: z.int(), + p75Latency: z.int(), + p90Latency: z.int(), + p95Latency: z.int(), + p99Latency: z.int(), +}); + +const grpcUptimeParameters = z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + regions: z.enum(monitorRegions).or(z.string()).array().optional(), + interval: z.int().optional(), +}); + +const grpcUptimeShape = z.object({ + interval: z.coerce.date(), + success: z.int(), + degraded: z.int(), + error: z.int(), +}); + export const TINYBIRD_DEFAULT_URL = "https://api.tinybird.co"; /** @@ -1698,6 +1767,452 @@ export class OSTinybird { }); } + public get grpcListDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_list_1d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("grpc").prefault("grpc"), + id: z.string().nullable(), + latency: z.int(), + servingStatus: z.string().nullable(), + grpcCode: z.int().nullable(), + service: z.string().nullable(), + monitorId: z.coerce.string(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcListWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_list_7d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("grpc").prefault("grpc"), + id: z.string().nullable(), + latency: z.int(), + servingStatus: z.string().nullable(), + grpcCode: z.int().nullable(), + service: z.string().nullable(), + monitorId: z.coerce.string(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcListBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_list_14d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("grpc").prefault("grpc"), + id: z.string().nullable(), + latency: z.int(), + servingStatus: z.string().nullable(), + grpcCode: z.int().nullable(), + service: z.string().nullable(), + monitorId: z.coerce.string(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcGetBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_get_14d__v0", + parameters: z.object({ + id: z.string().nullable(), + monitorId: z.string(), + }), + data: z.object({ + type: z.literal("grpc").prefault("grpc"), + id: z.string().nullable(), + uri: z.string(), + latency: z.int(), + servingStatus: z.string().nullable(), + grpcCode: z.int().nullable(), + service: z.string().nullable(), + monitorId: z.coerce.string(), + error: z.coerce.boolean(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + errorMessage: z.string().nullable(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_1d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: grpcMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_7d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: grpcMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_14d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: grpcMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetrics30d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_30d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: grpcMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetrics90d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_90d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: grpcMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByIntervalDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_interval_1d__v0", + parameters: grpcMetricsByIntervalParameters, + data: grpcMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByIntervalWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_interval_7d__v0", + parameters: grpcMetricsByIntervalParameters, + data: grpcMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByIntervalBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_interval_14d__v0", + parameters: grpcMetricsByIntervalParameters, + data: grpcMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByInterval30d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_interval_30d__v0", + parameters: grpcMetricsByIntervalParameters, + data: grpcMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByInterval90d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_interval_90d__v0", + parameters: grpcMetricsByIntervalParameters, + data: grpcMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsLatency1d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_latency_1d__v0", + parameters: z.object({ + monitorId: z.string(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: grpcMetricsLatencyShape, + }); + } + + public get grpcMetricsLatency7d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_latency_7d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: grpcMetricsLatencyShape, + }); + } + + public get grpcMetricsLatency30d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_latency_30d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: grpcMetricsLatencyShape, + }); + } + + public get grpcMetricsLatency90d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_latency_90d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: grpcMetricsLatencyShape, + }); + } + + public get grpcMetricsLatency1dMulti() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_latency_1d_multi__v0", + parameters: z.object({ + monitorIds: z.string().array().min(1), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: z.object({ + timestamp: z.int(), + monitorId: z.coerce.string(), + p50Latency: z.int(), + p75Latency: z.int(), + p90Latency: z.int(), + p95Latency: z.int(), + p99Latency: z.int(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcStatus45d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_status_45d__v0", + parameters: z.object({ + monitorIds: z.string().array(), + days: z.int().max(45).optional(), + }), + data: z.object({ + day: z.string().transform((val) => { + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) + return new Date(`${val} GMT`).toISOString(); + }), + count: z.number().prefault(0), + ok: z.number().prefault(0), + degraded: z.number().prefault(0), + error: z.number().prefault(0), + monitorId: z.coerce.string(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcUptimeWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_uptime_7d__v0", + parameters: grpcUptimeParameters, + data: grpcUptimeShape, + }); + } + + public get grpcUptime30d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_uptime_30d__v0", + parameters: grpcUptimeParameters, + data: grpcUptimeShape, + }); + } + + public get grpcUptime90d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_uptime_90d__v0", + parameters: grpcUptimeParameters, + data: grpcUptimeShape, + }); + } + + public get grpcGlobalMetricsDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_global_1d__v0", + parameters: z.object({ + monitorIds: z.string().array(), + }), + data: z.object({ + minLatency: z.int(), + maxLatency: z.int(), + p50Latency: z.int(), + p75Latency: z.int(), + p90Latency: z.int(), + p95Latency: z.int(), + p99Latency: z.int(), + lastTimestamp: z.int(), + count: z.int(), + monitorId: z.coerce.string(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcWorkspace30d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_workspace_30d__v0", + parameters: z.object({ + workspaceId: z.string(), + }), + data: z.object({ + day: z + .string() + .transform((val) => new Date(`${val} GMT`).toISOString()), + count: z.int(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcGetMonthly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_get_30d__v0", + parameters: z.object({ + monitorId: z.string(), + region: z.enum(monitorRegions).or(z.string()).optional(), + cronTimestamp: z.int().optional(), + }), + data: z.object({ + type: z.literal("grpc").prefault("grpc"), + id: z.string().nullable(), + uri: z.string(), + latency: z.int(), + servingStatus: z.string().nullable(), + grpcCode: z.int().nullable(), + service: z.string().nullable(), + monitorId: z.coerce.string(), + error: z.coerce.boolean(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + errorMessage: z.string().nullable(), + workspaceId: z.coerce.string(), + }), + // REMINDER: cache the result for accessing the data for a check as it won't change + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcStatusWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_status_7d__v0", + parameters: z.object({ + monitorId: z.string(), + }), + data: z.object({ + day: z.string().transform((val) => { + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) + return new Date(`${val} GMT`).toISOString(); + }), + count: z.number().prefault(0), + ok: z.number().prefault(0), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByRegionDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_region_1d__v0", + parameters: grpcMetricsByRegionParameters, + data: grpcMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByRegionWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_region_7d__v0", + parameters: grpcMetricsByRegionParameters, + data: grpcMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByRegionBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_region_14d__v0", + parameters: grpcMetricsByRegionParameters, + data: grpcMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + /** * Region + timestamp metrics (quantiles) – aggregated by interval. * NOTE: The Tinybird pipe returns one row per region & interval with latency quantiles. diff --git a/packages/tinybird/src/schema.ts b/packages/tinybird/src/schema.ts index aae67c4e..248459f5 100644 --- a/packages/tinybird/src/schema.ts +++ b/packages/tinybird/src/schema.ts @@ -1,6 +1,14 @@ import { z } from "zod"; -export const jobTypes = ["http", "tcp", "icmp", "udp", "dns", "ssl"] as const; +export const jobTypes = [ + "http", + "tcp", + "icmp", + "grpc", + "udp", + "dns", + "ssl", +] as const; export const jobTypeEnum = z.enum(jobTypes); export type JobType = z.infer; diff --git a/packages/utils/src/constants.ts b/packages/utils/src/constants.ts index 0ffb264d..4100888a 100644 --- a/packages/utils/src/constants.ts +++ b/packages/utils/src/constants.ts @@ -16,6 +16,7 @@ export const MONITOR_JOB_TYPES = [ "http", "tcp", "icmp", + "grpc", "udp", "dns", "ssl", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index ec9a4400..4fef0aeb 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -7,7 +7,10 @@ export { DNSPayloadSchema, type DNSPayload, icmpPayloadSchema, + grpcPayloadSchema, + GRPC_TLS_MODES, type IcmpPayload, + type GrpcPayload, } from "./payloads"; export { MONITOR_METHODS, diff --git a/packages/utils/src/payloads.ts b/packages/utils/src/payloads.ts index e213f435..7bf73494 100644 --- a/packages/utils/src/payloads.ts +++ b/packages/utils/src/payloads.ts @@ -89,3 +89,28 @@ export const icmpPayloadSchema = z.object({ }); export type IcmpPayload = z.infer; + +export const GRPC_TLS_MODES = ["plaintext", "tls", "tls_insecure"] as const; + +export const grpcPayloadSchema = z.object({ + status: z.enum(MONITOR_STATUSES), + workspaceId: z.string(), + uri: z.string(), + monitorId: z.string(), + service: z.string().optional(), + tls: z.enum(GRPC_TLS_MODES).prefault("tls"), + headers: z.record(z.string(), z.string()).optional(), + cronTimestamp: z.number(), + timeout: z.number().prefault(45000), + degradedAfter: z.number().nullable(), + trigger: z.enum(["cron", "api"]).optional().nullable().prefault("cron"), + otelConfig: z + .object({ + endpoint: z.string(), + headers: z.record(z.string(), z.string()), + }) + .optional(), + retry: z.number().prefault(3), +}); + +export type GrpcPayload = z.infer; -- 2.51.2 From e06791013b0a976a047724ee347a424ca33a3ced Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Thu, 27 Aug 2026 16:12:53 +0200 Subject: [PATCH 169/266] ci: small fix (#2617) * small fix * small fix --- apps/web/src/content/pages/changelog/grpc-monitoring.mdx | 2 +- apps/web/src/content/pages/changelog/icmp-monitoring.mdx | 2 +- packages/api/src/router/checker.test.ts | 9 +++++---- packages/notifications/discord/src/mock.ts | 2 ++ packages/notifications/email/src/mock.ts | 2 ++ packages/notifications/google-chat/src/mock.ts | 2 ++ packages/notifications/ms-teams/src/index.test.ts | 2 ++ packages/notifications/ms-teams/src/mock.ts | 2 ++ packages/notifications/slack/src/mock.ts | 2 ++ 9 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/web/src/content/pages/changelog/grpc-monitoring.mdx b/apps/web/src/content/pages/changelog/grpc-monitoring.mdx index b755fe62..6a11707f 100644 --- a/apps/web/src/content/pages/changelog/grpc-monitoring.mdx +++ b/apps/web/src/content/pages/changelog/grpc-monitoring.mdx @@ -1,7 +1,7 @@ --- title: "gRPC Monitoring" description: "Monitor your gRPC services with health checks from openstatus." -publishedAt: "2026-08-26" +publishedAt: "2026-08-27" author: "openstatus" category: "monitoring" --- diff --git a/apps/web/src/content/pages/changelog/icmp-monitoring.mdx b/apps/web/src/content/pages/changelog/icmp-monitoring.mdx index 8501892b..2a944588 100644 --- a/apps/web/src/content/pages/changelog/icmp-monitoring.mdx +++ b/apps/web/src/content/pages/changelog/icmp-monitoring.mdx @@ -1,7 +1,7 @@ --- title: "ICMP Monitoring" description: "Monitor your hosts with ICMP ping from openstatus." -publishedAt: "2026-07-21" +publishedAt: "2026-08-26" author: "openstatus" category: "monitoring" --- diff --git a/packages/api/src/router/checker.test.ts b/packages/api/src/router/checker.test.ts index 5df2fccd..df87db09 100644 --- a/packages/api/src/router/checker.test.ts +++ b/packages/api/src/router/checker.test.ts @@ -56,6 +56,7 @@ describe("testGrpc", () => { const result = await testGrpc({ url: "api.example.com:443", + tls: "tls", region: "ams", }); expect(result.state).toBe("success"); @@ -71,7 +72,7 @@ describe("testGrpc", () => { ); await expect( - testGrpc({ url: "api.example.com:443", region: "ams" }), + testGrpc({ url: "api.example.com:443", tls: "tls", region: "ams" }), ).rejects.toThrow("service reports NOT_SERVING"); }); @@ -84,7 +85,7 @@ describe("testGrpc", () => { ); await expect( - testGrpc({ url: "api.example.com:443", region: "ams" }), + testGrpc({ url: "api.example.com:443", tls: "tls", region: "ams" }), ).rejects.toThrow("does not implement"); }); @@ -94,7 +95,7 @@ describe("testGrpc", () => { ); await expect( - testGrpc({ url: "api.example.com:443", region: "ams" }), + testGrpc({ url: "api.example.com:443", tls: "tls", region: "ams" }), ).rejects.toThrow("SERVICE_UNKNOWN"); }); @@ -103,7 +104,7 @@ describe("testGrpc", () => { stubChecker({ message: "uri not reachable" }); await expect( - testGrpc({ url: "api.example.com:443", region: "ams" }), + testGrpc({ url: "api.example.com:443", tls: "tls", region: "ams" }), ).rejects.toThrow("uri not reachable"); }); }); diff --git a/packages/notifications/discord/src/mock.ts b/packages/notifications/discord/src/mock.ts index 9b6395cd..72ad71db 100644 --- a/packages/notifications/discord/src/mock.ts +++ b/packages/notifications/discord/src/mock.ts @@ -33,6 +33,8 @@ const monitor: Monitor = { otelHeaders: [], retry: 3, followRedirects: false, + grpcService: null, + grpcTls: null, }; const notification: Notification = { diff --git a/packages/notifications/email/src/mock.ts b/packages/notifications/email/src/mock.ts index b902ce8c..db9a25b1 100644 --- a/packages/notifications/email/src/mock.ts +++ b/packages/notifications/email/src/mock.ts @@ -28,6 +28,8 @@ const monitor: Monitor = { followRedirects: false, retry: 3, externalName: null, + grpcService: null, + grpcTls: null, }; const notification: Notification = { diff --git a/packages/notifications/google-chat/src/mock.ts b/packages/notifications/google-chat/src/mock.ts index d7c2ea15..1d18bd0f 100644 --- a/packages/notifications/google-chat/src/mock.ts +++ b/packages/notifications/google-chat/src/mock.ts @@ -28,6 +28,8 @@ const monitor: Monitor = { followRedirects: true, retry: 3, externalName: null, + grpcService: null, + grpcTls: null, }; const notification: Notification = { diff --git a/packages/notifications/ms-teams/src/index.test.ts b/packages/notifications/ms-teams/src/index.test.ts index 6baccbba..62e31ac3 100644 --- a/packages/notifications/ms-teams/src/index.test.ts +++ b/packages/notifications/ms-teams/src/index.test.ts @@ -77,6 +77,8 @@ describe("Microsoft Teams Notifications", () => { otelHeaders: [], retry: 3, followRedirects: false, + grpcService: null, + grpcTls: null, }); const createMockNotification = () => ({ diff --git a/packages/notifications/ms-teams/src/mock.ts b/packages/notifications/ms-teams/src/mock.ts index b2b1c31e..bddc2d40 100644 --- a/packages/notifications/ms-teams/src/mock.ts +++ b/packages/notifications/ms-teams/src/mock.ts @@ -38,6 +38,8 @@ const monitor: Monitor = { otelHeaders: [], retry: 3, followRedirects: false, + grpcService: null, + grpcTls: null, }; const notification: Notification = { diff --git a/packages/notifications/slack/src/mock.ts b/packages/notifications/slack/src/mock.ts index dae6b22e..dbc5fce0 100644 --- a/packages/notifications/slack/src/mock.ts +++ b/packages/notifications/slack/src/mock.ts @@ -33,6 +33,8 @@ const monitor: Monitor = { retry: 3, followRedirects: false, externalName: null, + grpcService: null, + grpcTls: null, }; const notification: Notification = { -- 2.51.2 From 07d885a06c5fa5ba0f777d9f5b009c4cb91709fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=AF=BC=ED=98=B8?= Date: Fri, 28 Aug 2026 00:44:20 +0900 Subject: [PATCH 170/266] perf(server): reuse validated page monitors (#2583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 유민호 <287127232+yoominho91@users.noreply.github.com> --- apps/server/src/routes/v1/pages/post.ts | 48 ++++++++++++------------- apps/server/src/routes/v1/pages/put.ts | 36 +++++++++---------- 2 files changed, 41 insertions(+), 43 deletions(-) diff --git a/apps/server/src/routes/v1/pages/post.ts b/apps/server/src/routes/v1/pages/post.ts index c128e8d3..260b56e2 100644 --- a/apps/server/src/routes/v1/pages/post.ts +++ b/apps/server/src/routes/v1/pages/post.ts @@ -134,24 +134,28 @@ export function registerPostPage(api: typeof pagesApi) { const { monitors, ...rest } = input; - if (monitors?.length) { - const monitorIds = isNumberArray(monitors) + const monitorIds = monitors + ? isNumberArray(monitors) ? monitors - : monitors.map((m) => m.monitorId); - - const _monitors = await db - .select() - .from(monitor) - .where( - and( - inArray(monitor.id, monitorIds), - eq(monitor.workspaceId, workspaceId), - isNull(monitor.deletedAt), - ), - ) - .all(); - - if (_monitors.length !== monitors.length) { + : monitors.map((m) => m.monitorId) + : []; + + const monitorsData = monitors?.length + ? await db + .select() + .from(monitor) + .where( + and( + inArray(monitor.id, monitorIds), + eq(monitor.workspaceId, workspaceId), + isNull(monitor.deletedAt), + ), + ) + .all() + : []; + + if (monitors?.length) { + if (monitorsData.length !== monitors.length) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Some of the monitors ${monitorIds.join(", ")} not found`, @@ -159,6 +163,8 @@ export function registerPostPage(api: typeof pagesApi) { } } + const monitorsById = new Map(monitorsData.map((m) => [m.id, m])); + const _page = await db .insert(page) .values({ @@ -177,13 +183,7 @@ export function registerPostPage(api: typeof pagesApi) { for (const [index, m] of monitors.entries()) { const values = typeof m === "number" ? { monitorId: m } : m; - const _monitor = await db.query.monitor.findFirst({ - where: and( - eq(monitor.id, values.monitorId), - eq(monitor.workspaceId, workspaceId), - isNull(monitor.deletedAt), - ), - }); + const _monitor = monitorsById.get(values.monitorId); if (!_monitor) { throw new OpenStatusApiError({ diff --git a/apps/server/src/routes/v1/pages/put.ts b/apps/server/src/routes/v1/pages/put.ts index 5514ba6e..7911e148 100644 --- a/apps/server/src/routes/v1/pages/put.ts +++ b/apps/server/src/routes/v1/pages/put.ts @@ -143,19 +143,21 @@ export function registerPutPage(api: typeof pagesApi) { : monitors.map((m) => m.monitorId) : []; - if (monitors?.length) { - const monitorsData = await db - .select() - .from(monitor) - .where( - and( - inArray(monitor.id, monitorIds), - eq(monitor.workspaceId, workspaceId), - isNull(monitor.deletedAt), - ), - ) - .all(); + const monitorsData = monitors?.length + ? await db + .select() + .from(monitor) + .where( + and( + inArray(monitor.id, monitorIds), + eq(monitor.workspaceId, workspaceId), + isNull(monitor.deletedAt), + ), + ) + .all() + : []; + if (monitors?.length) { if (monitorsData.length !== monitors.length) { throw new OpenStatusApiError({ code: "BAD_REQUEST", @@ -164,6 +166,8 @@ export function registerPutPage(api: typeof pagesApi) { } } + const monitorsById = new Map(monitorsData.map((m) => [m.id, m])); + const newPage = await db .update(page) .set({ @@ -209,13 +213,7 @@ export function registerPutPage(api: typeof pagesApi) { for (const [index, m] of monitors.entries()) { const values = typeof m === "number" ? { monitorId: m } : m; - const _monitor = await db.query.monitor.findFirst({ - where: and( - eq(monitor.id, values.monitorId), - eq(monitor.workspaceId, workspaceId), - isNull(monitor.deletedAt), - ), - }); + const _monitor = monitorsById.get(values.monitorId); if (!_monitor) { throw new OpenStatusApiError({ -- 2.51.2 From 04725ac88356cebe14414aa7f8d86f28b9623bd7 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 28 Aug 2026 18:31:54 +0800 Subject: [PATCH 171/266] fix: correct monitor sorting by group and groupOrder on /monitors page (#2527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: correct monitor sorting by group and groupOrder on /monitors page The /monitors page was displaying monitors without proper sorting, causing incorrect ordering within component groups. Monitors were only filtered by visibility but not sorted. This fix implements proper sorting logic that: 1. Groups monitors by their monitorGroupId 2. Orders groups by their position (using minimum order of monitors in group) 3. Sorts monitors within each group by their groupOrder field This matches the sorting behavior used in the trackers logic on the main status page and ensures monitors appear in the correct order as configured in the dashboard. * perf: optimize monitor sorting and fix ungrouped monitor ordering Fixed two issues with the monitor sorting logic: 1. Ungrouped monitors now sort correctly by their order field - Previously, ungrouped monitors (monitorGroupId = null) were not explicitly sorted, relying only on stable sort to preserve order - Now matches trackers behavior by explicitly sorting by order 2. Improved performance from O(n² log n) to O(n log n) - Precompute group minOrder map before sorting instead of recalculating on every comparison - Eliminates redundant filter/map operations that were running for each pairwise comparison - Mirrors the groupedMap.minOrder approach used in statusPage.ts These changes make the sort self-sufficient and performant regardless of source data ordering. * ci: apply automated fixes * refactor: move monitors sorting from client to server - Add server-side sorting logic to statusPage.get endpoint - Monitors now sorted by group (using minimum order) and groupOrder - Remove client-side sorting logic from monitors page component - Add comprehensive e2e tests for monitors sorting behavior Fixes maintainer feedback: sorting is now handled server-side with tests * ci: apply automated fixes * fix: tests --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Maximilian Kaske --- .../[locale]/(public)/monitors/page.tsx | 1 + .../api/src/router/statusPage.e2e.test.ts | 306 ++++++++++++++++++ packages/api/src/router/statusPage.ts | 43 +++ 3 files changed, 350 insertions(+) diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/page.tsx index 6bc56d9c..f3babab2 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/page.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/page.tsx @@ -34,6 +34,7 @@ export default function Page() { if (!page) return null; + // Filter for public monitors only (sorting is handled server-side) const publicMonitors = page.monitors.filter((monitor) => monitor.public); return ( diff --git a/packages/api/src/router/statusPage.e2e.test.ts b/packages/api/src/router/statusPage.e2e.test.ts index a474b731..1a632a19 100644 --- a/packages/api/src/router/statusPage.e2e.test.ts +++ b/packages/api/src/router/statusPage.e2e.test.ts @@ -934,6 +934,312 @@ describe("statusPage.get endpoint validation", () => { }); }); +describe("statusPage.get monitors sorting", () => { + const sortingTestSlug = "monitors-sorting-test-page"; + let sortingTestPageId: number; + let sortingTestWorkspaceId: number; + let group1Id: number; + let group2Id: number; + + beforeAll(async () => { + // Clean up any existing test data + await db.delete(page).where(eq(page.slug, sortingTestSlug)); + + // Use workspace id 1 from seed data + const existingWorkspace = await db.query.workspace.findFirst({ + where: eq(workspace.id, 1), + }); + + if (!existingWorkspace) { + throw new Error("Test workspace not found"); + } + + sortingTestWorkspaceId = existingWorkspace.id; + + // Create test page + const testPage = await db + .insert(page) + .values({ + workspaceId: sortingTestWorkspaceId, + title: "Monitors Sorting Test Page", + description: "Test page for monitors sorting", + slug: sortingTestSlug, + customDomain: "", + }) + .returning() + .get(); + + sortingTestPageId = testPage.id; + + // Import pageComponentGroup schema + const { pageComponentGroup } = await import("@openstatus/db/src/schema"); + + // Create two monitor groups + const group1 = await db + .insert(pageComponentGroup) + .values({ + workspaceId: sortingTestWorkspaceId, + pageId: sortingTestPageId, + name: "Group 1", + defaultOpen: true, + }) + .returning() + .get(); + group1Id = group1.id; + + const group2 = await db + .insert(pageComponentGroup) + .values({ + workspaceId: sortingTestWorkspaceId, + pageId: sortingTestPageId, + name: "Group 2", + defaultOpen: true, + }) + .returning() + .get(); + group2Id = group2.id; + + // Create monitors with specific order/groupOrder values for testing sorting + // Group 1 should appear first (min order = 1) + // Group 2 should appear second (min order = 5) + // Ungrouped monitors interspersed based on their order values + + // Monitor 1: Ungrouped, order = 0 (should be first) + const monitor1 = await db + .insert(monitor) + .values({ + workspaceId: sortingTestWorkspaceId, + url: "https://monitor1.test", + name: "Monitor 1 Ungrouped", + periodicity: "30s", + active: true, + }) + .returning() + .get(); + + await db.insert(pageComponent).values({ + workspaceId: sortingTestWorkspaceId, + pageId: sortingTestPageId, + monitorId: monitor1.id, + name: monitor1.name, + type: "monitor", + order: 0, + groupId: null, + groupOrder: null, + }); + + // Monitor 2: Group 1, order = 1, groupOrder = 1 (group should be second) + const monitor2 = await db + .insert(monitor) + .values({ + workspaceId: sortingTestWorkspaceId, + url: "https://monitor2.test", + name: "Monitor 2 Group 1 First", + periodicity: "30s", + active: true, + }) + .returning() + .get(); + + await db.insert(pageComponent).values({ + workspaceId: sortingTestWorkspaceId, + pageId: sortingTestPageId, + monitorId: monitor2.id, + name: monitor2.name, + type: "monitor", + order: 1, + groupId: group1Id, + groupOrder: 1, + }); + + // Monitor 3: Group 1, order = 3, groupOrder = 2 (should be second in group 1) + const monitor3 = await db + .insert(monitor) + .values({ + workspaceId: sortingTestWorkspaceId, + url: "https://monitor3.test", + name: "Monitor 3 Group 1 Second", + periodicity: "30s", + active: true, + }) + .returning() + .get(); + + await db.insert(pageComponent).values({ + workspaceId: sortingTestWorkspaceId, + pageId: sortingTestPageId, + monitorId: monitor3.id, + name: monitor3.name, + type: "monitor", + order: 3, + groupId: group1Id, + groupOrder: 2, + }); + + // Monitor 4: Ungrouped, order = 4 (should come after Group 1, before Group 2) + const monitor4 = await db + .insert(monitor) + .values({ + workspaceId: sortingTestWorkspaceId, + url: "https://monitor4.test", + name: "Monitor 4 Ungrouped", + periodicity: "30s", + active: true, + }) + .returning() + .get(); + + await db.insert(pageComponent).values({ + workspaceId: sortingTestWorkspaceId, + pageId: sortingTestPageId, + monitorId: monitor4.id, + name: monitor4.name, + type: "monitor", + order: 4, + groupId: null, + groupOrder: null, + }); + + // Monitor 5: Group 2, order = 5, groupOrder = 1 (group should be fourth) + const monitor5 = await db + .insert(monitor) + .values({ + workspaceId: sortingTestWorkspaceId, + url: "https://monitor5.test", + name: "Monitor 5 Group 2 First", + periodicity: "30s", + active: true, + }) + .returning() + .get(); + + await db.insert(pageComponent).values({ + workspaceId: sortingTestWorkspaceId, + pageId: sortingTestPageId, + monitorId: monitor5.id, + name: monitor5.name, + type: "monitor", + order: 5, + groupId: group2Id, + groupOrder: 1, + }); + + // Monitor 6: Group 2, order = 6, groupOrder = 0 (should be first in group 2) + const monitor6 = await db + .insert(monitor) + .values({ + workspaceId: sortingTestWorkspaceId, + url: "https://monitor6.test", + name: "Monitor 6 Group 2 Zero", + periodicity: "30s", + active: true, + }) + .returning() + .get(); + + await db.insert(pageComponent).values({ + workspaceId: sortingTestWorkspaceId, + pageId: sortingTestPageId, + monitorId: monitor6.id, + name: monitor6.name, + type: "monitor", + order: 6, + groupId: group2Id, + groupOrder: 0, + }); + }); + + afterAll(async () => { + // Clean up test data + await db.delete(page).where(eq(page.slug, sortingTestSlug)); + }); + + test("Monitors are sorted correctly by group and order", async () => { + const { edgeRouter } = await import("../edge"); + const { createInnerTRPCContext } = await import("../trpc"); + + const ctx = createInnerTRPCContext({ + req: undefined, + // @ts-expect-error - auth not required for public procedure + auth: undefined, + }); + + const caller = edgeRouter.createCaller(ctx); + const result = await caller.statusPage.get({ slug: sortingTestSlug }); + + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + + if (!result) { + throw new Error("Result should not be null"); + } + + expect(result.monitors.length).toBe(6); + + // Expected order based on sorting logic: + // 1. Monitor 1 (ungrouped, order=0) + // 2. Monitor 2 (group1, order=1, groupOrder=1) - group1 min order = 1 + // 3. Monitor 3 (group1, order=3, groupOrder=2) + // 4. Monitor 4 (ungrouped, order=4) + // 5. Monitor 6 (group2, order=6, groupOrder=0) - group2 min order = 5 + // 6. Monitor 5 (group2, order=5, groupOrder=1) + + expect(result.monitors[0].name).toBe("Monitor 1 Ungrouped"); + expect(result.monitors[0].monitorGroupId).toBeNull(); + + expect(result.monitors[1].name).toBe("Monitor 2 Group 1 First"); + expect(result.monitors[1].monitorGroupId).toBe(group1Id); + + expect(result.monitors[2].name).toBe("Monitor 3 Group 1 Second"); + expect(result.monitors[2].monitorGroupId).toBe(group1Id); + + expect(result.monitors[3].name).toBe("Monitor 4 Ungrouped"); + expect(result.monitors[3].monitorGroupId).toBeNull(); + + expect(result.monitors[4].name).toBe("Monitor 6 Group 2 Zero"); + expect(result.monitors[4].monitorGroupId).toBe(group2Id); + + expect(result.monitors[5].name).toBe("Monitor 5 Group 2 First"); + expect(result.monitors[5].monitorGroupId).toBe(group2Id); + }); + + test("Grouped monitors are sorted by groupOrder within their group", async () => { + const { edgeRouter } = await import("../edge"); + const { createInnerTRPCContext } = await import("../trpc"); + + const ctx = createInnerTRPCContext({ + req: undefined, + // @ts-expect-error - auth not required for public procedure + auth: undefined, + }); + + const caller = edgeRouter.createCaller(ctx); + const result = await caller.statusPage.get({ slug: sortingTestSlug }); + + if (!result) { + throw new Error("Result should not be null"); + } + + // Find all monitors in group 1 + const group1Monitors = result.monitors.filter( + (m) => m.monitorGroupId === group1Id, + ); + + expect(group1Monitors.length).toBe(2); + expect(group1Monitors[0].groupOrder).toBe(1); + expect(group1Monitors[1].groupOrder).toBe(2); + + // Find all monitors in group 2 + const group2Monitors = result.monitors.filter( + (m) => m.monitorGroupId === group2Id, + ); + + expect(group2Monitors.length).toBe(2); + expect(group2Monitors[0].groupOrder).toBe(0); + expect(group2Monitors[1].groupOrder).toBe(1); + }); +}); + describe("statusPage.get gates incidents by barType (calendar manual mode)", () => { const barTypeSlug = "bar-type-incident-gating-test-page"; let barTypePageId: number; diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index 4b16bf9d..255cc6f3 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -279,6 +279,49 @@ export const statusPageRouter = createTRPCRouter({ privateLocationCount: privateLocationCounts.get(m.id) ?? 0, })); + // Sort monitors to match trackers behavior: group by monitorGroupId, order + // groups by minimum order, sort within groups by groupOrder, and sort + // ungrouped monitors by order + const groupMinOrderMap = new Map(); + for (const monitor of monitorsWithPrivateLocationCount) { + const groupId = monitor.monitorGroupId; + if (groupId !== null) { + const order = monitor.order ?? 0; + const currentMin = + groupMinOrderMap.get(groupId) ?? Number.MAX_SAFE_INTEGER; + groupMinOrderMap.set(groupId, Math.min(currentMin, order)); + } + } + + monitorsWithPrivateLocationCount.sort((a, b) => { + const aGroupId = a.monitorGroupId ?? null; + const bGroupId = b.monitorGroupId ?? null; + + // If both monitors are in the same group (or both ungrouped with null) + if (aGroupId === bGroupId) { + if (aGroupId === null) { + // Both ungrouped - sort by order + return (a.order ?? 0) - (b.order ?? 0); + } + // Both in same group - sort by groupOrder within the group + return (a.groupOrder ?? 0) - (b.groupOrder ?? 0); + } + + // Different groups or one is ungrouped - sort by group position + // For grouped monitors, use precomputed minimum order of the group + // For ungrouped monitors, use their own order + const aGroupMinOrder = + aGroupId !== null + ? (groupMinOrderMap.get(aGroupId) ?? 0) + : (a.order ?? 0); + const bGroupMinOrder = + bGroupId !== null + ? (groupMinOrderMap.get(bGroupId) ?? 0) + : (b.order ?? 0); + + return aGroupMinOrder - bGroupMinOrder; + }); + // no barType gate: incident-driven error is already suppressed per // monitor in manual mode; report-driven error (major_outage) must show const status = monitorsWithPrivateLocationCount.some( -- 2.51.2 From e1a98255e39c9cc9f6d4853dbce30c3f6c42514a Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 28 Aug 2026 19:27:38 +0800 Subject: [PATCH 172/266] fix: notification setup improvements (#2536) * fix: add validation to all notification forms - Add name field validation with clear 'Name is required' error messages - Add pre-test validation to prevent API calls with empty required fields - Show specific error messages for each provider Fixed forms: WhatsApp, Slack, Webhook, Discord, PagerDuty, OpsGenie, Ntfy, MS Teams, Grafana OnCall, Google Chat * fix: add missing validation and improve error messages in notification forms - Email: add name and email validation - SMS: add name and phone number validation - Telegram: add name and chatId validation, add pre-test validation - Webhook: change 'endpoint' to 'URL' for consistency - OpsGenie: replace z.record with explicit schema for better region error messages * fix: improve opsgenie region validation and change SMS label to Phone Number * ci: apply automated fixes * fix: correct Zod enum API usage for OpsGenie region validation * fix: improve notification form validation - PagerDuty: fix validation message to match UI label 'Config' - SMS: add trim() to reject whitespace-only names and phone numbers - Slack: add trim() to reject whitespace-only names - OpsGenie: add trim() to reject whitespace-only names and API keys - OpsGenie: change region schema from string to enum to match backend - OpsGenie: add region validation in testAction before sending test * fix: review --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Maximilian Kaske --- .../components/telegram-form-actions.tsx | 7 ++++++ .../forms/notifications/form-discord.tsx | 10 ++++++-- .../forms/notifications/form-email.tsx | 8 +++++-- .../forms/notifications/form-google-chat.tsx | 10 ++++++-- .../notifications/form-grafana-oncall.tsx | 10 ++++++-- .../forms/notifications/form-ms-teams.tsx | 10 ++++++-- .../forms/notifications/form-ntfy.tsx | 10 ++++++-- .../forms/notifications/form-opsgenie.tsx | 23 ++++++++++++++++--- .../forms/notifications/form-pagerduty.tsx | 10 ++++---- .../forms/notifications/form-slack.tsx | 10 ++++++-- .../forms/notifications/form-sms.tsx | 6 ++--- .../forms/notifications/form-telegram.tsx | 4 ++-- .../forms/notifications/form-webhook.tsx | 10 ++++++-- .../forms/notifications/form-whatsapp.tsx | 12 +++++++--- .../db/src/schema/notifications/validation.ts | 4 +++- 15 files changed, 112 insertions(+), 32 deletions(-) diff --git a/apps/dashboard/src/components/forms/components/telegram-form-actions.tsx b/apps/dashboard/src/components/forms/components/telegram-form-actions.tsx index 6e715ed9..6c8ccfa0 100644 --- a/apps/dashboard/src/components/forms/components/telegram-form-actions.tsx +++ b/apps/dashboard/src/components/forms/components/telegram-form-actions.tsx @@ -29,6 +29,13 @@ export function TelegramFormActions({ function testAction() { if (isPending) return; + // Validate chat ID before sending test + const chatId = form.getValues("data.chatId"); + if (!chatId || chatId.trim() === "") { + toast.error("Please enter a chat ID before sending test"); + return; + } + startTransition(async () => { try { const provider = form.getValues("provider"); diff --git a/apps/dashboard/src/components/forms/notifications/form-discord.tsx b/apps/dashboard/src/components/forms/notifications/form-discord.tsx index b3273fd3..0a2ad4cb 100644 --- a/apps/dashboard/src/components/forms/notifications/form-discord.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-discord.tsx @@ -30,7 +30,7 @@ import { CheckboxTree } from "@/components/ui/checkbox-tree"; import { useTRPC } from "@/lib/trpc/client"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("discord"), data: z.url("Please enter a valid URL"), monitors: z.array(z.number()), @@ -97,10 +97,16 @@ export function FormDiscord({ function testAction() { if (isPending) return; + // Validate webhook URL field before sending test + const data = form.getValues("data"); + if (!data || data.trim() === "") { + toast.error("Please enter a webhook URL before sending test"); + return; + } + startTransition(async () => { try { const provider = form.getValues("provider"); - const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { diff --git a/apps/dashboard/src/components/forms/notifications/form-email.tsx b/apps/dashboard/src/components/forms/notifications/form-email.tsx index a6c1d681..0ee27e4d 100644 --- a/apps/dashboard/src/components/forms/notifications/form-email.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-email.tsx @@ -26,9 +26,13 @@ import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { CheckboxTree } from "@/components/ui/checkbox-tree"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("email"), - data: z.email(), + data: z + .string() + .trim() + .min(1, "Email is required") + .email("Please enter a valid email address"), monitors: z.array(z.number()), }); diff --git a/apps/dashboard/src/components/forms/notifications/form-google-chat.tsx b/apps/dashboard/src/components/forms/notifications/form-google-chat.tsx index cdfa882c..fa7a229f 100644 --- a/apps/dashboard/src/components/forms/notifications/form-google-chat.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-google-chat.tsx @@ -29,7 +29,7 @@ import { CheckboxTree } from "@/components/ui/checkbox-tree"; import { useTRPC } from "@/lib/trpc/client"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("google-chat"), data: z.url("Please enter a valid URL"), monitors: z.array(z.number()), @@ -96,10 +96,16 @@ export function FormGoogleChat({ function testAction() { if (isPending) return; + // Validate webhook URL field before sending test + const data = form.getValues("data"); + if (!data || data.trim() === "") { + toast.error("Please enter a webhook URL before sending test"); + return; + } + startTransition(async () => { try { const provider = form.getValues("provider"); - const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { diff --git a/apps/dashboard/src/components/forms/notifications/form-grafana-oncall.tsx b/apps/dashboard/src/components/forms/notifications/form-grafana-oncall.tsx index 0de2ba85..0483e89b 100644 --- a/apps/dashboard/src/components/forms/notifications/form-grafana-oncall.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-grafana-oncall.tsx @@ -31,7 +31,7 @@ import { useTRPC } from "@/lib/trpc/client"; import { useFormSheetDirty } from "../form-sheet"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("grafana-oncall"), data: z.record(z.string(), z.string()), monitors: z.array(z.number()), @@ -101,10 +101,16 @@ export function FormGrafanaOncall({ function testAction() { if (isPending) return; + // Validate webhook URL field before sending test + const data = form.getValues("data"); + if (!data.webhookUrl || data.webhookUrl.trim() === "") { + toast.error("Please enter a webhook URL before sending test"); + return; + } + startTransition(async () => { try { const provider = form.getValues("provider"); - const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { diff --git a/apps/dashboard/src/components/forms/notifications/form-ms-teams.tsx b/apps/dashboard/src/components/forms/notifications/form-ms-teams.tsx index 087aec7f..22affa12 100644 --- a/apps/dashboard/src/components/forms/notifications/form-ms-teams.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-ms-teams.tsx @@ -31,7 +31,7 @@ import { useTRPC } from "@/lib/trpc/client"; import { useFormSheetDirty } from "../form-sheet"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("ms-teams"), data: z.object({ webhookUrl: safeUrlSchema, @@ -101,10 +101,16 @@ export function FormMsTeams({ function testAction() { if (isPending) return; + // Validate webhook URL field before sending test + const data = form.getValues("data"); + if (!data.webhookUrl || data.webhookUrl.trim() === "") { + toast.error("Please enter a webhook URL before sending test"); + return; + } + startTransition(async () => { try { const provider = form.getValues("provider"); - const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { diff --git a/apps/dashboard/src/components/forms/notifications/form-ntfy.tsx b/apps/dashboard/src/components/forms/notifications/form-ntfy.tsx index fab7874e..250b7779 100644 --- a/apps/dashboard/src/components/forms/notifications/form-ntfy.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-ntfy.tsx @@ -29,7 +29,7 @@ import { CheckboxTree } from "@/components/ui/checkbox-tree"; import { useTRPC } from "@/lib/trpc/client"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("ntfy"), data: z.record(z.string(), z.string()), monitors: z.array(z.number()), @@ -100,10 +100,16 @@ export function FormNtfy({ function testAction() { if (isPending) return; + // Validate topic field before sending test + const data = form.getValues("data"); + if (!data.topic || data.topic.trim() === "") { + toast.error("Please enter a topic before sending test"); + return; + } + startTransition(async () => { try { const provider = form.getValues("provider"); - const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { diff --git a/apps/dashboard/src/components/forms/notifications/form-opsgenie.tsx b/apps/dashboard/src/components/forms/notifications/form-opsgenie.tsx index afd5c320..0b0df5c8 100644 --- a/apps/dashboard/src/components/forms/notifications/form-opsgenie.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-opsgenie.tsx @@ -35,9 +35,14 @@ import { CheckboxTree } from "@/components/ui/checkbox-tree"; import { useTRPC } from "@/lib/trpc/client"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("opsgenie"), - data: z.record(z.string(), z.string()), + data: z.object({ + apiKey: z.string().trim().min(1, "API key is required"), + region: z.enum(["us", "eu"], { + message: "Please select a region", + }), + }), monitors: z.array(z.number()), }); @@ -99,10 +104,22 @@ export function FormOpsGenie({ function testAction() { if (isPending) return; + // Validate API key field before sending test + const data = form.getValues("data"); + if (!data.apiKey || data.apiKey.trim() === "") { + toast.error("Please enter an API key before sending test"); + return; + } + + // Validate region field before sending test + if (!data.region) { + toast.error("Please select a region before sending test"); + return; + } + startTransition(async () => { try { const provider = form.getValues("provider"); - const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { diff --git a/apps/dashboard/src/components/forms/notifications/form-pagerduty.tsx b/apps/dashboard/src/components/forms/notifications/form-pagerduty.tsx index d51c9f66..250234de 100644 --- a/apps/dashboard/src/components/forms/notifications/form-pagerduty.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-pagerduty.tsx @@ -31,9 +31,9 @@ import { CheckboxTree } from "@/components/ui/checkbox-tree"; import { useTRPC } from "@/lib/trpc/client"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("pagerduty"), - data: z.string(), + data: z.string().trim().min(1, "PagerDuty configuration is required"), monitors: z.array(z.number()), }); @@ -115,8 +115,10 @@ export function FormPagerDuty({ try { const provider = form.getValues("provider"); const data = form.getValues("data"); - if (!data) { - toast.error("No PagerDuty configuration found"); + if (!data || data.trim() === "") { + toast.error( + "Please enter PagerDuty configuration before sending test", + ); return; } const promise = sendTestMutation.mutateAsync({ diff --git a/apps/dashboard/src/components/forms/notifications/form-slack.tsx b/apps/dashboard/src/components/forms/notifications/form-slack.tsx index 8dcf0446..2aaefa8b 100644 --- a/apps/dashboard/src/components/forms/notifications/form-slack.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-slack.tsx @@ -30,7 +30,7 @@ import { CheckboxTree } from "@/components/ui/checkbox-tree"; import { useTRPC } from "@/lib/trpc/client"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("slack"), data: z.url("Please enter a valid URL"), monitors: z.array(z.number()), @@ -97,10 +97,16 @@ export function FormSlack({ function testAction() { if (isPending) return; + // Validate webhook URL field before sending test + const data = form.getValues("data"); + if (!data || data.trim() === "") { + toast.error("Please enter a webhook URL before sending test"); + return; + } + startTransition(async () => { try { const provider = form.getValues("provider"); - const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { diff --git a/apps/dashboard/src/components/forms/notifications/form-sms.tsx b/apps/dashboard/src/components/forms/notifications/form-sms.tsx index dad393b6..4eb9629e 100644 --- a/apps/dashboard/src/components/forms/notifications/form-sms.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-sms.tsx @@ -25,9 +25,9 @@ import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { CheckboxTree } from "@/components/ui/checkbox-tree"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("sms"), - data: z.string(), + data: z.string().trim().min(1, "Phone number is required"), monitors: z.array(z.number()), }); @@ -108,7 +108,7 @@ export function FormSms({ name="data" render={({ field }) => ( - SMS + Phone Number diff --git a/apps/dashboard/src/components/forms/notifications/form-telegram.tsx b/apps/dashboard/src/components/forms/notifications/form-telegram.tsx index a005f2a3..dbc03e3b 100644 --- a/apps/dashboard/src/components/forms/notifications/form-telegram.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-telegram.tsx @@ -30,10 +30,10 @@ import { TelegramFormActions } from "../components/telegram-form-actions"; import { TelegramManualInput } from "../components/telegram-manual-input"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("telegram"), data: z.object({ - chatId: z.string(), + chatId: z.string().trim().min(1, "Chat ID is required"), }), monitors: z.array(z.number()), }); diff --git a/apps/dashboard/src/components/forms/notifications/form-webhook.tsx b/apps/dashboard/src/components/forms/notifications/form-webhook.tsx index 9d838cc4..844effd7 100644 --- a/apps/dashboard/src/components/forms/notifications/form-webhook.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-webhook.tsx @@ -31,7 +31,7 @@ import { CheckboxTree } from "@/components/ui/checkbox-tree"; import { useTRPC } from "@/lib/trpc/client"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("webhook"), data: z.object({ endpoint: z.string().url(), @@ -113,10 +113,16 @@ export function FormWebhook({ function testAction() { if (isPending) return; + // Validate webhook endpoint before sending test + const endpoint = form.getValues("data.endpoint"); + if (!endpoint || endpoint.trim() === "") { + toast.error("Please enter a webhook URL before sending test"); + return; + } + startTransition(async () => { try { const provider = form.getValues("provider"); - const endpoint = form.getValues("data.endpoint"); const headers = form.getValues("data.headers"); const promise = sendTestMutation.mutateAsync({ provider, diff --git a/apps/dashboard/src/components/forms/notifications/form-whatsapp.tsx b/apps/dashboard/src/components/forms/notifications/form-whatsapp.tsx index 025030d8..19c713cb 100644 --- a/apps/dashboard/src/components/forms/notifications/form-whatsapp.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-whatsapp.tsx @@ -29,9 +29,9 @@ import { CheckboxTree } from "@/components/ui/checkbox-tree"; import { useTRPC } from "@/lib/trpc/client"; const schema = z.object({ - name: z.string(), + name: z.string().trim().min(1, "Name is required"), provider: z.literal("whatsapp"), - data: z.string(), + data: z.string().trim().min(1, "Phone number is required"), monitors: z.array(z.number()), }); @@ -96,10 +96,16 @@ export function FormWhatsApp({ function testAction() { if (isPending) return; + // Validate phone number field before sending test + const data = form.getValues("data"); + if (!data || data.trim() === "") { + toast.error("Please enter a phone number before sending test"); + return; + } + startTransition(async () => { try { const provider = form.getValues("provider"); - const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { diff --git a/packages/db/src/schema/notifications/validation.ts b/packages/db/src/schema/notifications/validation.ts index 063384b0..3095acf7 100644 --- a/packages/db/src/schema/notifications/validation.ts +++ b/packages/db/src/schema/notifications/validation.ts @@ -61,7 +61,9 @@ export const pagerdutyDataSchema = z.object({ pagerduty: z.string() }); export const opsgenieDataSchema = z.object({ opsgenie: z.object({ apiKey: z.string(), - region: z.enum(["us", "eu"]), + region: z.enum(["us", "eu"], { + message: "Please select a region", + }), }), }); export const telegramDataSchema = z.object({ -- 2.51.2 From d79e759ae0dfe9d5d1aa57fb0d5122b98807533f Mon Sep 17 00:00:00 2001 From: shaurya Date: Mon, 31 Aug 2026 15:35:34 +0530 Subject: [PATCH 173/266] docs: fix 404 link to contributing guidelines in README (#2624) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 818d9c2a..2f214519 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ See [apps/dashboard/README.md](apps/dashboard/README.md) for full steps (env, db ## Contributing -If you want to help us build the best status page and monitoring platform, check our [contributing guidelines](https://github.com/openstatusHQ/openstatus/blob/main/CONTRIBUTING.MD). +If you want to help us build the best status page and monitoring platform, check our [contributing guidelines](CONTRIBUTING.md).
-- 2.51.2 From aef03254300f8e884053af3d2e0aad7ef22f5701 Mon Sep 17 00:00:00 2001 From: Waltrick <118665499+solomonerous@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:05:57 +0700 Subject: [PATCH 174/266] docs: note the Deno prerequisite for pnpm verify in CONTRIBUTING (#2623) pnpm verify runs the check task in most packages through deno check, but CONTRIBUTING never mentioned Deno, so a fresh contributor following the guide alone hits 'deno: command not found' across all 39 type-check tasks. Point step 3 at the README requirements list and call out the failure mode explicitly. --- CONTRIBUTING.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0625cf94..6b5a84ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,12 @@ To contribute code changes, follow these steps: 3. Run `pnpm verify` — formatting, lint, doc references and type checks. It needs no database and takes seconds. `pnpm verify:test` runs the tests for the packages your diff touches; those do need a local libSQL server. + + Note: the type checks (`check` task) in most packages run through + [Deno](https://deno.com/), which must be installed locally. Without it, + `pnpm verify` fails with `deno: command not found` in every package. + See the [Requirements](./README.md#requirements) list in the README for the + full toolchain (Node.js, pnpm, Bun, Deno, Turso CLI). 4. Make commits with clear and descriptive messages. Each commit should have a single logical purpose. 5. Push your branch to your forked repository. 6. Open a pull request (PR) from your branch to the original repository's `main` branch. -- 2.51.2 From 5ebb760074a5519df34a20d2a631ff411fd315b4 Mon Sep 17 00:00:00 2001 From: shaurya Date: Tue, 1 Sep 2026 12:56:24 +0530 Subject: [PATCH 175/266] docs(react): fix two dead documentation links in README (#2625) Co-authored-by: Shaurya <19599684+no-hup@users.noreply.github.com> --- packages/react/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 95c9f176..4c2c5f90 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -61,7 +61,7 @@ access the type response of the api call to: `https://api.openstatus.dev/public/status/:slug` Learn more about our supported -[API endpoints](https://www.openstatus.dev/docs/api-reference/auth). +[API endpoints](https://api.openstatus.dev/v1). ```ts import { getStatus } from "@openstatus/react"; @@ -89,7 +89,7 @@ export type Status = | "incident"; ``` -Learn more in the [docs](https://www.openstatus.dev/docs/packages/react). +Learn more in the [docs](https://www.openstatus.dev/docs/guides/how-to-use-react-widget). ### About OpenStatus -- 2.51.2 From aa2a85995f496d3710caab0d14979d49dde66129 Mon Sep 17 00:00:00 2001 From: Waltrick <118665499+solomonerous@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:52:25 +0700 Subject: [PATCH 176/266] fix(status-page): make badge status API URL configurable (#2622) * fix(status-page): make badge status API URL configurable getStatus() hardcoded https://api.openstatus.dev, so on self-hosted instances every badge lookup hit the OpenStatus Cloud API, failed to resolve the local page slug, and fell back to "unknown" (#2517). - getStatus() now takes an optional baseUrl and defaults to NEXT_PUBLIC_OPENSTATUS_API_URL when set, keeping current behavior for cloud when it is unset - self-host compose files point the status-page service at http://server:3000 by default * fix(status-page): address review on badge API URL configuration - read OPENSTATUS_API_URL at runtime instead of a NEXT_PUBLIC_-prefixed var: Next.js inlines the latter at image build time, so prebuilt GHCR images could never pick up the runtime compose value - normalize the configured base URL: strip trailing slashes and fall back to the cloud API when empty - drop the env default from docker-compose-lightweight.yaml: that stack does not deploy apps/server, so http://server:3000 was a dead endpoint; operators can point OPENSTATUS_API_URL at any reachable /public/status source * fix(status-page): degrade to unknown on badge fetch rejection getStatus only handled HTTP non-ok responses; a network-level fetch rejection (unreachable server during restart, DNS failure) escaped uncaught and turned into a 500 from the badge routes. Catch it and return the unknown status as intended. --- coolify-deployment.yaml | 1 + docker-compose.github-packages.yaml | 1 + docker-compose.yaml | 1 + packages/react/src/widget.tsx | 28 +++++++++++++++++++--------- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/coolify-deployment.yaml b/coolify-deployment.yaml index f430ab19..8af68968 100644 --- a/coolify-deployment.yaml +++ b/coolify-deployment.yaml @@ -329,6 +329,7 @@ services: - PORT=${STATUS_PAGE_PORT:-3000} - HOSTNAME=${STATUS_PAGE_HOSTNAME:-0.0.0.0} - AUTH_TRUST_HOST=${STATUS_PAGE_AUTH_TRUST_HOST:-true} + - OPENSTATUS_API_URL=${OPENSTATUS_API_URL:-http://server:3000} depends_on: workflows: condition: service_healthy diff --git a/docker-compose.github-packages.yaml b/docker-compose.github-packages.yaml index 09de8d10..334361fb 100644 --- a/docker-compose.github-packages.yaml +++ b/docker-compose.github-packages.yaml @@ -285,6 +285,7 @@ services: - PORT=3000 - HOSTNAME=0.0.0.0 - AUTH_TRUST_HOST=true + - OPENSTATUS_API_URL=${OPENSTATUS_API_URL:-http://server:3000} depends_on: db-migrate: condition: service_completed_successfully diff --git a/docker-compose.yaml b/docker-compose.yaml index 309ab6b8..5f5f652f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -267,6 +267,7 @@ services: - PORT=3000 - HOSTNAME=0.0.0.0 - AUTH_TRUST_HOST=true + - OPENSTATUS_API_URL=${OPENSTATUS_API_URL:-http://server:3000} depends_on: db-migrate: condition: service_completed_successfully diff --git a/packages/react/src/widget.tsx b/packages/react/src/widget.tsx index 96ec64ee..f6457858 100644 --- a/packages/react/src/widget.tsx +++ b/packages/react/src/widget.tsx @@ -11,16 +11,26 @@ export type Status = export type StatusResponse = { status: Status }; -export async function getStatus(slug: string): Promise { - const res = await fetch(`https://api.openstatus.dev/public/status/${slug}`, { - cache: "no-cache", - }); - - if (res.ok) { - const data = (await res.json()) as StatusResponse; - return data; - } +export async function getStatus( + slug: string, + baseUrl = process.env.OPENSTATUS_API_URL ?? "https://api.openstatus.dev", +): Promise { + // read at runtime on the server: no NEXT_PUBLIC_ prefix, so deployments + // shipping prebuilt images can still point badges at their own API + const base = baseUrl.replace(/\/+$/, "") || "https://api.openstatus.dev"; + try { + const res = await fetch(`${base}/public/status/${slug}`, { + cache: "no-cache", + }); + if (res.ok) { + const data = (await res.json()) as StatusResponse; + return data; + } + } catch { + // network-level failures (unreachable host, DNS, …) degrade to "unknown" + // instead of bubbling a 500 out of the badge routes + } return { status: "unknown" }; } -- 2.51.2 From bf1b728d8b6fa88a145a53288eadfe1ee5b6f37c Mon Sep 17 00:00:00 2001 From: Waltrick <118665499+solomonerous@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:57:12 +0700 Subject: [PATCH 177/266] fix(self-hosting): run database migrations in Coolify deployment (#2621) * fix(self-hosting): run database migrations in Coolify deployment coolify-deployment.yaml relied on prebuilt GHCR images but never ran schema migrations, so fresh deployments started with an empty database and sign-in failed with LibsqlError on the user table (#2532). - publish the one-shot migration runner (packages/db/Dockerfile) as ghcr.io/openstatushq/openstatus-db-migrate via docker-publish.yml - add a db-migrate service to coolify-deployment.yaml and gate the app services on its completion - document that migrations run automatically on every deploy * ci: drop dead db-migrate entry from per-app change detection db-migrate lives under packages/db/, so the apps// grep in the else branch can never match it; its builds are triggered by the packages/** path above. --- .github/workflows/docker-publish.yml | 7 +++++-- COOLIFY_ENVIRONMENT_GUIDE.md | 2 ++ coolify-deployment.yaml | 26 ++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 87220d5a..7e44e722 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -18,7 +18,7 @@ on: services: description: 'Services to build (comma-separated)' required: false - default: 'server,dashboard,workflows,private-location,status-page,checker' + default: 'server,dashboard,workflows,private-location,status-page,checker,db-migrate' type: string concurrency: @@ -53,7 +53,7 @@ jobs: # packages/** changes affect all services if echo "$CHANGED" | grep -q '^packages/'; then - SERVICES_LIST=("server" "dashboard" "workflows" "private-location" "status-page" "checker") + SERVICES_LIST=("server" "dashboard" "workflows" "private-location" "status-page" "checker" "db-migrate") else for svc in server dashboard workflows private-location status-page checker; do if echo "$CHANGED" | grep -q "^apps/$svc/"; then @@ -101,6 +101,9 @@ jobs: - service: checker context: apps/checker dockerfile: apps/checker/Dockerfile + - service: db-migrate + context: . + dockerfile: packages/db/Dockerfile steps: - name: Checkout repository diff --git a/COOLIFY_ENVIRONMENT_GUIDE.md b/COOLIFY_ENVIRONMENT_GUIDE.md index 017d5dee..af44f0d5 100644 --- a/COOLIFY_ENVIRONMENT_GUIDE.md +++ b/COOLIFY_ENVIRONMENT_GUIDE.md @@ -12,6 +12,8 @@ This guide explains how to configure environment variables for OpenStatus deploy ``` 3. Click **"Deploy"** +> **ℹ️ Database migrations:** the stack includes a one-shot `db-migrate` service that applies the database schema before any app service starts. It runs on every deploy and safely skips already-applied migrations — no manual migration step is needed. + ### Step 2: Configure Environment Variables 1. After deployment, click on the **OpenStatus stack** 2. Go to **"Settings"** → **"Environment Variables"** diff --git a/coolify-deployment.yaml b/coolify-deployment.yaml index 8af68968..a59c8ff0 100644 --- a/coolify-deployment.yaml +++ b/coolify-deployment.yaml @@ -141,6 +141,24 @@ services: reservations: memory: 256M + # One-shot schema migration. Runs before any app service starts; drizzle + # skips already-applied migrations, so it is safe on every deploy. + db-migrate: + image: ghcr.io/openstatushq/openstatus-db-migrate:latest + container_name: openstatus-db-migrate + networks: + - openstatus + environment: + - DATABASE_URL=${DATABASE_URL:-http://libsql:8080} + - DATABASE_AUTH_TOKEN=${DATABASE_AUTH_TOKEN:-} + depends_on: + libsql: + condition: service_healthy + # retries absorb transient DB races; exits 0 once migrations are applied + restart: on-failure:3 + # pull on every deploy so a stale :latest image never skips new migrations + pull_policy: always + tinybird: image: tinybirdco/tinybird-local:latest container_name: openstatus-tinybird @@ -179,6 +197,8 @@ services: - DATABASE_AUTH_TOKEN=${DATABASE_AUTH_TOKEN:-} - PORT=${WORKFLOWS_PORT:-3000} depends_on: + db-migrate: + condition: service_completed_successfully libsql: condition: service_healthy healthcheck: @@ -207,6 +227,8 @@ services: - DATABASE_AUTH_TOKEN=${DATABASE_AUTH_TOKEN:-} - PORT=${SERVER_PORT:-3000} depends_on: + db-migrate: + condition: service_completed_successfully workflows: condition: service_healthy libsql: @@ -296,6 +318,8 @@ services: - HOSTNAME=${DASHBOARD_HOSTNAME:-0.0.0.0} - AUTH_TRUST_HOST=${DASHBOARD_AUTH_TRUST_HOST:-true} depends_on: + db-migrate: + condition: service_completed_successfully workflows: condition: service_healthy libsql: @@ -331,6 +355,8 @@ services: - AUTH_TRUST_HOST=${STATUS_PAGE_AUTH_TRUST_HOST:-true} - OPENSTATUS_API_URL=${OPENSTATUS_API_URL:-http://server:3000} depends_on: + db-migrate: + condition: service_completed_successfully workflows: condition: service_healthy libsql: -- 2.51.2 From 7f52f656e1e59e3c85a6c877cb905b026c1200a8 Mon Sep 17 00:00:00 2001 From: Harsh Kumar <9u.harsh@gmail.com> Date: Tue, 1 Sep 2026 14:31:11 +0530 Subject: [PATCH 178/266] fix(docs): clean up TOC labels and stop sideways scrolling (#2619) * fix(docs): drop inline-code backticks from TOC labels * fix(docs): wrap long TOC entries instead of scrolling sideways --- apps/web/src/content/docs-toc.tsx | 2 +- apps/web/src/content/toc.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/content/docs-toc.tsx b/apps/web/src/content/docs-toc.tsx index f9a4642f..d2b744fe 100644 --- a/apps/web/src/content/docs-toc.tsx +++ b/apps/web/src/content/docs-toc.tsx @@ -60,7 +60,7 @@ export function TableOfContents({ items }: { items: TocItem[] }) { Date: Tue, 1 Sep 2026 14:32:10 +0530 Subject: [PATCH 179/266] fix(support): reject whitespace-only input and show a type error (#2618) --- .../src/components/forms/support-contact/form.tsx | 10 ++++------ packages/api/src/router/feedback.ts | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/src/components/forms/support-contact/form.tsx b/apps/dashboard/src/components/forms/support-contact/form.tsx index a96bb514..e35b42a0 100644 --- a/apps/dashboard/src/components/forms/support-contact/form.tsx +++ b/apps/dashboard/src/components/forms/support-contact/form.tsx @@ -50,16 +50,14 @@ export const types = [ ]; export const schema = z.object({ - name: z.string().min(1, { - error: "Name is required", + name: z.string().trim().min(1, "Name is required"), + type: z.enum(["bug", "demo", "feature", "security", "question"], { + error: "Type is required", }), - type: z.enum(["bug", "demo", "feature", "security", "question"]), email: z.email({ error: "Invalid email address", }), - message: z.string().min(1, { - error: "Message is required", - }), + message: z.string().trim().min(1, "Message is required"), blocker: z.boolean(), }); diff --git a/packages/api/src/router/feedback.ts b/packages/api/src/router/feedback.ts index cb25f29b..43b4a870 100644 --- a/packages/api/src/router/feedback.ts +++ b/packages/api/src/router/feedback.ts @@ -12,7 +12,7 @@ export const feedbackRouter = createTRPCRouter({ submit: protectedProcedure .input( z.object({ - message: z.string().min(1, "Message required"), + message: z.string().trim().min(1, "Message required"), source: z.enum(feedbackSource), path: z.string(), isMobile: z.boolean().optional(), -- 2.51.2 From 55bd69843b879148439fc713abb47b5477401486 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Wed, 2 Sep 2026 11:13:17 +0200 Subject: [PATCH 180/266] deps: upgrade to effect 4 (#2626) --- apps/workflows/src/cron/checker.ts | 12 +- .../src/cron/external-incidents-prune.ts | 2 +- apps/workflows/src/cron/external-status.ts | 10 +- apps/workflows/src/cron/index.ts | 2 +- packages/emails/src/client.tsx | 4 +- .../status-fetcher/__tests__/fetch.test.ts | 2 +- packages/status-fetcher/__tests__/helpers.ts | 6 +- packages/status-fetcher/src/fetch.ts | 40 +++--- packages/subscriptions/src/channels/retry.ts | 8 +- pnpm-lock.yaml | 121 +++++++++++++++--- pnpm-workspace.yaml | 5 +- 11 files changed, 151 insertions(+), 61 deletions(-) diff --git a/apps/workflows/src/cron/checker.ts b/apps/workflows/src/cron/checker.ts index 30e226cd..b7a0a435 100644 --- a/apps/workflows/src/cron/checker.ts +++ b/apps/workflows/src/cron/checker.ts @@ -33,7 +33,7 @@ import { type tpcPayloadSchema, transformHeaders, } from "@openstatus/utils"; -import { Effect, Either, Schedule } from "effect"; +import { Effect, Result, Schedule } from "effect"; import { z } from "zod"; import { env } from "../env"; @@ -239,22 +239,22 @@ export async function sendCheckerTasks( times: 3, schedule: Schedule.exponential("1000 millis"), }), - Effect.either, + Effect.result, ), { concurrency: 100 }, ), ); for (const result of results) { - if (Either.isLeft(result)) { + if (Result.isFailure(result)) { logger.error("Task creation failed after retries", { - error_message: result.left.message, + error_message: result.failure.message, }); } } - const success = results.filter(Either.isRight).length; - const failed = results.filter(Either.isLeft).length; + const success = results.filter(Result.isSuccess).length; + const failed = results.filter(Result.isFailure).length; logger.info("Completed cron job", { periodicity, diff --git a/apps/workflows/src/cron/external-incidents-prune.ts b/apps/workflows/src/cron/external-incidents-prune.ts index c14b5b6a..a63b2803 100644 --- a/apps/workflows/src/cron/external-incidents-prune.ts +++ b/apps/workflows/src/cron/external-incidents-prune.ts @@ -38,7 +38,7 @@ export async function handleExternalIncidentsPruneCron(c: Context) { void cronCompleted(); }), ), - Effect.catchAll((e) => + Effect.catch((e) => Effect.sync(() => { logger.error("external-incidents-prune tick errored: {message}", { message: e.message, diff --git a/apps/workflows/src/cron/external-status.ts b/apps/workflows/src/cron/external-status.ts index 9ab070d7..56d7b613 100644 --- a/apps/workflows/src/cron/external-status.ts +++ b/apps/workflows/src/cron/external-status.ts @@ -183,7 +183,7 @@ function runStatusPhase( ), // Failure reporting is deferred: the detect step after this phase // either merges it into a detection story or reports it plain. - Effect.catchAll((err: FetchError) => + Effect.catch((err: FetchError) => Effect.succeed({ kind: "fail", slug: entry.id, @@ -237,7 +237,7 @@ function runIncidentPhase( ), ), ), - Effect.catchAll((err: FetchError) => + Effect.catch((err: FetchError) => Effect.sync(() => { reportFetchFailure({ phase: "incidents", @@ -307,7 +307,7 @@ function runComponentPhase( }), ), ), - Effect.catchAll((err: FetchError) => + Effect.catch((err: FetchError) => Effect.sync(() => { reportFetchFailure({ phase: "components", @@ -531,7 +531,7 @@ function applyDetection(args: { }); return { kind: outcome, slug: entry.id }; }), - Effect.catchAll((e) => + Effect.catch((e) => Effect.sync((): DetectOutcome => { logger.warn( "external-status detect: write failed for slug={slug}: {message}", @@ -734,7 +734,7 @@ export async function handleExternalStatusCron(c: Context) { void cronCompleted(); }), ), - Effect.catchAll((e) => + Effect.catch((e) => Effect.sync(() => { logger.error("external-status tick errored: {message}", { message: e.message, diff --git a/apps/workflows/src/cron/index.ts b/apps/workflows/src/cron/index.ts index c5096571..4b0df286 100644 --- a/apps/workflows/src/cron/index.ts +++ b/apps/workflows/src/cron/index.ts @@ -75,7 +75,7 @@ app.get("/checker/:period", async (c) => { void cronCompleted(); }), ), - Effect.catchAll((e) => + Effect.catch((e) => Effect.sync(() => { console.error(e); void reportBackgroundError(e.message); diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index 074709ec..9aca7527 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -56,9 +56,9 @@ export class EmailClient { public readonly client: Resend; // Base delay for the per-batch send retry. Overridable so tests can run the // retry path without the real ~1s exponential sleep. - private readonly retryBackoff: Duration.DurationInput; + private readonly retryBackoff: Duration.Input; - constructor(opts: { apiKey: string; retryBackoff?: Duration.DurationInput }) { + constructor(opts: { apiKey: string; retryBackoff?: Duration.Input }) { this.client = new Resend(opts.apiKey); this.retryBackoff = opts.retryBackoff ?? "1000 millis"; } diff --git a/packages/status-fetcher/__tests__/fetch.test.ts b/packages/status-fetcher/__tests__/fetch.test.ts index 5500645b..91e3ef53 100644 --- a/packages/status-fetcher/__tests__/fetch.test.ts +++ b/packages/status-fetcher/__tests__/fetch.test.ts @@ -52,7 +52,7 @@ const expectFailure = (exit: Exit.Exit): FetchError => { if (!Exit.isFailure(exit)) { throw new Error("expected Exit.Failure"); } - const failure = Cause.failureOption(exit.cause); + const failure = Cause.findErrorOption(exit.cause); if (Option.isNone(failure)) { throw new Error("expected Cause.Fail"); } diff --git a/packages/status-fetcher/__tests__/helpers.ts b/packages/status-fetcher/__tests__/helpers.ts index bcf6a527..9e0650a5 100644 --- a/packages/status-fetcher/__tests__/helpers.ts +++ b/packages/status-fetcher/__tests__/helpers.ts @@ -34,7 +34,7 @@ export const expectFetchError = ( if (!Exit.isFailure(exit)) { throw new Error("expected Exit.Failure, got Success"); } - const failure = Cause.failureOption(exit.cause); + const failure = Cause.findErrorOption(exit.cause); if (Option.isNone(failure)) { throw new Error("expected Cause.Fail, got defect"); } @@ -67,7 +67,7 @@ export const expectIncidentsFetchError = ( if (!Exit.isFailure(exit)) { throw new Error("expected Exit.Failure, got Success"); } - const failure = Cause.failureOption(exit.cause); + const failure = Cause.findErrorOption(exit.cause); if (Option.isNone(failure)) { throw new Error("expected Cause.Fail, got defect"); } @@ -100,7 +100,7 @@ export const expectComponentsFetchError = ( if (!Exit.isFailure(exit)) { throw new Error("expected Exit.Failure, got Success"); } - const failure = Cause.failureOption(exit.cause); + const failure = Cause.findErrorOption(exit.cause); if (Option.isNone(failure)) { throw new Error("expected Cause.Fail, got defect"); } diff --git a/packages/status-fetcher/src/fetch.ts b/packages/status-fetcher/src/fetch.ts index 80f25448..1750b334 100644 --- a/packages/status-fetcher/src/fetch.ts +++ b/packages/status-fetcher/src/fetch.ts @@ -37,14 +37,14 @@ export class FetchError extends Error { } } -const DEFAULT_TIMEOUT: Duration.DurationInput = "30000 millis"; +const DEFAULT_TIMEOUT: Duration.Input = "30000 millis"; export type FetchBaseOptions = { url: string; init?: Omit & { headers?: Record; }; - timeout?: Duration.DurationInput; + timeout?: Duration.Input; fetcherName?: string; entryId?: string; }; @@ -94,15 +94,17 @@ const doFetch = ( }), catch: failWith(opts, "network"), }).pipe( - Effect.timeoutFail({ + Effect.timeoutOrElse({ duration: opts.timeout ?? DEFAULT_TIMEOUT, - onTimeout: () => - buildFetchError(opts, { - kind: "timeout", - cause: new Error( - `timeout after ${String(opts.timeout ?? DEFAULT_TIMEOUT)}`, - ), - }), + orElse: () => + Effect.fail( + buildFetchError(opts, { + kind: "timeout", + cause: new Error( + `timeout after ${String(opts.timeout ?? DEFAULT_TIMEOUT)}`, + ), + }), + ), }), Effect.flatMap((response) => response.ok @@ -127,15 +129,17 @@ const fetchBody = ( Effect.retry(retryPolicy), Effect.flatMap((response) => read(response).pipe( - Effect.timeoutFail({ + Effect.timeoutOrElse({ duration: opts.timeout ?? DEFAULT_TIMEOUT, - onTimeout: () => - buildFetchError(opts, { - kind: "timeout", - cause: new Error( - `body read timeout after ${String(opts.timeout ?? DEFAULT_TIMEOUT)}`, - ), - }), + orElse: () => + Effect.fail( + buildFetchError(opts, { + kind: "timeout", + cause: new Error( + `body read timeout after ${String(opts.timeout ?? DEFAULT_TIMEOUT)}`, + ), + }), + ), }), ), ), diff --git a/packages/subscriptions/src/channels/retry.ts b/packages/subscriptions/src/channels/retry.ts index c720ea7f..41e37180 100644 --- a/packages/subscriptions/src/channels/retry.ts +++ b/packages/subscriptions/src/channels/retry.ts @@ -46,10 +46,12 @@ export function postWebhookWithRetry(opts: { }), catch: (cause) => new WebhookSendError("Webhook request failed", { cause }), }).pipe( - Effect.timeoutFail({ + Effect.timeoutOrElse({ duration: `${opts.timeoutMs} millis`, - onTimeout: () => - new WebhookSendError(`Webhook timed out after ${opts.timeoutMs}ms`), + orElse: () => + Effect.fail( + new WebhookSendError(`Webhook timed out after ${opts.timeoutMs}ms`), + ), }), Effect.flatMap((response) => response.ok diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4578fa97..24599a9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,8 +331,8 @@ catalogs: specifier: 0.8.3 version: 0.8.3 effect: - specifier: 3.21.2 - version: 3.21.2 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 feed: specifier: 4.2.2 version: 4.2.2 @@ -1707,7 +1707,7 @@ importers: version: 0.45.2(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.38.0)(bun-types@1.3.14) effect: specifier: 'catalog:' - version: 3.21.2 + version: 4.0.0-rc.112 hono: specifier: 'catalog:' version: 4.12.21 @@ -2022,7 +2022,7 @@ importers: version: 0.13.11(typescript@5.9.3)(zod@4.1.13) effect: specifier: 'catalog:' - version: 3.21.2 + version: 4.0.0-rc.112 react: specifier: 'catalog:' version: 19.2.6 @@ -2713,7 +2713,7 @@ importers: version: 10.8.0 effect: specifier: 'catalog:' - version: 3.21.2 + version: 4.0.0-rc.112 zod: specifier: 'catalog:' version: 4.1.13 @@ -2735,7 +2735,7 @@ importers: dependencies: effect: specifier: 'catalog:' - version: 3.21.2 + version: 4.0.0-rc.112 node-html-parser: specifier: 'catalog:' version: 6.1.13 @@ -2772,7 +2772,7 @@ importers: version: 1.38.0 effect: specifier: 'catalog:' - version: 3.21.2 + version: 4.0.0-rc.112 zod: specifier: 'catalog:' version: 4.1.13 @@ -4818,6 +4818,36 @@ packages: '@cfworker/json-schema': optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@mswjs/interceptors@0.40.0': resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} engines: {node: '>=18'} @@ -8092,6 +8122,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -8452,8 +8483,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@3.21.2: - resolution: {integrity: sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==} + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -8680,9 +8711,9 @@ packages: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} - fast-check@3.23.2: - resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} - engines: {node: '>=8.0.0'} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -9849,6 +9880,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + msw@2.12.3: resolution: {integrity: sha512-/5rpGC0eK8LlFqsHaBmL19/PVKxu/CCt8pO1vzp9X6SDLsRDh/Ccudkf3Ur5lyaKxJz9ndAx+LaThdv0ySqB6A==} engines: {node: '>=18'} @@ -10020,6 +10058,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-html-parser@6.1.13: resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} @@ -10472,8 +10514,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} qr-code-styling@1.9.2: resolution: {integrity: sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==} @@ -13507,6 +13549,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@mswjs/interceptors@0.40.0': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -16854,10 +16914,10 @@ snapshots: ee-first@1.1.1: {} - effect@3.21.2: + effect@4.0.0-rc.112: dependencies: - '@standard-schema/spec': 1.1.0 - fast-check: 3.23.2 + fast-check: 4.9.0 + msgpackr: 2.1.0 electron-to-chromium@1.5.267: {} @@ -17191,9 +17251,9 @@ snapshots: iconv-lite: 0.4.24 tmp: 0.0.33 - fast-check@3.23.2: + fast-check@4.9.0: dependencies: - pure-rand: 6.1.0 + pure-rand: 8.4.2 fast-deep-equal@3.1.3: {} @@ -18682,6 +18742,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + msw@2.12.3(@types/node@24.12.4)(typescript@5.9.3): dependencies: '@inquirer/confirm': 5.1.21(@types/node@24.12.4) @@ -18854,6 +18930,11 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-html-parser@6.1.13: dependencies: css-select: 5.2.2 @@ -19363,7 +19444,7 @@ snapshots: punycode@2.3.1: {} - pure-rand@6.1.0: {} + pure-rand@8.4.2: {} qr-code-styling@1.9.2: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9c114121..9f4d8f5f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,9 @@ allowBuilds: "@tailwindcss/oxide": true core-js-pure: true esbuild: true + # Optional native accelerator for msgpackr (pulled in by effect). msgpackr + # falls back to its pure-JS path without it, so skip the native build. + msgpackr-extract: false msw: true protobufjs: true sharp: true @@ -140,7 +143,7 @@ catalog: drizzle-kit: 0.31.10 drizzle-orm: 0.45.2 drizzle-zod: 0.8.3 - effect: 3.21.2 + effect: 4.0.0-rc.112 feed: 4.2.2 gray-matter: 4.0.3 hono: 4.12.21 -- 2.51.2 From 648e2913f9736b5442e4385617ce7b8bc000d917 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Thu, 3 Sep 2026 10:10:46 +0200 Subject: [PATCH 181/266] workflow: improve notificaiton (#2630) * workflow: inbox unreadable * workflow: inbox unreadable * workflow: inbox unreadable qa * workflow: inbox unreadable qa * workflow: inbox unreadable qa * workflow: inbox unreadable qa * workflow: inbox unreadable qa --- apps/workflows/fly.toml | 4 + apps/workflows/src/checker/alerting.ts | 119 +- apps/workflows/src/checker/index.ts | 355 +- apps/workflows/src/checker/outbox.test.ts | 560 ++ apps/workflows/src/checker/outbox.ts | 702 +++ apps/workflows/src/checker/quorum.ts | 50 + apps/workflows/src/checker/sms-quota.ts | 93 + apps/workflows/src/checker/transition.test.ts | 409 ++ apps/workflows/src/checker/transition.ts | 384 ++ .../src/checker/update-status.test.ts | 130 + apps/workflows/src/cron/index.ts | 26 + apps/workflows/src/cron/outbox.ts | 131 + apps/workflows/src/cron/scheduler.test.ts | 18 + apps/workflows/src/cron/scheduler.ts | 95 + apps/workflows/src/cron/status-drift.test.ts | 192 + apps/workflows/src/cron/status-drift.ts | 100 + apps/workflows/src/env.ts | 4 + .../workflows/src/scripts/outbox-preflight.ts | 185 + apps/workflows/src/serve.ts | 20 +- packages/db/drizzle/0085_daily_glorian.sql | 100 + packages/db/drizzle/meta/0085_snapshot.json | 5519 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/incidents/incident.ts | 5 +- packages/db/src/schema/index.ts | 1 + .../schema/monitor_status/monitor_status.ts | 1 + .../db/src/schema/monitor_transition/index.ts | 3 + .../monitor_transition/monitor_transition.ts | 41 + .../schema/monitor_transition/validation.ts | 8 + packages/db/src/schema/notifications/index.ts | 1 + .../db/src/schema/notifications/outbox.ts | 142 + .../db/src/schema/notifications/validation.ts | 22 + packages/services/AGENTS.md | 8 + .../src/incident/__tests__/incident.test.ts | 3 + 33 files changed, 9120 insertions(+), 318 deletions(-) create mode 100644 apps/workflows/src/checker/outbox.test.ts create mode 100644 apps/workflows/src/checker/outbox.ts create mode 100644 apps/workflows/src/checker/quorum.ts create mode 100644 apps/workflows/src/checker/sms-quota.ts create mode 100644 apps/workflows/src/checker/transition.test.ts create mode 100644 apps/workflows/src/checker/transition.ts create mode 100644 apps/workflows/src/checker/update-status.test.ts create mode 100644 apps/workflows/src/cron/outbox.ts create mode 100644 apps/workflows/src/cron/scheduler.test.ts create mode 100644 apps/workflows/src/cron/scheduler.ts create mode 100644 apps/workflows/src/cron/status-drift.test.ts create mode 100644 apps/workflows/src/cron/status-drift.ts create mode 100644 apps/workflows/src/scripts/outbox-preflight.ts create mode 100644 packages/db/drizzle/0085_daily_glorian.sql create mode 100644 packages/db/drizzle/meta/0085_snapshot.json create mode 100644 packages/db/src/schema/monitor_transition/index.ts create mode 100644 packages/db/src/schema/monitor_transition/monitor_transition.ts create mode 100644 packages/db/src/schema/monitor_transition/validation.ts create mode 100644 packages/db/src/schema/notifications/outbox.ts diff --git a/apps/workflows/fly.toml b/apps/workflows/fly.toml index 458e9c2c..9e670264 100644 --- a/apps/workflows/fly.toml +++ b/apps/workflows/fly.toml @@ -6,6 +6,10 @@ app = 'openstatus-workflows' primary_region = 'ams' +# Long enough for shutdownOutbox to wait out an in-flight provider call +# (NOTIFICATION_TIMEOUT_MS + 5s) before Fly SIGKILLs. +kill_timeout = "25s" + [build] dockerfile = "./Dockerfile" diff --git a/apps/workflows/src/checker/alerting.ts b/apps/workflows/src/checker/alerting.ts index 1e517c0c..2db10c63 100644 --- a/apps/workflows/src/checker/alerting.ts +++ b/apps/workflows/src/checker/alerting.ts @@ -1,15 +1,14 @@ import { getLogger } from "@logtape/logtape"; -import { and, count, db, eq, gte, inArray, schema } from "@openstatus/db"; -import type { Incident, MonitorStatus } from "@openstatus/db/src/schema"; +import { db, eq, schema } from "@openstatus/db"; +import type { Incident } from "@openstatus/db/src/schema"; import { selectMonitorSchema, selectNotificationSchema, - selectWorkspaceSchema, } from "@openstatus/db/src/schema"; -import type { Region } from "@openstatus/db/src/schema/constants"; import { Effect, Schedule } from "effect"; import { checkerAudit } from "../utils/audit-log"; +import { loadSmsQuotaBlocked } from "./sms-quota"; import { providerToFunction } from "./utils"; const logger = getLogger("workflow"); @@ -39,6 +38,7 @@ export const triggerNotifications = async ({ }); const triggered: { notificationId: number; provider: string }[] = []; + const smsQuota = new Map(); let incident: Incident | undefined; if (incidentId) { @@ -70,52 +70,20 @@ export const triggerNotifications = async ({ for (const notif of notifications) { // for sms check we are in the quota if (notif.notification.provider === "sms") { - if (notif.notification.workspaceId === null) { + const notificationWorkspaceId = notif.notification.workspaceId; + if (notificationWorkspaceId === null) { continue; } - - const workspace = await db - .select() - .from(schema.workspace) - .where(eq(schema.workspace.id, notif.notification.workspaceId)); - - if (workspace.length !== 1) { - continue; - } - - const data = selectWorkspaceSchema.parse(workspace[0]); - - const oneMonthAgo = new Date(); - oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); - - const smsNotification = await db - .select() - .from(schema.notification) - .where( - and( - eq(schema.notification.workspaceId, notif.notification.workspaceId), - eq(schema.notification.provider, "sms"), - ), + if (!smsQuota.has(notificationWorkspaceId)) { + const blocked = await loadSmsQuotaBlocked([notificationWorkspaceId]); + smsQuota.set( + notificationWorkspaceId, + blocked.get(notificationWorkspaceId) ?? true, ); - const ids = smsNotification.map((notification) => notification.id); - - const smsSent = await db - .select({ count: count() }) - .from(schema.notificationTrigger) - .where( - and( - gte( - schema.notificationTrigger.cronTimestamp, - Math.floor(oneMonthAgo.getTime() / 1000), - ), - inArray(schema.notificationTrigger.notificationId, ids), - ), - ) - .all(); - - if ((smsSent[0]?.count ?? 0) > data.limits["sms-limit"]) { + } + if (smsQuota.get(notificationWorkspaceId) === true) { logger.warn( - `SMS quota exceeded for workspace ${notif.notification.workspaceId}`, + `SMS quota exceeded for workspace ${notificationWorkspaceId}`, ); continue; } @@ -238,17 +206,28 @@ export const triggerNotifications = async ({ break; } // ALPHA - await checkerAudit.publishAuditLog({ - id: `monitor:${monitorId}`, - action: "notification.sent", - targets: [{ id: monitorId, type: "monitor" }], - metadata: { + // Best-effort: the notification has been sent and its trigger row written, + // so throwing here would make Cloud Tasks retry a send that already + // happened — and the retry skips it on the trigger's unique index. + try { + await checkerAudit.publishAuditLog({ + id: `monitor:${monitorId}`, + action: "notification.sent", + targets: [{ id: monitorId, type: "monitor" }], + metadata: { + provider: notif.notification.provider, + cronTimestamp, + type: notifType, + notificationId: notif.notification.id, + }, + }); + } catch (err) { + logger.warn("Failed to publish notification audit log", { + monitor_id: monitorId, provider: notif.notification.provider, - cronTimestamp, - type: notifType, - notificationId: notif.notification.id, - }, - }); + error_message: err instanceof Error ? err.message : String(err), + }); + } } return triggered; @@ -272,31 +251,3 @@ const insertNotificationTrigger = async ({ }) .returning(); }; - -export const upsertMonitorStatus = async ({ - monitorId, - status, - region, -}: { - monitorId: string; - status: MonitorStatus; - region: Region; -}) => { - const newData = await db - .insert(schema.monitorStatusTable) - .values({ status, region, monitorId: Number(monitorId) }) - .onConflictDoUpdate({ - target: [ - schema.monitorStatusTable.monitorId, - schema.monitorStatusTable.region, - ], - set: { status, updatedAt: new Date() }, - }) - .returning(); - logger.debug("Upserted monitor status", { - monitor_id: monitorId, - region, - status, - updated_at: newData[0]?.updatedAt, - }); -}; diff --git a/apps/workflows/src/checker/index.ts b/apps/workflows/src/checker/index.ts index 7feeac00..872915d0 100644 --- a/apps/workflows/src/checker/index.ts +++ b/apps/workflows/src/checker/index.ts @@ -1,20 +1,16 @@ import { getLogger } from "@logtape/logtape"; -import { and, db, eq, inArray, schema } from "@openstatus/db"; -import { incidentTable } from "@openstatus/db/src/schema"; import { monitorRegions } from "@openstatus/db/src/schema/constants"; -import { - monitorStatusSchema, - selectMonitorSchema, -} from "@openstatus/db/src/schema/monitors/validation"; +import { monitorStatusSchema } from "@openstatus/db/src/schema/monitors/validation"; import { Hono } from "hono"; import { z } from "zod"; import { env } from "../env"; import type { Env } from "../index"; import { checkerAudit } from "../utils/audit-log"; -import { triggerNotifications, upsertMonitorStatus } from "./alerting"; -import { findOpenIncident, resolveIncident } from "./incident-utils"; +import { triggerNotifications } from "./alerting"; +import { enqueueOutbox } from "./outbox"; import { updateStatusPrivate } from "./private-location"; +import { EVENT_TYPE, applyStatusTransition, isStaleCheck } from "./transition"; export const checkerRoute = new Hono(); @@ -30,19 +26,70 @@ const payloadSchema = z.object({ latency: z.number().optional(), }); +type Payload = z.infer; + const logger = getLogger(["workflow"]); +async function publishStatusAudit(payload: Payload): Promise { + const { monitorId, region, statusCode, cronTimestamp, latency } = payload; + const id = `monitor:${monitorId}`; + const targets = [{ id: monitorId, type: "monitor" as const }]; + const metadata = { + region, + statusCode: statusCode ?? -1, + cronTimestamp, + latency, + }; + + // Best-effort, like publishIncidentAudit: the transition batch has already + // committed, and throwing here would make Cloud Tasks retry a transition that + // has landed. The retry short-circuits on the unchanged region status, so the + // notification this request still owes would never be sent. + try { + switch (payload.status) { + case "active": + await checkerAudit.publishAuditLog({ + id, + action: "monitor.recovered", + targets, + metadata, + }); + break; + case "degraded": + await checkerAudit.publishAuditLog({ + id, + action: "monitor.degraded", + targets, + metadata, + }); + break; + case "error": + await checkerAudit.publishAuditLog({ + id, + action: "monitor.failed", + targets, + metadata: { ...metadata, message: payload.message }, + }); + break; + } + } catch (error) { + logger.warn("Failed to publish status audit log", { + monitor_id: payload.monitorId, + error_message: error instanceof Error ? error.message : String(error), + }); + } +} + checkerRoute.post("/updateStatus", async (c) => { + const config = env(); const auth = c.req.header("Authorization"); - if (auth !== `Basic ${env().CRON_SECRET}`) { + if (auth !== `Basic ${config.CRON_SECRET}`) { logger.error("Unauthorized"); return c.text("Unauthorized", 401); } const event = c.get("event"); - const json = await c.req.json(); - - const result = payloadSchema.safeParse(json); + const result = payloadSchema.safeParse(await c.req.json()); if (!result.success) { return c.text("Unprocessable Entity", 422); @@ -57,6 +104,7 @@ checkerRoute.post("/updateStatus", async (c) => { status, latency, } = result.data; + const monitorIdNumber = Number(monitorId); logger.info("Updating monitor status", { monitor_id: monitorId, @@ -67,241 +115,88 @@ checkerRoute.post("/updateStatus", async (c) => { latency_ms: latency, }); - // First we upsert the monitor status - await upsertMonitorStatus({ - monitorId: monitorId, + const statusUpdate: Record = { status, - region: region, - }); - - const currentMonitor = await db - .select() - .from(schema.monitor) - .where(eq(schema.monitor.id, Number(monitorId))) - .get(); - - const monitor = selectMonitorSchema.parse(currentMonitor); - const numberOfRegions = monitor.regions.length; - - // Fetch all affected regions for notifications (single query) - const affectedRegions = await db - .select({ region: schema.monitorStatusTable.region }) - .from(schema.monitorStatusTable) - .where( - and( - eq(schema.monitorStatusTable.monitorId, monitor.id), - eq(schema.monitorStatusTable.status, status), - inArray(schema.monitorStatusTable.region, monitor.regions), - ), - ) - .all(); - - const affectedRegionsList = affectedRegions.map((r) => r.region); - const affectedRegionCount = affectedRegionsList.length; - - event.status_update = { - status: result.data.status, - message: result.data.message, - region: result.data.region, - status_code: result.data.statusCode, - cron_timestamp: result.data.cronTimestamp, - latency_ms: result.data.latency, - affectedRegionsCount: affectedRegionCount, - monitorId: monitor.id, + message, + region, + status_code: statusCode, + cron_timestamp: cronTimestamp, + latency_ms: latency, + monitorId: monitorIdNumber, }; + if (event) event.status_update = statusUpdate; - if (affectedRegionCount === 0) { + if (isStaleCheck(cronTimestamp, config.STALE_CHECK_MS)) { + statusUpdate.stale = true; return c.json({ success: true }, 200); } - // audit log the current state of the ping + const transition = await applyStatusTransition({ + monitorId: monitorIdNumber, + region, + status, + cronTimestamp, + statusCode, + message, + latency, + deadlineSeconds: Math.floor(config.OUTBOX_DEADLINE_MS / 1000), + rolloutPct: config.OUTBOX_ROLLOUT_PCT, + }); - switch (status) { - case "active": - await checkerAudit.publishAuditLog({ - id: `monitor:${monitorId}`, - action: "monitor.recovered", - targets: [{ id: monitorId, type: "monitor" }], - metadata: { - region, - statusCode: statusCode ?? -1, - cronTimestamp, - latency, - }, - }); - break; - case "degraded": - await checkerAudit.publishAuditLog({ - id: `monitor:${monitorId}`, - action: "monitor.degraded", - targets: [{ id: monitorId, type: "monitor" }], - metadata: { - region, - statusCode: statusCode ?? -1, - cronTimestamp, - latency, - }, - }); - break; - case "error": - await checkerAudit.publishAuditLog({ - id: `monitor:${monitorId}`, - action: "monitor.failed", - targets: [{ id: monitorId, type: "monitor" }], - metadata: { - region, - statusCode: statusCode ?? -1, - message, - cronTimestamp, - latency, - }, - }); - break; + if (transition.kind === "unchanged") { + statusUpdate.fast_path_skipped = true; + return c.json({ success: true }, 200); } - let triggeredNotifications: { notificationId: number; provider: string }[] = - []; - - if (affectedRegionCount >= numberOfRegions / 2 || numberOfRegions === 1) { - switch (status) { - case "active": { - if (monitor.status === "active") { - break; - } - - logger.info("Monitor status changed to active", { - monitor_id: monitor.id, - workspace_id: monitor.workspaceId, - }); - await db - .update(schema.monitor) - .set({ status: "active" }) - .where(eq(schema.monitor.id, monitor.id)); - - let incident = null; - if (monitor.status === "error") { - const incidents = await resolveIncident({ monitorId, cronTimestamp }); - incident = incidents[0] ?? null; - } - - triggeredNotifications = await triggerNotifications({ - monitorId, - statusCode, - message, - notifType: "recovery", - cronTimestamp, - regions: affectedRegionsList, - latency, - incidentId: incident?.id, - }); - - break; - } - case "degraded": - if (monitor.status === "degraded") { - break; - } - - logger.info("Monitor status changed to degraded", { - monitor_id: monitor.id, - workspace_id: monitor.workspaceId, - }); - - await db - .update(schema.monitor) - .set({ status: "degraded" }) - .where(eq(schema.monitor.id, monitor.id)); - - let incident = null; - if (monitor.status === "error") { - const incidents = await resolveIncident({ - monitorId, - cronTimestamp, - }); - incident = incidents[0] ?? null; - } - - triggeredNotifications = await triggerNotifications({ - monitorId, - statusCode, - message, - notifType: "degraded", - cronTimestamp, - latency, - regions: affectedRegionsList, - incidentId: incident?.id, - }); - - break; - case "error": - if (monitor.status === "error") { - break; - } - - logger.info("Monitor status changed to error", { - monitor_id: monitor.id, - workspace_id: monitor.workspaceId, - }); - - await db - .update(schema.monitor) - .set({ status: "error" }) - .where(eq(schema.monitor.id, monitor.id)); + if (transition.kind === "monitor-missing") { + statusUpdate.monitor_missing = true; + return c.json({ success: true }, 200); + } - try { - const existingIncident = await findOpenIncident(Number(monitorId)); - if (existingIncident) { - logger.info("Already in incident", { - incident_id: existingIncident.id, - }); - break; - } + statusUpdate.affectedRegionsCount = transition.affectedRegions.length; + statusUpdate.quorum_count = transition.quorumCount; + statusUpdate.region_count = transition.regionCount; + statusUpdate.transition_applied = transition.transitioned; + statusUpdate.outbox_rows = transition.outboxRows.length; - const [newIncident] = await db - .insert(incidentTable) - .values({ - monitorId: Number(monitorId), - workspaceId: monitor.workspaceId, - startedAt: new Date(cronTimestamp), - }) - .returning(); + await publishStatusAudit(result.data); - if (!newIncident?.id) { - break; - } + if (!transition.transitioned) { + return c.text("Ok", 200); + } - await checkerAudit.publishAuditLog({ - id: `monitor:${monitorId}`, - action: "incident.created", - targets: [{ id: monitorId, type: "monitor" }], - metadata: { cronTimestamp, incidentId: newIncident.id }, - }); + logger.info("Monitor status changed", { + monitor_id: monitorIdNumber, + status, + }); - triggeredNotifications = await triggerNotifications({ - monitorId, - statusCode, - message, - notifType: "alert", - cronTimestamp, - latency, - regions: affectedRegionsList, - incidentId: newIncident.id, - }); - } catch (error) { - logger.warning("Failed to create incident", { error }); - } + let triggeredNotifications: { notificationId: number; provider: string }[] = + []; - break; - default: - logger.error("should not happen"); - break; - } + // Ownership is whatever the batch actually wrote, not a second copy of the + // rollout formula: `pending` means the drainer owns it, `settled` with an + // `inline` outcome means the inline sender does. + if (transition.outboxRows.some((row) => row.deliveryStatus === "pending")) { + enqueueOutbox(transition.outboxRows.map((row) => row.id)); + triggeredNotifications = transition.outboxRows.map((row) => ({ + notificationId: row.notificationId, + provider: row.provider, + })); + } else if (transition.outboxRows.length > 0) { + triggeredNotifications = await triggerNotifications({ + monitorId, + statusCode, + message, + notifType: EVENT_TYPE[status], + cronTimestamp, + regions: transition.affectedRegions, + latency, + incidentId: transition.incidentId ?? undefined, + }); } - (event.status_update as Record).notificationTriggered = - triggeredNotifications.length > 0; - (event.status_update as Record).notifications = - triggeredNotifications; + statusUpdate.notificationTriggered = triggeredNotifications.length > 0; + statusUpdate.notifications = triggeredNotifications; return c.text("Ok", 200); }); diff --git a/apps/workflows/src/checker/outbox.test.ts b/apps/workflows/src/checker/outbox.test.ts new file mode 100644 index 00000000..626d6a09 --- /dev/null +++ b/apps/workflows/src/checker/outbox.test.ts @@ -0,0 +1,560 @@ +import { and, count, db, eq } from "@openstatus/db"; +import type { NotificationOutboxPayload } from "@openstatus/db/src/schema"; +import { + notificationOutbox, + monitor, + notificationDeadLetter, + notificationTrigger, +} from "@openstatus/db/src/schema"; +import { + createMonitor, + createNotification, + createTestWorkspace, + linkNotificationToMonitor, +} from "@openstatus/db/src/test/factories"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + type Stub, + stub, + test, +} from "@openstatus/test-utils"; + +import { checkerAudit } from "../utils/audit-log"; +import { drainOutbox, shutdownOutbox, sweepExpiredOutbox } from "./outbox"; +import { providerToFunction } from "./utils"; + +// Deno has no module mocking; stub the singleton the drainer resolves at call +// time, as alerting.test.ts does. +// biome-ignore lint/suspicious/noExplicitAny: heterogeneous provider stubs +type AnyStub = Stub; +let stubs: AnyStub[] = []; + +let workspaceId: number; +let monitorId: number; +let notificationId: number; + +const PAYLOAD: NotificationOutboxPayload = { regions: ["ams"] }; + +beforeAll(async () => { + const { workspace } = await createTestWorkspace(); + workspaceId = workspace.id; + const monitorRow = await createMonitor(workspaceId, { regions: "ams" }); + monitorId = monitorRow.id; + const notif = await createNotification(workspaceId, { provider: "email" }); + notificationId = notif.id; + await linkNotificationToMonitor(notificationId, monitorId); +}); + +afterAll(async () => { + await db.delete(monitor).where(eq(monitor.workspaceId, workspaceId)).run(); +}); + +beforeEach(() => { + stubs = []; + stubs.push( + stub(checkerAudit, "publishAuditLog", () => Promise.resolve()) as AnyStub, + ); +}); + +afterEach(async () => { + for (const s of stubs) s.restore(); + stubs = []; + await db + .delete(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .run(); + await db + .delete(notificationDeadLetter) + .where(eq(notificationDeadLetter.monitorId, monitorId)) + .run(); + await db + .delete(notificationTrigger) + .where(eq(notificationTrigger.monitorId, monitorId)) + .run(); +}); + +async function insertOutboxRow(overrides: { + cronTimestamp: number; + eventType?: "alert" | "recovery"; + deadlineOffsetSeconds?: number; + attempts?: number; + lockedBy?: string; + lockedUntilOffsetSeconds?: number; +}) { + const now = Math.floor(Date.now() / 1000); + const [row] = await db + .insert(notificationOutbox) + .values({ + dedupKey: `${overrides.cronTimestamp}:${monitorId}:test:${notificationId}`, + monitorId, + workspaceId, + notificationId, + provider: "email", + eventType: overrides.eventType ?? "alert", + fromStatus: "active", + toStatus: "error", + cronTimestamp: overrides.cronTimestamp, + incidentId: null, + payload: PAYLOAD, + attempts: overrides.attempts ?? 0, + lockedBy: overrides.lockedBy ?? null, + lockedUntil: + overrides.lockedUntilOffsetSeconds === undefined + ? null + : now + overrides.lockedUntilOffsetSeconds, + nextAttemptAt: now, + deadlineAt: now + (overrides.deadlineOffsetSeconds ?? 300), + createdAt: now, + }) + .returning(); + if (!row) throw new Error("outbox insert returned no row"); + return row; +} + +describe("drainOutbox", () => { + test("delivers a row, marks it done and records the send", async () => { + stubs.push( + stub(providerToFunction.email, "sendAlert", () => Promise.resolve()), + ); + await insertOutboxRow({ cronTimestamp: Date.now() }); + + const summary = await drainOutbox({ + timeoutMs: 500, + rolloutPct: 100, + monitorIds: [monitorId], + }); + + expect(summary.delivered).toBe(1); + expect(summary.dead).toBe(0); + + const rows = await db + .select() + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(rows[0]?.deliveryStatus).toBe("settled"); + expect(rows[0]?.deliveredAt).not.toBe(null); + expect(rows[0]?.lockedUntil).toBe(null); + + const triggers = await db + .select({ total: count() }) + .from(notificationTrigger) + .where(eq(notificationTrigger.monitorId, monitorId)) + .all(); + expect(triggers[0]?.total).toBe(1); + }); + + test("dead-letters a row past its deadline and frees the channel", async () => { + stubs.push( + stub(providerToFunction.email, "sendAlert", () => + Promise.reject(new Error("provider down")), + ), + ); + await insertOutboxRow({ + cronTimestamp: Date.now(), + deadlineOffsetSeconds: 1, + }); + + const summary = await drainOutbox({ + timeoutMs: 500, + rolloutPct: 100, + monitorIds: [monitorId], + }); + + expect(summary.dead).toBe(1); + + const remaining = await db + .select({ total: count() }) + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(remaining[0]?.total).toBe(0); + + const dead = await db + .select() + .from(notificationDeadLetter) + .where(eq(notificationDeadLetter.monitorId, monitorId)) + .all(); + expect(dead.length).toBe(1); + expect(dead[0]?.finalError).toContain("provider down"); + }); + + test("a hanging provider is bounded by the timeout", async () => { + stubs.push( + stub( + providerToFunction.email, + "sendAlert", + () => new Promise(() => {}), + ), + ); + await insertOutboxRow({ + cronTimestamp: Date.now(), + deadlineOffsetSeconds: 1, + }); + + const started = Date.now(); + const summary = await drainOutbox({ + timeoutMs: 100, + rolloutPct: 100, + monitorIds: [monitorId], + }); + + expect(summary.dead).toBe(1); + expect(Date.now() - started).toBeLessThan(5000); + }); + + test("claims only the oldest pending row per channel", async () => { + stubs.push( + stub(providerToFunction.email, "sendAlert", () => Promise.resolve()), + ); + stubs.push( + stub(providerToFunction.email, "sendRecovery", () => Promise.resolve()), + ); + + const base = Date.now(); + const first = await insertOutboxRow({ + cronTimestamp: base, + eventType: "alert", + }); + await insertOutboxRow({ + cronTimestamp: base + 1000, + eventType: "recovery", + }); + + const summary = await drainOutbox({ + timeoutMs: 500, + rolloutPct: 100, + monitorIds: [monitorId], + }); + expect(summary.claimed).toBe(1); + + const done = await db + .select({ id: notificationOutbox.id }) + .from(notificationOutbox) + .where( + and( + eq(notificationOutbox.monitorId, monitorId), + eq(notificationOutbox.deliveryStatus, "settled"), + ), + ) + .all(); + expect(done.length).toBe(1); + expect(done[0]?.id).toBe(first.id); + + const second = await drainOutbox({ + timeoutMs: 500, + rolloutPct: 100, + monitorIds: [monitorId], + }); + expect(second.claimed).toBe(1); + }); + + test("an sms row over quota is recorded, not delivered", async () => { + stubs.push( + stub(providerToFunction.sms, "sendAlert", () => Promise.resolve()), + ); + + const { workspace: smsWorkspace } = await createTestWorkspace({ + plan: "free", + }); + const smsMonitor = await createMonitor(smsWorkspace.id, { + regions: "ams", + }); + const smsNotification = await createNotification(smsWorkspace.id, { + provider: "sms", + data: JSON.stringify({ sms: "+10000000000" }), + }); + await linkNotificationToMonitor(smsNotification.id, smsMonitor.id); + + // free plan allows zero SMS, so a single recorded send is already over. + await db + .insert(notificationTrigger) + .values({ + monitorId: smsMonitor.id, + notificationId: smsNotification.id, + cronTimestamp: Date.now(), + }) + .run(); + + const now = Math.floor(Date.now() / 1000); + await db + .insert(notificationOutbox) + .values({ + dedupKey: `sms:${smsMonitor.id}:${smsNotification.id}`, + monitorId: smsMonitor.id, + workspaceId: smsWorkspace.id, + notificationId: smsNotification.id, + provider: "sms", + eventType: "alert", + fromStatus: "active", + toStatus: "error", + cronTimestamp: Date.now(), + payload: PAYLOAD, + nextAttemptAt: now, + deadlineAt: now + 300, + createdAt: now, + }) + .run(); + + const summary = await drainOutbox({ + timeoutMs: 500, + rolloutPct: 100, + monitorIds: [smsMonitor.id], + }); + expect(summary.skipped).toBe(1); + + const rows = await db + .select() + .from(notificationOutbox) + .where(eq(notificationOutbox.notificationId, smsNotification.id)) + .all(); + expect(rows[0]?.deliveryStatus).toBe("settled"); + expect(rows[0]?.deliveredAt).toBe(null); + expect(rows[0]?.lastError).toBe("sms-quota-exceeded"); + + await db.delete(monitor).where(eq(monitor.id, smsMonitor.id)).run(); + }); +}); + +describe("retry", () => { + test("a failed send is handed back with its backoff recorded", async () => { + stubs.push( + stub(providerToFunction.email, "sendAlert", () => + Promise.reject(new Error("provider down")), + ), + ); + await insertOutboxRow({ cronTimestamp: Date.now() }); + + const summary = await drainOutbox({ + timeoutMs: 500, + rolloutPct: 100, + monitorIds: [monitorId], + }); + + expect(summary.retried).toBe(1); + expect(summary.dead).toBe(0); + expect(summary.nextRetryMs).not.toBe(null); + + const now = Math.floor(Date.now() / 1000); + const rows = await db + .select() + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(rows[0]?.deliveryStatus).toBe("pending"); + expect(rows[0]?.attempts).toBe(1); + expect(rows[0]?.lockedBy).toBe(null); + expect(rows[0]?.lockedUntil).toBe(null); + expect(rows[0]?.lastError).toContain("provider down"); + expect(rows[0]?.nextAttemptAt).toBeGreaterThan(now); + + // The backoff is the claim predicate, so nothing is claimable until it ends. + const again = await drainOutbox({ + timeoutMs: 500, + rolloutPct: 100, + monitorIds: [monitorId], + }); + expect(again.claimed).toBe(0); + }); + + test("a lapsed lease lets the other machine take the row over", async () => { + stubs.push( + stub(providerToFunction.email, "sendAlert", () => Promise.resolve()), + ); + // A worker that died mid-attempt: still claimed, lease already expired. + await insertOutboxRow({ + cronTimestamp: Date.now(), + attempts: 1, + lockedBy: "dead-worker", + lockedUntilOffsetSeconds: -1, + }); + + const summary = await drainOutbox({ + timeoutMs: 500, + rolloutPct: 100, + monitorIds: [monitorId], + }); + + expect(summary.delivered).toBe(1); + + const rows = await db + .select() + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(rows[0]?.deliveryStatus).toBe("settled"); + expect(rows[0]?.attempts).toBe(2); + }); + + test("a live lease is left alone", async () => { + stubs.push( + stub(providerToFunction.email, "sendAlert", () => Promise.resolve()), + ); + await insertOutboxRow({ + cronTimestamp: Date.now(), + attempts: 1, + lockedBy: "peer-worker", + lockedUntilOffsetSeconds: 60, + }); + + const summary = await drainOutbox({ + timeoutMs: 500, + rolloutPct: 100, + monitorIds: [monitorId], + }); + + expect(summary.claimed).toBe(0); + + const rows = await db + .select() + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(rows[0]?.lockedBy).toBe("peer-worker"); + expect(rows[0]?.deliveryStatus).toBe("pending"); + }); +}); + +describe("shutdownOutbox", () => { + test("does not release a row whose send is already in flight", async () => { + let dispatched = false; + let release: () => void = () => {}; + const pending = new Promise((resolve) => { + release = resolve; + }); + stubs.push( + stub(providerToFunction.email, "sendAlert", () => { + dispatched = true; + return pending; + }), + ); + + const row = await insertOutboxRow({ cronTimestamp: Date.now() }); + const drain = drainOutbox({ + timeoutMs: 5000, + rolloutPct: 100, + monitorIds: [monitorId], + }); + + while (!dispatched) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + // Grace expires while the provider call is still outstanding. + await shutdownOutbox(50); + + const during = await db + .select() + .from(notificationOutbox) + .where(eq(notificationOutbox.id, row.id)) + .all(); + expect(during[0]?.lockedBy).not.toBe(null); + expect(during[0]?.deliveryStatus).toBe("pending"); + // The lease covers one delivery batch, not the whole message. + expect(during[0]?.lockedUntil).toBeLessThan(during[0]?.deadlineAt ?? 0); + + release(); + await drain; + + const after = await db + .select() + .from(notificationOutbox) + .where(eq(notificationOutbox.id, row.id)) + .all(); + expect(after[0]?.deliveryStatus).toBe("settled"); + }); + + test("hands claimed work back instead of delivering it", async () => { + stubs.push( + stub(providerToFunction.email, "sendAlert", () => Promise.resolve()), + ); + await insertOutboxRow({ cronTimestamp: Date.now() }); + + await shutdownOutbox(); + + const summary = await drainOutbox({ + timeoutMs: 100, + rolloutPct: 100, + monitorIds: [monitorId], + }); + expect(summary.released).toBe(1); + expect(summary.delivered).toBe(0); + + const rows = await db + .select() + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(rows[0]?.deliveryStatus).toBe("pending"); + expect(rows[0]?.lockedBy).toBe(null); + expect(rows[0]?.lockedUntil).toBe(null); + }); +}); + +describe("sweepExpiredOutbox", () => { + test("never-owned rows are discarded, abandoned rows dead-letter", async () => { + const twoHoursAgo = Math.floor(Date.now() / 1000) - 2 * 60 * 60; + + const [neverOwned] = await db + .insert(notificationOutbox) + .values({ + dedupKey: `expired-never:${monitorId}`, + monitorId, + workspaceId, + notificationId, + provider: "email", + eventType: "alert", + fromStatus: "active", + toStatus: "error", + cronTimestamp: Date.now(), + payload: PAYLOAD, + nextAttemptAt: twoHoursAgo, + deadlineAt: twoHoursAgo, + createdAt: twoHoursAgo, + }) + .returning(); + + await db + .insert(notificationOutbox) + .values({ + dedupKey: `expired-abandoned:${monitorId}`, + monitorId, + workspaceId, + notificationId, + provider: "email", + eventType: "alert", + fromStatus: "active", + toStatus: "error", + cronTimestamp: Date.now() + 1, + payload: PAYLOAD, + attempts: 2, + nextAttemptAt: twoHoursAgo, + deadlineAt: twoHoursAgo, + createdAt: twoHoursAgo, + }) + .run(); + + await sweepExpiredOutbox(); + + const remaining = await db + .select({ total: count() }) + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(remaining[0]?.total).toBe(0); + + const dead = await db + .select() + .from(notificationDeadLetter) + .where(eq(notificationDeadLetter.monitorId, monitorId)) + .all(); + expect(dead.length).toBe(1); + expect(dead[0]?.attempts).toBe(2); + expect(neverOwned).not.toBe(undefined); + }); +}); diff --git a/apps/workflows/src/checker/outbox.ts b/apps/workflows/src/checker/outbox.ts new file mode 100644 index 00000000..05bbad81 --- /dev/null +++ b/apps/workflows/src/checker/outbox.ts @@ -0,0 +1,702 @@ +import { getLogger } from "@logtape/logtape"; +import { and, db, eq, inArray, lt, notInArray, sql } from "@openstatus/db"; +import type { + Incident, + Monitor, + Notification, +} from "@openstatus/db/src/schema"; +import { + notificationOutbox, + incidentTable, + monitor, + notification, + notificationDeadLetter, + notificationTrigger, + selectMonitorSchema, + selectNotificationSchema, +} from "@openstatus/db/src/schema"; +import { withBusyRetry } from "@openstatus/services"; +import * as Sentry from "@sentry/deno"; +import { Effect, Exit, Queue } from "effect"; + +import { env } from "../env"; +import { checkerAudit } from "../utils/audit-log"; +import { loadSmsQuotaBlocked } from "./sms-quota"; +import { providerToFunction } from "./utils"; + +const logger = getLogger(["workflow"]); + +const CLAIM_LIMIT = 20; +const DELIVERY_CONCURRENCY = 5; +const MAX_BACKOFF_MS = 30_000; + +/** + * A claim leases a row for one delivery attempt, not for the whole message: a + * worker that dies mid-send has to hand the row back long before the deadline. + * The lease still has to outlast the worst case for a claimed row, which is + * waiting behind a full batch at the delivery concurrency, plus the commit. + */ +const LEASE_SLACK_MS = 5_000; + +function leaseSeconds(limit: number, timeoutMs: number): number { + const waves = Math.ceil(limit / DELIVERY_CONCURRENCY); + return Math.ceil((waves * timeoutMs + LEASE_SLACK_MS) / 1000); +} + +const workerId = crypto.randomUUID(); + +type OutboxRow = typeof notificationOutbox.$inferSelect; + +type DeliveryOutcome = + | { kind: "delivered"; row: OutboxRow } + | { kind: "skipped"; row: OutboxRow; reason: string } + | { kind: "released"; row: OutboxRow } + | { + kind: "retry"; + row: OutboxRow; + error: string; + delayMs: number; + nextAttemptAt: number; + } + | { kind: "dead"; row: OutboxRow; error: string }; + +export type DrainSummary = { + claimed: number; + delivered: number; + skipped: number; + released: number; + retried: number; + dead: number; + /** Time until the earliest backoff this drain wrote, so the caller can wake. */ + nextRetryMs: number | null; +}; + +let shuttingDown = false; + +// Rows whose provider call has been dispatched and not yet resolved. Shutdown +// must not hand these to the peer machine: the send can still succeed after the +// row is released, and the peer would deliver it a second time. +const inFlightSends = new Set(); +const activeDrains = new Set>(); + +const SEND_METHOD = { + alert: "sendAlert", + recovery: "sendRecovery", + degraded: "sendDegraded", +} as const; + +function backoffMs(attempt: number): number { + const ceiling = Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS); + return ceiling / 2 + Math.random() * (ceiling / 2); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Claims only the oldest pending row per (monitor, notification), so an alert + * and the recovery behind it can never be delivered out of order, including + * across machines. `rolloutPct` gates which monitors the drainer owns; at 0 the + * inline sender is still authoritative and nothing here is claimable. + */ +async function claimRows( + limit: number, + rolloutPct: number, + lease: number, + monitorIds?: number[], +): Promise { + const scope = + monitorIds === undefined + ? sql`` + : sql` AND o.monitor_id IN (${sql.join( + monitorIds.map((id) => sql`${id}`), + sql`, `, + )})`; + + return withBusyRetry(() => + db + .update(notificationOutbox) + .set({ + lockedBy: workerId, + lockedUntil: sql`min(unixepoch() + ${lease}, ${notificationOutbox.deadlineAt})`, + attempts: sql`${notificationOutbox.attempts} + 1`, + }) + .where( + sql`${notificationOutbox.id} IN ( + SELECT o.id FROM ${notificationOutbox} o + WHERE o.delivery_status = 'pending' + AND (o.monitor_id % 100) < ${rolloutPct} + AND o.next_attempt_at <= unixepoch() + AND o.deadline_at > unixepoch()${scope} + AND (o.locked_until IS NULL OR o.locked_until < unixepoch()) + AND NOT EXISTS ( + SELECT 1 FROM ${notificationOutbox} older + WHERE older.monitor_id = o.monitor_id + AND older.notification_id = o.notification_id + AND older.delivery_status = 'pending' + AND older.id < o.id) + ORDER BY o.id LIMIT ${limit})`, + ) + .returning(), + ); +} + +type DeliveryDeps = { + monitors: Map; + notifications: Map; + incidents: Map; + smsBlocked: Map; +}; + +async function loadDeps(rows: OutboxRow[]): Promise { + const monitorIds = [...new Set(rows.map((row) => row.monitorId))]; + const notificationIds = [...new Set(rows.map((row) => row.notificationId))]; + const incidentIds = [ + ...new Set( + rows + .map((row) => row.incidentId) + .filter((id): id is number => id !== null), + ), + ]; + + const [monitorRows, notificationRows, incidentRows] = await withBusyRetry( + () => + db.batch([ + db.select().from(monitor).where(inArray(monitor.id, monitorIds)), + db + .select() + .from(notification) + .where(inArray(notification.id, notificationIds)), + db + .select() + .from(incidentTable) + .where( + incidentIds.length === 0 + ? sql`1 = 0` + : inArray(incidentTable.id, incidentIds), + ), + ]), + ); + + const monitors = new Map(); + for (const row of monitorRows) { + const parsed = selectMonitorSchema.safeParse(row); + if (parsed.success) monitors.set(row.id, parsed.data); + } + + const notifications = new Map(); + for (const row of notificationRows) { + const parsed = selectNotificationSchema.safeParse(row); + if (parsed.success) notifications.set(row.id, parsed.data); + } + + const incidents = new Map(); + for (const row of incidentRows) { + incidents.set(row.id, row); + } + + const smsWorkspaces = [ + ...new Set( + rows + .filter((row) => row.provider === "sms") + .map((row) => row.workspaceId) + .filter((id): id is number => id !== null), + ), + ]; + const smsBlocked = await loadSmsQuotaBlocked(smsWorkspaces); + + return { monitors, notifications, incidents, smsBlocked }; +} + +function deliverRow( + row: OutboxRow, + deps: DeliveryDeps, + timeoutMs: number, +): Effect.Effect { + return Effect.gen(function* () { + if (shuttingDown) return { kind: "released", row } as const; + + const monitorRow = deps.monitors.get(row.monitorId); + const notificationRow = deps.notifications.get(row.notificationId); + if (!monitorRow || !notificationRow) { + return { + kind: "dead", + row, + error: "monitor or notification no longer exists", + } as const; + } + + if (row.provider === "sms" && row.workspaceId !== null) { + if (deps.smsBlocked.get(row.workspaceId) === true) { + return { kind: "skipped", row, reason: "sms-quota-exceeded" } as const; + } + } + + const send = providerToFunction[row.provider][SEND_METHOD[row.eventType]]; + const context = { + monitor: monitorRow, + notification: notificationRow, + statusCode: row.payload.statusCode, + message: row.payload.message, + cronTimestamp: row.cronTimestamp, + regions: row.payload.regions, + latency: row.payload.latency, + incident: + row.incidentId === null + ? undefined + : deps.incidents.get(row.incidentId), + }; + + inFlightSends.add(row.id); + const exit = yield* Effect.exit( + Effect.tryPromise({ + try: () => send(context), + catch: (error) => new Error(errorMessage(error)), + }).pipe(Effect.timeout(timeoutMs)), + ); + inFlightSends.delete(row.id); + + if (Exit.isSuccess(exit)) return { kind: "delivered", row } as const; + + // The backoff is written to the row rather than slept through, so a retry + // survives this process and either machine can pick it up. + const error = String(exit.cause); + const delayMs = backoffMs(row.attempts); + if (Date.now() + delayMs >= row.deadlineAt * 1000) { + return { kind: "dead", row, error } as const; + } + + return { + kind: "retry", + row, + error, + delayMs, + nextAttemptAt: Math.ceil((Date.now() + delayMs) / 1000), + } as const; + }); +} + +async function commitDelivered(rows: OutboxRow[]): Promise { + if (rows.length === 0) return; + const ids = rows.map((row) => row.id); + + await withBusyRetry(() => + db.batch([ + db + .update(notificationOutbox) + .set({ + deliveryStatus: "settled", + outcome: "delivered", + deliveredAt: Math.floor(Date.now() / 1000), + lockedBy: null, + lockedUntil: null, + }) + .where(inArray(notificationOutbox.id, ids)), + db + .insert(notificationTrigger) + .values( + rows.map((row) => ({ + monitorId: row.monitorId, + notificationId: row.notificationId, + cronTimestamp: row.cronTimestamp, + })), + ) + .onConflictDoNothing(), + ]), + ); + + // Best-effort: the rows are settled and the triggers written. Throwing here + // would abort the rest of the drain's commits and take the consumer loop down + // with it, for a telemetry write. + try { + await Promise.all( + rows.map((row) => + checkerAudit.publishAuditLog({ + id: `monitor:${row.monitorId}`, + action: "notification.sent", + targets: [{ id: String(row.monitorId), type: "monitor" }], + metadata: { + provider: row.provider, + cronTimestamp: row.cronTimestamp, + type: row.eventType, + notificationId: row.notificationId, + }, + }), + ), + ); + } catch (error) { + logger.warn("Failed to publish notification audit log", { + delivered_count: rows.length, + error_message: errorMessage(error), + }); + } +} + +async function commitSkipped( + entries: { row: OutboxRow; reason: string }[], +): Promise { + const [first, ...rest] = entries.map((entry) => + db + .update(notificationOutbox) + .set({ + deliveryStatus: "settled", + outcome: "skipped", + lockedBy: null, + lockedUntil: null, + lastError: entry.reason, + }) + .where(eq(notificationOutbox.id, entry.row.id)), + ); + if (first === undefined) return; + await withBusyRetry(() => db.batch([first, ...rest])); +} + +/** + * Hands the row back with its backoff recorded. `attempts` already counts this + * claim, so the row carries how many sends it has cost across every worker. + */ +async function commitRetry( + entries: { row: OutboxRow; error: string; nextAttemptAt: number }[], +): Promise { + const [first, ...rest] = entries.map((entry) => + db + .update(notificationOutbox) + .set({ + nextAttemptAt: entry.nextAttemptAt, + lockedBy: null, + lockedUntil: null, + lastError: entry.error.slice(0, 2000), + }) + .where(eq(notificationOutbox.id, entry.row.id)), + ); + if (first === undefined) return; + await withBusyRetry(() => db.batch([first, ...rest])); +} + +async function commitReleased(rows: OutboxRow[]): Promise { + if (rows.length === 0) return; + await withBusyRetry(() => + db + .update(notificationOutbox) + .set({ lockedBy: null, lockedUntil: null }) + .where( + inArray( + notificationOutbox.id, + rows.map((row) => row.id), + ), + ), + ); +} + +async function commitDead( + entries: { row: OutboxRow; error: string }[], +): Promise { + if (entries.length === 0) return; + const diedAt = Math.floor(Date.now() / 1000); + + await withBusyRetry(() => + db.batch([ + db.insert(notificationDeadLetter).values( + entries.map(({ row, error }) => ({ + outboxId: row.id, + dedupKey: row.dedupKey, + monitorId: row.monitorId, + workspaceId: row.workspaceId, + notificationId: row.notificationId, + provider: row.provider, + eventType: row.eventType, + fromStatus: row.fromStatus, + toStatus: row.toStatus, + cronTimestamp: row.cronTimestamp, + incidentId: row.incidentId, + payload: row.payload, + attempts: row.attempts, + finalError: error.slice(0, 2000), + diedAt, + })), + ), + db.delete(notificationOutbox).where( + inArray( + notificationOutbox.id, + entries.map(({ row }) => row.id), + ), + ), + ]), + ); + + for (const { row, error } of entries) { + logger.error("Notification dead-lettered", { + monitor_id: row.monitorId, + notification_id: row.notificationId, + provider: row.provider, + event_type: row.eventType, + attempts: row.attempts, + error_message: error, + }); + Sentry.captureException( + new Error( + `Notification dead-lettered: ${row.provider} for monitor ${row.monitorId}`, + ), + ); + } +} + +export type DrainOptions = { + limit?: number; + timeoutMs?: number; + rolloutPct?: number; + /** Restrict the drain to these monitors. */ + monitorIds?: number[]; +}; + +/** One claim-and-deliver cycle. Safe to run concurrently on both machines. */ +export function drainOutbox(options: DrainOptions = {}): Promise { + const drain = runDrain(options); + activeDrains.add(drain); + return drain.finally(() => { + activeDrains.delete(drain); + }); +} + +async function runDrain(options: DrainOptions): Promise { + const limit = options.limit ?? CLAIM_LIMIT; + const timeoutMs = options.timeoutMs ?? env().NOTIFICATION_TIMEOUT_MS; + const rolloutPct = options.rolloutPct ?? env().OUTBOX_ROLLOUT_PCT; + const rows = await claimRows( + limit, + rolloutPct, + leaseSeconds(limit, timeoutMs), + options.monitorIds, + ); + if (rows.length === 0) { + return { + claimed: 0, + delivered: 0, + skipped: 0, + released: 0, + retried: 0, + dead: 0, + nextRetryMs: null, + }; + } + + const deps = await loadDeps(rows); + + const outcomes = await Effect.runPromise( + Effect.forEach(rows, (row) => deliverRow(row, deps, timeoutMs), { + concurrency: DELIVERY_CONCURRENCY, + }), + ); + + const delivered = outcomes + .filter((outcome) => outcome.kind === "delivered") + .map((outcome) => outcome.row); + const skipped = outcomes.filter((outcome) => outcome.kind === "skipped"); + const released = outcomes + .filter((outcome) => outcome.kind === "released") + .map((outcome) => outcome.row); + const retried = outcomes.filter((outcome) => outcome.kind === "retry"); + const dead = outcomes.filter((outcome) => outcome.kind === "dead"); + + await commitDelivered(delivered); + await commitSkipped(skipped); + await commitReleased(released); + await commitRetry(retried); + await commitDead(dead); + + return { + claimed: rows.length, + delivered: delivered.length, + skipped: skipped.length, + released: released.length, + retried: retried.length, + dead: dead.length, + nextRetryMs: + retried.length === 0 + ? null + : Math.min(...retried.map((outcome) => outcome.delayMs)), + }; +} + +const EXPIRED_GRACE_SECONDS = 60 * 60; + +/** + * A row can expire without ever being claimed: the rollout gate excluded it, or + * nothing drained for longer than the deadline. Claiming it later would page + * someone about an outage that is long over, so the claim skips expired rows and + * this sweep retires them. `attempts > 0` means a worker claimed it and it still + * never landed, which is a dead letter; `attempts = 0` means we never owned it. + */ +export async function sweepExpiredOutbox(): Promise<{ + deadLettered: number; + discarded: number; +}> { + const cutoff = Math.floor(Date.now() / 1000) - EXPIRED_GRACE_SECONDS; + + const expired = await withBusyRetry(() => + db + .select() + .from(notificationOutbox) + .where( + and( + eq(notificationOutbox.deliveryStatus, "pending"), + lt(notificationOutbox.deadlineAt, cutoff), + ), + ) + .limit(200) + .all(), + ); + + if (expired.length === 0) return { deadLettered: 0, discarded: 0 }; + + const abandoned = expired.filter((row) => row.attempts > 0); + const neverOwned = expired.filter((row) => row.attempts === 0); + + await commitDead( + abandoned.map((row) => ({ + row, + error: "expired before delivery completed", + })), + ); + + if (neverOwned.length > 0) { + await withBusyRetry(() => + db.delete(notificationOutbox).where( + inArray( + notificationOutbox.id, + neverOwned.map((row) => row.id), + ), + ), + ); + logger.info("Discarded outbox rows that were never owned", { + count: neverOwned.length, + }); + } + + return { deadLettered: abandoned.length, discarded: neverOwned.length }; +} + +/** Drains until the queue is empty, so one wake-up clears a whole burst. */ +export async function drainUntilEmpty( + options: DrainOptions = {}, +): Promise { + const total: DrainSummary = { + claimed: 0, + delivered: 0, + skipped: 0, + released: 0, + retried: 0, + dead: 0, + nextRetryMs: null, + }; + + const resolved: DrainOptions = { + limit: options.limit ?? CLAIM_LIMIT, + timeoutMs: options.timeoutMs ?? env().NOTIFICATION_TIMEOUT_MS, + rolloutPct: options.rolloutPct ?? env().OUTBOX_ROLLOUT_PCT, + monitorIds: options.monitorIds, + }; + + while (!shuttingDown) { + const summary = await drainOutbox(resolved); + total.claimed += summary.claimed; + total.delivered += summary.delivered; + total.skipped += summary.skipped; + total.released += summary.released; + total.retried += summary.retried; + total.dead += summary.dead; + if (summary.nextRetryMs !== null) { + total.nextRetryMs = + total.nextRetryMs === null + ? summary.nextRetryMs + : Math.min(total.nextRetryMs, summary.nextRetryMs); + } + if (summary.claimed < (resolved.limit ?? CLAIM_LIMIT)) break; + } + + return total; +} + +const wakeQueue = Effect.runSync(Queue.make()); + +/** The durable state is the outbox row; this only decides when we look. */ +export function enqueueOutbox(ids: number[]): void { + for (const id of ids) { + Queue.offerUnsafe(wakeQueue, id); + } +} + +const retryTimers = new Set>(); + +/** + * The backoff is already durable in `next_attempt_at` and the safety-net cron + * would find it; this only makes the wait match the backoff instead of the cron. + */ +function scheduleRetryWake(delayMs: number): void { + if (shuttingDown) return; + const timer = setTimeout(() => { + retryTimers.delete(timer); + Queue.offerUnsafe(wakeQueue, 0); + }, delayMs); + retryTimers.add(timer); +} + +export function startOutboxConsumer(): void { + const loop = Effect.gen(function* () { + while (!shuttingDown) { + yield* Queue.takeAll(wakeQueue); + const summary = yield* Effect.promise(() => drainUntilEmpty()); + if (summary.nextRetryMs !== null) scheduleRetryWake(summary.nextRetryMs); + } + }); + + void Effect.runPromise(loop).catch((error) => { + logger.error("Outbox consumer stopped", { + error_message: errorMessage(error), + }); + }); +} + +/** + * Fly SIGTERMs on every deploy and on the daily restart. Releasing unstarted + * claims hands them to the peer machine now instead of at lease expiry. + */ +export async function shutdownOutbox(graceMs?: number): Promise { + shuttingDown = true; + for (const timer of retryTimers) clearTimeout(timer); + retryTimers.clear(); + await Effect.runPromise(Queue.shutdown(wakeQueue)); + await waitForDrains(graceMs ?? env().NOTIFICATION_TIMEOUT_MS + 5_000); + + // Anything still sending keeps its lease: the provider call can succeed after + // we stop watching, so the peer waits for the lease to lapse rather than + // starting a second send while this one is outstanding. + const stillSending = [...inFlightSends]; + await withBusyRetry(() => + db + .update(notificationOutbox) + .set({ lockedBy: null, lockedUntil: null }) + .where( + stillSending.length === 0 + ? eq(notificationOutbox.lockedBy, workerId) + : and( + eq(notificationOutbox.lockedBy, workerId), + notInArray(notificationOutbox.id, stillSending), + ), + ), + ); +} + +async function waitForDrains(graceMs: number): Promise { + if (activeDrains.size === 0) return; + let timer: ReturnType | undefined; + const grace = new Promise((resolve) => { + timer = setTimeout(resolve, graceMs); + }); + await Promise.race([ + Promise.allSettled(activeDrains).then(() => undefined), + grace, + ]); + if (timer !== undefined) clearTimeout(timer); +} diff --git a/apps/workflows/src/checker/quorum.ts b/apps/workflows/src/checker/quorum.ts new file mode 100644 index 00000000..e261f5ef --- /dev/null +++ b/apps/workflows/src/checker/quorum.ts @@ -0,0 +1,50 @@ +import { type SQL, sql } from "@openstatus/db"; +import type { MonitorStatus } from "@openstatus/db/src/schema"; +import { monitor, monitorStatusTable } from "@openstatus/db/src/schema"; + +export type QuorumParams = { + toStatus: MonitorStatus; + regionsJson: string; + regionCount: number; +}; + +/** + * The rule itself: `affected >= total / 2`, without floating point. Every other + * expression in this file — and every caller — goes through it, so a change to + * how quorum is decided happens here and nowhere else. + */ +export function quorumMetSql(affected: SQL, total: SQL): SQL { + return sql`${affected} * 2 >= ${total}`; +} + +/** Regions of this monitor currently reporting `toStatus`. Correlates on `monitor.id`. */ +export function quorumCountSql({ + toStatus, + regionsJson, +}: Pick): SQL { + return sql`(SELECT count(*) FROM ${monitorStatusTable} + WHERE ${monitorStatusTable.monitorId} = ${monitor.id} + AND ${monitorStatusTable.status} = ${toStatus} + AND ${monitorStatusTable.region} IN (SELECT value FROM json_each(${regionsJson})))`; +} + +/** Hot path: the region list arrives as a bound JSON array. */ +export function quorumGuardSql(params: QuorumParams): SQL { + return sql`(${params.regionCount} > 0 AND ${quorumMetSql( + quorumCountSql(params), + sql`${params.regionCount}`, + )})`; +} + +/** + * `monitor.regions` is a comma-joined text column. Deriving the count and + * membership in SQL is only worth it where the monitor row is not already in + * hand — currently just the drift sweep, which scans every monitor at once. + */ +export function csvRegionCountSql(): SQL { + return sql`(length(${monitor.regions}) - length(replace(${monitor.regions}, ',', '')) + 1)`; +} + +export function csvRegionMemberSql(region: SQL): SQL { + return sql`instr(',' || ${monitor.regions} || ',', ',' || ${region} || ',') > 0`; +} diff --git a/apps/workflows/src/checker/sms-quota.ts b/apps/workflows/src/checker/sms-quota.ts new file mode 100644 index 00000000..7a93c6ba --- /dev/null +++ b/apps/workflows/src/checker/sms-quota.ts @@ -0,0 +1,93 @@ +import { and, count, db, eq, gte, inArray } from "@openstatus/db"; +import { + notification, + notificationTrigger, + selectWorkspaceSchema, + workspace, +} from "@openstatus/db/src/schema"; +import { withBusyRetry } from "@openstatus/services"; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * `cron_timestamp` is stored in milliseconds; comparing it against a + * seconds-resolution cutoff made the window unbounded, so every workspace that + * had ever passed its limit stayed blocked. + * + * Two round trips regardless of how many workspaces are asked about. + */ +export async function loadSmsQuotaBlocked( + workspaceIds: number[], +): Promise> { + const blocked = new Map(); + if (workspaceIds.length === 0) return blocked; + + const [workspaceRows, notificationRows] = await withBusyRetry(() => + db.batch([ + db.select().from(workspace).where(inArray(workspace.id, workspaceIds)), + db + .select({ id: notification.id, workspaceId: notification.workspaceId }) + .from(notification) + .where( + and( + inArray(notification.workspaceId, workspaceIds), + eq(notification.provider, "sms"), + ), + ), + ]), + ); + + const notificationsByWorkspace = new Map(); + for (const row of notificationRows) { + if (row.workspaceId === null) continue; + const ids = notificationsByWorkspace.get(row.workspaceId) ?? []; + ids.push(row.id); + notificationsByWorkspace.set(row.workspaceId, ids); + } + + const notificationIds = notificationRows.map((row) => row.id); + const sentRows = + notificationIds.length === 0 + ? [] + : await withBusyRetry(() => + db + .select({ + notificationId: notificationTrigger.notificationId, + total: count(), + }) + .from(notificationTrigger) + .where( + and( + inArray(notificationTrigger.notificationId, notificationIds), + gte( + notificationTrigger.cronTimestamp, + Date.now() - THIRTY_DAYS_MS, + ), + ), + ) + .groupBy(notificationTrigger.notificationId) + .all(), + ); + + const sentByNotification = new Map(); + for (const row of sentRows) { + if (row.notificationId === null) continue; + sentByNotification.set(row.notificationId, row.total); + } + + for (const row of workspaceRows) { + const parsed = selectWorkspaceSchema.safeParse(row); + if (!parsed.success) { + blocked.set(row.id, true); + continue; + } + const ids = notificationsByWorkspace.get(row.id) ?? []; + const sent = ids.reduce( + (total, id) => total + (sentByNotification.get(id) ?? 0), + 0, + ); + blocked.set(row.id, sent > parsed.data.limits["sms-limit"]); + } + + return blocked; +} diff --git a/apps/workflows/src/checker/transition.test.ts b/apps/workflows/src/checker/transition.test.ts new file mode 100644 index 00000000..fe67e82c --- /dev/null +++ b/apps/workflows/src/checker/transition.test.ts @@ -0,0 +1,409 @@ +import { and, count, db, eq, isNull } from "@openstatus/db"; +import { + monitorTransition, + notificationOutbox, + incidentTable, + monitor, + monitorStatusTable, +} from "@openstatus/db/src/schema"; +import { + createMonitor, + createNotification, + createTestWorkspace, + linkNotificationToMonitor, +} from "@openstatus/db/src/test/factories"; +import { + afterAll, + beforeAll, + describe, + expect, + test, +} from "@openstatus/test-utils"; + +import { drainOutbox } from "./outbox"; +import { applyStatusTransition, isStaleCheck } from "./transition"; + +const REGIONS = ["ams", "arn", "atl", "bog", "bom", "bos"] as const; +const DEADLINE_SECONDS = 300; + +let workspaceId: number; + +beforeAll(async () => { + const { workspace } = await createTestWorkspace(); + workspaceId = workspace.id; +}); + +afterAll(async () => { + await db.delete(monitor).where(eq(monitor.workspaceId, workspaceId)).run(); +}); + +async function makeMonitor(regionCount: number, withNotification = true) { + const regions = REGIONS.slice(0, regionCount); + const row = await createMonitor(workspaceId, { + regions: regions.join(","), + }); + if (withNotification) { + const notif = await createNotification(workspaceId); + await linkNotificationToMonitor(notif.id, row.id); + } + return { monitorId: row.id, regions }; +} + +async function seedRegionStatus( + monitorId: number, + regions: readonly string[], + status: "active" | "error" | "degraded", +) { + if (regions.length === 0) return; + await db + .insert(monitorStatusTable) + .values( + regions.map((region) => ({ + monitorId, + region, + status, + cronTimestamp: 0, + })), + ) + .run(); +} + +function transitionInput( + monitorId: number, + region: string, + cronTimestamp: number, +) { + return { + monitorId, + region, + status: "error" as const, + cronTimestamp, + deadlineSeconds: DEADLINE_SECONDS, + rolloutPct: 100, + }; +} + +describe("quorum", () => { + const cases: { regions: number; affected: number }[] = []; + for (let regions = 1; regions <= 6; regions++) { + for (let affected = 1; affected <= regions; affected++) { + cases.push({ regions, affected }); + } + } + + for (const { regions, affected } of cases) { + test(`${affected} of ${regions} regions in error`, async () => { + const { monitorId, regions: list } = await makeMonitor(regions, false); + await seedRegionStatus(monitorId, list.slice(0, affected - 1), "error"); + + const result = await applyStatusTransition( + transitionInput(monitorId, list[affected - 1], Date.now()), + ); + + // Matches the pre-existing `affected >= regions / 2 || regions === 1`. + const expected = affected >= regions / 2 || regions === 1; + + expect(result.kind).toBe("evaluated"); + if (result.kind !== "evaluated") return; + expect(result.quorumCount).toBe(affected); + expect(result.regionCount).toBe(regions); + expect(result.transitioned).toBe(expected); + }); + } +}); + +describe("fast path", () => { + test("an unchanged region status writes nothing and short-circuits", async () => { + const { monitorId, regions } = await makeMonitor(1); + const first = await applyStatusTransition( + transitionInput(monitorId, regions[0], Date.now()), + ); + expect(first.kind).toBe("evaluated"); + + const second = await applyStatusTransition( + transitionInput(monitorId, regions[0], Date.now() + 1), + ); + expect(second.kind).toBe("unchanged"); + }); + + test("an older cronTimestamp is rejected", async () => { + const { monitorId, regions } = await makeMonitor(1); + const now = Date.now(); + await applyStatusTransition(transitionInput(monitorId, regions[0], now)); + + const stale = await applyStatusTransition({ + monitorId, + region: regions[0], + status: "active", + cronTimestamp: now - 60_000, + deadlineSeconds: DEADLINE_SECONDS, + rolloutPct: 100, + }); + + expect(stale.kind).toBe("unchanged"); + + const rows = await db + .select({ status: monitorStatusTable.status }) + .from(monitorStatusTable) + .where( + and( + eq(monitorStatusTable.monitorId, monitorId), + eq(monitorStatusTable.region, regions[0]), + ), + ) + .all(); + expect(rows[0]?.status).toBe("error"); + }); +}); + +describe("replay", () => { + test("repeating the same check produces one incident and one outbox row", async () => { + const { monitorId, regions } = await makeMonitor(1); + const cronTimestamp = Date.now(); + + for (let i = 0; i < 5; i++) { + await applyStatusTransition( + transitionInput(monitorId, regions[0], cronTimestamp), + ); + } + + const incidents = await db + .select({ total: count() }) + .from(incidentTable) + .where( + and( + eq(incidentTable.monitorId, monitorId), + isNull(incidentTable.resolvedAt), + ), + ) + .all(); + expect(incidents[0]?.total).toBe(1); + + const outbox = await db + .select({ total: count() }) + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(outbox[0]?.total).toBe(1); + }); +}); + +describe("decision journal", () => { + test("records the quorum inputs and the outcome", async () => { + const { monitorId, regions } = await makeMonitor(3); + const cronTimestamp = Date.now(); + + await applyStatusTransition( + transitionInput(monitorId, regions[0], cronTimestamp), + ); + + const rows = await db + .select() + .from(monitorTransition) + .where(eq(monitorTransition.monitorId, monitorId)) + .all(); + + expect(rows.length).toBe(1); + expect(rows[0]?.quorumCount).toBe(1); + expect(rows[0]?.regionCount).toBe(3); + expect(rows[0]?.transitioned).toBe(false); + expect(rows[0]?.fromStatus).toBe("active"); + expect(rows[0]?.toStatus).toBe("error"); + }); +}); + +describe("recovery", () => { + test("resolves the open incident and enqueues a recovery", async () => { + const { monitorId, regions } = await makeMonitor(1); + const down = Date.now(); + await applyStatusTransition(transitionInput(monitorId, regions[0], down)); + + const recovery = await applyStatusTransition({ + monitorId, + region: regions[0], + status: "active", + cronTimestamp: down + 60_000, + deadlineSeconds: DEADLINE_SECONDS, + rolloutPct: 100, + }); + + expect(recovery.kind).toBe("evaluated"); + if (recovery.kind !== "evaluated") return; + expect(recovery.transitioned).toBe(true); + expect(recovery.outboxRows.length).toBe(1); + + const open = await db + .select({ total: count() }) + .from(incidentTable) + .where( + and( + eq(incidentTable.monitorId, monitorId), + isNull(incidentTable.resolvedAt), + ), + ) + .all(); + expect(open[0]?.total).toBe(0); + + const events = await db + .select({ eventType: notificationOutbox.eventType }) + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(events.map((row) => row.eventType).sort()).toEqual([ + "alert", + "recovery", + ]); + }); +}); + +describe("concurrency", () => { + test("parallel region failures produce exactly one incident", async () => { + const { monitorId, regions } = await makeMonitor(4); + const cronTimestamp = Date.now(); + + await Promise.all( + regions.map((region) => + applyStatusTransition( + transitionInput(monitorId, region, cronTimestamp), + ), + ), + ); + + const incidents = await db + .select({ total: count() }) + .from(incidentTable) + .where(eq(incidentTable.monitorId, monitorId)) + .all(); + expect(incidents[0]?.total).toBe(1); + + const outbox = await db + .select({ total: count() }) + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(outbox[0]?.total).toBe(1); + + const monitorRow = await db + .select({ status: monitor.status }) + .from(monitor) + .where(eq(monitor.id, monitorId)) + .all(); + expect(monitorRow[0]?.status).toBe("error"); + }); +}); + +describe("isStaleCheck", () => { + test("accepts a fresh payload and rejects an old one", () => { + const now = 1_000_000; + expect(isStaleCheck(now - 1000, 600_000, now)).toBe(false); + expect(isStaleCheck(now - 700_000, 600_000, now)).toBe(true); + }); +}); + +describe("degraded", () => { + test("enqueues a degraded notification and resolves an open incident", async () => { + const { monitorId, regions } = await makeMonitor(1); + const down = Date.now(); + await applyStatusTransition(transitionInput(monitorId, regions[0], down)); + + const degraded = await applyStatusTransition({ + monitorId, + region: regions[0], + status: "degraded", + cronTimestamp: down + 60_000, + deadlineSeconds: DEADLINE_SECONDS, + rolloutPct: 100, + }); + + expect(degraded.kind).toBe("evaluated"); + if (degraded.kind !== "evaluated") return; + expect(degraded.transitioned).toBe(true); + expect(degraded.outboxRows.length).toBe(1); + expect(degraded.incidentId).not.toBe(null); + + const events = await db + .select({ + eventType: notificationOutbox.eventType, + incidentId: notificationOutbox.incidentId, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(events.map((row) => row.eventType).sort()).toEqual([ + "alert", + "degraded", + ]); + // the degraded row captured the incident before it was resolved + for (const row of events) expect(row.incidentId).not.toBe(null); + + const open = await db + .select({ total: count() }) + .from(incidentTable) + .where( + and( + eq(incidentTable.monitorId, monitorId), + isNull(incidentTable.resolvedAt), + ), + ) + .all(); + expect(open[0]?.total).toBe(0); + }); +}); + +describe("rollout gate", () => { + test("a row written outside the rollout is not redelivered when the gate opens", async () => { + const { monitorId, regions } = await makeMonitor(1); + + const result = await applyStatusTransition({ + monitorId, + region: regions[0], + status: "error", + cronTimestamp: Date.now(), + deadlineSeconds: DEADLINE_SECONDS, + rolloutPct: 0, + }); + + expect(result.kind).toBe("evaluated"); + if (result.kind !== "evaluated") return; + expect(result.transitioned).toBe(true); + + const rows = await db + .select() + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(rows.length).toBe(1); + expect(rows[0]?.deliveryStatus).toBe("settled"); + expect(rows[0]?.outcome).toBe("inline"); + + // The inline sender already delivered this one; opening the gate must not + // make the drainer claim it a second time. + const summary = await drainOutbox({ + timeoutMs: 100, + rolloutPct: 100, + monitorIds: [monitorId], + }); + expect(summary.claimed).toBe(0); + }); + + test("a row written inside the rollout is claimable", async () => { + const { monitorId, regions } = await makeMonitor(1); + + await applyStatusTransition({ + monitorId, + region: regions[0], + status: "error", + cronTimestamp: Date.now(), + deadlineSeconds: DEADLINE_SECONDS, + rolloutPct: 100, + }); + + const rows = await db + .select() + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .all(); + expect(rows[0]?.deliveryStatus).toBe("pending"); + expect(rows[0]?.outcome).toBe(null); + }); +}); diff --git a/apps/workflows/src/checker/transition.ts b/apps/workflows/src/checker/transition.ts new file mode 100644 index 00000000..86ce13f0 --- /dev/null +++ b/apps/workflows/src/checker/transition.ts @@ -0,0 +1,384 @@ +import { getLogger } from "@logtape/logtape"; +import { type SQL, and, db, eq, sql } from "@openstatus/db"; +import type { + NotificationOutboxPayload, + MonitorStatus, + NotificationProvider, +} from "@openstatus/db/src/schema"; +import { + monitorTransition, + notificationOutbox, + notificationOutboxEventType, + incidentTable, + monitor, + monitorStatusTable, + notification, + notificationsToMonitors, + selectMonitorSchema, +} from "@openstatus/db/src/schema"; +import { withBusyRetry } from "@openstatus/services"; + +import { checkerAudit } from "../utils/audit-log"; + +const logger = getLogger(["workflow"]); +import { quorumCountSql, quorumGuardSql } from "./quorum"; + +export type EventType = (typeof notificationOutboxEventType)[number]; + +export const EVENT_TYPE: Record = { + active: "recovery", + degraded: "degraded", + error: "alert", +}; + +export type TransitionInput = { + monitorId: number; + region: string; + status: MonitorStatus; + cronTimestamp: number; + statusCode?: number; + message?: string; + latency?: number; + deadlineSeconds: number; + /** Monitors outside this gate are still delivered by the inline sender. */ + rolloutPct: number; +}; + +export type OutboxRowRef = { + id: number; + notificationId: number; + provider: NotificationProvider; + /** `pending` means the drainer owns delivery; `settled` means the inline sender does. */ + deliveryStatus: "pending" | "settled"; +}; + +export type TransitionResult = + | { kind: "unchanged" } + | { kind: "monitor-missing" } + | { + kind: "evaluated"; + transitioned: boolean; + quorumCount: number; + regionCount: number; + affectedRegions: string[]; + outboxRows: OutboxRowRef[]; + incidentId: number | null; + incidentCreatedId: number | null; + incidentResolvedIds: number[]; + }; + +/** + * A Cloud Tasks retry can land after a later check already reported. The stored + * `cron_timestamp` only advances on a status change, so it cannot bound this on + * its own. + */ +export function isStaleCheck( + cronTimestamp: number, + maxAgeMs: number, + now = Date.now(), +): boolean { + return now - cronTimestamp > maxAgeMs; +} + +type JournalRow = { quorum_count: number; transitioned: number }; +type OutboxInsertRow = { + id: number; + notification_id: number; + incident_id: number | null; + provider: NotificationProvider; + delivery_status: "pending" | "settled"; +}; + +function journalStatement( + input: TransitionInput, + regionsJson: string, + regionCount: number, + guard: SQL, +) { + const count = quorumCountSql({ + toStatus: input.status, + regionsJson, + }); + const transitioning = sql`${guard} AND ${monitor.status} <> ${input.status}`; + + return db.all(sql` + INSERT INTO ${monitorTransition} + (monitor_id, region, cron_timestamp, from_status, to_status, + quorum_count, region_count, transitioned, outbox_rows, created_at) + SELECT + ${monitor.id}, ${input.region}, ${input.cronTimestamp}, + ${monitor.status}, ${input.status}, + ${count}, ${regionCount}, + CASE WHEN ${transitioning} THEN 1 ELSE 0 END, + CASE WHEN ${transitioning} THEN + (SELECT count(*) FROM ${notificationsToMonitors} + WHERE ${notificationsToMonitors.monitorId} = ${monitor.id}) + ELSE 0 END, + unixepoch() + FROM ${monitor} + WHERE ${monitor.id} = ${input.monitorId} + RETURNING quorum_count, transitioned + `); +} + +function createIncidentStatement(input: TransitionInput, guard: SQL) { + return db.all<{ id: number }>(sql` + INSERT INTO ${incidentTable} (monitor_id, workspace_id, started_at) + SELECT ${monitor.id}, ${monitor.workspaceId}, ${Math.floor(input.cronTimestamp / 1000)} + FROM ${monitor} + WHERE ${monitor.id} = ${input.monitorId} + AND ${monitor.status} <> ${input.status} + AND ${guard} + AND NOT EXISTS ( + SELECT 1 FROM ${incidentTable} + WHERE ${incidentTable.monitorId} = ${monitor.id} + AND ${incidentTable.resolvedAt} IS NULL) + ON CONFLICT DO NOTHING + RETURNING id + `); +} + +function resolveIncidentStatement(input: TransitionInput, guard: SQL) { + return db.all<{ id: number }>(sql` + UPDATE ${incidentTable} + SET resolved_at = ${Math.floor(input.cronTimestamp / 1000)}, auto_resolved = 1 + WHERE ${incidentTable.monitorId} = ${input.monitorId} + AND ${incidentTable.resolvedAt} IS NULL + AND EXISTS ( + SELECT 1 FROM ${monitor} + WHERE ${monitor.id} = ${input.monitorId} + AND ${monitor.status} <> ${input.status} + AND ${guard}) + RETURNING id + `); +} + +function outboxStatement( + input: TransitionInput, + payload: NotificationOutboxPayload, + guard: SQL, +) { + const dedupPrefix = `${input.cronTimestamp}:${input.monitorId}:${input.status}:`; + // Outside the rollout the inline sender owns this delivery, so the row is + // written already consumed: it still feeds the shadow diff, but raising + // OUTBOX_ROLLOUT_PCT can never make the drainer re-send it. + const owned = sql`(${monitor.id} % 100) < ${input.rolloutPct}`; + + return db.all(sql` + INSERT INTO ${notificationOutbox} + (dedup_key, monitor_id, workspace_id, notification_id, provider, event_type, + from_status, to_status, cron_timestamp, incident_id, payload, + delivery_status, outcome, next_attempt_at, deadline_at, created_at) + SELECT + ${dedupPrefix} || ${notification.id}, + ${monitor.id}, ${monitor.workspaceId}, ${notification.id}, + ${notification.provider}, ${EVENT_TYPE[input.status]}, + ${monitor.status}, ${input.status}, ${input.cronTimestamp}, + (SELECT id FROM ${incidentTable} + WHERE ${incidentTable.monitorId} = ${monitor.id} + AND ${incidentTable.resolvedAt} IS NULL + ORDER BY id DESC LIMIT 1), + ${JSON.stringify(payload)}, + CASE WHEN ${owned} THEN 'pending' ELSE 'settled' END, + CASE WHEN ${owned} THEN NULL ELSE 'inline' END, + unixepoch(), unixepoch() + ${input.deadlineSeconds}, unixepoch() + FROM ${monitor} + JOIN ${notificationsToMonitors} + ON ${notificationsToMonitors.monitorId} = ${monitor.id} + JOIN ${notification} + ON ${notification.id} = ${notificationsToMonitors.notificationId} + WHERE ${monitor.id} = ${input.monitorId} + AND ${monitor.status} <> ${input.status} + AND ${guard} + ON CONFLICT (dedup_key) DO NOTHING + RETURNING id, notification_id, incident_id, provider, delivery_status + `); +} + +function casStatement(input: TransitionInput, guard: SQL) { + return db.all<{ id: number }>(sql` + UPDATE ${monitor} + SET status = ${input.status}, updated_at = unixepoch() + WHERE ${monitor.id} = ${input.monitorId} + AND ${monitor.status} <> ${input.status} + AND ${guard} + RETURNING id + `); +} + +/** + * Writes the region status and, only when that changed something, evaluates the + * monitor transition as one atomic batch. An outbox row exists iff the + * compare-and-swap matched, so a notification is owed exactly once. + */ +export async function applyStatusTransition( + input: TransitionInput, +): Promise { + const changed = await withBusyRetry(() => + db.all<{ region: string }>(sql` + INSERT INTO ${monitorStatusTable} + (monitor_id, region, status, cron_timestamp, updated_at) + VALUES (${input.monitorId}, ${input.region}, ${input.status}, ${input.cronTimestamp}, unixepoch()) + ON CONFLICT (monitor_id, region) DO UPDATE + SET status = excluded.status, + cron_timestamp = excluded.cron_timestamp, + updated_at = unixepoch() + WHERE ${monitorStatusTable.status} <> excluded.status + AND excluded.cron_timestamp > ${monitorStatusTable.cronTimestamp} + RETURNING region + `), + ); + + if (changed.length === 0) return { kind: "unchanged" }; + + return evaluateTransition(input); +} + +/** + * The transition half, without the region write. Exported as the repair entry + * point: the region write and this evaluation are separate transactions, so a + * crash or a failed batch between them leaves `monitor.status` behind, and the + * fast path means replaying the same check will not re-evaluate it. + */ +export async function evaluateTransition( + input: TransitionInput, +): Promise { + const [monitorRows, statusRows] = await withBusyRetry(() => + db.batch([ + db.select().from(monitor).where(eq(monitor.id, input.monitorId)), + db + .select({ region: monitorStatusTable.region }) + .from(monitorStatusTable) + .where( + and( + eq(monitorStatusTable.monitorId, input.monitorId), + eq(monitorStatusTable.status, input.status), + ), + ), + ]), + ); + + const parsed = selectMonitorSchema.safeParse(monitorRows[0]); + if (!parsed.success) return { kind: "monitor-missing" }; + + const regions = parsed.data.regions; + const regionsJson = JSON.stringify(regions); + const regionCount = regions.length; + const configuredRegions = new Set(regions); + const affectedRegions = statusRows + .map((row) => row.region) + .filter((region) => configuredRegions.has(region)); + + const payload: NotificationOutboxPayload = { + regions: affectedRegions, + statusCode: input.statusCode, + message: input.message, + latency: input.latency, + }; + + const guard = quorumGuardSql({ + toStatus: input.status, + regionsJson, + regionCount, + }); + const journal = journalStatement(input, regionsJson, regionCount, guard); + const outbox = outboxStatement(input, payload, guard); + const cas = casStatement(input, guard); + + let journalRows: JournalRow[]; + let outboxRows: OutboxInsertRow[]; + let incidentRows: { id: number }[] = []; + + // All three statuses enqueue notifications; only the incident statement moves. + // The outbox row reads incident_id by subquery, so it must run after the + // incident is created but before an existing one is resolved. + if (input.status === "error") { + const [journalResult, incidentResult, outboxResult] = await withBusyRetry( + () => + db.batch([journal, createIncidentStatement(input, guard), outbox, cas]), + ); + journalRows = journalResult; + outboxRows = outboxResult; + incidentRows = incidentResult; + } else { + const [journalResult, outboxResult, incidentResult] = await withBusyRetry( + () => + db.batch([ + journal, + outbox, + resolveIncidentStatement(input, guard), + cas, + ]), + ); + journalRows = journalResult; + outboxRows = outboxResult; + incidentRows = incidentResult; + } + + const incidentCreatedId = + input.status === "error" ? (incidentRows[0]?.id ?? null) : null; + const incidentResolvedIds = + input.status === "error" ? [] : incidentRows.map((row) => row.id); + + // Published here rather than in the route so drift repair keeps the trail too. + await publishIncidentAudit(input, incidentCreatedId, incidentResolvedIds); + + return { + kind: "evaluated", + transitioned: (journalRows[0]?.transitioned ?? 0) === 1, + quorumCount: journalRows[0]?.quorum_count ?? 0, + regionCount, + affectedRegions, + outboxRows: outboxRows.map((row) => ({ + id: row.id, + notificationId: row.notification_id, + provider: row.provider, + deliveryStatus: row.delivery_status, + })), + incidentId: outboxRows[0]?.incident_id ?? null, + incidentCreatedId, + incidentResolvedIds, + }; +} + +async function publishIncidentAudit( + input: TransitionInput, + createdId: number | null, + resolvedIds: number[], +): Promise { + const targets = [{ id: String(input.monitorId), type: "monitor" as const }]; + const entries: Promise[] = []; + + if (createdId !== null) { + entries.push( + checkerAudit.publishAuditLog({ + id: `monitor:${input.monitorId}`, + action: "incident.created", + targets, + metadata: { cronTimestamp: input.cronTimestamp, incidentId: createdId }, + }), + ); + } + + for (const incidentId of resolvedIds) { + entries.push( + checkerAudit.publishAuditLog({ + id: `monitor:${input.monitorId}`, + action: "incident.resolved", + targets, + metadata: { cronTimestamp: input.cronTimestamp, incidentId }, + }), + ); + } + + // Best-effort: the incident is already committed, and failing here would make + // Cloud Tasks retry a transition that has landed. + try { + await Promise.all(entries); + } catch (error) { + logger.warn("Failed to publish incident audit log", { + monitor_id: input.monitorId, + error_message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/apps/workflows/src/checker/update-status.test.ts b/apps/workflows/src/checker/update-status.test.ts new file mode 100644 index 00000000..b0a975b2 --- /dev/null +++ b/apps/workflows/src/checker/update-status.test.ts @@ -0,0 +1,130 @@ +import { db, eq } from "@openstatus/db"; +import { + incidentTable, + monitor, + notificationOutbox, + notificationTrigger, +} from "@openstatus/db/src/schema"; +import { + createMonitor, + createNotification, + createTestWorkspace, + linkNotificationToMonitor, +} from "@openstatus/db/src/test/factories"; +import { + afterAll, + afterEach, + assertSpyCalls, + beforeAll, + beforeEach, + describe, + expect, + type Stub, + stub, + test, +} from "@openstatus/test-utils"; + +import { env } from "../env"; +import { checkerAudit } from "../utils/audit-log"; +import { checkerRoute } from "./index"; +import { providerToFunction } from "./utils"; + +// biome-ignore lint/suspicious/noExplicitAny: heterogeneous provider stubs +type AnyStub = Stub; +let stubs: AnyStub[] = []; + +let workspaceId: number; +let monitorId: number; +let notificationId: number; + +const cronSecret = env().CRON_SECRET; + +beforeAll(async () => { + const { workspace } = await createTestWorkspace(); + workspaceId = workspace.id; + const monitorRow = await createMonitor(workspaceId, { + regions: "ams", + status: "active", + }); + monitorId = monitorRow.id; + const notif = await createNotification(workspaceId, { provider: "email" }); + notificationId = notif.id; + await linkNotificationToMonitor(notificationId, monitorId); +}); + +afterAll(async () => { + await db.delete(monitor).where(eq(monitor.workspaceId, workspaceId)).run(); +}); + +beforeEach(() => { + stubs = []; +}); + +afterEach(async () => { + for (const s of stubs) s.restore(); + stubs = []; + await db + .delete(notificationTrigger) + .where(eq(notificationTrigger.monitorId, monitorId)) + .run(); + await db + .delete(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorId)) + .run(); + await db + .delete(incidentTable) + .where(eq(incidentTable.monitorId, monitorId)) + .run(); + await db + .update(monitor) + .set({ status: "active" }) + .where(eq(monitor.id, monitorId)) + .run(); +}); + +function post(payload: Record) { + return checkerRoute.request("/updateStatus", { + method: "POST", + headers: { + Authorization: `Basic ${cronSecret}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); +} + +describe("updateStatus audit failures", () => { + test("a failing audit log does not cost the notification", async () => { + // Tinybird is down: every audit publish rejects, including the status one + // that runs after the transition batch has committed. + stubs.push( + stub(checkerAudit, "publishAuditLog", () => + Promise.reject(new Error("tinybird unavailable")), + ) as AnyStub, + ); + const sendAlert = stub(providerToFunction.email, "sendAlert", () => + Promise.resolve(), + ); + stubs.push(sendAlert as AnyStub); + + const res = await post({ + monitorId: String(monitorId), + region: "ams", + status: "error", + cronTimestamp: Date.now(), + statusCode: 500, + }); + + // Retrying this transition is useless — the region status is already + // written, so the retry takes the fast path and sends nothing. + expect(res.status).toBe(200); + assertSpyCalls(sendAlert, 1); + + const rows = await db + .select() + .from(monitor) + .where(eq(monitor.id, monitorId)) + .all(); + expect(rows[0]?.status).toBe("error"); + }); +}); diff --git a/apps/workflows/src/cron/index.ts b/apps/workflows/src/cron/index.ts index 4b0df286..0768ac6e 100644 --- a/apps/workflows/src/cron/index.ts +++ b/apps/workflows/src/cron/index.ts @@ -16,7 +16,13 @@ import { StepPaused, workflowStepSchema, } from "./monitor"; +import { + handleOutboxDrainCron, + handleOutboxRetentionCron, + handleOutboxShadowCron, +} from "./outbox"; import { handlePrivateLocationHealthCron } from "./private-location-health"; +import { handleStatusDriftCron } from "./status-drift"; import { handleUptimeFreezeCron } from "./uptime-freeze"; const app = new Hono({ strict: false }); @@ -162,4 +168,24 @@ app.get("/monitors/:step", async (c) => { return c.json({ success: true }, 200); }); +app.get("/outbox/drain", async (c) => { + const summary = await handleOutboxDrainCron(); + return c.json({ success: true, ...summary }, 200); +}); + +app.get("/outbox/retention", async (c) => { + const summary = await handleOutboxRetentionCron(); + return c.json({ success: true, ...summary }, 200); +}); + +app.get("/outbox/shadow", async (c) => { + const result = await handleOutboxShadowCron(); + return c.json({ success: true, ...result }, 200); +}); + +app.get("/status-drift", async (c) => { + const result = await handleStatusDriftCron(); + return c.json({ success: true, ...result }, 200); +}); + export { app as cronRouter }; diff --git a/apps/workflows/src/cron/outbox.ts b/apps/workflows/src/cron/outbox.ts new file mode 100644 index 00000000..8e0d2b5f --- /dev/null +++ b/apps/workflows/src/cron/outbox.ts @@ -0,0 +1,131 @@ +import { getLogger } from "@logtape/logtape"; +import { and, db, lt, sql } from "@openstatus/db"; +import { + monitorTransition, + notificationOutbox, + notificationTrigger, +} from "@openstatus/db/src/schema"; +import { withBusyRetry } from "@openstatus/services"; +import * as Sentry from "@sentry/deno"; + +import { drainUntilEmpty, sweepExpiredOutbox } from "../checker/outbox"; + +const logger = getLogger(["workflow"]); + +const OUTBOX_RETENTION_DAYS = 45; +const DECISION_RETENTION_DAYS = 90; +const SHADOW_WINDOW_MS = 15 * 60 * 1000; + +export async function handleOutboxDrainCron() { + const summary = await drainUntilEmpty(); + const expired = await sweepExpiredOutbox(); + if ( + summary.claimed > 0 || + expired.deadLettered > 0 || + expired.discarded > 0 + ) { + logger.info("Outbox safety-net drain", { ...summary, ...expired }); + } + return { ...summary, ...expired }; +} + +export async function handleOutboxRetentionCron() { + const nowSeconds = Math.floor(Date.now() / 1000); + const outboxCutoff = nowSeconds - OUTBOX_RETENTION_DAYS * 24 * 60 * 60; + const decisionCutoff = nowSeconds - DECISION_RETENTION_DAYS * 24 * 60 * 60; + + const [outboxDeleted, decisionsDeleted] = await withBusyRetry(() => + db.batch([ + // Pending rows older than the retention window are unreachable: the + // delivery deadline is minutes, not weeks. + db + .delete(notificationOutbox) + .where(lt(notificationOutbox.createdAt, outboxCutoff)) + .returning({ id: notificationOutbox.id }), + db + .delete(monitorTransition) + .where(lt(monitorTransition.createdAt, decisionCutoff)) + .returning({ id: monitorTransition.id }), + ]), + ); + + logger.info("Outbox retention", { + outbox_deleted: outboxDeleted.length, + decisions_deleted: decisionsDeleted.length, + }); + + return { + outboxDeleted: outboxDeleted.length, + decisionsDeleted: decisionsDeleted.length, + }; +} + +/** + * Shadow gate: while the inline sender still delivers, every outbox row must + * have a matching notification_trigger and vice versa. A mismatch means the SQL + * quorum and the TypeScript quorum disagree on real data. + */ +export async function handleOutboxShadowCron() { + const since = Date.now() - SHADOW_WINDOW_MS; + + const missingTrigger = await withBusyRetry(() => + db + .select({ + monitorId: notificationOutbox.monitorId, + notificationId: notificationOutbox.notificationId, + cronTimestamp: notificationOutbox.cronTimestamp, + }) + .from(notificationOutbox) + .where( + and( + sql`${notificationOutbox.cronTimestamp} >= ${since}`, + sql`NOT EXISTS ( + SELECT 1 FROM ${notificationTrigger} + WHERE ${notificationTrigger.monitorId} = ${notificationOutbox.monitorId} + AND ${notificationTrigger.notificationId} = ${notificationOutbox.notificationId} + AND ${notificationTrigger.cronTimestamp} = ${notificationOutbox.cronTimestamp})`, + ), + ) + .all(), + ); + + const missingOutbox = await withBusyRetry(() => + db + .select({ + monitorId: notificationTrigger.monitorId, + notificationId: notificationTrigger.notificationId, + cronTimestamp: notificationTrigger.cronTimestamp, + }) + .from(notificationTrigger) + .where( + and( + sql`${notificationTrigger.cronTimestamp} >= ${since}`, + sql`NOT EXISTS ( + SELECT 1 FROM ${notificationOutbox} + WHERE ${notificationOutbox.monitorId} = ${notificationTrigger.monitorId} + AND ${notificationOutbox.notificationId} = ${notificationTrigger.notificationId} + AND ${notificationOutbox.cronTimestamp} = ${notificationTrigger.cronTimestamp})`, + ), + ) + .all(), + ); + + const result = { + missingTrigger: missingTrigger.length, + missingOutbox: missingOutbox.length, + }; + + if (missingTrigger.length > 0 || missingOutbox.length > 0) { + logger.error("Outbox shadow mismatch", { + ...result, + sample_missing_trigger: missingTrigger.slice(0, 5), + sample_missing_outbox: missingOutbox.slice(0, 5), + }); + Sentry.captureMessage( + `Outbox shadow mismatch: ${missingTrigger.length} outbox rows without a trigger, ${missingOutbox.length} triggers without an outbox row`, + "error", + ); + } + + return result; +} diff --git a/apps/workflows/src/cron/scheduler.test.ts b/apps/workflows/src/cron/scheduler.test.ts new file mode 100644 index 00000000..517e7c36 --- /dev/null +++ b/apps/workflows/src/cron/scheduler.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "@openstatus/test-utils"; +import { Cron, Result } from "effect"; + +import { SCHEDULED_TASKS } from "./scheduler"; + +describe("SCHEDULED_TASKS", () => { + test("every expression parses", () => { + for (const task of SCHEDULED_TASKS) { + const parsed = Cron.parse(task.expression); + expect(Result.isSuccess(parsed)).toBe(true); + } + }); + + test("task names are unique", () => { + const names = SCHEDULED_TASKS.map((task) => task.name); + expect(new Set(names).size).toBe(names.length); + }); +}); diff --git a/apps/workflows/src/cron/scheduler.ts b/apps/workflows/src/cron/scheduler.ts new file mode 100644 index 00000000..fe541414 --- /dev/null +++ b/apps/workflows/src/cron/scheduler.ts @@ -0,0 +1,95 @@ +import { getLogger } from "@logtape/logtape"; +import * as Sentry from "@sentry/deno"; +import { Effect, Fiber, Schedule } from "effect"; + +import { handleOutboxDrainCron, handleOutboxRetentionCron } from "./outbox"; +import { handleStatusDriftCron } from "./status-drift"; + +const logger = getLogger(["workflow"]); + +type ScheduledTask = { + name: string; + expression: string; + run: () => Promise; +}; + +/** + * Internal maintenance only, so it runs in-process rather than needing a + * schedule added outside this repo. Every task is safe to run on both machines + * at once: the outbox claim is atomic, drift repair is guarded by the same + * compare-and-swap as a live check, and retention deletes are idempotent. + */ +export const SCHEDULED_TASKS: ScheduledTask[] = [ + { + name: "outbox-drain", + expression: "* * * * *", + run: handleOutboxDrainCron, + }, + { + name: "status-drift", + expression: "*/5 * * * *", + run: handleStatusDriftCron, + }, + { + name: "outbox-retention", + expression: "17 3 * * *", + run: handleOutboxRetentionCron, + }, +]; + +type RunningTask = Fiber.Fiber; + +let running: RunningTask[] = []; + +function scheduleTask(task: ScheduledTask): RunningTask { + const body = Effect.tryPromise({ + try: () => task.run(), + catch: (error) => + error instanceof Error ? error : new Error(String(error)), + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + logger.error("Scheduled task failed", { + task: task.name, + error_message: error.message, + }); + Sentry.captureException(error); + }), + ), + ); + + return Effect.runFork( + Effect.repeat(body, Schedule.cron(task.expression)).pipe( + Effect.catch((error) => + Effect.sync(() => { + logger.error("Scheduled task stopped", { + task: task.name, + error_message: String(error), + }); + Sentry.captureException( + new Error(`Scheduled task ${task.name} stopped: ${String(error)}`), + ); + }), + ), + ), + ); +} + +export function startScheduler(): void { + if (running.length > 0) return; + running = SCHEDULED_TASKS.map(scheduleTask); + logger.info("Started in-process scheduler", { + tasks: SCHEDULED_TASKS.map((task) => `${task.name}@${task.expression}`), + }); +} + +export async function stopScheduler(): Promise { + const fibers = running; + running = []; + await Effect.runPromise( + Effect.forEach(fibers, (fiber) => Fiber.interrupt(fiber), { + concurrency: "unbounded", + discard: true, + }), + ); +} diff --git a/apps/workflows/src/cron/status-drift.test.ts b/apps/workflows/src/cron/status-drift.test.ts new file mode 100644 index 00000000..7a032fc4 --- /dev/null +++ b/apps/workflows/src/cron/status-drift.test.ts @@ -0,0 +1,192 @@ +import { and, count, db, eq, isNull } from "@openstatus/db"; +import { + notificationOutbox, + incidentTable, + monitor, + monitorStatusTable, +} from "@openstatus/db/src/schema"; +import { + createMonitor, + createNotification, + createTestWorkspace, + linkNotificationToMonitor, +} from "@openstatus/db/src/test/factories"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + type Stub, + stub, + test, +} from "@openstatus/test-utils"; + +import { applyStatusTransition } from "../checker/transition"; +import { providerToFunction } from "../checker/utils"; +import { checkerAudit } from "../utils/audit-log"; +import { handleStatusDriftCron } from "./status-drift"; + +// biome-ignore lint/suspicious/noExplicitAny: heterogeneous provider stubs +type AnyStub = Stub; +let stubs: AnyStub[] = []; +let workspaceId: number; + +beforeAll(async () => { + const { workspace } = await createTestWorkspace(); + workspaceId = workspace.id; +}); + +beforeEach(() => { + stubs = []; + stubs.push( + stub(checkerAudit, "publishAuditLog", () => Promise.resolve()) as AnyStub, + ); + stubs.push( + stub(providerToFunction.email, "sendAlert", () => Promise.resolve()), + ); +}); + +afterEach(() => { + for (const s of stubs) s.restore(); + stubs = []; +}); + +afterAll(async () => { + await db.delete(monitor).where(eq(monitor.workspaceId, workspaceId)).run(); +}); + +describe("handleStatusDriftCron", () => { + test("re-evaluates a monitor whose region write landed without its transition", async () => { + const monitorRow = await createMonitor(workspaceId, { + regions: "ams", + active: true, + }); + const notif = await createNotification(workspaceId); + await linkNotificationToMonitor(notif.id, monitorRow.id); + + // Exactly the state a crash between the region write and the batch leaves: + // the region says error, the monitor still says active. + await db + .insert(monitorStatusTable) + .values({ + monitorId: monitorRow.id, + region: "ams", + status: "error", + cronTimestamp: Date.now(), + }) + .run(); + + // A replay of the same check cannot recover it: the region status is + // unchanged, so the fast path short-circuits. + const replay = await applyStatusTransition({ + monitorId: monitorRow.id, + region: "ams", + status: "error", + cronTimestamp: Date.now() + 1000, + deadlineSeconds: 300, + rolloutPct: 100, + }); + expect(replay.kind).toBe("unchanged"); + + const beforeRepair = await db + .select({ status: monitor.status }) + .from(monitor) + .where(eq(monitor.id, monitorRow.id)) + .all(); + expect(beforeRepair[0]?.status).toBe("active"); + + const result = await handleStatusDriftCron(); + expect(result.repaired).toBeGreaterThanOrEqual(1); + + const afterRepair = await db + .select({ status: monitor.status }) + .from(monitor) + .where(eq(monitor.id, monitorRow.id)) + .all(); + expect(afterRepair[0]?.status).toBe("error"); + + const incidents = await db + .select({ total: count() }) + .from(incidentTable) + .where( + and( + eq(incidentTable.monitorId, monitorRow.id), + isNull(incidentTable.resolvedAt), + ), + ) + .all(); + expect(incidents[0]?.total).toBe(1); + + const outbox = await db + .select({ total: count() }) + .from(notificationOutbox) + .where(eq(notificationOutbox.monitorId, monitorRow.id)) + .all(); + expect(outbox[0]?.total).toBe(1); + }); + + test("a monitor below quorum is not a drift candidate", async () => { + const monitorRow = await createMonitor(workspaceId, { + regions: "ams,arn,atl,bog", + active: true, + }); + + await db + .insert(monitorStatusTable) + .values({ + monitorId: monitorRow.id, + region: "ams", + status: "error", + cronTimestamp: Date.now(), + }) + .run(); + + await handleStatusDriftCron(); + + const after = await db + .select({ status: monitor.status }) + .from(monitor) + .where(eq(monitor.id, monitorRow.id)) + .all(); + expect(after[0]?.status).toBe("active"); + }); +}); + +describe("drift repair delivery", () => { + test("sends the notification the repair is recovering", async () => { + const sent: string[] = []; + for (const s of stubs) s.restore(); + stubs = [ + stub(checkerAudit, "publishAuditLog", () => Promise.resolve()) as AnyStub, + stub(providerToFunction.email, "sendAlert", () => { + sent.push("alert"); + return Promise.resolve(); + }), + ]; + + const monitorRow = await createMonitor(workspaceId, { + regions: "ams", + active: true, + }); + const notif = await createNotification(workspaceId); + await linkNotificationToMonitor(notif.id, monitorRow.id); + + await db + .insert(monitorStatusTable) + .values({ + monitorId: monitorRow.id, + region: "ams", + status: "error", + cronTimestamp: Date.now(), + }) + .run(); + + // OUTBOX_ROLLOUT_PCT defaults to 0, so the repair must fall back to the + // inline sender rather than writing a row nobody delivers. + const result = await handleStatusDriftCron(); + expect(result.repaired).toBeGreaterThanOrEqual(1); + expect(sent.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/apps/workflows/src/cron/status-drift.ts b/apps/workflows/src/cron/status-drift.ts new file mode 100644 index 00000000..d936a55e --- /dev/null +++ b/apps/workflows/src/cron/status-drift.ts @@ -0,0 +1,100 @@ +import { getLogger } from "@logtape/logtape"; +import { db, sql } from "@openstatus/db"; +import type { MonitorStatus } from "@openstatus/db/src/schema"; +import { monitor, monitorStatusTable } from "@openstatus/db/src/schema"; +import { withBusyRetry } from "@openstatus/services"; +import * as Sentry from "@sentry/deno"; + +import { triggerNotifications } from "../checker/alerting"; +import { enqueueOutbox } from "../checker/outbox"; +import { + csvRegionCountSql, + csvRegionMemberSql, + quorumMetSql, +} from "../checker/quorum"; +import { EVENT_TYPE, evaluateTransition } from "../checker/transition"; +import { env } from "../env"; + +const logger = getLogger(["workflow"]); + +const CANDIDATE_LIMIT = 200; + +type DriftRow = { monitor_id: number; status: MonitorStatus }; + +/** + * Finds monitors whose regions already carry a quorum for a status the monitor + * itself does not have, using the same quorum rule as a live check. + */ +async function findDrift(): Promise { + return withBusyRetry(() => + db.all(sql` + SELECT ${monitorStatusTable.monitorId} AS monitor_id, + ${monitorStatusTable.status} AS status + FROM ${monitorStatusTable} + JOIN ${monitor} ON ${monitor.id} = ${monitorStatusTable.monitorId} + WHERE ${monitor.deletedAt} IS NULL + AND ${monitor.active} = 1 + AND ${monitor.regions} <> '' + AND ${monitorStatusTable.status} <> ${monitor.status} + AND ${csvRegionMemberSql(sql`${monitorStatusTable.region}`)} + GROUP BY ${monitorStatusTable.monitorId}, ${monitorStatusTable.status} + HAVING ${quorumMetSql(sql`count(*)`, csvRegionCountSql())} + LIMIT ${CANDIDATE_LIMIT} + `), + ); +} + +/** + * Safety net for the gap between the region write and the transition batch: + * they are separate transactions, so a crash or a failed batch can leave a + * monitor whose regions say "down" but whose status still says "up", which no + * later check will re-evaluate because the region status stopped changing. + */ +export async function handleStatusDriftCron() { + const candidates = await findDrift(); + if (candidates.length === 0) return { candidates: 0, repaired: 0 }; + + let repaired = 0; + + for (const candidate of candidates) { + const result = await evaluateTransition({ + monitorId: candidate.monitor_id, + region: "drift-repair", + status: candidate.status, + cronTimestamp: Date.now(), + deadlineSeconds: Math.floor(env().OUTBOX_DEADLINE_MS / 1000), + rolloutPct: env().OUTBOX_ROLLOUT_PCT, + }); + + if (result.kind !== "evaluated" || !result.transitioned) continue; + + repaired += 1; + logger.warn("Repaired monitor status drift", { + monitor_id: candidate.monitor_id, + status: candidate.status, + outbox_rows: result.outboxRows.length, + }); + + // A repair that does not deliver is the failure it exists to fix. + if (result.outboxRows.some((row) => row.deliveryStatus === "pending")) { + enqueueOutbox(result.outboxRows.map((row) => row.id)); + } else if (result.outboxRows.length > 0) { + await triggerNotifications({ + monitorId: String(candidate.monitor_id), + notifType: EVENT_TYPE[candidate.status], + cronTimestamp: Date.now(), + regions: result.affectedRegions, + incidentId: result.incidentId ?? undefined, + }); + } + } + + if (repaired > 0) { + Sentry.captureMessage( + `Repaired ${repaired} monitor(s) whose status had drifted from their region quorum`, + "warning", + ); + } + + return { candidates: candidates.length, repaired }; +} diff --git a/apps/workflows/src/env.ts b/apps/workflows/src/env.ts index 42e57785..f245135d 100644 --- a/apps/workflows/src/env.ts +++ b/apps/workflows/src/env.ts @@ -24,5 +24,9 @@ export const env = () => SENTRY_DSN: z.string().prefault(""), AXIOM_TOKEN: z.string().prefault(""), AXIOM_DATASET: z.string().prefault(""), + STALE_CHECK_MS: z.coerce.number().prefault(600_000), + OUTBOX_DEADLINE_MS: z.coerce.number().prefault(300_000), + NOTIFICATION_TIMEOUT_MS: z.coerce.number().prefault(10_000), + OUTBOX_ROLLOUT_PCT: z.coerce.number().min(0).max(100).prefault(0), }) .parse(process.env); diff --git a/apps/workflows/src/scripts/outbox-preflight.ts b/apps/workflows/src/scripts/outbox-preflight.ts new file mode 100644 index 00000000..8656537a --- /dev/null +++ b/apps/workflows/src/scripts/outbox-preflight.ts @@ -0,0 +1,185 @@ +import { + and, + count, + db, + eq, + gte, + inArray, + isNotNull, + isNull, + sql, +} from "@openstatus/db"; +import { + incidentTable, + monitor, + notification, + notificationTrigger, + notificationsToMonitors, + selectWorkspaceSchema, + workspace, +} from "@openstatus/db/src/schema"; + +const CHECKS_PER_REGION_PER_DAY: Record = { + "30s": 2880, + "1m": 1440, + "5m": 288, + "10m": 144, + "30m": 48, + "1h": 24, + other: 0, +}; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +async function duplicateOpenIncidents() { + const rows = await db + .select({ + monitorId: incidentTable.monitorId, + openCount: count(incidentTable.id), + }) + .from(incidentTable) + // NULLs are distinct in a SQLite unique index, so incidents with no monitor + // can never collide with incident_open_idx however many are open. + .where( + and(isNull(incidentTable.resolvedAt), isNotNull(incidentTable.monitorId)), + ) + .groupBy(incidentTable.monitorId) + .having(sql`count(${incidentTable.id}) > 1`) + .all(); + + console.log( + `\n[1] Monitors with more than one open incident: ${rows.length}`, + ); + for (const row of rows) { + console.log(` monitor ${row.monitorId}: ${row.openCount} open`); + } + if (rows.length > 0) { + console.log( + " -> migration 0085 will FAIL until these are resolved (unique incident_open_idx)", + ); + } +} + +async function smsQuotaBlastRadius() { + const smsNotifications = await db + .select({ id: notification.id, workspaceId: notification.workspaceId }) + .from(notification) + .where(eq(notification.provider, "sms")) + .all(); + + const byWorkspace = new Map(); + for (const row of smsNotifications) { + if (row.workspaceId === null) continue; + const ids = byWorkspace.get(row.workspaceId) ?? []; + ids.push(row.id); + byWorkspace.set(row.workspaceId, ids); + } + + console.log(`\n[2] Workspaces with SMS notifications: ${byWorkspace.size}`); + + const cutoffMs = Date.now() - THIRTY_DAYS_MS; + let blocked = 0; + + for (const [workspaceId, notificationIds] of byWorkspace) { + const rows = await db + .select() + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .all(); + const parsed = selectWorkspaceSchema.safeParse(rows[0]); + if (!parsed.success) continue; + const limit = parsed.data.limits["sms-limit"]; + + const allTime = await db + .select({ total: count() }) + .from(notificationTrigger) + .where(inArray(notificationTrigger.notificationId, notificationIds)) + .all(); + + const lastMonth = await db + .select({ total: count() }) + .from(notificationTrigger) + .where( + and( + inArray(notificationTrigger.notificationId, notificationIds), + gte(notificationTrigger.cronTimestamp, cutoffMs), + ), + ) + .all(); + + const allTimeCount = allTime[0]?.total ?? 0; + const lastMonthCount = lastMonth[0]?.total ?? 0; + + if (allTimeCount > limit && lastMonthCount <= limit) { + blocked += 1; + console.log( + ` workspace ${workspaceId}: limit ${limit}, all-time ${allTimeCount}, real 30d ${lastMonthCount} -> UNBLOCKS on fix`, + ); + } + } + + console.log( + ` -> ${blocked} workspace(s) currently blocked by the ms/s bug and will resume sending SMS`, + ); +} + +async function writeVolumeBaseline() { + const monitors = await db + .select({ + id: monitor.id, + regions: monitor.regions, + periodicity: monitor.periodicity, + }) + .from(monitor) + .where(eq(monitor.active, true)) + .all(); + + let checksPerDay = 0; + for (const row of monitors) { + const regionCount = row.regions.split(",").filter(Boolean).length; + checksPerDay += + regionCount * (CHECKS_PER_REGION_PER_DAY[row.periodicity] ?? 0); + } + + console.log(`\n[3] Active monitors: ${monitors.length}`); + console.log(` Checks per day: ${checksPerDay.toLocaleString()}`); + console.log( + ` Current monitor_status writes per day: ${checksPerDay.toLocaleString()} (one per check)`, + ); + console.log( + ` After the conditional upsert these become zero-row statements.`, + ); +} + +async function averageChannelsPerMonitor() { + const rows = await db + .select({ + links: count(notificationsToMonitors.notificationId), + monitors: sql`count(distinct ${notificationsToMonitors.monitorId})`, + }) + .from(notificationsToMonitors) + .all(); + + const links = rows[0]?.links ?? 0; + const monitors = rows[0]?.monitors ?? 0; + const average = monitors === 0 ? 0 : links / monitors; + + console.log(`\n[4] Notification links: ${links} across ${monitors} monitors`); + console.log( + ` C (avg channels per notified monitor): ${average.toFixed(2)}`, + ); + console.log( + ` Writes per transition (4C + 4): ${(4 * average + 4).toFixed(1)} rows`, + ); +} + +async function main() { + console.log("checker outbox preflight"); + await duplicateOpenIncidents(); + await smsQuotaBlastRadius(); + await writeVolumeBaseline(); + await averageChannelsPerMonitor(); + console.log(""); +} + +await main(); diff --git a/apps/workflows/src/serve.ts b/apps/workflows/src/serve.ts index 6f520562..12c5df1e 100644 --- a/apps/workflows/src/serve.ts +++ b/apps/workflows/src/serve.ts @@ -2,14 +2,30 @@ import { getLogger } from "@logtape/logtape"; +import { shutdownOutbox, startOutboxConsumer } from "./checker/outbox"; +import { startScheduler, stopScheduler } from "./cron/scheduler"; import { env } from "./env"; import { app } from "./index"; const { NODE_ENV, PORT } = env(); -getLogger(["workflow"]).info("Starting server", { +const logger = getLogger(["workflow"]); + +logger.info("Starting server", { port: PORT, environment: NODE_ENV, }); -Deno.serve({ port: PORT }, app.fetch); +startOutboxConsumer(); +startScheduler(); + +const server = Deno.serve({ port: PORT }, app.fetch); + +Deno.addSignalListener("SIGTERM", () => { + void (async () => { + logger.info("SIGTERM received, releasing outbox claims"); + await stopScheduler(); + await shutdownOutbox(); + await server.shutdown(); + })(); +}); diff --git a/packages/db/drizzle/0085_daily_glorian.sql b/packages/db/drizzle/0085_daily_glorian.sql new file mode 100644 index 00000000..1c31471e --- /dev/null +++ b/packages/db/drizzle/0085_daily_glorian.sql @@ -0,0 +1,100 @@ +CREATE TABLE `notification_dead_letter` ( + `id` integer PRIMARY KEY NOT NULL, + `outbox_id` integer NOT NULL, + `dedup_key` text NOT NULL, + `monitor_id` integer NOT NULL, + `workspace_id` integer, + `notification_id` integer NOT NULL, + `provider` text NOT NULL, + `event_type` text NOT NULL, + `from_status` text NOT NULL, + `to_status` text NOT NULL, + `cron_timestamp` integer NOT NULL, + `incident_id` integer, + `payload` text NOT NULL, + `attempts` integer NOT NULL, + `final_error` text, + `died_at` integer NOT NULL, + FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `notification_dead_letter_dedup_key_idx` ON `notification_dead_letter` (`dedup_key`);--> statement-breakpoint +CREATE INDEX `notification_dead_letter_workspace_id_died_at_idx` ON `notification_dead_letter` (`workspace_id`,`died_at`);--> statement-breakpoint +CREATE TABLE `notification_outbox` ( + `id` integer PRIMARY KEY NOT NULL, + `dedup_key` text NOT NULL, + `monitor_id` integer NOT NULL, + `workspace_id` integer, + `notification_id` integer NOT NULL, + `provider` text NOT NULL, + `event_type` text NOT NULL, + `from_status` text NOT NULL, + `to_status` text NOT NULL, + `cron_timestamp` integer NOT NULL, + `incident_id` integer, + `payload` text NOT NULL, + `delivery_status` text DEFAULT 'pending' NOT NULL, + `outcome` text, + `attempts` integer DEFAULT 0 NOT NULL, + `next_attempt_at` integer NOT NULL, + `deadline_at` integer NOT NULL, + `locked_by` text, + `locked_until` integer, + `delivered_at` integer, + `last_error` text, + `created_at` integer NOT NULL, + FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`notification_id`) REFERENCES `notification`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`incident_id`) REFERENCES `incident`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `notification_outbox_dedup_key_idx` ON `notification_outbox` (`dedup_key`);--> statement-breakpoint +CREATE INDEX `notification_outbox_claim_idx` ON `notification_outbox` (`next_attempt_at`) WHERE "notification_outbox"."delivery_status" = 'pending';--> statement-breakpoint +CREATE INDEX `notification_outbox_notification_id_cron_timestamp_idx` ON `notification_outbox` (`notification_id`,`cron_timestamp`);--> statement-breakpoint +CREATE INDEX `notification_outbox_channel_idx` ON `notification_outbox` (`monitor_id`,`notification_id`) WHERE "notification_outbox"."delivery_status" = 'pending';--> statement-breakpoint +CREATE TABLE `monitor_transition` ( + `id` integer PRIMARY KEY NOT NULL, + `monitor_id` integer NOT NULL, + `region` text NOT NULL, + `cron_timestamp` integer NOT NULL, + `from_status` text NOT NULL, + `to_status` text NOT NULL, + `quorum_count` integer NOT NULL, + `region_count` integer NOT NULL, + `transitioned` integer NOT NULL, + `outbox_rows` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `monitor_transition_monitor_id_cron_timestamp_idx` ON `monitor_transition` (`monitor_id`,`cron_timestamp`);--> statement-breakpoint +CREATE INDEX `monitor_transition_created_at_idx` ON `monitor_transition` (`created_at`);--> statement-breakpoint +-- The check-then-act race in createIncident left monitors with several open +-- incidents, which the partial unique index below would reject. Keep the newest +-- open incident per monitor and resolve the ones it superseded, at the keeper's +-- start (never before their own). The keeper set is an uncorrelated subquery, so +-- it is materialised once and does not shift as rows are resolved. +UPDATE `incident` +SET `resolved_at` = max( + `started_at`, + coalesce( + (SELECT k.`started_at` + FROM `incident` k + WHERE k.`monitor_id` = `incident`.`monitor_id` + AND k.`id` IN (SELECT max(x.`id`) FROM `incident` x + WHERE x.`resolved_at` IS NULL + AND x.`monitor_id` IS NOT NULL + GROUP BY x.`monitor_id`)), + `started_at`)), + `auto_resolved` = 1 +WHERE `resolved_at` IS NULL + AND `monitor_id` IS NOT NULL + AND `id` NOT IN (SELECT max(x.`id`) FROM `incident` x + WHERE x.`resolved_at` IS NULL + AND x.`monitor_id` IS NOT NULL + GROUP BY x.`monitor_id`);--> statement-breakpoint +DROP INDEX `incident_open_idx`;--> statement-breakpoint +CREATE UNIQUE INDEX `incident_open_idx` ON `incident` (`monitor_id`) WHERE "incident"."resolved_at" IS NULL;--> statement-breakpoint +ALTER TABLE `monitor_status` ADD `cron_timestamp` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0085_snapshot.json b/packages/db/drizzle/meta/0085_snapshot.json new file mode 100644 index 00000000..ab1b795d --- /dev/null +++ b/packages/db/drizzle/meta/0085_snapshot.json @@ -0,0 +1,5519 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "10787b50-ac75-4913-ba21-f3d2da7acc6e", + "prevId": "b39ead3f-410d-45d6-8d9d-b169d00d0039", + "tables": { + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_until": { + "name": "paid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "limits": { + "name": "limits", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "workos_organization_id": { + "name": "workos_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_enabled": { + "name": "sso_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "workspace_stripe_id_unique": { + "name": "workspace_stripe_id_unique", + "columns": [ + "stripe_id" + ], + "isUnique": true + }, + "workspace_workos_organization_id_unique": { + "name": "workspace_workos_organization_id_unique", + "columns": [ + "workos_organization_id" + ], + "isUnique": true + }, + "workspace_id_dsn_unique": { + "name": "workspace_id_dsn_unique", + "columns": [ + "id", + "dsn" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sso_domain": { + "name": "workspace_sso_domain", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "workspace_sso_domain_domain_unique": { + "name": "workspace_sso_domain_domain_unique", + "columns": [ + "domain" + ], + "isUnique": true + }, + "workspace_sso_domain_workspace_id_idx": { + "name": "workspace_sso_domain_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sso_domain_workspace_id_workspace_id_fk": { + "name": "workspace_sso_domain_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sso_domain", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": [ + "provider", + "provider_account_id" + ], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_tenant_id_unique": { + "name": "user_tenant_id_unique", + "columns": [ + "tenant_id" + ], + "isUnique": true + }, + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_to_workspaces": { + "name": "users_to_workspaces", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "users_to_workspaces_workspace_id_idx": { + "name": "users_to_workspaces_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_workspaces_user_id_user_id_fk": { + "name": "users_to_workspaces_user_id_user_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_to_workspaces_workspace_id_workspace_id_fk": { + "name": "users_to_workspaces_workspace_id_workspace_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_workspaces_user_id_workspace_id_pk": { + "columns": [ + "user_id", + "workspace_id" + ], + "name": "users_to_workspaces_user_id_workspace_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_token": { + "name": "verification_token", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verification_token_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report": { + "name": "status_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_workspace_created_idx": { + "name": "status_report_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "status_report_page_id_idx": { + "name": "status_report_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_workspace_id_workspace_id_fk": { + "name": "status_report_workspace_id_workspace_id_fk", + "tableFrom": "status_report", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_report_page_id_page_id_fk": { + "name": "status_report_page_id_page_id_fk", + "tableFrom": "status_report", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_update": { + "name": "status_report_update", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_status_report_id_idx": { + "name": "status_report_update_status_report_id_idx", + "columns": [ + "status_report_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_status_report_id_status_report_id_fk": { + "name": "status_report_update_status_report_id_status_report_id_fk", + "tableFrom": "status_report_update", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential": { + "name": "credential", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_workspace_id_idx": { + "name": "integration_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_workspace_id_workspace_id_fk": { + "name": "integration_workspace_id_workspace_id_fk", + "tableFrom": "integration", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page": { + "name": "page", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "slug": { + "name": "slug", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "force_theme": { + "name": "force_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "custom_theme": { + "name": "custom_theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_protected": { + "name": "password_protected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'public'" + }, + "auth_email_domains": { + "name": "auth_email_domains", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ip_ranges": { + "name": "allowed_ip_ranges", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homepage_url": { + "name": "homepage_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_url": { + "name": "contact_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "locales": { + "name": "locales", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_page": { + "name": "legacy_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_index": { + "name": "allow_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_monitor_values": { + "name": "show_monitor_values", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_slug_unique": { + "name": "page_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "page_lower_slug_idx": { + "name": "page_lower_slug_idx", + "columns": [ + "LOWER(\"slug\")" + ], + "isUnique": false + }, + "page_lower_custom_domain_idx": { + "name": "page_lower_custom_domain_idx", + "columns": [ + "LOWER(\"custom_domain\")" + ], + "isUnique": false + }, + "page_workspace_id_idx": { + "name": "page_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_workspace_id_workspace_id_fk": { + "name": "page_workspace_id_workspace_id_fk", + "tableFrom": "page", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor": { + "name": "monitor", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "periodicity": { + "name": "periodicity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "external_name": { + "name": "external_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 45000 + }, + "degraded_after": { + "name": "degraded_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assertions": { + "name": "assertions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_endpoint": { + "name": "otel_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_headers": { + "name": "otel_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "retry": { + "name": "retry", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "follow_redirects": { + "name": "follow_redirects", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "grpc_service": { + "name": "grpc_service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grpc_tls": { + "name": "grpc_tls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'tls'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_workspace_id_active_idx": { + "name": "monitor_workspace_id_active_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false, + "where": "\"monitor\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "monitor_workspace_id_workspace_id_fk": { + "name": "monitor_workspace_id_workspace_id_fk", + "tableFrom": "monitor", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_subscriber": { + "name": "page_subscriber", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_type": { + "name": "channel_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_config": { + "name": "channel_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'self_signup'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_subscriber_page_id_idx": { + "name": "page_subscriber_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "idx_page_subscriber_email_page_active": { + "name": "idx_page_subscriber_email_page_active", + "columns": [ + "LOWER(\"email\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'email'" + }, + "idx_page_subscriber_webhook_page_active": { + "name": "idx_page_subscriber_webhook_page_active", + "columns": [ + "LOWER(\"webhook_url\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'webhook'" + }, + "idx_page_subscriber_slack_channel_page_active": { + "name": "idx_page_subscriber_slack_channel_page_active", + "columns": [ + "slack_channel_id", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'slack'" + } + }, + "foreignKeys": { + "page_subscriber_page_id_page_id_fk": { + "name": "page_subscriber_page_id_page_id_fk", + "tableFrom": "page_subscriber", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_subscriber_channel_check": { + "name": "page_subscriber_channel_check", + "value": "(\"page_subscriber\".\"channel_type\" = 'email' AND \"page_subscriber\".\"email\" IS NOT NULL AND \"page_subscriber\".\"webhook_url\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'webhook' AND \"page_subscriber\".\"webhook_url\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'slack' AND \"page_subscriber\".\"slack_channel_id\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL AND \"page_subscriber\".\"webhook_url\" IS NULL)" + } + } + }, + "page_subscriber_to_page_component": { + "name": "page_subscriber_to_page_component", + "columns": { + "page_subscriber_id": { + "name": "page_subscriber_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk": { + "name": "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_subscriber", + "columnsFrom": [ + "page_subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_subscriber_to_page_component_page_component_id_page_component_id_fk": { + "name": "page_subscriber_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk": { + "columns": [ + "page_subscriber_id", + "page_component_id" + ], + "name": "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notification_workspace_id_idx": { + "name": "notification_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_workspace_id_workspace_id_fk": { + "name": "notification_workspace_id_workspace_id_fk", + "tableFrom": "notification", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_trigger": { + "name": "notification_trigger", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_id_monitor_id_crontimestampe": { + "name": "notification_id_monitor_id_crontimestampe", + "columns": [ + "notification_id", + "monitor_id", + "cron_timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_trigger_monitor_id_monitor_id_fk": { + "name": "notification_trigger_monitor_id_monitor_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_trigger_notification_id_notification_id_fk": { + "name": "notification_trigger_notification_id_notification_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications_to_monitors": { + "name": "notifications_to_monitors", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notifications_to_monitors_notification_id_idx": { + "name": "notifications_to_monitors_notification_id_idx", + "columns": [ + "notification_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_to_monitors_monitor_id_monitor_id_fk": { + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_to_monitors_notification_id_notification_id_fk": { + "name": "notifications_to_monitors_notification_id_notification_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notifications_to_monitors_monitor_id_notification_id_pk": { + "columns": [ + "monitor_id", + "notification_id" + ], + "name": "notifications_to_monitors_monitor_id_notification_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_dead_letter": { + "name": "notification_dead_letter", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "outbox_id": { + "name": "outbox_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dedup_key": { + "name": "dedup_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "incident_id": { + "name": "incident_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "final_error": { + "name": "final_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "died_at": { + "name": "died_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_dead_letter_dedup_key_idx": { + "name": "notification_dead_letter_dedup_key_idx", + "columns": [ + "dedup_key" + ], + "isUnique": true + }, + "notification_dead_letter_workspace_id_died_at_idx": { + "name": "notification_dead_letter_workspace_id_died_at_idx", + "columns": [ + "workspace_id", + "died_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_dead_letter_monitor_id_monitor_id_fk": { + "name": "notification_dead_letter_monitor_id_monitor_id_fk", + "tableFrom": "notification_dead_letter", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_dead_letter_workspace_id_workspace_id_fk": { + "name": "notification_dead_letter_workspace_id_workspace_id_fk", + "tableFrom": "notification_dead_letter", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_outbox": { + "name": "notification_outbox", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dedup_key": { + "name": "dedup_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "incident_id": { + "name": "incident_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deadline_at": { + "name": "deadline_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_outbox_dedup_key_idx": { + "name": "notification_outbox_dedup_key_idx", + "columns": [ + "dedup_key" + ], + "isUnique": true + }, + "notification_outbox_claim_idx": { + "name": "notification_outbox_claim_idx", + "columns": [ + "next_attempt_at" + ], + "isUnique": false, + "where": "\"notification_outbox\".\"delivery_status\" = 'pending'" + }, + "notification_outbox_notification_id_cron_timestamp_idx": { + "name": "notification_outbox_notification_id_cron_timestamp_idx", + "columns": [ + "notification_id", + "cron_timestamp" + ], + "isUnique": false + }, + "notification_outbox_channel_idx": { + "name": "notification_outbox_channel_idx", + "columns": [ + "monitor_id", + "notification_id" + ], + "isUnique": false, + "where": "\"notification_outbox\".\"delivery_status\" = 'pending'" + } + }, + "foreignKeys": { + "notification_outbox_monitor_id_monitor_id_fk": { + "name": "notification_outbox_monitor_id_monitor_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_outbox_workspace_id_workspace_id_fk": { + "name": "notification_outbox_workspace_id_workspace_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notification_outbox_notification_id_notification_id_fk": { + "name": "notification_outbox_notification_id_notification_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_outbox_incident_id_incident_id_fk": { + "name": "notification_outbox_incident_id_incident_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "incident", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_status": { + "name": "monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_status_idx": { + "name": "monitor_status_idx", + "columns": [ + "monitor_id", + "region" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_status_monitor_id_monitor_id_fk": { + "name": "monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_status_monitor_id_region_pk": { + "columns": [ + "monitor_id", + "region" + ], + "name": "monitor_status_monitor_id_region_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invitation_workspace_id_idx": { + "name": "invitation_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incident": { + "name": "incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'triage'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "incident_screenshot_url": { + "name": "incident_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recovery_screenshot_url": { + "name": "recovery_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_resolved": { + "name": "auto_resolved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "incident_workspace_id_started_at_idx": { + "name": "incident_workspace_id_started_at_idx", + "columns": [ + "workspace_id", + "started_at" + ], + "isUnique": false + }, + "incident_open_idx": { + "name": "incident_open_idx", + "columns": [ + "monitor_id" + ], + "isUnique": true, + "where": "\"incident\".\"resolved_at\" IS NULL" + }, + "incident_monitor_id_started_at_unique": { + "name": "incident_monitor_id_started_at_unique", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "incident_monitor_id_monitor_id_fk": { + "name": "incident_monitor_id_monitor_id_fk", + "tableFrom": "incident", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set default", + "onUpdate": "no action" + }, + "incident_workspace_id_workspace_id_fk": { + "name": "incident_workspace_id_workspace_id_fk", + "tableFrom": "incident", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_acknowledged_by_user_id_fk": { + "name": "incident_acknowledged_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "acknowledged_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_resolved_by_user_id_fk": { + "name": "incident_resolved_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag": { + "name": "monitor_tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_workspace_id_idx": { + "name": "monitor_tag_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_workspace_id_workspace_id_fk": { + "name": "monitor_tag_workspace_id_workspace_id_fk", + "tableFrom": "monitor_tag", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag_to_monitor": { + "name": "monitor_tag_to_monitor", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_tag_id": { + "name": "monitor_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_to_monitor_monitor_tag_id_idx": { + "name": "monitor_tag_to_monitor_monitor_tag_id_idx", + "columns": [ + "monitor_tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor_tag", + "columnsFrom": [ + "monitor_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { + "columns": [ + "monitor_id", + "monitor_tag_id" + ], + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "application": { + "name": "application", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "application_dsn_unique": { + "name": "application_dsn_unique", + "columns": [ + "dsn" + ], + "isUnique": true + }, + "application_workspace_id_idx": { + "name": "application_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "application_workspace_id_workspace_id_fk": { + "name": "application_workspace_id_workspace_id_fk", + "tableFrom": "application", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance": { + "name": "maintenance", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_page_id_idx": { + "name": "maintenance_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "maintenance_workspace_id_idx": { + "name": "maintenance_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_workspace_id_workspace_id_fk": { + "name": "maintenance_workspace_id_workspace_id_fk", + "tableFrom": "maintenance", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "maintenance_page_id_page_id_fk": { + "name": "maintenance_page_id_page_id_fk", + "tableFrom": "maintenance", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "check": { + "name": "check", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "count_requests": { + "name": "count_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "check_workspace_id_idx": { + "name": "check_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "check_workspace_id_workspace_id_fk": { + "name": "check_workspace_id_workspace_id_fk", + "tableFrom": "check", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_run": { + "name": "monitor_run", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runned_at": { + "name": "runned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_run_workspace_id_created_at_idx": { + "name": "monitor_run_workspace_id_created_at_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "monitor_run_monitor_id_idx": { + "name": "monitor_run_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_run_workspace_id_workspace_id_fk": { + "name": "monitor_run_workspace_id_workspace_id_fk", + "tableFrom": "monitor_run", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "monitor_run_monitor_id_monitor_id_fk": { + "name": "monitor_run_monitor_id_monitor_id_fk", + "tableFrom": "monitor_run", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_monitor_status": { + "name": "private_location_monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_monitor_status_pl_id_idx": { + "name": "private_location_monitor_status_pl_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_monitor_status_monitor_id_monitor_id_fk": { + "name": "private_location_monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_monitor_status_private_location_id_private_location_id_fk": { + "name": "private_location_monitor_status_private_location_id_private_location_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "private_location_monitor_status_monitor_id_private_location_id_pk": { + "columns": [ + "monitor_id", + "private_location_id" + ], + "name": "private_location_monitor_status_monitor_id_private_location_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location": { + "name": "private_location", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'error'" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_workspace_id_idx": { + "name": "private_location_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_workspace_id_workspace_id_fk": { + "name": "private_location_workspace_id_workspace_id_fk", + "tableFrom": "private_location", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_to_monitor": { + "name": "private_location_to_monitor", + "columns": { + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "private_location_to_monitor_private_location_id_idx": { + "name": "private_location_to_monitor_private_location_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + }, + "private_location_to_monitor_monitor_id_idx": { + "name": "private_location_to_monitor_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_to_monitor_private_location_id_private_location_id_fk": { + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_to_monitor_monitor_id_monitor_id_fk": { + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_group": { + "name": "monitor_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_group_workspace_id_idx": { + "name": "monitor_group_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_group_page_id_idx": { + "name": "monitor_group_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_group_workspace_id_workspace_id_fk": { + "name": "monitor_group_workspace_id_workspace_id_fk", + "tableFrom": "monitor_group", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_group_page_id_page_id_fk": { + "name": "monitor_group_page_id_page_id_fk", + "tableFrom": "monitor_group", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer": { + "name": "viewer", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "viewer_email_unique": { + "name": "viewer_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_accounts": { + "name": "viewer_accounts", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_accounts_user_id_viewer_id_fk": { + "name": "viewer_accounts_user_id_viewer_id_fk", + "tableFrom": "viewer_accounts", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "viewer_accounts_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "viewer_accounts_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_session": { + "name": "viewer_session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_session_user_id_viewer_id_fk": { + "name": "viewer_session_user_id_viewer_id_fk", + "tableFrom": "viewer_session", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hashed_token": { + "name": "hashed_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"write\"]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_key_prefix_unique": { + "name": "api_key_prefix_unique", + "columns": [ + "prefix" + ], + "isUnique": true + }, + "api_key_hashed_token_unique": { + "name": "api_key_hashed_token_unique", + "columns": [ + "hashed_token" + ], + "isUnique": true + }, + "api_key_prefix_idx": { + "name": "api_key_prefix_idx", + "columns": [ + "prefix" + ], + "isUnique": false + }, + "api_key_workspace_id_idx": { + "name": "api_key_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_id_user_id_fk": { + "name": "api_key_created_by_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_to_page_component": { + "name": "maintenance_to_page_component", + "columns": { + "maintenance_id": { + "name": "maintenance_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_to_page_component_page_component_id_idx": { + "name": "maintenance_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_to_page_component_maintenance_id_maintenance_id_fk": { + "name": "maintenance_to_page_component_maintenance_id_maintenance_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "maintenance", + "columnsFrom": [ + "maintenance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_to_page_component_page_component_id_page_component_id_fk": { + "name": "maintenance_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "maintenance_to_page_component_maintenance_id_page_component_id_pk": { + "columns": [ + "maintenance_id", + "page_component_id" + ], + "name": "maintenance_to_page_component_maintenance_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component": { + "name": "page_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monitor'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_order": { + "name": "group_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_workspace_id_idx": { + "name": "page_component_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "page_component_page_id_monitor_id_unique": { + "name": "page_component_page_id_monitor_id_unique", + "columns": [ + "page_id", + "monitor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "page_component_workspace_id_workspace_id_fk": { + "name": "page_component_workspace_id_workspace_id_fk", + "tableFrom": "page_component", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_page_id_page_id_fk": { + "name": "page_component_page_id_page_id_fk", + "tableFrom": "page_component", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_monitor_id_monitor_id_fk": { + "name": "page_component_monitor_id_monitor_id_fk", + "tableFrom": "page_component", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_group_id_page_component_groups_id_fk": { + "name": "page_component_group_id_page_component_groups_id_fk", + "tableFrom": "page_component", + "tableTo": "page_component_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_component_type_check": { + "name": "page_component_type_check", + "value": "\"page_component\".\"type\" = 'monitor' AND \"page_component\".\"monitor_id\" IS NOT NULL OR \"page_component\".\"type\" = 'static' AND \"page_component\".\"monitor_id\" IS NULL" + } + } + }, + "status_report_update_to_page_component": { + "name": "status_report_update_to_page_component", + "columns": { + "status_report_update_id": { + "name": "status_report_update_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_to_page_component_page_component_id_idx": { + "name": "status_report_update_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk": { + "name": "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "status_report_update", + "columnsFrom": [ + "status_report_update_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_update_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_update_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_update_to_page_component_status_report_update_id_page_component_id_pk": { + "columns": [ + "status_report_update_id", + "page_component_id" + ], + "name": "status_report_update_to_page_component_status_report_update_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_to_page_component": { + "name": "status_report_to_page_component", + "columns": { + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_to_page_component_page_component_id_idx": { + "name": "status_report_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_to_page_component_status_report_id_status_report_id_fk": { + "name": "status_report_to_page_component_status_report_id_status_report_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_to_page_component_status_report_id_page_component_id_pk": { + "columns": [ + "status_report_id", + "page_component_id" + ], + "name": "status_report_to_page_component_status_report_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component_groups": { + "name": "page_component_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_open": { + "name": "default_open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_groups_page_id_idx": { + "name": "page_component_groups_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "page_component_groups_workspace_id_idx": { + "name": "page_component_groups_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_component_groups_workspace_id_workspace_id_fk": { + "name": "page_component_groups_workspace_id_workspace_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_groups_page_id_page_id_fk": { + "name": "page_component_groups_page_id_page_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocker": { + "name": "blocker", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "feedback_workspace_id_idx": { + "name": "feedback_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "feedback_workspace_id_workspace_id_fk": { + "name": "feedback_workspace_id_workspace_id_fk", + "tableFrom": "feedback", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_user_id_user_id_fk": { + "name": "feedback_user_id_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_fields": { + "name": "changed_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + "workspace_id", + "entity_type", + "entity_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service": { + "name": "external_service", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_page_url": { + "name": "status_page_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_config": { + "name": "api_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_slug_unique": { + "name": "external_service_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "external_service_deleted_at_idx": { + "name": "external_service_deleted_at_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_component": { + "name": "external_service_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_component_id": { + "name": "upstream_component_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "indicator": { + "name": "indicator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_component_unique_idx": { + "name": "external_service_component_unique_idx", + "columns": [ + "external_service_id", + "upstream_component_id" + ], + "isUnique": true + }, + "external_service_component_slug_unique_idx": { + "name": "external_service_component_slug_unique_idx", + "columns": [ + "external_service_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_service_component_external_service_id_external_service_id_fk": { + "name": "external_service_component_external_service_id_external_service_id_fk", + "tableFrom": "external_service_component", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_incident": { + "name": "external_service_incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_incident_id": { + "name": "provider_incident_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortlink": { + "name": "shortlink", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affected_component_ids": { + "name": "affected_component_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload_purged_at": { + "name": "raw_payload_purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_incident_unique_idx": { + "name": "external_service_incident_unique_idx", + "columns": [ + "external_service_id", + "provider_incident_id" + ], + "isUnique": true + }, + "external_service_incident_started_at_idx": { + "name": "external_service_incident_started_at_idx", + "columns": [ + "external_service_id", + "started_at" + ], + "isUnique": false + }, + "external_service_incident_resolved_at_idx": { + "name": "external_service_incident_resolved_at_idx", + "columns": [ + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_incident_external_service_id_external_service_id_fk": { + "name": "external_service_incident_external_service_id_external_service_id_fk", + "tableFrom": "external_service_incident", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_report": { + "name": "external_service_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_service_component_id": { + "name": "external_service_component_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_hash": { + "name": "reporter_hash", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text(2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_report_service_idx": { + "name": "external_service_report_service_idx", + "columns": [ + "external_service_id", + "created_at" + ], + "isUnique": false + }, + "external_service_report_component_idx": { + "name": "external_service_report_component_idx", + "columns": [ + "external_service_component_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_report_external_service_id_external_service_id_fk": { + "name": "external_service_report_external_service_id_external_service_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_service_report_external_service_component_id_external_service_component_id_fk": { + "name": "external_service_report_external_service_component_id_external_service_component_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service_component", + "columnsFrom": [ + "external_service_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_session": { + "name": "chat_session", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_session_workspace_user_updated_idx": { + "name": "chat_session_workspace_user_updated_idx", + "columns": [ + "workspace_id", + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_session_workspace_id_workspace_id_fk": { + "name": "chat_session_workspace_id_workspace_id_fk", + "tableFrom": "chat_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_session_user_id_user_id_fk": { + "name": "chat_session_user_id_user_id_fk", + "tableFrom": "chat_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "frozen_monitor_uptime": { + "name": "frozen_monitor_uptime", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "days": { + "name": "days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "frozen_monitor_uptime_workspace_id_idx": { + "name": "frozen_monitor_uptime_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "frozen_monitor_uptime_monitor_id_month_unique": { + "name": "frozen_monitor_uptime_monitor_id_month_unique", + "columns": [ + "monitor_id", + "month" + ], + "isUnique": true + } + }, + "foreignKeys": { + "frozen_monitor_uptime_workspace_id_workspace_id_fk": { + "name": "frozen_monitor_uptime_workspace_id_workspace_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frozen_monitor_uptime_monitor_id_monitor_id_fk": { + "name": "frozen_monitor_uptime_monitor_id_monitor_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_transition": { + "name": "monitor_transition", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quorum_count": { + "name": "quorum_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region_count": { + "name": "region_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transitioned": { + "name": "transitioned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outbox_rows": { + "name": "outbox_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "monitor_transition_monitor_id_cron_timestamp_idx": { + "name": "monitor_transition_monitor_id_cron_timestamp_idx", + "columns": [ + "monitor_id", + "cron_timestamp" + ], + "isUnique": false + }, + "monitor_transition_created_at_idx": { + "name": "monitor_transition_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_transition_monitor_id_monitor_id_fk": { + "name": "monitor_transition_monitor_id_monitor_id_fk", + "tableFrom": "monitor_transition", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "page_lower_slug_idx": { + "columns": { + "LOWER(\"slug\")": { + "isExpression": true + } + } + }, + "page_lower_custom_domain_idx": { + "columns": { + "LOWER(\"custom_domain\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_email_page_active": { + "columns": { + "LOWER(\"email\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_webhook_page_active": { + "columns": { + "LOWER(\"webhook_url\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 02b53c50..54eb8118 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -596,6 +596,13 @@ "when": 1787773726456, "tag": "0084_flaky_thundra", "breakpoints": true + }, + { + "idx": 85, + "version": "6", + "when": 1788374582005, + "tag": "0085_daily_glorian", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/incidents/incident.ts b/packages/db/src/schema/incidents/incident.ts index 9823274c..d1520425 100644 --- a/packages/db/src/schema/incidents/incident.ts +++ b/packages/db/src/schema/incidents/incident.ts @@ -5,6 +5,7 @@ import { sqliteTable, text, unique, + uniqueIndex, } from "drizzle-orm/sqlite-core"; import { monitor } from "../monitors"; @@ -68,8 +69,8 @@ export const incidentTable = sqliteTable( table.startedAt, ), // Partial: open incidents are looked up on every check result, every region, - // every minute. Keeps that b-tree small enough to stay hot. - index("incident_open_idx") + // every minute. Unique so a monitor cannot hold two open incidents at once. + uniqueIndex("incident_open_idx") .on(table.monitorId) .where(sql`${table.resolvedAt} IS NULL`), ], diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index ddc5bece..7dd0664a 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -26,3 +26,4 @@ export * from "./audit_logs"; export * from "./external_services"; export * from "./chat_sessions"; export * from "./frozen_uptime"; +export * from "./monitor_transition"; diff --git a/packages/db/src/schema/monitor_status/monitor_status.ts b/packages/db/src/schema/monitor_status/monitor_status.ts index 7690d614..25f3dc7c 100644 --- a/packages/db/src/schema/monitor_status/monitor_status.ts +++ b/packages/db/src/schema/monitor_status/monitor_status.ts @@ -19,6 +19,7 @@ export const monitorStatusTable = sqliteTable( status: text("status", { enum: monitorStatusEnum }) .default("active") .notNull(), + cronTimestamp: integer("cron_timestamp").notNull().default(0), createdAt: integer("created_at", { mode: "timestamp" }).default( sql`(strftime('%s', 'now'))`, diff --git a/packages/db/src/schema/monitor_transition/index.ts b/packages/db/src/schema/monitor_transition/index.ts new file mode 100644 index 00000000..615e01be --- /dev/null +++ b/packages/db/src/schema/monitor_transition/index.ts @@ -0,0 +1,3 @@ +export * from "./monitor_transition"; +export * from "./validation"; +export type * from "./validation"; diff --git a/packages/db/src/schema/monitor_transition/monitor_transition.ts b/packages/db/src/schema/monitor_transition/monitor_transition.ts new file mode 100644 index 00000000..902c9dcc --- /dev/null +++ b/packages/db/src/schema/monitor_transition/monitor_transition.ts @@ -0,0 +1,41 @@ +import { relations } from "drizzle-orm"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +import { monitorStatus } from "../monitors/constants"; +import { monitor } from "../monitors/monitor"; + +export const monitorTransition = sqliteTable( + "monitor_transition", + { + id: integer("id").primaryKey(), + monitorId: integer("monitor_id") + .notNull() + .references(() => monitor.id, { onDelete: "cascade" }), + region: text("region").notNull(), + cronTimestamp: integer("cron_timestamp").notNull(), + fromStatus: text("from_status", { enum: monitorStatus }).notNull(), + toStatus: text("to_status", { enum: monitorStatus }).notNull(), + quorumCount: integer("quorum_count").notNull(), + regionCount: integer("region_count").notNull(), + transitioned: integer("transitioned", { mode: "boolean" }).notNull(), + outboxRows: integer("outbox_rows").default(0).notNull(), + createdAt: integer("created_at").notNull(), + }, + (t) => [ + index("monitor_transition_monitor_id_cron_timestamp_idx").on( + t.monitorId, + t.cronTimestamp, + ), + index("monitor_transition_created_at_idx").on(t.createdAt), + ], +); + +export const monitorTransitionRelations = relations( + monitorTransition, + ({ one }) => ({ + monitor: one(monitor, { + fields: [monitorTransition.monitorId], + references: [monitor.id], + }), + }), +); diff --git a/packages/db/src/schema/monitor_transition/validation.ts b/packages/db/src/schema/monitor_transition/validation.ts new file mode 100644 index 00000000..df06661a --- /dev/null +++ b/packages/db/src/schema/monitor_transition/validation.ts @@ -0,0 +1,8 @@ +import { createSelectSchema } from "drizzle-zod"; + +import { monitorTransition } from "./monitor_transition"; + +export const selectMonitorTransitionSchema = + createSelectSchema(monitorTransition); + +export type MonitorTransitionRow = typeof monitorTransition.$inferSelect; diff --git a/packages/db/src/schema/notifications/index.ts b/packages/db/src/schema/notifications/index.ts index fadeb49a..2a8d29cb 100644 --- a/packages/db/src/schema/notifications/index.ts +++ b/packages/db/src/schema/notifications/index.ts @@ -1,4 +1,5 @@ export * from "./constants"; export * from "./notification"; +export * from "./outbox"; export * from "./validation"; export type * from "./validation"; diff --git a/packages/db/src/schema/notifications/outbox.ts b/packages/db/src/schema/notifications/outbox.ts new file mode 100644 index 00000000..36e24243 --- /dev/null +++ b/packages/db/src/schema/notifications/outbox.ts @@ -0,0 +1,142 @@ +import { relations, sql } from "drizzle-orm"; +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; + +import { incidentTable } from "../incidents/incident"; +import { monitorStatus } from "../monitors/constants"; +import { monitor } from "../monitors/monitor"; +import { workspace } from "../workspaces/workspace"; +import { notificationProvider } from "./constants"; +import { notification } from "./notification"; +import type { NotificationOutboxPayload } from "./validation"; + +export const notificationOutboxEventType = [ + "alert", + "recovery", + "degraded", +] as const; + +export const notificationOutboxDeliveryStatus = ["pending", "settled"] as const; + +/** + * How a settled row ended: delivered by the drainer, deliberately skipped, or + * never ours because the inline sender owned the monitor at write time. + */ +export const notificationOutboxOutcome = [ + "delivered", + "skipped", + "inline", +] as const; + +export const notificationOutbox = sqliteTable( + "notification_outbox", + { + id: integer("id").primaryKey(), + dedupKey: text("dedup_key").notNull(), + monitorId: integer("monitor_id") + .notNull() + .references(() => monitor.id, { onDelete: "cascade" }), + workspaceId: integer("workspace_id").references(() => workspace.id), + notificationId: integer("notification_id") + .notNull() + .references(() => notification.id, { onDelete: "cascade" }), + provider: text("provider", { enum: notificationProvider }).notNull(), + eventType: text("event_type", { + enum: notificationOutboxEventType, + }).notNull(), + fromStatus: text("from_status", { enum: monitorStatus }).notNull(), + toStatus: text("to_status", { enum: monitorStatus }).notNull(), + cronTimestamp: integer("cron_timestamp").notNull(), + incidentId: integer("incident_id").references(() => incidentTable.id, { + onDelete: "set null", + }), + payload: text("payload", { mode: "json" }) + .$type() + .notNull(), + deliveryStatus: text("delivery_status", { + enum: notificationOutboxDeliveryStatus, + }) + .default("pending") + .notNull(), + outcome: text("outcome", { enum: notificationOutboxOutcome }), + attempts: integer("attempts").default(0).notNull(), + nextAttemptAt: integer("next_attempt_at").notNull(), + deadlineAt: integer("deadline_at").notNull(), + lockedBy: text("locked_by"), + lockedUntil: integer("locked_until"), + deliveredAt: integer("delivered_at"), + lastError: text("last_error"), + createdAt: integer("created_at").notNull(), + }, + (t) => [ + uniqueIndex("notification_outbox_dedup_key_idx").on(t.dedupKey), + index("notification_outbox_claim_idx") + .on(t.nextAttemptAt) + .where(sql`${t.deliveryStatus} = 'pending'`), + index("notification_outbox_notification_id_cron_timestamp_idx").on( + t.notificationId, + t.cronTimestamp, + ), + index("notification_outbox_channel_idx") + .on(t.monitorId, t.notificationId) + .where(sql`${t.deliveryStatus} = 'pending'`), + ], +); + +export const notificationDeadLetter = sqliteTable( + "notification_dead_letter", + { + id: integer("id").primaryKey(), + outboxId: integer("outbox_id").notNull(), + dedupKey: text("dedup_key").notNull(), + monitorId: integer("monitor_id") + .notNull() + .references(() => monitor.id, { onDelete: "cascade" }), + workspaceId: integer("workspace_id").references(() => workspace.id), + notificationId: integer("notification_id").notNull(), + provider: text("provider", { enum: notificationProvider }).notNull(), + eventType: text("event_type", { + enum: notificationOutboxEventType, + }).notNull(), + fromStatus: text("from_status", { enum: monitorStatus }).notNull(), + toStatus: text("to_status", { enum: monitorStatus }).notNull(), + cronTimestamp: integer("cron_timestamp").notNull(), + incidentId: integer("incident_id"), + payload: text("payload", { mode: "json" }) + .$type() + .notNull(), + attempts: integer("attempts").notNull(), + finalError: text("final_error"), + diedAt: integer("died_at").notNull(), + }, + (t) => [ + uniqueIndex("notification_dead_letter_dedup_key_idx").on(t.dedupKey), + index("notification_dead_letter_workspace_id_died_at_idx").on( + t.workspaceId, + t.diedAt, + ), + ], +); + +export const notificationOutboxRelations = relations( + notificationOutbox, + ({ one }) => ({ + monitor: one(monitor, { + fields: [notificationOutbox.monitorId], + references: [monitor.id], + }), + notification: one(notification, { + fields: [notificationOutbox.notificationId], + references: [notification.id], + }), + incident: one(incidentTable, { + fields: [notificationOutbox.incidentId], + references: [incidentTable.id], + }), + }), +); diff --git a/packages/db/src/schema/notifications/validation.ts b/packages/db/src/schema/notifications/validation.ts index 3095acf7..7e67d0e6 100644 --- a/packages/db/src/schema/notifications/validation.ts +++ b/packages/db/src/schema/notifications/validation.ts @@ -4,6 +4,7 @@ import * as z from "zod"; import { notificationProvider } from "./constants"; import { notification } from "./notification"; +import { notificationDeadLetter, notificationOutbox } from "./outbox"; export const notificationProviderSchema = z.enum(notificationProvider); @@ -189,3 +190,24 @@ export const InsertNotificationWithDataSchema = z.discriminatedUnion( export type InsertNotificationWithData = z.infer< typeof InsertNotificationWithDataSchema >; + +export const notificationOutboxPayloadSchema = z.object({ + regions: z.array(z.string()), + statusCode: z.number().optional(), + message: z.string().optional(), + latency: z.number().optional(), +}); + +export type NotificationOutboxPayload = z.infer< + typeof notificationOutboxPayloadSchema +>; + +export const selectNotificationOutboxSchema = + createSelectSchema(notificationOutbox); +export const selectNotificationDeadLetterSchema = createSelectSchema( + notificationDeadLetter, +); + +export type NotificationOutboxRow = typeof notificationOutbox.$inferSelect; +export type NotificationDeadLetterRow = + typeof notificationDeadLetter.$inferSelect; diff --git a/packages/services/AGENTS.md b/packages/services/AGENTS.md index 17da257c..51e4009c 100644 --- a/packages/services/AGENTS.md +++ b/packages/services/AGENTS.md @@ -3,6 +3,14 @@ Every workspace-scoped mutation lives here, not in a tRPC router or a Hono handler. Routers validate input, call a verb, map errors. +One documented exception: the checker ingest path +(`apps/workflows/src/checker/transition.ts`) writes `monitor_status`, +`incident` and the outbox directly. `ServiceContext` requires a `Workspace` that +path would have to load on every check, `withTransaction` opens an interactive +transaction where it needs a single atomic `db.batch()`, and a fail-closed +`emitAudit` would roll back a real status transition because an audit insert +failed. Do not "fix" it by routing it through a verb. + ## Shape of a verb - **One file per verb** under `packages/services/src//` (`create.ts`, diff --git a/packages/services/src/incident/__tests__/incident.test.ts b/packages/services/src/incident/__tests__/incident.test.ts index db0028be..439b26fa 100644 --- a/packages/services/src/incident/__tests__/incident.test.ts +++ b/packages/services/src/incident/__tests__/incident.test.ts @@ -292,9 +292,12 @@ describe("list / get", () => { workspaceId: teamCtx.workspace.id, monitorId: testMonitorId, }); + // Only one incident per monitor may be open at a time + // (partial unique index `incident_open_idx`), so the second is resolved. const b = await insertIncident(tx, { workspaceId: teamCtx.workspace.id, monitorId: testMonitorId, + resolvedAt: new Date(), }); const { items } = await listIncidents({ -- 2.51.2 From f1178730a1ef658aba684eb5dd67fb59a7cee642 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:25:03 +0000 Subject: [PATCH 182/266] docs(readme): add deploy on railway button (#2628) Add a "Self-Hosting with Railway" section to the README. The section has the one-click deploy button and a link to the template repository. --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 2f214519..0d7cbb52 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,14 @@ ghcr.io/openstatushq/openstatus-checker:latest [Complete Coolify Deployment Guide](./COOLIFY_DEPLOYMENT.md) +### Self-Hosting with Railway + +Deploy the full stack (dashboard, status pages, API, workflows, probes, libSQL, and Tinybird Local) to one Railway project with one click: + +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/openstatus?utm_medium=integration&utm_source=button&utm_campaign=openstatus) + +The template source and the setup instructions are in [ephraimduncan/openstatus-railway](https://github.com/ephraimduncan/openstatus-railway). + ### Manual Setup #### Requirements -- 2.51.2 From 21bf088bf19ce8463f14fb69854cc41869dbab92 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:41:41 +0200 Subject: [PATCH 183/266] fix: derive status bar radius from --radius (#2616) * fix: status bar radius css var * refactor: radius scale * chore: registry and agents --- apps/dashboard/src/app/globals.css | 8 ------- .../components/chart/chart-tooltip-number.tsx | 2 +- .../tool-renderers/list-response-logs.tsx | 2 +- .../data-table/response-logs/columns.tsx | 6 ++--- .../response-logs/data-table-basics.tsx | 4 ++-- .../status-page-history/table-cell-uptime.tsx | 4 ++-- .../src/components/development-indicator.tsx | 2 +- apps/status-page/AGENTS.md | 11 +++++++++ apps/status-page/src/app/globals.css | 9 ------- .../components/chart/chart-legend-badge.tsx | 2 +- .../components/chart/chart-tooltip-number.tsx | 2 +- apps/web/src/content/image-zoom.tsx | 4 ++-- apps/web/src/content/simple-chart.tsx | 2 +- packages/ui/AGENTS.md | 24 +++++++++++++++++++ packages/ui/REGISTRY.md | 18 ++++++++++++++ .../ui/src/components/blocks/status-bar.tsx | 15 +++++------- .../src/components/blocks/status-events.tsx | 2 +- packages/ui/src/components/ui/chart.tsx | 4 ++-- packages/ui/src/components/ui/checkbox.tsx | 2 +- packages/ui/src/components/ui/input-group.tsx | 7 +++--- packages/ui/src/components/ui/tooltip.tsx | 2 +- packages/ui/src/globals.css | 20 +++++++++++++--- 22 files changed, 99 insertions(+), 53 deletions(-) diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 4130e0bd..3ecbe2c9 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -52,12 +52,4 @@ [data-status-preview] .rounded-full { border-radius: calc(var(--radius) * 99999999); } - [data-status-preview] .rounded-b-full { - border-bottom-left-radius: calc(var(--radius) * 99999999); - border-bottom-right-radius: calc(var(--radius) * 99999999); - } - [data-status-preview] .rounded-t-full { - border-top-left-radius: calc(var(--radius) * 99999999); - border-top-right-radius: calc(var(--radius) * 99999999); - } } \ No newline at end of file diff --git a/apps/dashboard/src/components/chart/chart-tooltip-number.tsx b/apps/dashboard/src/components/chart/chart-tooltip-number.tsx index 998315d5..0bda8c61 100644 --- a/apps/dashboard/src/components/chart/chart-tooltip-number.tsx +++ b/apps/dashboard/src/components/chart/chart-tooltip-number.tsx @@ -44,7 +44,7 @@ export function ChartTooltipNumberRaw({ <>
diff --git a/apps/dashboard/src/components/data-table/response-logs/columns.tsx b/apps/dashboard/src/components/data-table/response-logs/columns.tsx index 74f22b20..3173d571 100644 --- a/apps/dashboard/src/components/data-table/response-logs/columns.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/columns.tsx @@ -33,13 +33,13 @@ export function getColumns( cell: ({ row }) => { const value = row.getValue("requestStatus"); if (value === "error") { - return
; + return
; } if (value === "degraded") { - return
; + return
; } if (value === "success") { - return
; + return
; } return
-
; }, diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx index 6642dea2..66b49848 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx @@ -96,7 +96,7 @@ export function DataTableBasicsHTTP({
@@ -130,7 +130,7 @@ export function TableCellUptime({
diff --git a/apps/dashboard/src/components/development-indicator.tsx b/apps/dashboard/src/components/development-indicator.tsx index d76af66e..0d41d017 100644 --- a/apps/dashboard/src/components/development-indicator.tsx +++ b/apps/dashboard/src/components/development-indicator.tsx @@ -23,7 +23,7 @@ export function DevelopmentIndicator() { -
+
In Beta
diff --git a/apps/status-page/AGENTS.md b/apps/status-page/AGENTS.md index 10176814..e072a5b8 100644 --- a/apps/status-page/AGENTS.md +++ b/apps/status-page/AGENTS.md @@ -32,6 +32,17 @@ the page's access type — a gated page must not inherit a public TTL. Themes come from `@openstatus/theme-store` as OKLCH CSS variables. Add or edit a theme in that package; do not hard-code colours in a component. +`--radius` is themed too, and this app overrides it to `0rem`. The scale in +`packages/ui/src/globals.css` is proportional to it (`xs` 0.2, `rounded` 0.4, +`sm` 0.6, `md` 0.8, `lg` 1.0, `xl` 1.4), so every step collapses with the theme +— use the tokens, never a hard-coded `rounded-[4px]`, and prefer `rounded-lg` +over the equivalent `rounded-(--radius)`. A pill that stays a pill under a +square theme is the bug. `rounded-full` is only for a chip that wraps a glyph +(`StatusIcon`); a coloured status marker takes `rounded-lg`, matching the bar it +describes. The `.rounded-full` override in `globals.css` exists only so +Tailwind's `9999px` doesn't defeat `--radius: 0` — never add more of those, fix +the component (see `packages/ui/AGENTS.md`). + ## Impact labels Status-page impact labels are coloured text only — no dots, no chevrons. The diff --git a/apps/status-page/src/app/globals.css b/apps/status-page/src/app/globals.css index 685715b1..634c72aa 100644 --- a/apps/status-page/src/app/globals.css +++ b/apps/status-page/src/app/globals.css @@ -13,7 +13,6 @@ @theme inline { --font-sans: var(--font-geist-sans); --font-mono: var(--font-commit-mono, var(--font-geist-mono)); - --radius-xs: calc(var(--radius) - 8px); } :root { @@ -34,12 +33,4 @@ .rounded-full { border-radius: calc(var(--radius) * 99999999); } - .rounded-b-full { - border-bottom-left-radius: calc(var(--radius) * 99999999); - border-bottom-right-radius: calc(var(--radius) * 99999999); - } - .rounded-t-full { - border-top-left-radius: calc(var(--radius) * 99999999); - border-top-right-radius: calc(var(--radius) * 99999999); - } } diff --git a/apps/status-page/src/components/chart/chart-legend-badge.tsx b/apps/status-page/src/components/chart/chart-legend-badge.tsx index ef0d13f3..29209c53 100644 --- a/apps/status-page/src/components/chart/chart-legend-badge.tsx +++ b/apps/status-page/src/components/chart/chart-legend-badge.tsx @@ -113,7 +113,7 @@ export function ChartLegendBadge({ ) : (
( <>
(
handlers.onClick(index)} onFocus={() => handlers.onFocus(index)} onBlur={handlers.onBlur} @@ -528,7 +528,7 @@ const StatusBarItem = forwardRef( aria-expanded={isActive} data-slot="status-bar-item" > -
+
{/* Render bar segments */} {item.bar.map((segment, segmentIndex) => { if (renderBar) { @@ -537,10 +537,7 @@ const StatusBarItem = forwardRef( return (
{labels.clickAgainToUnpin} - + Esc
@@ -737,7 +734,7 @@ function StatusBarContent({
) : (
) { } const inputGroupAddonVariants = cva( - "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded [&>svg:not([class*='size-'])]:size-4", { variants: { align: { @@ -84,10 +84,9 @@ const inputGroupButtonVariants = cva( { variants: { size: { - xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + xs: "h-6 gap-1 rounded px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", - "icon-xs": - "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-xs": "size-6 rounded p-0 has-[>svg]:p-0", "icon-sm": "size-8 p-0 has-[>svg]:p-0", }, }, diff --git a/packages/ui/src/components/ui/tooltip.tsx b/packages/ui/src/components/ui/tooltip.tsx index 13fb106f..060f7ce1 100644 --- a/packages/ui/src/components/ui/tooltip.tsx +++ b/packages/ui/src/components/ui/tooltip.tsx @@ -51,7 +51,7 @@ function TooltipContent({ {...props} > {children} - + ); diff --git a/packages/ui/src/globals.css b/packages/ui/src/globals.css index a0a7fc49..ac749f81 100644 --- a/packages/ui/src/globals.css +++ b/packages/ui/src/globals.css @@ -36,10 +36,15 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); + /* Proportional, not subtractive: `--radius` is theme-controlled on the status + * page (0rem-0.625rem), where a fixed `- 4px` offset collapses whole steps to + * 0. Ratios are anchored on the 0.625rem default, so those values are + * unchanged (2/6/8/10/14px) and every other radius scales with the theme. */ + --radius-xs: calc(var(--radius) * 0.2); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --radius-xl: calc(var(--radius) * 1.4); --color-success: var(--success); --color-warning: var(--warning); @@ -176,3 +181,12 @@ @apply bg-background text-foreground; } } + +@layer utilities { + /* Tailwind's bare `rounded` is a hard-coded 0.25rem (its `--radius` lives in + * `@theme default`, which our `:root --radius` cannot override). Re-point it + * at the scale so it collapses with the theme like every other step. */ + .rounded { + border-radius: calc(var(--radius) * 0.4); + } +} -- 2.51.2 From 54aab5e0c003223433791411febdbb2746eb5979 Mon Sep 17 00:00:00 2001 From: Bryan FRIMIN Date: Thu, 3 Sep 2026 14:55:01 +0200 Subject: [PATCH 184/266] Add probo color theme (#2598) * Add probo color theme Signed-off-by: Bryan Frimin * fix: improve Probo theme accessibility Use an accessible primary foreground and preserve pill bars when radius tokens are absent. --------- Signed-off-by: Bryan Frimin Co-authored-by: Maximilian Kaske --- packages/theme-store/README.md | 1 + packages/theme-store/src/index.ts | 2 + packages/theme-store/src/probo.ts | 99 +++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 packages/theme-store/src/probo.ts diff --git a/packages/theme-store/README.md b/packages/theme-store/README.md index 19494726..fd69ab76 100644 --- a/packages/theme-store/README.md +++ b/packages/theme-store/README.md @@ -18,6 +18,7 @@ Community themes are predefined color schemes that users can apply to their stat - **Openstatus (Rounded)** - The rounded openstatus theme (similar to the legacy page) - **Supabase** - Theme matching Supabase's brand colors - **GitHub (High Contrast)** - High contrast theme inspired by GitHub's design +- **Probo** - Theme matching Probo's brand colors ## Creating a New Theme diff --git a/packages/theme-store/src/index.ts b/packages/theme-store/src/index.ts index 4262f2f4..e4bd03a7 100644 --- a/packages/theme-store/src/index.ts +++ b/packages/theme-store/src/index.ts @@ -10,6 +10,7 @@ import { GITHUB_HIGH_CONTRAST_THEME } from "./github"; import { GRUVBOX_THEME } from "./gruvbox"; import { OPENSTATUS_ROUNDED_THEME, OPENSTATUS_THEME } from "./openstatus"; import { PASSBOLT_THEME } from "./passbolt"; +import { PROBO_THEME } from "./probo"; import { SUPABASE_THEME } from "./supabase"; import { TOMORROW_THEME } from "./tomorrow"; import type { Theme, ThemeDefinition, ThemeMap } from "./types"; @@ -24,6 +25,7 @@ const THEMES_LIST = [ PASSBOLT_THEME, GRUVBOX_THEME, TOMORROW_THEME, + PROBO_THEME, ] satisfies Theme[]; // NOTE: runtime validation to ensure that the theme IDs are unique diff --git a/packages/theme-store/src/probo.ts b/packages/theme-store/src/probo.ts new file mode 100644 index 00000000..06e23a31 --- /dev/null +++ b/packages/theme-store/src/probo.ts @@ -0,0 +1,99 @@ +import type { Theme } from "./types"; + +export const PROBO_THEME = { + id: "probo", + name: "Probo", + author: { name: "@probo", url: "https://www.probo.com" }, + light: { + "--radius": "0.25rem", + "--background": "#fdfdfc", + "--foreground": "#21201c", + "--card": "#fdfdfc", + "--card-foreground": "#21201c", + "--popover": "#fdfdfc", + "--popover-foreground": "#21201c", + "--primary": "#978365", + "--primary-foreground": "#111110", + "--secondary": "#f1f0ef", + "--secondary-foreground": "#21201c", + "--muted": "#f1f0ef", + "--muted-foreground": "#63635e", + "--accent": "#f1f0ef", + "--accent-foreground": "#21201c", + "--border": "#dad9d6", + "--input": "#cfceca", + "--ring": "#bcbbb5", + "--destructive": "#e5484d", + "--chart-1": "#978365", + "--chart-2": "#30a46c", + "--chart-3": "#00749e", + "--chart-4": "#ffc53d", + "--chart-5": "#e5484d", + "--success": "#30a46c", + "--warning": "#ffc53d", + "--info": "#00749e", + "--rainbow-1": "#e5484d", + "--rainbow-2": "#978365", + "--rainbow-3": "#ffc53d", + "--rainbow-4": "#30a46c", + "--rainbow-5": "#7ce2fe", + "--rainbow-6": "#8d8d86", + "--rainbow-7": "#dc3e42", + "--rainbow-8": "#8c7a5e", + "--rainbow-9": "#ffba18", + "--rainbow-10": "#2b9a66", + "--rainbow-11": "#74daf8", + "--rainbow-12": "#82827c", + "--rainbow-13": "#ce2c31", + "--rainbow-14": "#71624b", + "--rainbow-15": "#ab6400", + "--rainbow-16": "#218358", + "--rainbow-17": "#00749e", + }, + dark: { + "--radius": "0.25rem", + "--background": "#111110", + "--foreground": "#eeeeec", + "--card": "#111110", + "--card-foreground": "#eeeeec", + "--popover": "#111110", + "--popover-foreground": "#eeeeec", + "--primary": "#978365", + "--primary-foreground": "#111110", + "--secondary": "#222221", + "--secondary-foreground": "#eeeeec", + "--muted": "#222221", + "--muted-foreground": "#b5b3ad", + "--accent": "#222221", + "--accent-foreground": "#eeeeec", + "--border": "#3b3a37", + "--input": "#494844", + "--ring": "#62605b", + "--destructive": "#e5484d", + "--chart-1": "#978365", + "--chart-2": "#30a46c", + "--chart-3": "#75c7f0", + "--chart-4": "#ffc53d", + "--chart-5": "#e5484d", + "--success": "#30a46c", + "--warning": "#ffc53d", + "--info": "#75c7f0", + "--rainbow-1": "#e5484d", + "--rainbow-2": "#978365", + "--rainbow-3": "#ffc53d", + "--rainbow-4": "#30a46c", + "--rainbow-5": "#7ce2fe", + "--rainbow-6": "#6f6d66", + "--rainbow-7": "#ec5d5e", + "--rainbow-8": "#a39073", + "--rainbow-9": "#ffd60a", + "--rainbow-10": "#33b074", + "--rainbow-11": "#a8eeff", + "--rainbow-12": "#7c7b74", + "--rainbow-13": "#ff9592", + "--rainbow-14": "#cbb99f", + "--rainbow-15": "#ffca16", + "--rainbow-16": "#3dd68c", + "--rainbow-17": "#75c7f0", + }, +} as const satisfies Theme; -- 2.51.2 From 309c20c1b8f4461a631ff7df078603561fa51baf Mon Sep 17 00:00:00 2001 From: Erin Date: Thu, 3 Sep 2026 15:44:04 +0200 Subject: [PATCH 185/266] fix(header-analysis): reject unknown Vercel regions (#2629) --- .../src/parser/x-vercel-id.test.ts | 10 ++++++++++ .../header-analysis/src/parser/x-vercel-id.ts | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/header-analysis/src/parser/x-vercel-id.test.ts b/packages/header-analysis/src/parser/x-vercel-id.test.ts index ece9a28b..fd1c0f31 100644 --- a/packages/header-analysis/src/parser/x-vercel-id.test.ts +++ b/packages/header-analysis/src/parser/x-vercel-id.test.ts @@ -33,4 +33,14 @@ describe("parseXVercelId", () => { expect(result.error.message).toBe("Couldn't parse the header."); } }); + + it("fails when the region id is not in the list", () => { + const result = parseXVercelId("zzz9::qwert-1700000000000-abc123"); + expect(result.status).toBe("failed"); + if (result.status === "failed") { + expect(result.error.message).toBe( + "It seems like the region 'zzz9' is not listed.", + ); + } + }); }); diff --git a/packages/header-analysis/src/parser/x-vercel-id.ts b/packages/header-analysis/src/parser/x-vercel-id.ts index b282ad41..f4cec48e 100644 --- a/packages/header-analysis/src/parser/x-vercel-id.ts +++ b/packages/header-analysis/src/parser/x-vercel-id.ts @@ -9,10 +9,20 @@ export function parseXVercelId(header: string): ParserReturn { return { status: "failed", error: new Error("Couldn't parse the header.") }; } - const data = arr.map((r) => { + const data: Region[] = []; + for (const r of arr) { const regionId = r.replace(/:+/, ""); - return regions[regionId]; - }); + const region = regions[regionId]; + if (!region) { + return { + status: "failed", + error: new Error( + `It seems like the region '${regionId}' is not listed.`, + ), + }; + } + data.push(region); + } return { status: "success", data }; } -- 2.51.2 From 7828cf5171230df1fb3fe37dbeabf4dd478acce4 Mon Sep 17 00:00:00 2001 From: Harsh Kumar <9u.harsh@gmail.com> Date: Thu, 3 Sep 2026 19:18:19 +0530 Subject: [PATCH 186/266] fix(validation): reject whitespace-only names across dashboard forms (#2632) * fix(api-key): reject whitespace-only key name * fix(private-location): reject whitespace-only location name * fix(monitor-tag): reject whitespace-only tag name * fix(status-report): reject whitespace-only report title * fix(maintenance): reject whitespace-only maintenance title * fix(page-component): reject whitespace-only component name * fix(feedback): reject whitespace-only feedback message * fix(onboarding): reject whitespace-only component name * fix(page-component): reject whitespace-only name on order update * fix(page-component): reject whitespace-only group name * fix(page-component): let component row grow for error text * fix(workspace): reject whitespace-only workspace name --- apps/dashboard/src/components/forms/api-key/form.tsx | 2 +- .../src/components/forms/components/form-components.tsx | 6 +++--- apps/dashboard/src/components/forms/maintenance/form.tsx | 2 +- .../src/components/forms/monitor-tag/form-monitor-tag.tsx | 2 +- .../src/components/forms/onboarding/create-page.tsx | 2 +- .../src/components/forms/private-location/form.tsx | 2 +- .../src/components/forms/settings/form-workspace.tsx | 2 +- .../dashboard/src/components/forms/status-report/form.tsx | 2 +- apps/dashboard/src/components/nav/nav-feedback.tsx | 2 +- packages/services/src/api-key/schemas.ts | 2 +- packages/services/src/maintenance/schemas.ts | 4 ++-- packages/services/src/monitor-tag/schemas.ts | 2 +- packages/services/src/page-component/schemas.ts | 8 ++++---- packages/services/src/private-location/schemas.ts | 4 ++-- packages/services/src/status-report/schemas.ts | 4 ++-- packages/services/src/workspace/schemas.ts | 2 +- 16 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/dashboard/src/components/forms/api-key/form.tsx b/apps/dashboard/src/components/forms/api-key/form.tsx index d8db436f..d5482a4a 100644 --- a/apps/dashboard/src/components/forms/api-key/form.tsx +++ b/apps/dashboard/src/components/forms/api-key/form.tsx @@ -34,7 +34,7 @@ import { toast } from "sonner"; import { z } from "zod"; export const schema = z.object({ - name: z.string().min(1, "Name is required"), + name: z.string().trim().min(1, "Name is required"), description: z.string().optional(), expiresAt: z.string().optional(), // Single-value radio. The wire format on the create-key API is diff --git a/apps/dashboard/src/components/forms/components/form-components.tsx b/apps/dashboard/src/components/forms/components/form-components.tsx index 1f65910c..d44063e8 100644 --- a/apps/dashboard/src/components/forms/components/form-components.tsx +++ b/apps/dashboard/src/components/forms/components/form-components.tsx @@ -106,7 +106,7 @@ const componentSchema = z.object({ id: z.number(), monitorId: z.number().nullish(), order: z.number(), - name: z.string().min(1, { message: "Name is required" }), + name: z.string().trim().min(1, { message: "Name is required" }), description: z.string().optional(), type: z.enum(["monitor", "static"]), }); @@ -117,7 +117,7 @@ const schema = z.object({ z.object({ id: z.number(), order: z.number(), - name: z.string(), + name: z.string().trim().min(1, { message: "Name is required" }), defaultOpen: z.boolean(), components: z.array(componentSchema).min(1, { message: "At least one component is required", @@ -738,7 +738,7 @@ function ComponentRow({ className={cn("rounded-md", className)} {...props} > -
+
; diff --git a/apps/dashboard/src/components/forms/status-report/form.tsx b/apps/dashboard/src/components/forms/status-report/form.tsx index 72e5b658..bf769987 100644 --- a/apps/dashboard/src/components/forms/status-report/form.tsx +++ b/apps/dashboard/src/components/forms/status-report/form.tsx @@ -64,7 +64,7 @@ import { useTRPC } from "@/lib/trpc/client"; const schema = z.object({ status: z.enum(statusReportStatus), - title: z.string().min(1, "Title is required.").max(256), + title: z.string().trim().min(1, "Title is required.").max(256), message: z.string(), date: z.date(), pageComponents: z.array(z.number()), diff --git a/apps/dashboard/src/components/nav/nav-feedback.tsx b/apps/dashboard/src/components/nav/nav-feedback.tsx index 8f97d903..84c1cb0a 100644 --- a/apps/dashboard/src/components/nav/nav-feedback.tsx +++ b/apps/dashboard/src/components/nav/nav-feedback.tsx @@ -27,7 +27,7 @@ import { z } from "zod"; import { useTRPC } from "@/lib/trpc/client"; const schema = z.object({ - message: z.string().min(1), + message: z.string().trim().min(1), }); export function NavFeedback() { diff --git a/packages/services/src/api-key/schemas.ts b/packages/services/src/api-key/schemas.ts index edc2e489..a9e536ac 100644 --- a/packages/services/src/api-key/schemas.ts +++ b/packages/services/src/api-key/schemas.ts @@ -19,7 +19,7 @@ export const apiKeyCreateScopesSchema = z .default(["write"]); export const CreateApiKeyInput = z.object({ - name: z.string().min(1, "Name is required"), + name: z.string().trim().min(1, "Name is required"), description: z.string().optional(), expiresAt: z.date().optional(), scopes: apiKeyCreateScopesSchema, diff --git a/packages/services/src/maintenance/schemas.ts b/packages/services/src/maintenance/schemas.ts index 31172b46..62a0db48 100644 --- a/packages/services/src/maintenance/schemas.ts +++ b/packages/services/src/maintenance/schemas.ts @@ -12,7 +12,7 @@ export const maintenanceListPeriodSchema = z.enum(maintenanceListPeriods); export const CreateMaintenanceInput = z .object({ - title: z.string().min(1).max(256), + title: z.string().trim().min(1).max(256), message: z.string().min(1), from: z.coerce.date(), to: z.coerce.date(), @@ -27,7 +27,7 @@ export type CreateMaintenanceInput = z.infer; export const UpdateMaintenanceInput = z.object({ id: z.number().int(), - title: z.string().min(1).max(256).optional(), + title: z.string().trim().min(1).max(256).optional(), message: z.string().min(1).optional(), from: z.coerce.date().optional(), to: z.coerce.date().optional(), diff --git a/packages/services/src/monitor-tag/schemas.ts b/packages/services/src/monitor-tag/schemas.ts index 466b4782..70abfb0b 100644 --- a/packages/services/src/monitor-tag/schemas.ts +++ b/packages/services/src/monitor-tag/schemas.ts @@ -5,7 +5,7 @@ export type ListMonitorTagsInput = z.infer; const tagInput = z.object({ id: z.number().int().optional(), - name: z.string(), + name: z.string().trim().min(1), color: z.string(), }); diff --git a/packages/services/src/page-component/schemas.ts b/packages/services/src/page-component/schemas.ts index 8b6546d1..d228a787 100644 --- a/packages/services/src/page-component/schemas.ts +++ b/packages/services/src/page-component/schemas.ts @@ -17,7 +17,7 @@ const componentInput = z id: z.number().int().optional(), monitorId: z.number().int().nullish(), order: z.number().int(), - name: z.string(), + name: z.string().trim().min(1), description: z.string().nullish(), type: z.enum(["monitor", "static"]), }) @@ -37,7 +37,7 @@ const groupInput = z.object({ // assignments, subscriber scopes) off a cliff. id: z.number().int().optional(), order: z.number().int(), - name: z.string(), + name: z.string().trim().min(1), defaultOpen: z.boolean().optional().default(false), components: z.array(componentInput), }); @@ -60,7 +60,7 @@ export const CreatePageComponentInput = z pageId: z.number().int(), type: z.enum(["monitor", "static"]), monitorId: z.number().int().nullish(), - name: z.string().min(1).optional(), + name: z.string().trim().min(1).optional(), description: z.string().nullish(), order: z.number().int().default(0), groupId: z.number().int().nullish(), @@ -84,7 +84,7 @@ export type CreatePageComponentInput = z.input; /** Partial patch — `undefined` leaves a field as-is, `null` clears it. */ export const UpdatePageComponentInput = z.object({ id: z.number().int(), - name: z.string().min(1).optional(), + name: z.string().trim().min(1).optional(), description: z.string().nullish(), order: z.number().int().optional(), groupId: z.number().int().nullish(), diff --git a/packages/services/src/private-location/schemas.ts b/packages/services/src/private-location/schemas.ts index 6cb07a62..7e04516e 100644 --- a/packages/services/src/private-location/schemas.ts +++ b/packages/services/src/private-location/schemas.ts @@ -18,7 +18,7 @@ export const PrivateLocationMetadata = z export type PrivateLocationMetadata = z.infer; export const CreatePrivateLocationInput = z.object({ - name: z.string().min(1), + name: z.string().trim().min(1), token: z.string().min(1).optional(), monitors: monitorIds, metadata: PrivateLocationMetadata.optional(), @@ -29,7 +29,7 @@ export type CreatePrivateLocationInput = z.infer< export const UpdatePrivateLocationInput = z.object({ id: z.number().int(), - name: z.string().min(1).optional(), + name: z.string().trim().min(1).optional(), monitors: monitorIds.optional(), metadata: PrivateLocationMetadata.optional(), }); diff --git a/packages/services/src/status-report/schemas.ts b/packages/services/src/status-report/schemas.ts index 0536ac11..b4e17a1b 100644 --- a/packages/services/src/status-report/schemas.ts +++ b/packages/services/src/status-report/schemas.ts @@ -27,7 +27,7 @@ export type StatusReportListPeriod = (typeof statusReportListPeriods)[number]; export const statusReportListPeriodSchema = z.enum(statusReportListPeriods); export const CreateStatusReportInput = z.object({ - title: z.string().min(1).max(256), + title: z.string().trim().min(1).max(256), status: statusReportStatusSchema, message: z.string(), date: z.coerce.date(), @@ -40,7 +40,7 @@ export type CreateStatusReportInput = z.infer; export const UpdateStatusReportInput = z.object({ id: z.number().int(), - title: z.string().min(1).max(256).optional(), + title: z.string().trim().min(1).max(256).optional(), status: statusReportStatusSchema.optional(), /** When provided, replaces the full association set (empty array clears). */ pageComponentIds: z.array(z.number().int()).optional(), diff --git a/packages/services/src/workspace/schemas.ts b/packages/services/src/workspace/schemas.ts index 4a80811f..eec8be63 100644 --- a/packages/services/src/workspace/schemas.ts +++ b/packages/services/src/workspace/schemas.ts @@ -19,7 +19,7 @@ export type GetWorkspaceByStripeIdInput = z.infer< >; export const UpdateWorkspaceNameInput = z.object({ - name: z.string().min(1), + name: z.string().trim().min(1), }); export type UpdateWorkspaceNameInput = z.infer; -- 2.51.2 From 734bc44af73d4ef12a0056a9a83252b29d5b4fec Mon Sep 17 00:00:00 2001 From: Harsh Kumar <9u.harsh@gmail.com> Date: Fri, 4 Sep 2026 15:49:22 +0530 Subject: [PATCH 187/266] fix(status-page): keep slug prefix in links on subdomain-shaped hosts (#2594) * fix(status-page): keep slug prefix in links on subdomain-shaped hosts * test(status-page): cover pathname prefix resolution * fix(status-page): restrict slug prefix to theme explorer * fix(status-page): match only the canonical explorer host --- .../src/lib/resolve-pathname-prefix.test.ts | 117 ++++++++++++++++++ .../src/lib/resolve-pathname-prefix.ts | 22 +++- .../src/lib/theme-explorer-host.ts | 3 + 3 files changed, 139 insertions(+), 3 deletions(-) diff --git a/apps/status-page/src/lib/resolve-pathname-prefix.test.ts b/apps/status-page/src/lib/resolve-pathname-prefix.test.ts index 85e9d9b7..e76cf185 100644 --- a/apps/status-page/src/lib/resolve-pathname-prefix.test.ts +++ b/apps/status-page/src/lib/resolve-pathname-prefix.test.ts @@ -156,6 +156,123 @@ describe("resolvePathnamePrefix", () => { }); }); + describe("subdomain-shaped host that owns no page", () => { + // `themes.openstatus.dev` is the theme explorer: subdomain-shaped, but it + // owns no page of its own, so its demo page is served from + // `/status/{locale}` and its links must keep the slug prefix. + test("keeps the prefix on the explorer's status page", () => { + expect( + resolvePathnamePrefix({ + hostname: "themes.openstatus.dev", + pathname: "/status/en", + customDomain: undefined, + locale: "en", + defaultLocale, + }), + ).toBe("status/en"); + }); + + test("keeps the prefix on a deep path with a non-default locale", () => { + expect( + resolvePathnamePrefix({ + hostname: "themes.openstatus.dev", + pathname: "/status/fr/monitors/123", + customDomain: undefined, + locale: "fr", + defaultLocale, + }), + ).toBe("status/fr"); + }); + + test("keeps the prefix when the locale segment is absent", () => { + expect( + resolvePathnamePrefix({ + hostname: "themes.openstatus.dev", + pathname: "/status", + customDomain: undefined, + locale: "en", + defaultLocale, + }), + ).toBe("status/en"); + }); + + test("matches the slug segment case-insensitively", () => { + expect( + resolvePathnamePrefix({ + hostname: "themes.openstatus.dev", + pathname: "/Status/en", + customDomain: undefined, + locale: "en", + defaultLocale, + }), + ).toBe("Status/en"); + }); + + test("only the `status` slug is prefixed — the explorer root is not", () => { + expect( + resolvePathnamePrefix({ + hostname: "themes.openstatus.dev", + pathname: "/", + customDomain: undefined, + locale: "fr", + defaultLocale, + }), + ).toBe("fr"); + }); + + test("only the `status` slug is prefixed — other slugs are not", () => { + expect( + resolvePathnamePrefix({ + hostname: "themes.openstatus.dev", + pathname: "/acme/en", + customDomain: undefined, + locale: "en", + defaultLocale, + }), + ).toBe(""); + }); + }); + + describe("hostname-routed pages are unaffected", () => { + test("a subdomain page keeps dropping the prefix", () => { + expect( + resolvePathnamePrefix({ + hostname: "acme.openstatus.dev", + pathname: "/events", + customDomain: undefined, + locale: "en", + defaultLocale, + }), + ).toBe(""); + }); + + test("a subdomain page whose own slug is `status`", () => { + // Only the explorer host opts into the prefix, so a real page at + // `status.openstatus.dev/status` still drops it. + expect( + resolvePathnamePrefix({ + hostname: "status.openstatus.dev", + pathname: "/status", + customDomain: undefined, + locale: "fr", + defaultLocale, + }), + ).toBe("fr"); + }); + + test("a custom domain page keeps dropping the prefix", () => { + expect( + resolvePathnamePrefix({ + hostname: "status.acme.com", + pathname: "/status", + customDomain: "status.acme.com", + locale: "fr", + defaultLocale, + }), + ).toBe("fr"); + }); + }); + describe("edge cases", () => { test("www subdomain is treated as pathname routing", () => { expect( diff --git a/apps/status-page/src/lib/resolve-pathname-prefix.ts b/apps/status-page/src/lib/resolve-pathname-prefix.ts index 31a7f6ca..ed873095 100644 --- a/apps/status-page/src/lib/resolve-pathname-prefix.ts +++ b/apps/status-page/src/lib/resolve-pathname-prefix.ts @@ -1,8 +1,15 @@ +import { + THEME_EXPLORER_PAGE_SLUG, + isCanonicalThemeExplorerHost, +} from "./theme-explorer-host"; + /** * Computes the prefix used for client-side navigation links. * * - Hostname routing (subdomain / custom domain): locale only (empty for default) * - Pathname routing: always `{slug}/{locale}` + * - The theme explorer host is subdomain-shaped but owns no page, so its own + * demo page at `/status/{locale}` is pathname routed */ export function resolvePathnamePrefix({ hostname, @@ -30,12 +37,21 @@ export function resolvePathnamePrefix({ hostnames[0] !== "www" && !hostname.endsWith(".vercel.app"); - if (isCustomDomain || isSubdomain) { + const firstSegment = pathname.split("/")[1] || ""; + + // The theme explorer host is subdomain-shaped but owns no page of its own — + // its demo page is served from `/status/{locale}`, so links there keep the + // slug prefix instead of dropping it like a real subdomain page would. Only + // the canonical host serves that page, so it alone opts in. + const isThemeExplorerPage = + isCanonicalThemeExplorerHost(hostname) && + firstSegment.toLowerCase() === THEME_EXPLORER_PAGE_SLUG; + + if (!isThemeExplorerPage && (isCustomDomain || isSubdomain)) { // Subdomain or custom domain — no slug prefix needed return locale !== defaultLocale ? locale : ""; } // Pathname routing — always {slug}/{locale} - const slug = pathname.split("/")[1] || ""; - return `${slug}/${locale}`; + return `${firstSegment}/${locale}`; } diff --git a/apps/status-page/src/lib/theme-explorer-host.ts b/apps/status-page/src/lib/theme-explorer-host.ts index b43c9734..f2fb1ef2 100644 --- a/apps/status-page/src/lib/theme-explorer-host.ts +++ b/apps/status-page/src/lib/theme-explorer-host.ts @@ -4,6 +4,9 @@ import { stripHostPort } from "./domain"; export const THEME_EXPLORER_HOST = "themes.openstatus.dev"; export const THEME_EXPLORER_URL = `https://${THEME_EXPLORER_HOST}`; +/** The only page the explorer host serves, at `/status/{locale}`. */ +export const THEME_EXPLORER_PAGE_SLUG = "status"; + // The explorer lives at `/`, which is also where every unresolved host lands // (unknown slug, custom domain pointed at us but missing from `page`). Without // this allowlist those hosts render — and share — the explorer as their 404. -- 2.51.2 From e701720afd4525d7188aff004168e37714f9416b Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:14:32 +0200 Subject: [PATCH 188/266] fix: nullable page id relations (#2634) * fix: not found on invalid id * fix: maintenance update page id --- .../monitors/[id]/incidents/layout.tsx | 5 +- .../app/(dashboard)/monitors/[id]/layout.tsx | 8 +-- .../status-pages/[id]/components/layout.tsx | 5 +- .../status-pages/[id]/history/page.tsx | 5 +- .../(dashboard)/status-pages/[id]/layout.tsx | 8 +-- .../status-pages/[id]/maintenances/layout.tsx | 7 +-- .../[id]/status-reports/[reportId]/layout.tsx | 6 ++- .../[id]/status-reports/layout.tsx | 5 +- .../status-pages/[id]/subscribers/layout.tsx | 6 ++- .../maintenance/__tests__/maintenance.test.ts | 4 +- .../maintenance/__tests__/maintenance.test.ts | 51 +++++++++++++++++++ packages/services/src/maintenance/update.ts | 14 ++--- 12 files changed, 100 insertions(+), 24 deletions(-) diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx index 5c93f025..f25ec0a8 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx @@ -1,4 +1,5 @@ import { SidebarProvider } from "@openstatus/ui/components/ui/sidebar"; +import { notFound } from "next/navigation"; import { RIGHT_SIDEBAR_COOKIE, @@ -17,8 +18,10 @@ export default async function Layout({ }) { const queryClient = getQueryClient(); const { id } = await params; + const monitorId = Number.parseInt(id); + if (Number.isNaN(monitorId)) notFound(); await queryClient.prefetchQuery( - trpc.incident.list.queryOptions({ monitorId: Number.parseInt(id) }), + trpc.incident.list.queryOptions({ monitorId }), ); const defaultOpen = await getSidebarDefaultOpen(RIGHT_SIDEBAR_COOKIE, false); diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx index c3027e41..2e3433d0 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx @@ -1,3 +1,5 @@ +import { notFound } from "next/navigation"; + import { AppHeader, AppHeaderActions, @@ -23,12 +25,12 @@ export default async function Layout({ params: Promise<{ id: string }>; }) { const { id } = await params; + const monitorId = Number.parseInt(id); + if (Number.isNaN(monitorId)) notFound(); const queryClient = getQueryClient(); await Promise.all([ - fetchQueryOrNotFound( - trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), - ), + fetchQueryOrNotFound(trpc.monitor.get.queryOptions({ id: monitorId })), queryClient.prefetchQuery(trpc.privateLocation.list.queryOptions()), ]); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/layout.tsx index 147ed517..34155ed5 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/layout.tsx @@ -1,4 +1,5 @@ import { SidebarProvider } from "@openstatus/ui/components/ui/sidebar"; +import { notFound } from "next/navigation"; import { RIGHT_SIDEBAR_COOKIE, @@ -16,10 +17,12 @@ export default async function Layout({ params: Promise<{ id: string }>; }) { const { id } = await params; + const pageId = Number.parseInt(id); + if (Number.isNaN(pageId)) notFound(); const queryClient = getQueryClient(); await queryClient.prefetchQuery( - trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), + trpc.pageComponent.list.queryOptions({ pageId }), ); const defaultOpen = await getSidebarDefaultOpen(RIGHT_SIDEBAR_COOKIE, false); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx index 706e19c5..c37a21bc 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx @@ -1,3 +1,4 @@ +import { notFound } from "next/navigation"; import type { SearchParams } from "nuqs"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; @@ -13,12 +14,14 @@ export default async function Page({ searchParams: Promise; }) { const { id } = await params; + const pageId = Number.parseInt(id); + if (Number.isNaN(pageId)) notFound(); const queryClient = getQueryClient(); // NOTE: store in cache to avoid flicker on clients first render await searchParamsCache.parse(searchParams); await queryClient.prefetchQuery( - trpc.page.getUptimeHistory.queryOptions({ id: Number.parseInt(id) }), + trpc.page.getUptimeHistory.queryOptions({ id: pageId }), ); return ( diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx index 2dbe80f8..9d3e789c 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx @@ -1,3 +1,5 @@ +import { notFound } from "next/navigation"; + import { AppHeader, AppHeaderActions, @@ -18,10 +20,10 @@ export default async function Layout({ params: Promise<{ id: string }>; }) { const { id } = await params; + const pageId = Number.parseInt(id); + if (Number.isNaN(pageId)) notFound(); - await fetchQueryOrNotFound( - trpc.page.get.queryOptions({ id: Number.parseInt(id) }), - ); + await fetchQueryOrNotFound(trpc.page.get.queryOptions({ id: pageId })); return ( diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx index 3b65d74b..143aec60 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx @@ -1,4 +1,5 @@ import { SidebarProvider } from "@openstatus/ui/components/ui/sidebar"; +import { notFound } from "next/navigation"; import { RIGHT_SIDEBAR_COOKIE, @@ -16,12 +17,12 @@ export default async function Layout({ params: Promise<{ id: string }>; }) { const { id } = await params; + const pageId = Number.parseInt(id); + if (Number.isNaN(pageId)) notFound(); const queryClient = getQueryClient(); await queryClient.prefetchQuery( - trpc.maintenance.list.queryOptions({ - pageId: Number.parseInt(id), - }), + trpc.maintenance.list.queryOptions({ pageId }), ); const defaultOpen = await getSidebarDefaultOpen(RIGHT_SIDEBAR_COOKIE, false); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx index 59856254..557874e9 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx @@ -1,3 +1,5 @@ +import { notFound } from "next/navigation"; + import { HydrateClient, fetchQueryOrNotFound, trpc } from "@/lib/trpc/server"; export default async function Layout({ @@ -8,8 +10,10 @@ export default async function Layout({ params: Promise<{ id: string; reportId: string }>; }) { const { reportId } = await params; + const statusReportId = Number.parseInt(reportId); + if (Number.isNaN(statusReportId)) notFound(); await fetchQueryOrNotFound( - trpc.statusReport.get.queryOptions({ id: Number.parseInt(reportId) }), + trpc.statusReport.get.queryOptions({ id: statusReportId }), ); return {children}; } diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx index 9a6eb992..df7a4b6e 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx @@ -1,4 +1,5 @@ import { SidebarProvider } from "@openstatus/ui/components/ui/sidebar"; +import { notFound } from "next/navigation"; import { RIGHT_SIDEBAR_COOKIE, @@ -17,8 +18,10 @@ export default async function Layout({ }) { const queryClient = getQueryClient(); const { id } = await params; + const pageId = Number.parseInt(id); + if (Number.isNaN(pageId)) notFound(); await queryClient.prefetchQuery( - trpc.statusReport.list.queryOptions({ pageId: Number.parseInt(id) }), + trpc.statusReport.list.queryOptions({ pageId }), ); const defaultOpen = await getSidebarDefaultOpen(RIGHT_SIDEBAR_COOKIE, false); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx index 012f9e6c..dba0d471 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx @@ -1,3 +1,5 @@ +import { notFound } from "next/navigation"; + import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; export default async function Layout({ @@ -9,9 +11,11 @@ export default async function Layout({ }) { const queryClient = getQueryClient(); const { id } = await params; + const pageId = Number.parseInt(id); + if (Number.isNaN(pageId)) notFound(); await queryClient.prefetchQuery( - trpc.pageSubscriber.list.queryOptions({ pageId: Number.parseInt(id) }), + trpc.pageSubscriber.list.queryOptions({ pageId }), ); return {children}; diff --git a/apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts b/apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts index 5f8f6f0d..67d7b6dd 100644 --- a/apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts +++ b/apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts @@ -1191,7 +1191,7 @@ describe("MaintenanceService.UpdateMaintenance", () => { expect(afterRecord?.pageId).toBe(beforeRecord?.pageId); }); - test("clears pageId when removing all components", async () => { + test("keeps pageId when removing all components", async () => { const tempRecord = await db .insert(maintenance) .values({ @@ -1228,7 +1228,7 @@ describe("MaintenanceService.UpdateMaintenance", () => { .from(maintenance) .where(eq(maintenance.id, tempRecord.id)) .get(); - expect(afterRecord?.pageId).toBeNull(); + expect(afterRecord?.pageId).toBe(testPageId); const afterAssociations = await db .select() diff --git a/packages/services/src/maintenance/__tests__/maintenance.test.ts b/packages/services/src/maintenance/__tests__/maintenance.test.ts index 3b48ae00..7b6d96dc 100644 --- a/packages/services/src/maintenance/__tests__/maintenance.test.ts +++ b/packages/services/src/maintenance/__tests__/maintenance.test.ts @@ -310,6 +310,57 @@ describe("updateMaintenance", () => { }); }); + test("keeps pageId when clearing all components", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const record = await createMaintenance({ + ctx, + input: { + title: `${TEST_PREFIX}-keep-page`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [testPageComponentId], + }, + }); + + const updated = await updateMaintenance({ + ctx, + input: { id: record.id, pageComponentIds: [] }, + }); + expect(updated.pageId).toBe(testPageId); + + const assoc = await tx + .select() + .from(maintenancesToPageComponents) + .where(eq(maintenancesToPageComponents.maintenanceId, record.id)) + .all(); + expect(assoc).toHaveLength(0); + }); + }); + + test("moves pageId when components belong to another page", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const record = await createMaintenance({ + ctx, + input: { + title: `${TEST_PREFIX}-move-page`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [testPageComponentId], + }, + }); + + const updated = await updateMaintenance({ + ctx, + input: { id: record.id, pageComponentIds: [otherPageComponentId] }, + }); + expect(updated.pageId).toBe(otherPageId); + }); + }); + test("throws NotFoundError for cross-workspace update", async () => { await withTestTransaction(async (tx) => { const record = await createMaintenance({ diff --git a/packages/services/src/maintenance/update.ts b/packages/services/src/maintenance/update.ts index 47fa89f9..95c72f35 100644 --- a/packages/services/src/maintenance/update.ts +++ b/packages/services/src/maintenance/update.ts @@ -49,13 +49,13 @@ export async function updateMaintenance(args: { pageComponentIds: input.pageComponentIds, }); - // `pageId` follows the association set: a new non-empty set moves - // the maintenance to that page; an empty set nulls it. Matches the - // pattern established on status-report update and what the Connect - // `UpdateMaintenance` tests have encoded since the original handler. - // Mixed-page inputs are rejected upstream by - // `validatePageComponentIds` (all ids must share a page). - updateValues.pageId = validated.pageId; + // A non-empty set moves the maintenance to that page; an empty set + // only clears associations and keeps `pageId` (same as status-report). + // The dashboard edit sheet always sends the array, so nulling here + // orphaned every maintenance edited without components. + if (validated.pageId !== null) { + updateValues.pageId = validated.pageId; + } await updatePageComponentAssociations({ tx, -- 2.51.2 From 06bf24d821d146319234ccd7be2f3f4fc2456f46 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:40:52 +0200 Subject: [PATCH 189/266] fix: test checker timeout (#2209) * fix: test checker timeout * fix: review * fix: review * fix: comment * fix: tcp unreachable error * fix: preserve caller-signal TimeoutError in checkRegion Only map to TargetUnreachableError when checkRegion uses its own default timeout. A caller-supplied signal (e.g. probeCdnRegion) inspects the raw TimeoutError itself, so swallowing it broke the cdn-checker Timeout row. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: error fatigue on checker tests * fix: minor stuff --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../(landing)/play/checker/[slug]/page.tsx | 20 ++-- .../src/app/(landing)/play/checker/client.tsx | 52 ++++++-- .../src/app/api/checker/test/http/route.ts | 10 +- .../web/src/app/api/checker/test/tcp/route.ts | 55 ++++++--- apps/web/src/lib/checker/utils.ts | 79 +++++++----- packages/api/src/router/checker.test.ts | 113 +++++++++++++++++- packages/api/src/router/checker.ts | 89 +++++++------- 7 files changed, 304 insertions(+), 114 deletions(-) diff --git a/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx b/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx index 65c06f07..c5403165 100644 --- a/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx @@ -52,18 +52,22 @@ export async function generateMetadata({ if (!data) return metadata; - const regions = data.checks.sort((a, b) => a.latency - b.latency); + const regions = [...data.checks].sort((a, b) => a.latency - b.latency); const fastestRegion = regions[0]; const slowestRegion = regions[regions.length - 1]; const TITLE = data.url; - const DESCRIPTION = `${formatDate( - new Date(data.timestamp), - )} | Fastest: ${regionFormatter(fastestRegion.region)} (${latencyFormatter( - fastestRegion.latency, - )}) | Slowest: ${regionFormatter(slowestRegion.region)} (${latencyFormatter( - slowestRegion.latency, - )})`; + // `checks` is empty when every region failed (only the base hash is stored). + const DESCRIPTION = + fastestRegion && slowestRegion + ? `${formatDate( + new Date(data.timestamp), + )} | Fastest: ${regionFormatter(fastestRegion.region)} (${latencyFormatter( + fastestRegion.latency, + )}) | Slowest: ${regionFormatter(slowestRegion.region)} (${latencyFormatter( + slowestRegion.latency, + )})` + : `${formatDate(new Date(data.timestamp))} | No successful checks`; return { ...metadata, diff --git a/apps/web/src/app/(landing)/play/checker/client.tsx b/apps/web/src/app/(landing)/play/checker/client.tsx index 1db7ed7c..5061c632 100644 --- a/apps/web/src/app/(landing)/play/checker/client.tsx +++ b/apps/web/src/app/(landing)/play/checker/client.tsx @@ -132,6 +132,8 @@ export function Form({ startTransition(async () => { async function fetchAndReadStream() { let toastId: string | number | undefined; + let resultId: string | null = null; + let successCount = 0; try { toastId = toast.loading("Loading data from regions...", { duration: Number.POSITIVE_INFINITY, @@ -171,7 +173,14 @@ export function Form({ clearTimeout(timeoutId); const reader = response?.body?.getReader(); - if (!reader) return; + if (!reader) { + toast.error("Failed to read response", { + id: toastId, + description: "Please try again.", + className: "text-destructive!", + }); + return; + } const decoder = new TextDecoder(); let done = false; @@ -191,15 +200,7 @@ export function Form({ // Store the ID if it's a 32-char hex string if (is32CharHex(item)) { setId(item); - toast.success("Data is available!", { - id: toastId, - description: "Learn about the response details.", - action: { - label: "Details", - onClick: () => router.push(`/play/checker/${item}`), - }, - duration: 4000, - }); + resultId = item; return null; } @@ -225,6 +226,7 @@ export function Form({ .filter(notEmpty); if (results.length > 0) { + successCount += results.length; setValues((prev) => [...prev, ...results]); toast.loading( `Checking ${regionFormatter( @@ -238,13 +240,41 @@ export function Form({ } } } + + if (successCount === 0) { + toast.error("No region could reach the target", { + id: toastId, + description: "It may be down or blocking our requests.", + className: "text-destructive!", + }); + } else { + toast.success("Data is available!", { + id: toastId, + description: "Learn about the response details.", + ...(resultId + ? { + action: { + label: "Details", + onClick: () => router.push(`/play/checker/${resultId}`), + }, + } + : {}), + duration: 4000, + }); + } } catch (error) { console.error("Error fetching data:", error); if (error instanceof Error && error.name === "AbortError") { toast.error("Request timeout", { id: toastId, description: - "The request took too long and was aborted after 7 seconds.", + "The request took too long and was aborted after 10 seconds.", + className: "text-destructive!", + }); + } else { + toast.error("Something went wrong", { + id: toastId, + description: "Please try again.", className: "text-destructive!", }); } diff --git a/apps/web/src/app/api/checker/test/http/route.ts b/apps/web/src/app/api/checker/test/http/route.ts index 653c0c84..0edf2694 100644 --- a/apps/web/src/app/api/checker/test/http/route.ts +++ b/apps/web/src/app/api/checker/test/http/route.ts @@ -3,7 +3,10 @@ import { httpPayloadSchema } from "@openstatus/utils"; import { NextResponse } from "next/server"; import { z } from "zod"; -import { checkRegion } from "../../../../../lib/checker/utils"; +import { + TargetUnreachableError, + checkRegion, +} from "../../../../../lib/checker/utils"; import { isAnInvalidTestUrl } from "../../utils"; export const runtime = "edge"; @@ -37,7 +40,10 @@ export async function POST(request: Request) { return NextResponse.json(res); } catch (e) { - console.error(e); + // Unreachable/timeout targets are expected; only real bugs should reach Sentry. + if (!(e instanceof TargetUnreachableError)) { + console.error(e); + } return NextResponse.json({ success: false }, { status: 400 }); } } diff --git a/apps/web/src/app/api/checker/test/tcp/route.ts b/apps/web/src/app/api/checker/test/tcp/route.ts index 11f89c1d..800cbdcd 100644 --- a/apps/web/src/app/api/checker/test/tcp/route.ts +++ b/apps/web/src/app/api/checker/test/tcp/route.ts @@ -5,6 +5,12 @@ import { import { NextResponse } from "next/server"; import { z } from "zod"; +import { + CHECKER_REQUEST_TIMEOUT_MS, + TargetUnreachableError, + isTimeoutError, +} from "@/lib/checker/utils"; + import { TCPResponse, tcpPayload } from "./schema"; export const runtime = "edge"; @@ -34,36 +40,47 @@ export async function POST(request: Request) { return NextResponse.json(res); } catch (e) { - console.error(e); + // Unreachable/timeout targets are expected; only real bugs should reach Sentry. + if (!(e instanceof TargetUnreachableError)) { + console.error(e); + } return NextResponse.json({ success: false }, { status: 400 }); } } async function checkTCP(url: string, region: Region) { // - const res = await fetch(`https://checker.openstatus.dev/tcp/${region}`, { - headers: { - Authorization: `Basic ${process.env.CRON_SECRET}`, - "Content-Type": "application/json", - "fly-prefer-region": region, - }, - method: "POST", - body: JSON.stringify({ - uri: url, - }), - next: { revalidate: 0 }, - }); + let res: Response; + try { + res = await fetch(`https://checker.openstatus.dev/tcp/${region}`, { + headers: { + Authorization: `Basic ${process.env.CRON_SECRET}`, + "Content-Type": "application/json", + "fly-prefer-region": region, + }, + method: "POST", + body: JSON.stringify({ + uri: url, + }), + next: { revalidate: 0 }, + signal: AbortSignal.timeout(CHECKER_REQUEST_TIMEOUT_MS), + }); + } catch (e) { + if (isTimeoutError(e)) { + throw new TargetUnreachableError("checker request timed out"); + } + throw e; + } const json = await res.json(); const data = TCPResponse.safeParse(json); + // A timeout / unreachable target is an expected outcome, not a bug — throw so + // the caller returns 400, but the catch keeps it out of Sentry. A parse miss + // here may also be checker schema drift, so warn so it stays observable. if (!data.success) { - console.error(res); - console.error(JSON.stringify(json)); - console.error( - `something went wrong with request to ${url} error ${data.error.message}`, - ); - throw new Error(data.error.message); + console.warn("Unexpected TCP checker response shape:", json); + throw new TargetUnreachableError(data.error.message); } return data.data; diff --git a/apps/web/src/lib/checker/utils.ts b/apps/web/src/lib/checker/utils.ts index 72ca2ff7..ffe60560 100644 --- a/apps/web/src/lib/checker/utils.ts +++ b/apps/web/src/lib/checker/utils.ts @@ -182,6 +182,18 @@ type CheckRegionRequest = { // API Functions // ============================================================================ +// A timeout / unreachable target is an expected outcome, not a bug. Callers +// throw this so a route catch can return 400 while keeping it out of Sentry. +export class TargetUnreachableError extends Error {} + +// Bound the upstream checker request below the client's 10s abort so a stalled +// checker can't keep the route running to the platform limit. +export const CHECKER_REQUEST_TIMEOUT_MS = 9_000; + +export function isTimeoutError(e: unknown): boolean { + return e instanceof Error && e.name === "TimeoutError"; +} + export async function checkRegion( props: CheckRegionRequest, ): Promise { @@ -209,40 +221,51 @@ export async function checkRegion( break; } - const res = await fetch(endpoint, { - headers: { - Authorization: `Basic ${process.env.CRON_SECRET}`, - "Content-Type": "application/json", - ...regionHeader, - }, - method: "POST", - body: JSON.stringify({ - url, - method: method || "GET", - headers: headers?.reduce( - (acc, { key, value }) => { - if (!key) return acc; // key === "" is an invalid header - return { ...acc, [key]: value }; - }, - {} as Record, - ), - body: body ? body : undefined, - }), - signal, - next: { revalidate: 0 }, - }); + // A caller-supplied signal is the caller's to interpret (it inspects the raw + // TimeoutError); only our own default bound maps to TargetUnreachableError. + const usingDefaultTimeout = !signal; + + let res: Response; + try { + res = await fetch(endpoint, { + headers: { + Authorization: `Basic ${process.env.CRON_SECRET}`, + "Content-Type": "application/json", + ...regionHeader, + }, + method: "POST", + body: JSON.stringify({ + url, + method: method || "GET", + headers: headers?.reduce( + (acc, { key, value }) => { + if (!key) return acc; // key === "" is an invalid header + return { ...acc, [key]: value }; + }, + {} as Record, + ), + body: body ? body : undefined, + }), + next: { revalidate: 0 }, + signal: signal ?? AbortSignal.timeout(CHECKER_REQUEST_TIMEOUT_MS), + }); + } catch (e) { + if (usingDefaultTimeout && isTimeoutError(e)) { + throw new TargetUnreachableError("checker request timed out"); + } + throw e; + } const json = await res.json(); const data = checkerSchema.or(errorRequest).safeParse(json); if (!data.success) { - console.error(JSON.stringify(res)); - console.error(JSON.stringify(json)); - console.error( - `something went wrong with request to ${url} error ${data.error.message}`, - ); - throw new Error(data.error.message); + // Neither the success nor the error shape matched — likely checker schema + // drift rather than an unreachable target. Warn (not error) so it stays + // observable in production without paging Sentry. + console.warn("Unexpected checker response shape:", json); + throw new TargetUnreachableError(data.error.message); } return { diff --git a/packages/api/src/router/checker.test.ts b/packages/api/src/router/checker.test.ts index df87db09..4ab6e3af 100644 --- a/packages/api/src/router/checker.test.ts +++ b/packages/api/src/router/checker.test.ts @@ -1,7 +1,7 @@ import { expect } from "@std/expect"; import { afterEach, describe, test } from "@std/testing/bdd"; -import { testGrpc } from "./checker"; +import { testGrpc, testHttp, testTcp } from "./checker"; const originalFetch = globalThis.fetch; @@ -20,6 +20,117 @@ function stubChecker(body: unknown) { )) as typeof globalThis.fetch; } +/** Make the checker fetch reject, e.g. with the AbortSignal.timeout error. */ +function stubCheckerRejects(error: unknown) { + globalThis.fetch = (() => Promise.reject(error)) as typeof globalThis.fetch; +} + +/** Count console.error calls while `fn` runs, then restore. */ +async function countErrors(fn: () => Promise): Promise { + const origError = console.error; + let count = 0; + console.error = () => { + count++; + }; + try { + await fn().catch(() => {}); + } finally { + console.error = origError; + } + return count; +} + +const timing = { + dnsStart: 1, + dnsDone: 2, + connectStart: 2, + connectDone: 3, + tlsHandshakeStart: 3, + tlsHandshakeDone: 4, + firstByteStart: 4, + firstByteDone: 5, + transferStart: 5, + transferDone: 6, +}; + +describe("testHttp", () => { + const input = { + url: "https://example.com", + method: "GET" as const, + region: "ams" as const, + assertions: [], + }; + + test("accepts a 2XX response", async () => { + stubChecker({ + status: 200, + latency: 5, + headers: {}, + timestamp: 1_700_000_000_000, + timing, + region: "ams", + }); + + const result = await testHttp(input); + expect(result.state).toBe("success"); + }); + + test("maps the unreachable shape to its error message without logging", async () => { + // `checker.Http` answers 200 with `error` set and status/headers omitted. + stubChecker({ + error: "Timeout after 45000 ms", + latency: 45000, + timestamp: 1_700_000_000_000, + timing, + region: "ams", + }); + + await expect(testHttp(input)).rejects.toThrow("Timeout after 45000 ms"); + expect(await countErrors(() => testHttp(input))).toBe(0); + }); + + test("does not log a failed assertion", async () => { + stubChecker({ + status: 500, + latency: 5, + headers: {}, + timestamp: 1_700_000_000_000, + timing, + region: "ams", + }); + + await expect(testHttp(input)).rejects.toThrow("Assertion error"); + expect(await countErrors(() => testHttp(input))).toBe(0); + }); + + test("maps a checker timeout to BAD_REQUEST without logging", async () => { + stubCheckerRejects(new DOMException("timed out", "TimeoutError")); + + const error = await testHttp(input).catch((e) => e); + expect(error.code).toBe("BAD_REQUEST"); + expect(await countErrors(() => testHttp(input))).toBe(0); + }); + + test("still logs an unexpected failure", async () => { + stubCheckerRejects(new TypeError("fetch failed")); + + const error = await testHttp(input).catch((e) => e); + expect(error.code).toBe("INTERNAL_SERVER_ERROR"); + expect(await countErrors(() => testHttp(input))).toBe(1); + }); +}); + +describe("testTcp", () => { + const input = { url: "example.com:443", region: "ams" as const }; + + test("does not log an unreachable target", async () => { + stubChecker({ message: "uri not reachable" }); + + await expect(testTcp(input)).rejects.toThrow("uri not reachable"); + expect(await countErrors(() => testTcp(input))).toBe(0); + }); +}); + /** * The shape GRPCHandlerRegion returns for a completed RPC. `state` is absent — * the handler never sends it — so grpcOutput prefaults it to "success". diff --git a/packages/api/src/router/checker.ts b/packages/api/src/router/checker.ts index b16ca45e..314755f7 100644 --- a/packages/api/src/router/checker.ts +++ b/packages/api/src/router/checker.ts @@ -37,6 +37,34 @@ const ICMP_TEST_TIMEOUT = 5000; // Kept under ABORT_TIMEOUT so the checker answers before the fetch gives up. const GRPC_TEST_TIMEOUT = 5000; +// Unreachable targets, failed assertions and timeouts are expected outcomes +// already surfaced to the user as BAD_REQUEST; only unexpected failures should +// reach the logs (and Sentry). +function toCheckerError( + error: unknown, + label: string, + fallback: string, +): TRPCError { + if (error instanceof TRPCError) { + if (error.code !== "BAD_REQUEST") { + console.error(`Checker ${label} test failed`, error); + } + return error; + } + + if (error instanceof Error && error.name === "TimeoutError") { + return new TRPCError({ + code: "BAD_REQUEST", + message: `The ${label} check did not complete within ${ + ABORT_TIMEOUT / 1000 + } seconds. Please try again.`, + }); + } + + console.error(`Checker ${label} test failed`, error); + return new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: fallback }); +} + // Input schemas const httpTestInput = z.object({ url: safeUrlSchema, @@ -217,6 +245,13 @@ export const httpOutput = z state: z.literal("error").prefault("error"), message: z.string(), }), + ) + .or( + // A target the checker could not reach (timeout, DNS, refused): `checker.Http` + // answers 200 with `error` set and `status`/`headers` omitted. + z + .object({ error: z.string().min(1), timestamp: z.number() }) + .transform(({ error }) => ({ state: "error" as const, message: error })), ); export const dnsOutput = z @@ -325,15 +360,11 @@ export async function testHttp(input: z.infer) { return result.data; } catch (error) { - console.error("Checker HTTP test failed", error); - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: error instanceof Error ? error.message : "HTTP check failed", - }); + throw toCheckerError( + error, + "HTTP", + error instanceof Error ? error.message : "HTTP check failed", + ); } } @@ -376,15 +407,7 @@ export async function testTcp(input: z.infer) { return result.data; } catch (error) { - console.error("Checker TCP test failed", error); - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "TCP check failed", - }); + throw toCheckerError(error, "TCP", "TCP check failed"); } } @@ -446,15 +469,7 @@ export async function testDns(input: z.infer) { return result.data; } catch (error) { - console.error("Checker DNS test failed", error); - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "DNS check failed", - }); + throw toCheckerError(error, "DNS", "DNS check failed"); } } @@ -500,15 +515,7 @@ export async function testIcmp(input: z.infer) { return result.data; } catch (error) { - console.error("Checker ICMP test failed", error); - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "ICMP check failed", - }); + throw toCheckerError(error, "ICMP", "ICMP check failed"); } } @@ -575,15 +582,7 @@ export async function testGrpc(input: z.infer) { return result.data; } catch (error) { - console.error("Checker gRPC test failed", error); - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "gRPC check failed", - }); + throw toCheckerError(error, "gRPC", "gRPC check failed"); } } -- 2.51.2 From 3beebb82aef7b7837cc6e0558e4babddb90f28e9 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:01:27 +0200 Subject: [PATCH 190/266] fix: slack block updated scheduled label (#2635) --- .../src/channels/slack-blocks.test.ts | 20 +++++++++++++++++++ .../src/channels/slack-blocks.ts | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/subscriptions/src/channels/slack-blocks.test.ts b/packages/subscriptions/src/channels/slack-blocks.test.ts index 63f5443a..83dd0dcb 100644 --- a/packages/subscriptions/src/channels/slack-blocks.test.ts +++ b/packages/subscriptions/src/channels/slack-blocks.test.ts @@ -55,6 +55,26 @@ describe("buildRootMessage", () => { ); }); + test("context line says Updated for reports and Scheduled for maintenance", () => { + const contextText = (root: ReturnType) => + JSON.stringify(root.attachments[0]?.blocks); + expect(contextText(buildRootMessage(makeUpdate(), makeSub()))).toContain( + "Updated 2026-01-01T10:00:00.000Z", + ); + const maintenance = buildRootMessage( + makeUpdate({ + status: "maintenance", + updateId: undefined, + date: "2026-01-01T10:00:00.000Z - 2026-01-02T10:00:00.000Z", + }), + makeSub(), + ); + expect(contextText(maintenance)).toContain( + "Scheduled 2026-01-01T10:00:00.000Z - 2026-01-02T10:00:00.000Z", + ); + expect(contextText(maintenance)).not.toContain("Updated"); + }); + test("uses the custom domain origin when present", () => { const root = buildRootMessage( makeUpdate(), diff --git a/packages/subscriptions/src/channels/slack-blocks.ts b/packages/subscriptions/src/channels/slack-blocks.ts index ad843de8..0531fcce 100644 --- a/packages/subscriptions/src/channels/slack-blocks.ts +++ b/packages/subscriptions/src/channels/slack-blocks.ts @@ -95,12 +95,15 @@ export function buildRootMessage( }); } + // Maintenance carries a scheduled window, not an update timestamp. + const dateLabel = + pageUpdate.status === "maintenance" ? "Scheduled" : "Updated"; blocks.push({ type: "context", elements: [ { type: "mrkdwn", - text: `Updated ${pageUpdate.date} · <${eventUrl(pageUpdate, subscription)}|View details> · Manage with \`/openstatus unsubscribe\``, + text: `${dateLabel} ${pageUpdate.date} · <${eventUrl(pageUpdate, subscription)}|View details> · Manage with \`/openstatus unsubscribe\``, }, ], }); -- 2.51.2 From 982ed38055cb3e46fa87afea64157eb9d63cc39a Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:47:35 +0200 Subject: [PATCH 191/266] fix: stop Auth.js UnknownAction probes from reaching Sentry (#2636) * fix: alert fatigue unknown action authjs * fix: authjs vs next-auth instanceof error --- apps/dashboard/src/lib/auth/index.ts | 2 ++ apps/dashboard/src/lib/auth/logger.ts | 27 +++++++++++++++++++++ apps/status-page/src/lib/auth/index.ts | 2 ++ apps/status-page/src/lib/auth/logger.ts | 27 +++++++++++++++++++++ pnpm-lock.yaml | 32 ++++--------------------- pnpm-workspace.yaml | 2 +- 6 files changed, 64 insertions(+), 28 deletions(-) create mode 100644 apps/dashboard/src/lib/auth/logger.ts create mode 100644 apps/status-page/src/lib/auth/logger.ts diff --git a/apps/dashboard/src/lib/auth/index.ts b/apps/dashboard/src/lib/auth/index.ts index 3f129c53..1782fc1d 100644 --- a/apps/dashboard/src/lib/auth/index.ts +++ b/apps/dashboard/src/lib/auth/index.ts @@ -8,6 +8,7 @@ import { headers } from "next/headers"; import { cache } from "react"; import { adapter } from "./adapter"; +import { logger } from "./logger"; import { GitHubProvider, GoogleProvider, @@ -51,6 +52,7 @@ const { } = NextAuth({ // debug: true, adapter, + logger, providers: [ GitHubProvider, GoogleProvider, diff --git a/apps/dashboard/src/lib/auth/logger.ts b/apps/dashboard/src/lib/auth/logger.ts new file mode 100644 index 00000000..1d4291b3 --- /dev/null +++ b/apps/dashboard/src/lib/auth/logger.ts @@ -0,0 +1,27 @@ +import type { LoggerInstance } from "@auth/core/types"; +// Import from next-auth, not @auth/core: guarantees the same class next-auth +// throws, even if the catalog and next-auth pin different @auth/core versions. +import { AuthError } from "next-auth"; + +/** + * Auth.js' default logger, minus `UnknownAction`: scanners probing + * `/api/auth/` already get a 400, and each probe would otherwise + * reach Sentry as a new issue via captureConsoleIntegration. + */ +export const logger: Partial = { + error(error) { + if (error instanceof AuthError && error.type === "UnknownAction") return; + + const name = error instanceof AuthError ? error.type : error.name; + console.error(`[auth][error] ${name}: ${error.message}`); + + const cause = error.cause; + if (cause && typeof cause === "object" && "err" in cause) { + const { err, ...data } = cause as { err: unknown }; + if (err instanceof Error) console.error("[auth][cause]:", err.stack); + console.error("[auth][details]:", JSON.stringify(data, null, 2)); + } else if (error.stack) { + console.error(error.stack.replace(/.*/, "").substring(1)); + } + }, +}; diff --git a/apps/status-page/src/lib/auth/index.ts b/apps/status-page/src/lib/auth/index.ts index 80fad5f0..eef4f7b9 100644 --- a/apps/status-page/src/lib/auth/index.ts +++ b/apps/status-page/src/lib/auth/index.ts @@ -7,6 +7,7 @@ import { headers } from "next/headers"; import { getValidCustomDomain } from "../domain"; import { getQueryClient, trpc } from "../trpc/server"; import { adapter } from "./adapter"; +import { logger } from "./logger"; import { ResendProvider } from "./providers"; export type { DefaultSession }; @@ -14,6 +15,7 @@ export type { DefaultSession }; export const { handlers, signIn, signOut, auth } = NextAuth({ debug: process.env.NODE_ENV === "development", adapter, + logger, providers: [ResendProvider], callbacks: { async signIn(params) { diff --git a/apps/status-page/src/lib/auth/logger.ts b/apps/status-page/src/lib/auth/logger.ts new file mode 100644 index 00000000..1d4291b3 --- /dev/null +++ b/apps/status-page/src/lib/auth/logger.ts @@ -0,0 +1,27 @@ +import type { LoggerInstance } from "@auth/core/types"; +// Import from next-auth, not @auth/core: guarantees the same class next-auth +// throws, even if the catalog and next-auth pin different @auth/core versions. +import { AuthError } from "next-auth"; + +/** + * Auth.js' default logger, minus `UnknownAction`: scanners probing + * `/api/auth/` already get a 400, and each probe would otherwise + * reach Sentry as a new issue via captureConsoleIntegration. + */ +export const logger: Partial = { + error(error) { + if (error instanceof AuthError && error.type === "UnknownAction") return; + + const name = error instanceof AuthError ? error.type : error.name; + console.error(`[auth][error] ${name}: ${error.message}`); + + const cause = error.cause; + if (cause && typeof cause === "object" && "err" in cause) { + const { err, ...data } = cause as { err: unknown }; + if (err instanceof Error) console.error("[auth][cause]:", err.stack); + console.error("[auth][details]:", JSON.stringify(data, null, 2)); + } else if (error.stack) { + console.error(error.stack.replace(/.*/, "").substring(1)); + } + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24599a9a..e1339ea4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ catalogs: specifier: 3.0.190 version: 3.0.190 '@auth/core': - specifier: 0.40.0 - version: 0.40.0 + specifier: 0.41.2 + version: 0.41.2 '@auth/drizzle-adapter': specifier: 1.11.2 version: 1.11.2 @@ -559,7 +559,7 @@ importers: version: 3.0.190(react@19.2.6)(zod@4.1.13) '@auth/core': specifier: 'catalog:' - version: 0.40.0 + version: 0.41.2 '@auth/drizzle-adapter': specifier: 'catalog:' version: 1.11.2 @@ -1081,7 +1081,7 @@ importers: dependencies: '@auth/core': specifier: 'catalog:' - version: 0.40.0 + version: 0.41.2 '@auth/drizzle-adapter': specifier: 'catalog:' version: 1.11.2 @@ -1313,7 +1313,7 @@ importers: dependencies: '@auth/core': specifier: 'catalog:' - version: 0.40.0 + version: 0.41.2 '@auth/drizzle-adapter': specifier: 'catalog:' version: 1.11.2 @@ -3193,20 +3193,6 @@ packages: peerDependencies: zod: ^4.0.0 - '@auth/core@0.40.0': - resolution: {integrity: sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==} - peerDependencies: - '@simplewebauthn/browser': ^9.0.1 - '@simplewebauthn/server': ^9.0.2 - nodemailer: ^6.8.0 - peerDependenciesMeta: - '@simplewebauthn/browser': - optional: true - '@simplewebauthn/server': - optional: true - nodemailer: - optional: true - '@auth/core@0.41.2': resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==} peerDependencies: @@ -11936,14 +11922,6 @@ snapshots: openapi3-ts: 4.5.0 zod: 4.1.13 - '@auth/core@0.40.0': - dependencies: - '@panva/hkdf': 1.2.1 - jose: 6.1.3 - oauth4webapi: 3.8.3 - preact: 10.24.3 - preact-render-to-string: 6.5.11(preact@10.24.3) - '@auth/core@0.41.2': dependencies: '@panva/hkdf': 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9f4d8f5f..cc969f6c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,7 +32,7 @@ catalog: "@astrojs/sitemap": 3.7.2 "@astrojs/starlight": 0.37.7 "@astrojs/starlight-tailwind": 4.0.2 - "@auth/core": 0.40.0 + "@auth/core": 0.41.2 "@auth/drizzle-adapter": 1.11.2 "@aws-sdk/client-s3": 3.1051.0 "@bufbuild/buf": 1.69.0 -- 2.51.2 From 3c01986f69393c157e1af40d28aa34c9d2719ca5 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:31:54 +0200 Subject: [PATCH 192/266] fix: dashboard monitor settings form (#2638) * fix: dashboard monitor settings form * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../components/forms/monitor-tag/sheet.tsx | 5 +- .../forms/monitor/form-follow-redirect.tsx | 2 +- .../components/forms/monitor/form-general.tsx | 55 +++++++++++-------- .../components/forms/monitor/form-tags.tsx | 6 +- apps/web/src/content/docs.config.ts | 2 +- packages/ui/src/components/ui/form.tsx | 2 +- 6 files changed, 40 insertions(+), 32 deletions(-) diff --git a/apps/dashboard/src/components/forms/monitor-tag/sheet.tsx b/apps/dashboard/src/components/forms/monitor-tag/sheet.tsx index 3442c499..c5e7b647 100644 --- a/apps/dashboard/src/components/forms/monitor-tag/sheet.tsx +++ b/apps/dashboard/src/components/forms/monitor-tag/sheet.tsx @@ -49,7 +49,10 @@ export function FormSheetMonitorTag({ { + await onSubmit(values); + setOpen(false); + }} defaultValues={defaultValues} id="tags-form" className="my-4" diff --git a/apps/dashboard/src/components/forms/monitor/form-follow-redirect.tsx b/apps/dashboard/src/components/forms/monitor/form-follow-redirect.tsx index 8c783e30..65baeb81 100644 --- a/apps/dashboard/src/components/forms/monitor/form-follow-redirect.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-follow-redirect.tsx @@ -108,7 +108,7 @@ export function FormFollowRedirect({ Learn more about{" "} diff --git a/apps/dashboard/src/components/forms/monitor/form-general.tsx b/apps/dashboard/src/components/forms/monitor/form-general.tsx index 519de499..7ecbc425 100644 --- a/apps/dashboard/src/components/forms/monitor/form-general.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-general.tsx @@ -36,6 +36,7 @@ import { AlertDialogTitle, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; +import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, FormControl, @@ -57,7 +58,6 @@ import { SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; -import { Switch } from "@openstatus/ui/components/ui/switch"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { Tooltip, @@ -153,6 +153,9 @@ export function FormGeneral({ const [isPending, startTransition] = useTransition(); const watchType = form.watch("type"); const watchMethod = form.watch("method"); + // Each type has its own reference page; only http and dns support assertions. + const referenceUrl = `https://www.openstatus.dev/docs/reference/${watchType ?? "http"}-monitor/`; + const hasAssertions = watchType === "http" || watchType === "dns"; useEffect(() => { // NOTE: reset form when type changes @@ -259,8 +262,7 @@ export function FormGeneral({ - Internal name for your monitor. This will be used to - identify the monitor in the dashboard. + Internal name to identify the monitor in the dashboard. )} @@ -269,14 +271,18 @@ export function FormGeneral({ control={form.control} name="active" render={({ field }) => ( - - Active - - - + + {/* pt = label height + gap, so the checkbox sits on the input line */} +
+ + + + Active +
+ Uncheck to pause checks.
)} /> @@ -1131,21 +1137,22 @@ export function FormGeneral({ Learn more about{" "} - + Monitor Type - {" "} - and{" "} - - Assertions + {hasAssertions && ( + <> + {" "} + and{" "} + + Assertions + + + )} . We test your endpoint before saving the monitor. +
diff --git a/apps/web/src/content/docs.config.ts b/apps/web/src/content/docs.config.ts index 7f55327c..6d8a890e 100644 --- a/apps/web/src/content/docs.config.ts +++ b/apps/web/src/content/docs.config.ts @@ -245,12 +245,12 @@ export const docsNav: DocsNavSection[] = [ { slug: "reference/grpc-monitor", label: "gRPC Monitor Reference" }, { slug: "reference/http-monitor", label: "HTTP Monitor Reference" }, { slug: "reference/icmp-monitor", label: "ICMP Monitor Reference" }, - { slug: "reference/incident", label: "Incident Reference" }, { slug: "reference/tcp-monitor", label: "TCP Monitor Reference" }, { slug: "reference/notification", label: "Notification Channels Reference", }, + { slug: "reference/incident", label: "Incident Reference" }, { slug: "reference/location", label: "Location Reference" }, { slug: "reference/private-location", diff --git a/packages/ui/src/components/ui/form.tsx b/packages/ui/src/components/ui/form.tsx index a5f88f4b..f0023cdb 100644 --- a/packages/ui/src/components/ui/form.tsx +++ b/packages/ui/src/components/ui/form.tsx @@ -79,7 +79,7 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
-- 2.51.2 From 6b9503a05bef11e223365ab576fcd5d55abafab9 Mon Sep 17 00:00:00 2001 From: Matanya Date: Fri, 4 Sep 2026 10:10:12 -0600 Subject: [PATCH 193/266] fix(dashboard): send state parameter in generic OIDC authorization request (#2639) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/dashboard/src/lib/auth/providers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/dashboard/src/lib/auth/providers.ts b/apps/dashboard/src/lib/auth/providers.ts index d25b5d1c..82312540 100644 --- a/apps/dashboard/src/lib/auth/providers.ts +++ b/apps/dashboard/src/lib/auth/providers.ts @@ -28,6 +28,7 @@ export const OIDCProvider: OIDCConfig = { issuer: process.env.AUTH_OIDC_ISSUER, clientId: process.env.AUTH_OIDC_ID, clientSecret: process.env.AUTH_OIDC_SECRET, + checks: ["pkce", "state"], }; // The stock provider bakes an empty `connection=` into the authorize URL, and -- 2.51.2 From 245347f32165218d867cfd730b6945b5849ba3a4 Mon Sep 17 00:00:00 2001 From: "polylane[bot]" <277585245+polylane[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:33:29 +0200 Subject: [PATCH 194/266] Fix Notification dead-letters silently dropped, unmonitored (#2637) * fix(notifications): preserve ntfy HTTP status and enrich dead-letter Sentry context * ci: apply automated fixes --------- Co-authored-by: Polylane Automation Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- apps/workflows/src/checker/outbox.ts | 15 +++++++++++++++ packages/notifications/ntfy/src/index.ts | 12 +++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/workflows/src/checker/outbox.ts b/apps/workflows/src/checker/outbox.ts index 05bbad81..0d518f6a 100644 --- a/apps/workflows/src/checker/outbox.ts +++ b/apps/workflows/src/checker/outbox.ts @@ -438,6 +438,21 @@ async function commitDead( new Error( `Notification dead-lettered: ${row.provider} for monitor ${row.monitorId}`, ), + { + tags: { + provider: row.provider, + event_type: row.eventType, + from_status: row.fromStatus, + to_status: row.toStatus, + }, + extra: { + monitor_id: row.monitorId, + notification_id: row.notificationId, + workspace_id: row.workspaceId, + attempts: row.attempts, + final_error: error, + }, + }, ); } } diff --git a/packages/notifications/ntfy/src/index.ts b/packages/notifications/ntfy/src/index.ts index 64e094d0..a6dbe448 100644 --- a/packages/notifications/ntfy/src/index.ts +++ b/packages/notifications/ntfy/src/index.ts @@ -32,7 +32,9 @@ export const sendAlert = async ({ }, }); if (!res.ok) { - throw new Error(`Failed to send alert notification: ${res.statusText}`); + throw new Error( + `Failed to send alert notification: ${res.status} ${res.statusText}`, + ); } }; @@ -61,7 +63,9 @@ export const sendRecovery = async ({ }, }); if (!res.ok) { - throw new Error(`Failed to send recovery notification: ${res.statusText}`); + throw new Error( + `Failed to send recovery notification: ${res.status} ${res.statusText}`, + ); } }; @@ -91,7 +95,9 @@ export const sendDegraded = async ({ }, }); if (!res.ok) { - throw new Error(`Failed to send degraded notification: ${res.statusText}`); + throw new Error( + `Failed to send degraded notification: ${res.status} ${res.statusText}`, + ); } }; -- 2.51.2 From f4bf9a4c6ad76401b1ead6235b07914cf41d7c6d Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:45:56 +0200 Subject: [PATCH 195/266] feat: dashboard data-table-filters logs (#2614) * feat: dashboard data-table-filters logs * wip: grpc * fix: dead links * fix: regex * fix: minor stuff * ci: apply automated fixes * fix: facets and minor stuff * fix: minor stuff * wip: * fix: --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../(dashboard)/monitors/[id]/logs/client.tsx | 449 +++++++++---- .../(dashboard)/monitors/[id]/logs/page.tsx | 12 +- .../monitors/[id]/logs/search-params.ts | 26 - .../monitors/[id]/logs/table-schema.tsx | 261 ++++++++ .../settings/private-locations/client.tsx | 2 +- .../dashboard/src/app/api/ai-filters/route.ts | 88 +++ apps/dashboard/src/app/globals.css | 11 +- .../components/content/billing-overlay.tsx | 2 +- .../controls-search/popover-date.tsx | 140 ---- .../data-table/response-logs/columns.tsx | 2 +- .../response-logs/data-table-toolbar.tsx | 73 -- .../data-table/table-cell-region.tsx | 31 + .../src/components/nav/app-header.tsx | 2 +- .../dashboard/src/components/nav/nav-tabs.tsx | 2 +- .../components/onboarding/checks-table.tsx | 6 +- .../components/onboarding/feature-badges.tsx | 2 +- apps/dashboard/src/data/response-logs.ts | 10 +- .../src/lib/ai/create-ai-filter-handler.ts | 91 +++ apps/dashboard/src/lib/react-table.d.ts | 25 + oxlint.config.ts | 6 + .../api/src/router/tinybird/index.test.ts | 63 ++ packages/api/src/router/tinybird/index.ts | 123 ++-- packages/api/src/router/tinybird/utils.ts | 9 - .../src/monitor/__tests__/reads.test.ts | 328 +++++++++ .../__tests__/response-logs-cursor.test.ts | 196 ++++++ .../src/monitor/get-response-log-facets.ts | 114 ++++ packages/services/src/monitor/index.ts | 18 + .../monitor/list-response-logs-infinite.ts | 209 ++++++ .../src/monitor/response-logs-cursor.ts | 146 ++++ packages/services/src/monitor/schemas.ts | 45 ++ .../datasources/mv__dns_14d__v0.datasource | 19 + .../endpoints/endpoint__dns_list_14d__v1.pipe | 49 ++ .../endpoint__dns_list_facets_14d__v0.pipe | 61 ++ .../endpoint__grpc_list_14d__v1.pipe | 51 ++ .../endpoint__grpc_list_facets_14d__v0.pipe | 61 ++ .../endpoint__http_list_14d__v2.pipe | 50 ++ .../endpoints/endpoint__http_list_1d__v2.pipe | 50 ++ .../endpoints/endpoint__http_list_7d__v2.pipe | 50 ++ .../endpoint__http_list_facets_14d__v0.pipe | 68 ++ .../endpoint__icmp_list_14d__v1.pipe | 52 ++ .../endpoints/endpoint__icmp_list_1d__v1.pipe | 52 ++ .../endpoints/endpoint__icmp_list_7d__v1.pipe | 52 ++ .../endpoint__icmp_list_facets_14d__v0.pipe | 61 ++ .../endpoints/endpoint__tcp_list_14d__v2.pipe | 48 ++ .../endpoints/endpoint__tcp_list_1d__v2.pipe | 48 ++ .../endpoints/endpoint__tcp_list_7d__v2.pipe | 48 ++ .../endpoint__tcp_list_facets_14d__v0.pipe | 61 ++ .../aggregate__dns_14d__v0.pipe | 19 + packages/tinybird/src/client.test.ts | 118 ++++ packages/tinybird/src/client.ts | 461 +++++++++---- packages/ui/package.json | 47 +- .../custom/date-picker-with-range.tsx | 350 ++++++++++ packages/ui/src/components/custom/slider.tsx | 36 + .../ui/src/components/custom/sortable.tsx | 351 ++++++++++ packages/ui/src/components/custom/table.tsx | 99 +++ .../components/custom/text-with-tooltip.tsx | 67 ++ .../data-table-filters/controls.tsx | 38 ++ .../data-table-cell/data-table-cell-badge.tsx | 24 + .../data-table-cell/data-table-cell-bar.tsx | 46 ++ .../data-table-cell-boolean.tsx | 24 + .../data-table-cell/data-table-cell-code.tsx | 13 + .../data-table-cell/data-table-cell-gauge.tsx | 78 +++ .../data-table-cell-heatmap.tsx | 44 ++ .../data-table-cell-level-indicator.tsx | 30 + .../data-table-cell-number.tsx | 20 + .../data-table-cell/data-table-cell-star.tsx | 21 + .../data-table-cell-status-code.tsx | 19 + .../data-table-cell/data-table-cell-text.tsx | 11 + .../data-table-cell-timestamp.tsx | 18 + .../data-table-cell/index.tsx | 12 + .../data-table-column-header.tsx | 58 ++ .../data-table-filter-checkbox.tsx | 198 ++++++ .../data-table-filter-command-ai/index.tsx | 601 +++++++++++++++++ .../text-shimmer.tsx | 39 ++ .../data-table-filter-command/utils.ts | 353 ++++++++++ .../data-table-filter-controls-drawer.tsx | 82 +++ .../data-table-filter-controls.tsx | 89 +++ .../data-table-filter-input.tsx | 63 ++ .../data-table-filter-rail.tsx | 37 + .../data-table-filter-reset-button.tsx | 49 ++ .../data-table-filter-slider.tsx | 141 ++++ .../data-table-filter-timerange.tsx | 48 ++ .../data-table-infinite.tsx | 630 ++++++++++++++++++ .../hover-card-timestamp.tsx | 97 +++ .../data-table-provider.tsx | 121 ++++ .../data-table-refresh-button.tsx | 41 ++ .../data-table-reset-button.tsx | 40 ++ .../data-table-store-sync.tsx | 148 ++++ .../data-table-filters/data-table-toolbar.tsx | 101 +++ .../data-table-view-options.tsx | 127 ++++ .../components/data-table-filters/types.ts | 99 +++ .../components/data-table-filters/utils.ts | 65 ++ packages/ui/src/components/ui/accordion.tsx | 65 ++ packages/ui/src/hooks/use-hot-key.ts | 33 + packages/ui/src/hooks/use-local-storage.ts | 83 +++ .../src/lib/data-table-filters/ai/context.ts | 82 +++ .../src/lib/data-table-filters/ai/detect.ts | 47 ++ .../lib/data-table-filters/ai/diff-partial.ts | 103 +++ .../ui/src/lib/data-table-filters/ai/index.ts | 14 + .../data-table-filters/ai/output-schema.ts | 116 ++++ .../data-table-filters/ai/parse-response.ts | 147 ++++ .../src/lib/data-table-filters/ai/prompt.ts | 110 +++ .../ui/src/lib/data-table-filters/colors.ts | 28 + .../data-table-filters/data-table/faceted.ts | 60 ++ .../data-table-filters/data-table/types.ts | 12 + .../src/lib/data-table-filters/delimiters.ts | 9 + .../src/lib/data-table-filters/filterfns.ts | 27 + .../data-table-filters/filters/evaluate.ts | 102 +++ .../lib/data-table-filters/filters/index.ts | 264 ++++++++ .../data-table-filters/filters/normalize.ts | 203 ++++++ .../lib/data-table-filters/filters/types.ts | 52 ++ .../hooks/use-ai-filters.ts | 109 +++ .../ui/src/lib/data-table-filters/is-array.ts | 19 + .../lib/data-table-filters/local-storage.ts | 13 + .../src/lib/data-table-filters/status-code.ts | 48 ++ .../data-table-filters/store/adapter/types.ts | 145 ++++ .../store/adapters/memory/index.ts | 120 ++++ .../store/adapters/nuqs/index.ts | 203 ++++++ .../store/adapters/nuqs/parser-bridge.ts | 181 +++++ .../store/adapters/nuqs/server.ts | 118 ++++ .../store/adapters/nuqs/types.ts | 28 + .../lib/data-table-filters/store/context.ts | 39 ++ .../data-table-filters/store/hooks/index.ts | 3 + .../store/hooks/useFilterActions.ts | 138 ++++ .../store/hooks/useFilterField.ts | 87 +++ .../store/hooks/useFilterState.ts | 117 ++++ .../data-table-filters/store/parser/index.ts | 2 + .../store/parser/text-parser.ts | 202 ++++++ .../data-table-filters/store/parser/types.ts | 87 +++ .../store/provider/DataTableStoreProvider.tsx | 63 ++ .../data-table-filters/store/schema/field.ts | 200 ++++++ .../data-table-filters/store/schema/index.ts | 70 ++ .../store/schema/serialization.ts | 187 ++++++ .../data-table-filters/store/schema/types.ts | 100 +++ .../data-table-filters/table-schema/col.ts | 576 ++++++++++++++++ .../table-schema/generators/columns.tsx | 327 +++++++++ .../table-schema/generators/filter-fields.ts | 90 +++ .../table-schema/generators/filter-schema.ts | 188 ++++++ .../table-schema/generators/sheet-fields.ts | 43 ++ .../data-table-filters/table-schema/index.ts | 153 +++++ .../table-schema/presets.ts | 315 +++++++++ .../table-schema/serialize.ts | 273 ++++++++ .../data-table-filters/table-schema/types.ts | 510 ++++++++++++++ .../table-schema/validate.ts | 114 ++++ .../ui/src/lib/data-table-filters/tokenize.ts | 137 ++++ packages/ui/src/lib/date-preset.ts | 41 ++ packages/ui/src/lib/format.ts | 39 ++ packages/ui/src/react-table.d.ts | 25 + packages/ui/tsconfig.json | 1 + pnpm-lock.yaml | 44 ++ pnpm-workspace.yaml | 2 + 151 files changed, 14651 insertions(+), 609 deletions(-) delete mode 100644 apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/search-params.ts create mode 100644 apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/table-schema.tsx create mode 100644 apps/dashboard/src/app/api/ai-filters/route.ts delete mode 100644 apps/dashboard/src/components/controls-search/popover-date.tsx delete mode 100644 apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx create mode 100644 apps/dashboard/src/lib/ai/create-ai-filter-handler.ts create mode 100644 apps/dashboard/src/lib/react-table.d.ts delete mode 100644 packages/api/src/router/tinybird/utils.ts create mode 100644 packages/services/src/monitor/__tests__/response-logs-cursor.test.ts create mode 100644 packages/services/src/monitor/get-response-log-facets.ts create mode 100644 packages/services/src/monitor/list-response-logs-infinite.ts create mode 100644 packages/services/src/monitor/response-logs-cursor.ts create mode 100644 packages/tinybird/datasources/mv__dns_14d__v0.datasource create mode 100644 packages/tinybird/endpoints/endpoint__dns_list_14d__v1.pipe create mode 100644 packages/tinybird/endpoints/endpoint__dns_list_facets_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_list_14d__v1.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_list_facets_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__http_list_14d__v2.pipe create mode 100644 packages/tinybird/endpoints/endpoint__http_list_1d__v2.pipe create mode 100644 packages/tinybird/endpoints/endpoint__http_list_7d__v2.pipe create mode 100644 packages/tinybird/endpoints/endpoint__http_list_facets_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_list_14d__v1.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_list_1d__v1.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_list_7d__v1.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_list_facets_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__tcp_list_14d__v2.pipe create mode 100644 packages/tinybird/endpoints/endpoint__tcp_list_1d__v2.pipe create mode 100644 packages/tinybird/endpoints/endpoint__tcp_list_7d__v2.pipe create mode 100644 packages/tinybird/endpoints/endpoint__tcp_list_facets_14d__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__dns_14d__v0.pipe create mode 100644 packages/ui/src/components/custom/date-picker-with-range.tsx create mode 100644 packages/ui/src/components/custom/slider.tsx create mode 100644 packages/ui/src/components/custom/sortable.tsx create mode 100644 packages/ui/src/components/custom/table.tsx create mode 100644 packages/ui/src/components/custom/text-with-tooltip.tsx create mode 100644 packages/ui/src/components/data-table-filters/controls.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-badge.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-bar.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-boolean.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-code.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-gauge.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-heatmap.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-level-indicator.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-number.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-star.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-status-code.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-text.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/data-table-cell-timestamp.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-cell/index.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-column-header.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-checkbox.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-command-ai/index.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-command-ai/text-shimmer.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-command/utils.ts create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-controls-drawer.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-controls.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-input.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-rail.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-reset-button.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-slider.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-filter-timerange.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-infinite.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-infinite/hover-card-timestamp.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-provider.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-refresh-button.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-reset-button.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-store-sync.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-toolbar.tsx create mode 100644 packages/ui/src/components/data-table-filters/data-table-view-options.tsx create mode 100644 packages/ui/src/components/data-table-filters/types.ts create mode 100644 packages/ui/src/components/data-table-filters/utils.ts create mode 100644 packages/ui/src/components/ui/accordion.tsx create mode 100644 packages/ui/src/hooks/use-hot-key.ts create mode 100644 packages/ui/src/hooks/use-local-storage.ts create mode 100644 packages/ui/src/lib/data-table-filters/ai/context.ts create mode 100644 packages/ui/src/lib/data-table-filters/ai/detect.ts create mode 100644 packages/ui/src/lib/data-table-filters/ai/diff-partial.ts create mode 100644 packages/ui/src/lib/data-table-filters/ai/index.ts create mode 100644 packages/ui/src/lib/data-table-filters/ai/output-schema.ts create mode 100644 packages/ui/src/lib/data-table-filters/ai/parse-response.ts create mode 100644 packages/ui/src/lib/data-table-filters/ai/prompt.ts create mode 100644 packages/ui/src/lib/data-table-filters/colors.ts create mode 100644 packages/ui/src/lib/data-table-filters/data-table/faceted.ts create mode 100644 packages/ui/src/lib/data-table-filters/data-table/types.ts create mode 100644 packages/ui/src/lib/data-table-filters/delimiters.ts create mode 100644 packages/ui/src/lib/data-table-filters/filterfns.ts create mode 100644 packages/ui/src/lib/data-table-filters/filters/evaluate.ts create mode 100644 packages/ui/src/lib/data-table-filters/filters/index.ts create mode 100644 packages/ui/src/lib/data-table-filters/filters/normalize.ts create mode 100644 packages/ui/src/lib/data-table-filters/filters/types.ts create mode 100644 packages/ui/src/lib/data-table-filters/hooks/use-ai-filters.ts create mode 100644 packages/ui/src/lib/data-table-filters/is-array.ts create mode 100644 packages/ui/src/lib/data-table-filters/local-storage.ts create mode 100644 packages/ui/src/lib/data-table-filters/status-code.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/adapter/types.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/adapters/memory/index.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/adapters/nuqs/index.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/adapters/nuqs/parser-bridge.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/adapters/nuqs/server.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/adapters/nuqs/types.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/context.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/hooks/index.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/hooks/useFilterActions.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/hooks/useFilterField.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/hooks/useFilterState.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/parser/index.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/parser/text-parser.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/parser/types.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/provider/DataTableStoreProvider.tsx create mode 100644 packages/ui/src/lib/data-table-filters/store/schema/field.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/schema/index.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/schema/serialization.ts create mode 100644 packages/ui/src/lib/data-table-filters/store/schema/types.ts create mode 100644 packages/ui/src/lib/data-table-filters/table-schema/col.ts create mode 100644 packages/ui/src/lib/data-table-filters/table-schema/generators/columns.tsx create mode 100644 packages/ui/src/lib/data-table-filters/table-schema/generators/filter-fields.ts create mode 100644 packages/ui/src/lib/data-table-filters/table-schema/generators/filter-schema.ts create mode 100644 packages/ui/src/lib/data-table-filters/table-schema/generators/sheet-fields.ts create mode 100644 packages/ui/src/lib/data-table-filters/table-schema/index.ts create mode 100644 packages/ui/src/lib/data-table-filters/table-schema/presets.ts create mode 100644 packages/ui/src/lib/data-table-filters/table-schema/serialize.ts create mode 100644 packages/ui/src/lib/data-table-filters/table-schema/types.ts create mode 100644 packages/ui/src/lib/data-table-filters/table-schema/validate.ts create mode 100644 packages/ui/src/lib/data-table-filters/tokenize.ts create mode 100644 packages/ui/src/lib/date-preset.ts create mode 100644 packages/ui/src/lib/format.ts create mode 100644 packages/ui/src/react-table.d.ts diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx index 79df5d76..63392238 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx @@ -1,11 +1,31 @@ "use client"; +// REMINDER: React Compiler is not compatible with Tanstack Table v8 +// https://github.com/TanStack/table/issues/5567 +"use no memo"; + +import type { RouterOutputs } from "@openstatus/api"; import { Lock } from "@openstatus/icons"; -import { useQuery } from "@tanstack/react-query"; -import type { PaginationState } from "@tanstack/react-table"; +import { DataTableFilterAICommand } from "@openstatus/ui/components/data-table-filters/data-table-filter-command-ai/index"; +import { DataTableInfinite } from "@openstatus/ui/components/data-table-filters/data-table-infinite"; +import { useDataTable } from "@openstatus/ui/components/data-table-filters/data-table-provider"; +import { defineFilters } from "@openstatus/ui/lib/data-table-filters/filters/index"; +import { useMemoryAdapter } from "@openstatus/ui/lib/data-table-filters/store/adapters/memory/index"; +import { useNuqsAdapter } from "@openstatus/ui/lib/data-table-filters/store/adapters/nuqs/index"; +import { useFilterState } from "@openstatus/ui/lib/data-table-filters/store/hooks/index"; +import { DataTableStoreProvider } from "@openstatus/ui/lib/data-table-filters/store/provider/DataTableStoreProvider"; +import { + getDefaultColumnVisibility, + resolveColumns, +} from "@openstatus/ui/lib/data-table-filters/table-schema/index"; +import { + keepPreviousData, + useInfiniteQuery, + useQuery, +} from "@tanstack/react-query"; import { useParams } from "next/navigation"; -import { useQueryStates } from "nuqs"; -import { useCallback, useMemo } from "react"; +import { parseAsString, useQueryState } from "nuqs"; +import { useCallback, useEffect, useMemo } from "react"; import { Link } from "@/components/common/link"; import { @@ -14,154 +34,337 @@ import { BillingOverlayContainer, BillingOverlayDescription, } from "@/components/content/billing-overlay"; -import { - SectionDescription, - SectionGroup, - SectionHeader, - SectionTitle, -} from "@/components/content/section"; -import { Section } from "@/components/content/section"; -import { ButtonReset } from "@/components/controls-search/button-reset"; -import { CommandRegion } from "@/components/controls-search/command-region"; -import { DropdownStatus } from "@/components/controls-search/dropdown-status"; -import { DropdownTrigger } from "@/components/controls-search/dropdown-trigger"; -import { PopoverDate } from "@/components/controls-search/popover-date"; -import { getColumns } from "@/components/data-table/response-logs/columns"; import { Sheet } from "@/components/data-table/response-logs/data-table-sheet"; -import { DataTable } from "@/components/ui/data-table/data-table"; -import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination"; -import { DataTableSkeleton } from "@/components/ui/data-table/data-table-skeleton"; import { exampleLogs } from "@/data/response-logs"; import { useTRPC } from "@/lib/trpc/client"; -import { searchParamsParsers } from "./search-params"; +import { + createLogsTable, + RETENTION_DAYS, + type ResponseLog, +} from "./table-schema"; + +const TABLE_ID = "response-logs"; +const PAGE_SIZE = 50; +const MAX_WINDOW_MS = RETENTION_DAYS * 24 * 60 * 60 * 1000; + +type Monitor = RouterOutputs["monitor"]["get"]; + +/** The shape the generated filter schema keeps in the URL. */ +type LogsFilterState = { + requestStatus?: string[]; + timestamp?: Date[]; + statusCode?: number[]; + latency?: number[]; + region?: string[]; + trigger?: string[]; +}; export function Client() { const trpc = useTRPC(); const { id } = useParams<{ id: string }>(); - const [ - { regions, status, selected, trigger, from, to, pageIndex, pageSize }, - setSearchParams, - ] = useQueryStates(searchParamsParsers); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); - const enabled = workspace && workspace?.plan !== "free"; - const { data: _logs, isLoading } = useQuery({ - ...trpc.tinybird.list.queryOptions({ monitorId: id, from, to }), - enabled, - }); - const { data: _log } = useQuery({ - ...trpc.tinybird.get.queryOptions({ id: selected, monitorId: id }), - enabled: !!selected && enabled, + + if (!workspace || !monitor) return null; + + // No `SectionGroup` here: it centres content in a `max-w-4xl` column, and the + // table renders its own filter sidebar + toolbar shell that needs the full + // width of the content area. The box is pinned to the space left below the + // app header and the monitor tabs so the document never scrolls: the filter + // sidebar and the table body each own their scroll instead. + return ( +
+ {workspace.plan === "free" ? ( + + ) : ( + + )} +
+ ); +} + +function LogsTable({ monitor }: { monitor: Monitor }) { + const { schema, columns, filterFields, filterSchema } = useMemo( + () => + createLogsTable({ + regions: monitor.regions, + privateLocations: monitor.privateLocations ?? [], + jobType: monitor.jobType, + }), + [monitor.regions, monitor.privateLocations, monitor.jobType], + ); + + const adapter = useNuqsAdapter(filterSchema.definition, { id: TABLE_ID }); + + return ( + + + + ); +} + +function LogsTableInner({ + monitor, + columns, + filterFields, + schema, + filterSchema, +}: { + monitor: Monitor; + columns: ReturnType["columns"]; + filterFields: ReturnType["filterFields"]; + schema: ReturnType["schema"]; + filterSchema: ReturnType["filterSchema"]; +}) { + const trpc = useTRPC(); + const state = useFilterState(); + const [selected, setSelected] = useQueryState("selected", parseAsString); + + // The table re-runs every filter over the fetched rows, so the range sent to + // the pipes has to be the one `filterFn` will apply — a single date means + // that whole day there, not "everything since". + const filterDefs = useMemo(() => defineFilters(schema.definition), [schema]); + + const filters = useMemo(() => { + const range = filterDefs + .plan({ timestamp: state.timestamp }) + .find((op) => op.op === "dateRange"); + const from = range?.from; + const to = range?.to; + // The facet and list pipes only reach back `RETENTION_DAYS`, so a wider + // range would silently truncate instead of returning what the picker shows. + const floor = (to ?? new Date()).getTime() - MAX_WINDOW_MS; + + return { + monitorId: monitor.id, + from: from ? new Date(Math.max(from.getTime(), floor)) : undefined, + to, + regions: state.region?.length ? state.region : undefined, + status: state.requestStatus?.length + ? (state.requestStatus as ("success" | "error" | "degraded")[]) + : undefined, + trigger: state.trigger?.length + ? (state.trigger as ("cron" | "api")[]) + : undefined, + statusCodes: state.statusCode?.length ? state.statusCode : undefined, + latencyMin: state.latency?.[0], + latencyMax: state.latency?.[1], + }; + }, [state, monitor.id, filterDefs]); + + const { data, isFetching, isLoading, hasNextPage, fetchNextPage, refetch } = + useInfiniteQuery( + trpc.tinybird.listInfinite.infiniteQueryOptions( + { ...filters, limit: PAGE_SIZE }, + { + getNextPageParam: (page) => page.nextCursor ?? undefined, + getPreviousPageParam: (page) => page.prevCursor ?? undefined, + }, + ), + ); + + // Hold the previous counts while the next request is in flight. Without it + // every filter change empties `facets`, and the checkbox filters — which + // derive their option list from it — collapse to their declared set and + // rebuild a moment later. `isPending` is then only true on the first load, + // which is the one time there is nothing to hold on to. + const { data: facets, isPending: isFacetsPending } = useQuery({ + ...trpc.tinybird.listFacets.queryOptions(filters), + placeholderData: keepPreviousData, + staleTime: 30_000, }); - const pagination = useMemo( - () => ({ pageIndex, pageSize }), - [pageIndex, pageSize], + const rows = useMemo( + () => data?.pages.flatMap((page) => page.data) ?? [], + [data], ); - const setPagination = useCallback( - (p: PaginationState | ((old: PaginationState) => PaginationState)) => { - const next = typeof p === "function" ? p({ pageIndex, pageSize }) : p; + // `.hidden()` columns from the schema, plus the one that carries no value for + // the non-HTTP checkers. The status code column is dropped from the schema + // itself, so it needs no entry here. + const columnVisibility = useMemo( + () => ({ + ...getDefaultColumnVisibility(schema.definition), + // gRPC carries HTTP's phase timings. + ...(monitor.jobType === "http" || monitor.jobType === "grpc" + ? {} + : { timing: false }), + }), + [schema, monitor.jobType], + ); - if (next.pageIndex !== pageIndex || next.pageSize !== pageSize) { - setSearchParams({ - pageIndex: next.pageIndex, - pageSize: next.pageSize, - }); + // A checkbox filter looks its count up by the option's own value, and + // `col.presets.httpStatus()` options are numbers. The facet pipes return + // every value as a string, so numeric columns need the key coerced back. + const numericColumns = useMemo( + () => + new Set( + resolveColumns(schema.definition) + .filter((column) => column.kind === "number") + .map((column) => column.key), + ), + [schema], + ); + + const getFacetedUniqueValues = useCallback( + (_table: unknown, columnId: string) => { + const map = new Map(); + const toKey = numericColumns.has(columnId) ? Number : String; + for (const row of facets?.facets[columnId]?.rows ?? []) { + map.set(toKey(row.value), row.total); } + return map; }, - [pageIndex, pageSize, setSearchParams], + [facets, numericColumns], ); - const columns = useMemo( - () => getColumns(monitor?.privateLocations ?? []), - [monitor?.privateLocations], + const getFacetedMinMaxValues = useCallback( + (_table: unknown, columnId: string): [number, number] | undefined => { + if (columnId !== "latency") return undefined; + const latency = facets?.facets.latency; + if (latency?.min === undefined || latency.max === undefined) { + return undefined; + } + return [latency.min, latency.max]; + }, + [facets], ); - if (!workspace || !monitor) return null; - return ( - -
- - {monitor.name} - - {monitor.jobType === "http" ? ( - - {monitor.url} - - ) : ( - monitor.url - )} - - -
- - {monitor.jobType === "http" ? : null} - - - -
-
-
- {isLoading ? ( - - ) : !enabled ? ( - - ) : ( - { - if (!row.original.id) return; - setSearchParams({ selected: row.original.id }); - }} - columnFilters={[ - { id: "trigger", value: trigger }, - { id: "requestStatus", value: status }, - { id: "region", value: regions }, - ].filter((i) => Boolean(i.value))} - pagination={pagination} - setPagination={setPagination} - paginationComponent={DataTablePagination} - defaultColumnVisibility={ - // gRPC carries HTTP's phase timings, so only its status code - // column is meaningless. - monitor.jobType === "grpc" - ? { statusCode: false } - : monitor.jobType === "tcp" || - monitor.jobType === "dns" || - monitor.jobType === "icmp" - ? { timing: false, statusCode: false } - : {} - } - // NOTE: required to control the pagination - autoResetPageIndex={false} + <> + row.id ?? String(index)} + defaultRowSelection={selected ? { [selected]: true } : {}} + getFacetedUniqueValues={ + getFacetedUniqueValues as React.ComponentProps< + typeof DataTableInfinite + >["getFacetedUniqueValues"] + } + getFacetedMinMaxValues={ + getFacetedMinMaxValues as React.ComponentProps< + typeof DataTableInfinite + >["getFacetedMinMaxValues"] + } + totalRows={facets?.totalRowCount} + filterRows={facets?.filterRowCount} + totalRowsFetched={rows.length} + isFetching={isFetching} + isLoading={isLoading} + isFacetsLoading={isFacetsPending} + hasNextPage={hasNextPage} + fetchNextPage={fetchNextPage} + refetch={refetch} + tableId={TABLE_ID} + commandSlot={ + - )} - - setTimeout(() => setSearchParams({ selected: null }), 300) - } - /> -
-
+ } + sheetSlot={} + /> + + ); +} + +/** + * Bridges the table's row selection to the existing response-log sheet, and + * mirrors it into `selected` so a row survives a reload. Rows written before + * the checker stamped an id have no detail to fetch, so they resolve to null. + */ +function LogsSheet({ + monitor, + onSelect, +}: { + monitor: Monitor; + onSelect: (value: string | null) => void; +}) { + const trpc = useTRPC(); + const { table, rowSelection } = useDataTable(); + const selectedRow = table.getSelectedRowModel().rows[0]; + // The row model only holds fetched rows, so a bookmarked log from a later + // page would resolve to null and wipe `selected` from the URL. The selection + // record carries the id whether or not its row has been fetched. + const selectedId = selectedRow + ? (selectedRow.original.id ?? null) + : (Object.keys(rowSelection).find((key) => rowSelection[key]) ?? null); + + useEffect(() => { + onSelect(selectedId); + }, [selectedId, onSelect]); + + const { data: log } = useQuery({ + ...trpc.tinybird.get.queryOptions({ + id: selectedId ?? "", + monitorId: String(monitor.id), + }), + enabled: Boolean(selectedId), + }); + + return ( + { + table.resetRowSelection(); + setTimeout(() => onSelect(null), 300); + }} + /> ); } function BillingPlaceholder() { - const columns = useMemo(() => getColumns([]), []); + const { columns, filterFields, filterSchema, schema } = useMemo( + () => + createLogsTable({ + regions: Array.from(new Set(exampleLogs.map((log) => log.region))), + privateLocations: [], + }), + [], + ); + const adapter = useMemoryAdapter(filterSchema.definition, { + id: `${TABLE_ID}-example`, + }); + return ( - - + + + Promise.resolve()} + refetch={() => {}} + isFetching={false} + isLoading={false} + tableId={`${TABLE_ID}-example`} + commandSlot={ + + } + /> + @@ -172,7 +375,7 @@ function BillingPlaceholder() { Access response headers, timing phases and more for each request.{" "} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/page.tsx index 14d03f21..e3dc7ede 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/page.tsx @@ -1,15 +1,5 @@ -import type { SearchParams } from "nuqs/server"; - import { Client } from "./client"; -import { searchParamsCache } from "./search-params"; - -export default async function Page({ - searchParams, -}: { - searchParams: Promise; -}) { - // NOTE: store in cache to avoid flicker on clients first render - await searchParamsCache.parse(searchParams); +export default function Page() { return ; } diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/search-params.ts b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/search-params.ts deleted file mode 100644 index ae09140d..00000000 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/search-params.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { endOfDay } from "date-fns"; -import { startOfDay } from "date-fns"; -import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsIsoDateTime, - parseAsString, - parseAsStringLiteral, -} from "nuqs/server"; - -import { PERIODS, STATUS, TRIGGER } from "@/data/metrics.client"; - -export const searchParamsParsers = { - period: parseAsStringLiteral(PERIODS).withDefault("1d"), - regions: parseAsArrayOf(parseAsString), - status: parseAsStringLiteral(STATUS), - trigger: parseAsStringLiteral(TRIGGER), - selected: parseAsString, - from: parseAsIsoDateTime.withDefault(startOfDay(new Date())), - to: parseAsIsoDateTime.withDefault(endOfDay(new Date())), - pageIndex: parseAsInteger.withDefault(0), - pageSize: parseAsInteger.withDefault(20), -}; - -export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/table-schema.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/table-schema.tsx new file mode 100644 index 00000000..197b2600 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/table-schema.tsx @@ -0,0 +1,261 @@ +import type { RouterOutputs } from "@openstatus/api"; +import type { PrivateLocation } from "@openstatus/db/src/schema"; +import { monitorRegions } from "@openstatus/db/src/schema/constants"; +import { ApiTrigger, Clock } from "@openstatus/icons"; +import { getRegionInfo } from "@openstatus/regions"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@openstatus/ui/components/ui/tooltip"; +import { + col, + createTableSchema, +} from "@openstatus/ui/lib/data-table-filters/table-schema/index"; +import { + generateColumns, + generateFilterFields, + generateFilterSchema, +} from "@openstatus/ui/lib/data-table-filters/table-schema/index"; +import { addDays, addHours, endOfDay, startOfDay } from "date-fns"; + +import { HoverCardTiming } from "@/components/common/hover-card-timing"; +import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { TableCellRegion } from "@/components/data-table/table-cell-region"; +import { cn } from "@/lib/utils"; + +export type ResponseLog = + RouterOutputs["tinybird"]["listInfinite"]["data"][number]; + +type JobType = RouterOutputs["monitor"]["get"]["jobType"]; + +export const REQUEST_STATUS = ["success", "degraded", "error"] as const; +export const TRIGGERS = ["cron", "api"] as const; + +const STATUS_SWATCH: Record = { + success: "bg-success", + degraded: "bg-warning", + error: "bg-destructive", +}; + +function Dash() { + return
-
; +} + +/** The same square the table cell draws, so the filter row reads as its legend. */ +function StatusSwatch({ value }: { value: string }) { + const swatch = STATUS_SWATCH[value]; + if (!swatch) return null; + return
; +} + +/** + * The slider needs static bounds at schema-build time; the real ones arrive + * with the facets and are fed to the table through `getFacetedMinMaxValues`. + */ +const LATENCY_BOUNDS = { min: 0, max: 30_000 }; + +/** How far back the list and facet pipes reach. */ +export const RETENTION_DAYS = 14; + +/** + * The ranges the logs date popover offered before the table rewrite, capped at + * retention: the picker derives its selectable window from these bounds, so + * anything wider would query days the pipes cannot answer. + */ +function createTimestampPresets() { + const now = new Date(); + return [ + { label: "Today", from: startOfDay(now), to: endOfDay(now), shortcut: "t" }, + { + label: "Yesterday", + from: startOfDay(addDays(now, -1)), + to: endOfDay(addDays(now, -1)), + shortcut: "y", + }, + { label: "Last hour", from: addHours(now, -1), to: now, shortcut: "h" }, + { label: "Last 6 hours", from: addHours(now, -6), to: now, shortcut: "s" }, + { + label: "Last 24 hours", + from: addHours(now, -24), + to: now, + shortcut: "d", + }, + { + label: "Last 7 days", + from: startOfDay(addDays(now, -6)), + to: endOfDay(now), + shortcut: "w", + }, + { + label: `Last ${RETENTION_DAYS} days`, + from: startOfDay(addDays(now, -(RETENTION_DAYS - 1))), + to: endOfDay(now), + shortcut: "b", + }, + ]; +} + +export function createLogsTableSchema(options: { + regions: string[]; + privateLocations: PrivateLocation[]; + /** Defaults to the widest schema — every column the checkers can fill. */ + jobType?: JobType; +}) { + const { regions, privateLocations, jobType = "http" } = options; + + // Only the HTTP checker records a status code. Leaving the column in would + // expose a filter whose every option matches nothing on the other job types. + const hasStatusCode = jobType === "http"; + + // `regions` is only what the monitor runs in *today*, but the window still + // holds rows from regions since removed. Every known region therefore gets a + // label and an enum value — otherwise those rows are unfilterable and a + // hand-written region would parse back to null — and the facets decide which + // of them the filter actually offers. Configured regions stay first so the + // common case keeps the monitor's own order. + const regionOptions = [ + ...[ + ...regions, + ...monitorRegions.filter((region) => !regions.includes(region)), + ].map((region) => { + const info = getRegionInfo(region); + return { label: `${info.flag} ${info.code}`, value: region }; + }), + ...privateLocations.map((location) => ({ + label: `\u{1F310} ${location.name}`, + value: String(location.id), + })), + ]; + + return createTableSchema({ + requestStatus: col + .enum(REQUEST_STATUS) + .label("Result") + .hideHeader() + .size(28) + .defaultOpen() + .filterable("checkbox", { + options: REQUEST_STATUS.map((value) => ({ label: value, value })), + // A closed set of three. A window with no degraded rows must still + // offer the box, or the filter reads as broken rather than empty. + keepEmptyOptions: true, + component: ({ label, value }) => ( + + + {label} + + ), + }) + .display("custom", { + cell: (value) => { + const swatch = STATUS_SWATCH[String(value)]; + if (!swatch) return ; + return ( +
+
+
+ ); + }, + }), + + // `col.presets.timestamp()` but without `.sortable()`: the cursor pages on + // the pipe's own DESC order, so a header sort would only reorder the rows + // already fetched. + timestamp: col + .timestamp() + .label("Timestamp") + .display("timestamp") + .size(200) + .filterable("timerange", { presets: createTimestampPresets() }), + + ...(hasStatusCode + ? { statusCode: col.presets.httpStatus().label("Status").size(90) } + : {}), + + latency: col.presets + .duration("ms", LATENCY_BOUNDS) + .label("Latency") + .size(110) + .display("custom", { + cell: (value) => ( + + ), + }), + + region: col + .enum( + regionOptions.map((option) => option.value) as [string, ...string[]], + ) + .label("Region") + .size(120) + .filterable("checkbox", { options: regionOptions }) + .display("custom", { + cell: (value) => ( + + ), + }), + + timing: col + .record() + .label("Timing") + .size(130) + .display("custom", { + cell: (_value, row) => { + const log = row as ResponseLog; + if (!log.timing) return ; + return ; + }, + }), + + trigger: col + .enum(TRIGGERS) + .label("Trigger") + .size(80) + .hidden() + .filterable("checkbox", { + options: TRIGGERS.map((value) => ({ + label: value === "cron" ? "Scheduled" : "API", + value, + })), + }) + .display("custom", { + cell: (value) => { + if (value !== "cron" && value !== "api") return ; + const Icon = value === "cron" ? Clock : ApiTrigger; + const label = value === "cron" ? "Scheduled" : "API"; + return ( + + + + + + +

{label}

+
+
+
+ ); + }, + }), + }); +} + +export function createLogsTable(options: { + regions: string[]; + privateLocations: PrivateLocation[]; + jobType?: JobType; +}) { + const schema = createLogsTableSchema(options); + return { + schema, + columns: generateColumns(schema.definition), + filterFields: generateFilterFields(schema.definition), + filterSchema: generateFilterSchema(schema.definition), + }; +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx index 8b8f4dfa..86d4a57c 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx @@ -85,7 +85,7 @@ export function Client() { Create private locations to monitor your internal services.{" "} diff --git a/apps/dashboard/src/app/api/ai-filters/route.ts b/apps/dashboard/src/app/api/ai-filters/route.ts new file mode 100644 index 00000000..1bbf2eb5 --- /dev/null +++ b/apps/dashboard/src/app/api/ai-filters/route.ts @@ -0,0 +1,88 @@ +import { resolveChatModel } from "@openstatus/ai"; +import { monitorRegions } from "@openstatus/db/src/schema/constants"; +import { + createTableSchema, + type TableSchemaDefinition, +} from "@openstatus/ui/lib/data-table-filters/table-schema/index"; +import { cookies } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; + +import { createLogsTableSchema } from "@/app/(dashboard)/monitors/[id]/logs/table-schema"; +import { getChatServiceContext } from "@/lib/agent-tools/context"; +import { createAIFilterHandler } from "@/lib/ai/create-ai-filter-handler"; +import { chatRateLimit } from "@/lib/rate-limit/chat"; +import { WORKSPACE_SLUG_COOKIE } from "@/lib/workspace-cookie"; + +// Used when the request carries no usable schema. Covers every region the +// product offers, which is a superset of any single monitor's. +const fallbackSchema: TableSchemaDefinition = createLogsTableSchema({ + regions: [...monitorRegions], + privateLocations: [], +}).definition; + +/** Above this the payload is not a table schema, whatever it claims to be. */ +const MAX_SCHEMA_BYTES = 64_000; + +/** + * The client sends the schema of the table it is filtering, so the prompt and + * the output schema describe that monitor's regions and private locations — + * inferring against a generic schema silently drops both. `fromJSON` normalises + * and validates the untrusted JSON; anything it rejects falls back. + */ +function resolveSchema(raw: unknown): TableSchemaDefinition { + if (!raw) return fallbackSchema; + try { + if (JSON.stringify(raw).length > MAX_SCHEMA_BYTES) return fallbackSchema; + return createTableSchema.fromJSON(raw).definition; + } catch { + return fallbackSchema; + } +} + +export async function POST(req: NextRequest) { + const workspaceSlug = (await cookies()).get(WORKSPACE_SLUG_COOKIE)?.value; + const ctx = await getChatServiceContext({ workspaceSlug }); + if (!ctx || ctx.actor.type !== "user") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!ctx.workspace.limits["response-logs"]) { + return NextResponse.json( + { error: "Response logs are not enabled on this plan." }, + { status: 403 }, + ); + } + + const model = resolveChatModel({ plan: ctx.workspace.plan ?? "free" }); + if (!model) { + return NextResponse.json( + { error: "AI filters are not configured on this deployment." }, + { status: 503 }, + ); + } + + // Same carve-out as `api/chat`: the Redis counter only guards production. + if (process.env.NODE_ENV === "production") { + const limit = await chatRateLimit({ ctx }); + if (!limit.success) { + return NextResponse.json( + { + error: `Rate limit exceeded. Reset at ${new Date(limit.reset).toISOString()}`, + reset: limit.reset, + }, + { status: 429 }, + ); + } + } + + // Read from a clone: the handler consumes the original body itself. + let schema = fallbackSchema; + try { + const body = await req.clone().json(); + schema = resolveSchema(body?.schema); + } catch { + // Malformed body — the handler reports it. + } + + return createAIFilterHandler({ model, schema })(req); +} diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 3ecbe2c9..01323f24 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -9,6 +9,11 @@ @theme { --breakpoint-xs: 30rem; + /* Heights of the sticky app chrome. Pages that fill the viewport instead of + * growing the document (monitor logs) subtract both. */ + --spacing-app-header: 3.5rem; + --spacing-app-tabs: 41px; + /* A 0.3s hold, then a 0.3s half-turn, on repeat. The mark is symmetric under * a half turn, so every cycle ends where it visually began and the loop never * jumps. ease-in-out because a linear turn that stops dead looks broken. */ @@ -31,6 +36,10 @@ --font-sans: var(--font-inter); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-success: var(--success); + --color-warning: var(--warning); + --color-error: var(--destructive); + --color-info: var(--info); } @layer base { @@ -52,4 +61,4 @@ [data-status-preview] .rounded-full { border-radius: calc(var(--radius) * 99999999); } -} \ No newline at end of file +} diff --git a/apps/dashboard/src/components/content/billing-overlay.tsx b/apps/dashboard/src/components/content/billing-overlay.tsx index 7303642d..f520b046 100644 --- a/apps/dashboard/src/components/content/billing-overlay.tsx +++ b/apps/dashboard/src/components/content/billing-overlay.tsx @@ -21,7 +21,7 @@ export function BillingOverlay({ return (
({ from, to }); - - const presets = useMemo( - () => [ - { - id: "today", - label: "Today", - values: { - from: startOfDay(today.current), - to: endOfDay(today.current), - }, - shortcut: "t", - }, - { - id: "yesterday", - label: "Yesterday", - values: { - from: startOfDay(subDays(today.current, 1)), - to: endOfDay(subDays(today.current, 1)), - }, - shortcut: "y", - }, - { - id: "lastHour", - label: "Last hour", - values: { - from: subHours(today.current, 1), - to: today.current, - }, - shortcut: "h", - }, - { - id: "last6Hours", - label: "Last 6 hours", - values: { - from: subHours(today.current, 5), - to: today.current, - }, - shortcut: "s", - }, - { - id: "last24Hours", - label: "Last 24 hours", - values: { - from: subHours(today.current, 23), - to: today.current, - }, - shortcut: "d", - }, - { - id: "last7Days", - label: "Last 7 days", - values: { - from: subDays(today.current, 6), - to: today.current, - }, - shortcut: "w", - }, - { - id: "last14Days", - label: "Last 14 days", - values: { - from: subDays(today.current, 13), - to: today.current, - }, - shortcut: "b", - }, - ], - [today], - ); - - // instead use `range` state - const selected = presets.find((period) => { - return ( - from.getTime() === period.values.from.getTime() && - to.getTime() === period.values.to.getTime() - ); - }); - - useEffect(() => { - if (!open) { - setFrom(range.from ?? null); - setTo(range.to ?? null); - } - }, [open]); - - useEffect(() => { - const down = (e: KeyboardEvent) => { - if (!open) return; - - presets.map((preset) => { - if (preset.shortcut === e.key) { - setFrom(preset.values.from); - setTo(preset.values.to); - setRange({ from: preset.values.from, to: preset.values.to }); - } - }); - }; - document.addEventListener("keydown", down); - return () => document.removeEventListener("keydown", down); - }, [presets, open, setFrom, setTo]); - - return ( - - - - - - - - - ); -} diff --git a/apps/dashboard/src/components/data-table/response-logs/columns.tsx b/apps/dashboard/src/components/data-table/response-logs/columns.tsx index 3173d571..b8f575ff 100644 --- a/apps/dashboard/src/components/data-table/response-logs/columns.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/columns.tsx @@ -18,7 +18,7 @@ import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { TableCellRegion } from "@/components/data-table/table-cell-region"; import { getStatusCodeVariant, textColors } from "@/data/status-codes"; -type ResponseLog = RouterOutputs["tinybird"]["list"]["data"][number]; +type ResponseLog = RouterOutputs["tinybird"]["get"]["data"][number]; // export const columns: ColumnDef[] = export function getColumns( diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx deleted file mode 100644 index 2e6459d8..00000000 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import type { RouterOutputs } from "@openstatus/api"; -import { Monitor, Region, Warning, Close } from "@openstatus/icons"; -import { Button } from "@openstatus/ui/components/ui/button"; -import type { Table } from "@tanstack/react-table"; - -import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; -import { regions } from "@/data/regions"; -import { statusCodes } from "@/data/status-codes"; - -type ResponseLog = RouterOutputs["tinybird"]["list"]["data"][number]; - -export interface ResponseLogsDataTableToolbarProps { - table: Table; -} - -export function ResponseLogsDataTableToolbar({ - table, -}: ResponseLogsDataTableToolbarProps) { - const isFiltered = table.getState().columnFilters.length > 0; - - return ( -
-
- {table.getColumn("status") && ( - ({ - label: code.code.toString(), - value: code.code.toString(), - }))} - icon={Monitor} - /> - )} - {table.getColumn("region") && ( - ({ - label: region.location, - value: region.code, - }))} - icon={Region} - /> - )} - {table.getColumn("error") && ( - - )} - {isFiltered && ( - - )} -
- {/* */} -
- ); -} diff --git a/apps/dashboard/src/components/data-table/table-cell-region.tsx b/apps/dashboard/src/components/data-table/table-cell-region.tsx index 9996e7a1..3f5cfc6d 100644 --- a/apps/dashboard/src/components/data-table/table-cell-region.tsx +++ b/apps/dashboard/src/components/data-table/table-cell-region.tsx @@ -1,16 +1,25 @@ import type { PrivateLocation } from "@openstatus/db/src/schema"; import { getRegionInfo } from "@openstatus/regions"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@/lib/utils"; export function TableCellRegion({ value, privateLocations, + variant = "location", className, ...props }: React.ComponentProps<"div"> & { value: unknown; privateLocations?: PrivateLocation[]; + /** `"code"` trades the city name for the flag + region code, with the full name in a tooltip. */ + variant?: "location" | "code"; }) { if (typeof value !== "string" || value.length === 0) { return ( @@ -23,6 +32,28 @@ export function TableCellRegion({ location: privateLocations?.find((loc) => String(loc.id) === String(value)) ?.name, }); + + if (variant === "code") { + return ( + + + +
+ {" "} + {info.code} +
+
+ +

+ {info.location}{" "} + ({info.provider}) +

+
+
+
+ ); + } + return (
{info.location}{" "} diff --git a/apps/dashboard/src/components/nav/app-header.tsx b/apps/dashboard/src/components/nav/app-header.tsx index a66e29d1..6e6bf639 100644 --- a/apps/dashboard/src/components/nav/app-header.tsx +++ b/apps/dashboard/src/components/nav/app-header.tsx @@ -8,7 +8,7 @@ export function AppHeader({ return (
+