diff --git a/.gitignore b/.gitignore index 7274504c..25fa8787 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ openstatus-dev.db-wal openstatus-dev.db-shm apps/ingest-worker/.production.vars apps/ingest-worker/.dev.vars +apps/alerting-engine/tmp/ diff --git a/apps/checker/handlers/checker.go b/apps/checker/handlers/checker.go index 189eb88b..61db5c23 100644 --- a/apps/checker/handlers/checker.go +++ b/apps/checker/handlers/checker.go @@ -183,8 +183,14 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { // it's in error if not successful if isSuccessfull { data.Error = 0 + if req.DegradedAfter != 0 && res.Latency > req.DegradedAfter { + data.Body = res.Body + + } else { + data.Body = "" + + } // Small trick to avoid sending the body at the moment to TB - data.Body = "" } else { data.Error = 1 result.Error = "Error" @@ -192,91 +198,49 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { data.Assertions = assertionAsString - if req.Status == "active" { - if !isSuccessfull { - // Q: Why here we do not check if the status was previously active? - checker.UpdateStatus(ctx, checker.UpdateData{ - MonitorId: req.MonitorID, - Status: "error", - StatusCode: res.Status, - Region: h.Region, - Message: res.Error, - CronTimestamp: req.CronTimestamp, - Latency: res.Latency, - }) - } - // Check if the status is degraded - if isSuccessfull && req.DegradedAfter > 0 && res.Latency > req.DegradedAfter { - checker.UpdateStatus(ctx, checker.UpdateData{ - MonitorId: req.MonitorID, - Status: "degraded", - Region: h.Region, - StatusCode: res.Status, - CronTimestamp: req.CronTimestamp, - Latency: res.Latency, - }) - } + if !isSuccessfull && req.Status != "error" { + // Q: Why here we do not check if the status was previously active? + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "error", + StatusCode: res.Status, + Region: h.Region, + Message: res.Error, + CronTimestamp: req.CronTimestamp, + Latency: res.Latency, + }) } - - // We were in error - if req.Status == "error" { - if isSuccessfull && req.DegradedAfter > 0 && res.Latency > req.DegradedAfter { - checker.UpdateStatus(ctx, checker.UpdateData{ - MonitorId: req.MonitorID, - Status: "degraded", - Region: h.Region, - StatusCode: res.Status, - CronTimestamp: req.CronTimestamp, - Latency: res.Latency, - }) - } - - if isSuccessfull && req.DegradedAfter > 0 && res.Latency < req.DegradedAfter { - checker.UpdateStatus(ctx, checker.UpdateData{ - MonitorId: req.MonitorID, - Status: "active", - Region: h.Region, - StatusCode: res.Status, - CronTimestamp: req.CronTimestamp, - }) - } - - // This happens when we don't have a degradedAfter - if isSuccessfull && req.DegradedAfter == 0 { - checker.UpdateStatus(ctx, checker.UpdateData{ - MonitorId: req.MonitorID, - Status: "active", - Region: h.Region, - StatusCode: res.Status, - CronTimestamp: req.CronTimestamp, - Latency: res.Latency, - }) - } + // it's degraded + if isSuccessfull && req.DegradedAfter > 0 && res.Latency > req.DegradedAfter && req.Status != "degraded" { + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "degraded", + Region: h.Region, + StatusCode: res.Status, + CronTimestamp: req.CronTimestamp, + Latency: res.Latency, + }) } - - if req.Status == "degraded" { - // if we were in degraded and now we are successful, we should update the status to active - if isSuccessfull && req.DegradedAfter > 0 && res.Latency <= req.DegradedAfter { - checker.UpdateStatus(ctx, checker.UpdateData{ - MonitorId: req.MonitorID, - Status: "active", - Region: h.Region, - StatusCode: res.Status, - CronTimestamp: req.CronTimestamp, - Latency: res.Latency, - }) - } - - if !isSuccessfull { - checker.UpdateStatus(ctx, checker.UpdateData{ - MonitorId: req.MonitorID, - Status: "error", - Region: h.Region, - StatusCode: res.Status, - CronTimestamp: req.CronTimestamp, - Latency: res.Latency, - }) - } + // it's active + if isSuccessfull && req.DegradedAfter == 0 && req.Status != "active" { + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "active", + Region: h.Region, + StatusCode: res.Status, + CronTimestamp: req.CronTimestamp, + Latency: res.Latency, + }) + } + // it's active + if isSuccessfull && res.Latency < req.DegradedAfter && req.DegradedAfter != 0 && req.Status != "active" { + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "active", + Region: h.Region, + StatusCode: res.Status, + CronTimestamp: req.CronTimestamp, + }) } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { diff --git a/apps/checker/handlers/tcp.go b/apps/checker/handlers/tcp.go index 539c53da..f2c2a1f5 100644 --- a/apps/checker/handlers/tcp.go +++ b/apps/checker/handlers/tcp.go @@ -127,17 +127,17 @@ func (h Handler) TCPHandler(c *gin.Context) { JobType: "tcp", } - if req.Status == "active" && req.DegradedAfter > 0 && latency > req.DegradedAfter { + if req.DegradedAfter == 0 && req.Status != "active" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, - Status: "degraded", + Status: "active", Region: h.Region, CronTimestamp: req.CronTimestamp, Latency: latency, }) } - if req.Status == "degraded" && req.DegradedAfter > 0 && latency <= req.DegradedAfter { + if (req.DegradedAfter > 0 && latency < req.DegradedAfter) && req.Status != "active" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "active", @@ -147,27 +147,14 @@ func (h Handler) TCPHandler(c *gin.Context) { }) } - if req.Status == "error" { - if req.DegradedAfter == 0 || (req.DegradedAfter > 0 && latency < req.DegradedAfter) { - checker.UpdateStatus(ctx, checker.UpdateData{ - MonitorId: req.MonitorID, - Status: "active", - Region: h.Region, - CronTimestamp: req.CronTimestamp, - Latency: latency, - }) - } - - if req.DegradedAfter > 0 && latency > req.DegradedAfter { - checker.UpdateStatus(ctx, checker.UpdateData{ - MonitorId: req.MonitorID, - Status: "degraded", - Region: h.Region, - CronTimestamp: req.CronTimestamp, - Latency: latency, - }) - } - + 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, + }) } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { @@ -190,16 +177,14 @@ func (h Handler) TCPHandler(c *gin.Context) { }, 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, + }) - if req.Status != "error" { - checker.UpdateStatus(ctx, checker.UpdateData{ - MonitorId: req.MonitorID, - Status: "error", - Message: err.Error(), - Region: h.Region, - CronTimestamp: req.CronTimestamp, - }) - } } returnData := c.Query("data") diff --git a/apps/server/src/routes/checker/index.ts b/apps/server/src/routes/checker/index.ts index ab8812a0..348c98ee 100644 --- a/apps/server/src/routes/checker/index.ts +++ b/apps/server/src/routes/checker/index.ts @@ -2,21 +2,37 @@ import { Client } from "@upstash/qstash"; import { Hono } from "hono"; import { z } from "zod"; -import { and, db, eq, isNull, schema } from "@openstatus/db"; +import { and, count, db, eq, isNull, schema } from "@openstatus/db"; import { incidentTable, workspace } from "@openstatus/db/src/schema"; import { monitorStatusSchema, selectMonitorSchema, } from "@openstatus/db/src/schema/monitors/validation"; -import { Redis } from "@openstatus/upstash"; import { env } from "@/env"; import { checkerAudit } from "@/utils/audit-log"; import { flyRegions } from "@openstatus/db/src/schema/constants"; +import { Tinybird } from "@openstatus/tinybird"; import { triggerNotifications, upsertMonitorStatus } from "./alerting"; export const checkerRoute = new Hono(); -const redis = Redis.fromEnv(); + +const tb = new Tinybird({ token: process.env.TINY_BIRD_API_KEY || "" }); + +const payloadSchema = z.object({ + monitorId: z.string(), + message: z.string().optional(), + statusCode: z.number().optional(), + region: z.enum(flyRegions), + cronTimestamp: z.number(), + status: monitorStatusSchema, + latency: z.number().optional(), +}); + +const publishStatus = tb.buildIngestEndpoint({ + datasource: "alerts__v0", + event: payloadSchema, +}); checkerRoute.post("/updateStatus", async (c) => { const auth = c.req.header("Authorization"); @@ -26,15 +42,6 @@ checkerRoute.post("/updateStatus", async (c) => { } const json = await c.req.json(); - const payloadSchema = z.object({ - monitorId: z.string(), - message: z.string().optional(), - statusCode: z.number().optional(), - region: z.enum(flyRegions), - cronTimestamp: z.number(), - status: monitorStatusSchema, - latency: z.number().optional(), - }); const result = payloadSchema.safeParse(json); @@ -53,112 +60,41 @@ checkerRoute.post("/updateStatus", async (c) => { console.log(`📝 update monitor status ${JSON.stringify(result.data)}`); - // we check if it's an error - // If status not in 200> and <300 - // if there's no incident create one and notify - // publish event to TB + // First we upsert the monitor status + await upsertMonitorStatus({ + monitorId: monitorId, + status, + region: region, + }); + await publishStatus(result.data); - // if status is ok checked if there's an open incident - // if open incident publish incident recovered - const incident = await db + const currentMonitor = await db .select() - .from(incidentTable) + .from(schema.monitor) + .where(eq(schema.monitor.id, Number(monitorId))) + .get(); + + const monitor = selectMonitorSchema.parse(currentMonitor); + const numberOfRegions = monitor.regions.length; + + const affectedRegion = await db + .select({ count: count() }) + .from(schema.monitorStatusTable) .where( and( - eq(incidentTable.monitorId, Number(monitorId)), - isNull(incidentTable.resolvedAt), - isNull(incidentTable.acknowledgedAt), + eq(schema.monitorStatusTable.monitorId, monitor.id), + eq(schema.monitorStatusTable.status, status), ), ) .get(); - if (status === "degraded") { - // We upsert the status of the monitor - await upsertMonitorStatus({ - monitorId: monitorId, - status: "degraded", - region: region, - }); - await checkerAudit.publishAuditLog({ - id: `monitor:${monitorId}`, - action: "monitor.degraded", - targets: [{ id: monitorId, type: "monitor" }], - metadata: { region, statusCode: statusCode ?? -1 }, - }); - const currentMonitor = await db - .select() - .from(schema.monitor) - .where(eq(schema.monitor.id, Number(monitorId))) - .get(); - if (currentMonitor?.status === "active") { - const redisKey = `${monitorId}-${cronTimestamp}-degraded`; - // We add the new region to the set - await redis.sadd(redisKey, region); - // let's add an expire to the set - // We get the number of regions affected - const nbAffectedRegion = await redis.scard(redisKey); - await redis.expire(redisKey, 60 * 60 * 24); - - const monitor = selectMonitorSchema.parse(currentMonitor); - - const numberOfRegions = monitor.regions.length; - - if (nbAffectedRegion >= numberOfRegions / 2 || numberOfRegions === 1) { - await triggerNotifications({ - monitorId, - statusCode, - message, - notifType: "degraded", - cronTimestamp, - incidentId: `${cronTimestamp}`, - region, - latency, - }); - } - } + if (!affectedRegion?.count) { + return; } - // if we are in error - if (status === "error") { - // trigger alerting - await checkerAudit.publishAuditLog({ - id: `monitor:${monitorId}`, - action: "monitor.failed", - targets: [{ id: monitorId, type: "monitor" }], - metadata: { region, statusCode, message }, - }); - // We upsert the status of the monitor - await upsertMonitorStatus({ - monitorId: monitorId, - status: "error", - region: region, - }); - if (incident === undefined) { - const redisKey = `${monitorId}-${cronTimestamp}-error`; - // We add the new region to the set - await redis.sadd(redisKey, region); - // let's add an expire to the set - // We get the number of regions affected - const nbAffectedRegion = await redis.scard(redisKey); - await redis.expire(redisKey, 60 * 60 * 24); - - 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; - - console.log( - `🤓 MonitorID ${monitorId} incident current affected ${nbAffectedRegion} total region ${numberOfRegions}`, - ); - // If the number of affected regions is greater than half of the total region, we trigger the alerting - // 4 of 6 monitor need to fail to trigger an alerting - if (nbAffectedRegion >= numberOfRegions / 2 || numberOfRegions === 1) { - // let's refetch the incident to avoid race condition + if (affectedRegion.count >= numberOfRegions / 2 || numberOfRegions === 1) { + switch (status) { + case "active": { const incident = await db .select() .from(incidentTable) @@ -167,12 +103,81 @@ checkerRoute.post("/updateStatus", async (c) => { eq(incidentTable.monitorId, Number(monitorId)), isNull(incidentTable.resolvedAt), isNull(incidentTable.acknowledgedAt), - eq(incidentTable.startedAt, new Date(cronTimestamp)), ), ) .get(); - if (incident === undefined) { + if (!incident) { + // it was just a single failure not a proper incident + return; + } + if (incident?.resolvedAt) { + // incident is already resolved + return; + } + + console.log(`🤓 recovering incident ${incident.id}`); + await db + .update(incidentTable) + .set({ + resolvedAt: new Date(cronTimestamp), + autoResolved: true, + }) + .where(eq(incidentTable.id, incident.id)) + .run(); + + await db + .update(schema.monitor) + .set({ status: "active" }) + .where(eq(schema.monitor.id, monitor.id)); + + await triggerNotifications({ + monitorId, + statusCode, + message, + notifType: "recovery", + cronTimestamp, + incidentId: `${cronTimestamp}`, + }); + + await checkerAudit.publishAuditLog({ + id: `monitor:${monitorId}`, + action: "monitor.recovered", + targets: [{ id: monitorId, type: "monitor" }], + metadata: { region: region, statusCode: statusCode ?? -1 }, + }); + + break; + } + case "degraded": + if (monitor.status !== "degraded") { + console.log( + `🔄 update monitorStatus ${monitor.id} status: DEGRADED}`, + ); + await db + .update(schema.monitor) + .set({ status: "degraded" }) + .where(eq(schema.monitor.id, monitor.id)); + // figure how to send the notification once + await triggerNotifications({ + monitorId, + statusCode, + message, + notifType: "degraded", + cronTimestamp, + latency, + incidentId: `${cronTimestamp}`, + }); + } + await checkerAudit.publishAuditLog({ + id: `monitor:${monitorId}`, + action: "monitor.degraded", + targets: [{ id: monitorId, type: "monitor" }], + metadata: { region, statusCode: statusCode ?? -1 }, + }); + break; + case "error": + try { const newIncident = await db .insert(incidentTable) .values({ @@ -180,160 +185,58 @@ checkerRoute.post("/updateStatus", async (c) => { workspaceId: monitor.workspaceId, startedAt: new Date(cronTimestamp), }) - .onConflictDoNothing() .returning(); + if (!newIncident[0].id) { + return; + } await triggerNotifications({ monitorId, statusCode, message, notifType: "alert", cronTimestamp, - incidentId: newIncident.length - ? String(newIncident[0]?.id) - : `${cronTimestamp}`, - region, - latency, + incidentId: String(newIncident[0].id), }); - if (newIncident.length > 0) { - const monitor = await db - .select({ - url: schema.monitor.url, - jobType: schema.monitor.jobType, - workspaceId: schema.monitor.workspaceId, - }) - .from(schema.monitor) - .where(eq(schema.monitor.id, Number(monitorId))) - .get(); - if (monitor && monitor.jobType === "http" && monitor.workspaceId) { - const currentWorkspace = await db - .select() - .from(workspace) - .where(eq(workspace.id, monitor.workspaceId)) - .get(); - if ( - !!currentWorkspace?.plan && - currentWorkspace?.plan !== "free" - ) { - await triggerScreenshot({ - data: { - url: monitor.url, - incidentId: newIncident[0].id, - kind: "incident", - }, - }); - } - } - } - } - } - } - } - // When the status is ok - if (status === "active") { - await upsertMonitorStatus({ - monitorId: monitorId, - status: "active", - region: region, - }); - - await checkerAudit.publishAuditLog({ - id: `monitor:${monitorId}`, - action: "monitor.recovered", - targets: [{ id: monitorId, type: "monitor" }], - metadata: { region: region, statusCode: statusCode ?? -1 }, - }); - - if (incident) { - const redisKey = `${monitorId}-${incident.id}-resolved`; - // // We add the new region to the set - await redis.sadd(redisKey, region); - // // let's add an expire to the set - // // We get the number of regions affected - const nbAffectedRegion = await redis.scard(redisKey); - await redis.expire(redisKey, 60 * 60 * 24); - - 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; - - console.log( - `🤓 MonitorId ${monitorId} recovering incident current ${nbAffectedRegion} total region ${numberOfRegions}`, - ); - // // If the number of affected regions is greater than half of the total region, we trigger the alerting - // // 4 of 6 monitor need to fail to trigger an alerting - if (nbAffectedRegion >= numberOfRegions / 2 || numberOfRegions === 1) { - const incident = await db - .select() - .from(incidentTable) - .where( - and( - eq(incidentTable.monitorId, Number(monitorId)), - isNull(incidentTable.resolvedAt), - isNull(incidentTable.acknowledgedAt), - ), - ) - .get(); - if (incident) { - console.log(`🤓 recovering incident ${incident.id}`); await db - .update(incidentTable) - .set({ - resolvedAt: new Date(cronTimestamp), - autoResolved: true, - }) - .where(eq(incidentTable.id, incident.id)) - .run(); + .update(schema.monitor) + .set({ status: "error" }) + .where(eq(schema.monitor.id, monitor.id)); - await triggerNotifications({ - monitorId, - statusCode, - message, - notifType: "recovery", - cronTimestamp, - incidentId: String(incident.id), - region, - latency, - }); - - const monitor = await db - .select({ - url: schema.monitor.url, - jobType: schema.monitor.jobType, - workspaceId: schema.monitor.workspaceId, - }) - .from(schema.monitor) - .where(eq(schema.monitor.id, Number(monitorId))) - .get(); if (monitor && monitor.jobType === "http" && monitor.workspaceId) { const currentWorkspace = await db .select() .from(workspace) .where(eq(workspace.id, monitor.workspaceId)) .get(); - if (!!currentWorkspace?.plan && currentWorkspace?.plan !== "free") { await triggerScreenshot({ data: { url: monitor.url, - incidentId: incident.id, - kind: "recovery", + incidentId: newIncident[0].id, + kind: "incident", }, }); } } + await checkerAudit.publishAuditLog({ + id: `monitor:${monitorId}`, + action: "monitor.failed", + targets: [{ id: monitorId, type: "monitor" }], + metadata: { region, statusCode, message }, + }); + } catch { + console.log("incident was already created"); } - } + break; + default: + console.log("should not happen"); + break; } } + // if we are in error return c.text("Ok", 200); }); diff --git a/apps/workflows/.dockerignore b/apps/workflows/.dockerignore index 8189e7b5..3d17e378 100644 --- a/apps/workflows/.dockerignore +++ b/apps/workflows/.dockerignore @@ -9,6 +9,4 @@ node_modules /packages/analytics /packages/api /packages/error -/packages/notifications -/packages/tinybird /packages/tracker diff --git a/apps/workflows/.env.test b/apps/workflows/.env.test new file mode 100644 index 00000000..b315cfb5 --- /dev/null +++ b/apps/workflows/.env.test @@ -0,0 +1,15 @@ +DATABASE_URL=http://127.0.0.1:8080 +DATABASE_AUTH_TOKEN= +NODE_ENV=test +UNKEY_TOKEN=test +TINY_BIRD_API_KEY=test +UPSTASH_REDIS_REST_URL=test +UPSTASH_REDIS_REST_TOKEN=test +QSTASH_CURRENT_SIGNING_KEY=test +QSTASH_NEXT_SIGNING_KEY=test +FLY_REGION=ams +RESEND_API_KEY=test +SQLD_HTTP_AUTH=basic:token +SCREENSHOT_SERVICE_URL=http://your.endpoint +OPENPANEL_CLIENT_ID=test +OPENPANEL_CLIENT_SECRET=test diff --git a/apps/workflows/Dockerfile b/apps/workflows/Dockerfile index 1c60a16d..386f582a 100644 --- a/apps/workflows/Dockerfile +++ b/apps/workflows/Dockerfile @@ -11,8 +11,15 @@ RUN \ --mount=type=bind,target=packages/assertions/package.json,source=packages/assertions/package.json \ --mount=type=bind,target=packages/db/package.json,source=packages/db/package.json \ --mount=type=bind,target=packages/emails/package.json,source=packages/emails/package.json \ + --mount=type=bind,target=packages/notifications/discord/package.json,source=packages/notifications/discord/package.json \ + --mount=type=bind,target=packages/notifications/email/package.json,source=packages/notifications/email/package.json \ + --mount=type=bind,target=packages/notifications/opsgenie/package.json,source=packages/notifications/opsgenie/package.json \ + --mount=type=bind,target=packages/notifications/pagerduty/package.json,source=packages/notifications/pagerduty/package.json \ + --mount=type=bind,target=packages/notifications/slack/package.json,source=packages/notifications/slack/package.json \ + --mount=type=bind,target=packages/notifications/twillio-sms/package.json,source=packages/notifications/twillio-sms/package.json \ --mount=type=bind,target=packages/utils/package.json,source=packages/utils/package.json \ --mount=type=bind,target=packages/tsconfig/package.json,source=packages/tsconfig/package.json \ + --mount=type=bind,target=packages/tinybird/package.json,source=packages/tinybird/package.json \ --mount=type=bind,target=packages/upstash/package.json,source=packages/upstash/package.json \ --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \ bun install --production --ignore-scripts --frozen-lockfile --verbose diff --git a/apps/workflows/dofigen.lock b/apps/workflows/dofigen.lock index 9d4e6d4b..6c826409 100644 --- a/apps/workflows/dofigen.lock +++ b/apps/workflows/dofigen.lock @@ -8,10 +8,25 @@ effective: | - /packages/analytics - /packages/api - /packages/error - - /packages/notifications - - /packages/tinybird - /packages/tracker builders: + build: + fromImage: + path: oven/bun + digest: sha256:e9382fda475d1ff0a939e925db3ca5a91b3b26cd71f23410dc5363262384bbc2 + workdir: /app/apps/workflows + env: + NODE_ENV: production + copy: + - paths: + - . + target: /app/ + - fromBuilder: install + paths: + - /app/node_modules + target: /app/node_modules + run: + - bun build --compile --sourcemap --format=cjs src/index.ts --outfile=app install: fromImage: path: oven/bun @@ -32,29 +47,26 @@ effective: | source: packages/db/package.json - target: packages/emails/package.json source: packages/emails/package.json + - target: packages/notifications/discord/package.json + source: packages/notifications/discord/package.json + - target: packages/notifications/email/package.json + source: packages/notifications/email/package.json + - target: packages/notifications/opsgenie/package.json + source: packages/notifications/opsgenie/package.json + - target: packages/notifications/pagerduty/package.json + source: packages/notifications/pagerduty/package.json + - target: packages/notifications/slack/package.json + source: packages/notifications/slack/package.json + - target: packages/notifications/twillio-sms/package.json + source: packages/notifications/twillio-sms/package.json - target: packages/utils/package.json source: packages/utils/package.json - target: packages/tsconfig/package.json source: packages/tsconfig/package.json + - target: packages/tinybird/package.json + source: packages/tinybird/package.json - target: packages/upstash/package.json source: packages/upstash/package.json - build: - fromImage: - path: oven/bun - digest: sha256:e9382fda475d1ff0a939e925db3ca5a91b3b26cd71f23410dc5363262384bbc2 - workdir: /app/apps/workflows - env: - NODE_ENV: production - copy: - - paths: - - . - target: /app/ - - fromBuilder: install - paths: - - /app/node_modules - target: /app/node_modules - run: - - bun build --compile --sourcemap --format=cjs src/index.ts --outfile=app fromImage: path: debian digest: sha256:e831d9a884d63734fe3dd9c491ed9a5a3d4c6a6d32c5b14f2067357c49b0b7e1 @@ -70,17 +82,17 @@ effective: | - port: 3000 images: registry.hub.docker.com:443: - library: - debian: - bullseye-slim: - digest: sha256:e831d9a884d63734fe3dd9c491ed9a5a3d4c6a6d32c5b14f2067357c49b0b7e1 oven: bun: latest: digest: sha256:e9382fda475d1ff0a939e925db3ca5a91b3b26cd71f23410dc5363262384bbc2 + library: + debian: + bullseye-slim: + digest: sha256:e831d9a884d63734fe3dd9c491ed9a5a3d4c6a6d32c5b14f2067357c49b0b7e1 resources: dofigen.yml: - hash: 48a9d086b070600575e9867af0c63f2bbcfd301c867121e671fc771ae50f34dc + hash: 89efc794b70865f5718e42302021382f7ed0c0ea9459a7bd9a8047cdd28757ae content: | ignore: - node_modules @@ -91,8 +103,6 @@ resources: - /packages/analytics - /packages/api - /packages/error - - /packages/notifications - - /packages/tinybird - /packages/tracker builders: install: @@ -105,8 +115,15 @@ resources: - packages/assertions/package.json - packages/db/package.json - packages/emails/package.json + - packages/notifications/discord/package.json + - packages/notifications/email/package.json + - packages/notifications/opsgenie/package.json + - packages/notifications/pagerduty/package.json + - packages/notifications/slack/package.json + - packages/notifications/twillio-sms/package.json - packages/utils/package.json - packages/tsconfig/package.json + - packages/tinybird/package.json - packages/upstash/package.json # Install dependencies diff --git a/apps/workflows/dofigen.yml b/apps/workflows/dofigen.yml index 90d7f5e7..d78b81c0 100644 --- a/apps/workflows/dofigen.yml +++ b/apps/workflows/dofigen.yml @@ -7,8 +7,6 @@ ignore: - /packages/analytics - /packages/api - /packages/error - - /packages/notifications - - /packages/tinybird - /packages/tracker builders: install: @@ -21,8 +19,15 @@ builders: - packages/assertions/package.json - packages/db/package.json - packages/emails/package.json + - packages/notifications/discord/package.json + - packages/notifications/email/package.json + - packages/notifications/opsgenie/package.json + - packages/notifications/pagerduty/package.json + - packages/notifications/slack/package.json + - packages/notifications/twillio-sms/package.json - packages/utils/package.json - packages/tsconfig/package.json + - packages/tinybird/package.json - packages/upstash/package.json # Install dependencies diff --git a/apps/workflows/package.json b/apps/workflows/package.json index 1bf17817..6358e7cf 100644 --- a/apps/workflows/package.json +++ b/apps/workflows/package.json @@ -9,8 +9,16 @@ "@google-cloud/tasks": "4.0.1", "@openstatus/db": "workspace:*", "@openstatus/emails": "workspace:*", + "@openstatus/notification-discord": "workspace:*", + "@openstatus/notification-emails": "workspace:*", + "@openstatus/notification-opsgenie": "workspace:*", + "@openstatus/notification-pagerduty": "workspace:*", + "@openstatus/notification-slack": "workspace:*", + "@openstatus/notification-twillio-sms": "workspace:*", + "@openstatus/tinybird": "workspace:*", "@openstatus/upstash": "workspace:*", "@openstatus/utils": "workspace:*", + "@upstash/qstash": "2.6.2", "hono": "4.5.3", "limiter": "^3.0.0", "zod": "3.23.8" diff --git a/apps/workflows/src/checker/alerting.test.ts b/apps/workflows/src/checker/alerting.test.ts new file mode 100644 index 00000000..c03f0fe4 --- /dev/null +++ b/apps/workflows/src/checker/alerting.test.ts @@ -0,0 +1,22 @@ +import { expect, mock, test } from "bun:test"; + +import { triggerNotifications } from "./alerting"; + +test.todo("should send email notification", async () => { + const fn = mock(() => {}); + mock.module("./utils.ts", () => { + return { + providerToFunction: { + email: fn, + }, + }; + }); + await triggerNotifications({ + monitorId: "1", + statusCode: 400, + notifType: "alert", + cronTimestamp: 123456, + incidentId: "1", + }); + expect(fn).toHaveBeenCalled(); +}); diff --git a/apps/workflows/src/checker/alerting.ts b/apps/workflows/src/checker/alerting.ts new file mode 100644 index 00000000..467074b2 --- /dev/null +++ b/apps/workflows/src/checker/alerting.ts @@ -0,0 +1,123 @@ +import { db, eq, schema } from "@openstatus/db"; +import type { MonitorStatus } from "@openstatus/db/src/schema"; +import { + selectMonitorSchema, + selectNotificationSchema, +} from "@openstatus/db/src/schema"; + +import type { + MonitorFlyRegion, + Region, +} from "@openstatus/db/src/schema/constants"; +import { checkerAudit } from "../utils/audit-log"; +import { providerToFunction } from "./utils"; + +export const triggerNotifications = async ({ + monitorId, + statusCode, + message, + notifType, + cronTimestamp, + incidentId, + region, + latency, +}: { + monitorId: string; + statusCode?: number; + message?: string; + notifType: "alert" | "recovery" | "degraded"; + cronTimestamp: number; + incidentId: string; + region?: Region; + latency?: number; +}) => { + console.log(`💌 triggerAlerting for ${monitorId}`); + const notifications = await db + .select() + .from(schema.notificationsToMonitors) + .innerJoin( + schema.notification, + eq(schema.notification.id, schema.notificationsToMonitors.notificationId), + ) + .innerJoin( + schema.monitor, + eq(schema.monitor.id, schema.notificationsToMonitors.monitorId), + ) + .where(eq(schema.monitor.id, Number(monitorId))) + .all(); + for (const notif of notifications) { + console.log( + `💌 sending notification for ${monitorId} and chanel ${notif.notification.provider} for ${notifType}`, + ); + const monitor = selectMonitorSchema.parse(notif.monitor); + switch (notifType) { + case "alert": + await providerToFunction[notif.notification.provider].sendAlert({ + monitor, + notification: selectNotificationSchema.parse(notif.notification), + statusCode, + message, + incidentId, + cronTimestamp, + region, + latency, + }); + break; + case "recovery": + await providerToFunction[notif.notification.provider].sendRecovery({ + monitor, + notification: selectNotificationSchema.parse(notif.notification), + statusCode, + message, + incidentId, + cronTimestamp, + region, + latency, + }); + break; + case "degraded": + await providerToFunction[notif.notification.provider].sendDegraded({ + monitor, + notification: selectNotificationSchema.parse(notif.notification), + statusCode, + message, + cronTimestamp, + region, + latency, + }); + break; + } + // ALPHA + await checkerAudit.publishAuditLog({ + id: `monitor:${monitorId}`, + action: "notification.sent", + targets: [{ id: monitorId, type: "monitor" }], + metadata: { provider: notif.notification.provider }, + }); + // + } +}; + +export const upsertMonitorStatus = async ({ + monitorId, + status, + region, +}: { + monitorId: string; + status: MonitorStatus; + region: MonitorFlyRegion; +}) => { + 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(); + console.log(`📈 upsertMonitorStatus for ${monitorId} in region ${region}`); + console.log(`🤔 upsert monitor ${JSON.stringify(newData)}`); +}; diff --git a/apps/workflows/src/checker/index.ts b/apps/workflows/src/checker/index.ts new file mode 100644 index 00000000..92388bf5 --- /dev/null +++ b/apps/workflows/src/checker/index.ts @@ -0,0 +1,272 @@ +import { Client } from "@upstash/qstash"; + +import { Hono } from "hono"; +import { z } from "zod"; + +import { and, count, db, eq, isNull, schema } from "@openstatus/db"; +import { incidentTable, workspace } from "@openstatus/db/src/schema"; +import { + monitorStatusSchema, + selectMonitorSchema, +} from "@openstatus/db/src/schema/monitors/validation"; + +import { flyRegions } from "@openstatus/db/src/schema/constants"; +import { Tinybird } from "@openstatus/tinybird"; +import { env } from "../env"; +import { checkerAudit } from "../utils/audit-log"; +import { triggerNotifications, upsertMonitorStatus } from "./alerting"; + +export const checkerRoute = new Hono(); + +const tb = new Tinybird({ token: env().TINY_BIRD_API_KEY }); + +const payloadSchema = z.object({ + monitorId: z.string(), + message: z.string().optional(), + statusCode: z.number().optional(), + region: z.enum(flyRegions), + cronTimestamp: z.number(), + status: monitorStatusSchema, + latency: z.number().optional(), +}); + +const publishStatus = tb.buildIngestEndpoint({ + datasource: "alerts__v0", + event: payloadSchema, +}); + +checkerRoute.post("/updateStatus", async (c) => { + const auth = c.req.header("Authorization"); + if (auth !== `Basic ${env().CRON_SECRET}`) { + console.error("Unauthorized"); + return c.text("Unauthorized", 401); + } + + const json = await c.req.json(); + + const result = payloadSchema.safeParse(json); + + if (!result.success) { + return c.text("Unprocessable Entity", 422); + } + const { + monitorId, + message, + region, + statusCode, + cronTimestamp, + status, + latency, + } = result.data; + + console.log(`📝 update monitor status ${JSON.stringify(result.data)}`); + + // First we upsert the monitor status + await upsertMonitorStatus({ + monitorId: monitorId, + status, + region: region, + }); + await publishStatus(result.data); + + 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; + + const affectedRegion = await db + .select({ count: count() }) + .from(schema.monitorStatusTable) + .where( + and( + eq(schema.monitorStatusTable.monitorId, monitor.id), + eq(schema.monitorStatusTable.status, status), + ), + ) + .get(); + + if (!affectedRegion?.count) { + return; + } + + if (affectedRegion.count >= numberOfRegions / 2 || numberOfRegions === 1) { + switch (status) { + case "active": { + const incident = await db + .select() + .from(incidentTable) + .where( + and( + eq(incidentTable.monitorId, Number(monitorId)), + isNull(incidentTable.resolvedAt), + isNull(incidentTable.acknowledgedAt), + ), + ) + .get(); + + if (!incident) { + // it was just a single failure not a proper incident + return; + } + if (incident?.resolvedAt) { + // incident is already resolved + return; + } + + console.log(`🤓 recovering incident ${incident.id}`); + await db + .update(incidentTable) + .set({ + resolvedAt: new Date(cronTimestamp), + autoResolved: true, + }) + .where(eq(incidentTable.id, incident.id)) + .run(); + + await db + .update(schema.monitor) + .set({ status: "active" }) + .where(eq(schema.monitor.id, monitor.id)); + + await triggerNotifications({ + monitorId, + statusCode, + message, + notifType: "recovery", + cronTimestamp, + incidentId: `${cronTimestamp}`, + }); + + await checkerAudit.publishAuditLog({ + id: `monitor:${monitorId}`, + action: "monitor.recovered", + targets: [{ id: monitorId, type: "monitor" }], + metadata: { region: region, statusCode: statusCode ?? -1 }, + }); + + break; + } + case "degraded": + if (monitor.status !== "degraded") { + console.log( + `🔄 update monitorStatus ${monitor.id} status: DEGRADED}`, + ); + await db + .update(schema.monitor) + .set({ status: "degraded" }) + .where(eq(schema.monitor.id, monitor.id)); + // figure how to send the notification once + await triggerNotifications({ + monitorId, + statusCode, + message, + notifType: "degraded", + cronTimestamp, + latency, + incidentId: `${cronTimestamp}`, + }); + } + await checkerAudit.publishAuditLog({ + id: `monitor:${monitorId}`, + action: "monitor.degraded", + targets: [{ id: monitorId, type: "monitor" }], + metadata: { region, statusCode: statusCode ?? -1 }, + }); + break; + case "error": + try { + const newIncident = await db + .insert(incidentTable) + .values({ + monitorId: Number(monitorId), + workspaceId: monitor.workspaceId, + startedAt: new Date(cronTimestamp), + }) + .returning(); + + if (!newIncident[0].id) { + return; + } + await triggerNotifications({ + monitorId, + statusCode, + message, + notifType: "alert", + cronTimestamp, + incidentId: String(newIncident[0].id), + }); + + await db + .update(schema.monitor) + .set({ status: "error" }) + .where(eq(schema.monitor.id, monitor.id)); + + if (monitor && monitor.jobType === "http" && monitor.workspaceId) { + const currentWorkspace = await db + .select() + .from(workspace) + .where(eq(workspace.id, monitor.workspaceId)) + .get(); + if (!!currentWorkspace?.plan && currentWorkspace?.plan !== "free") { + await triggerScreenshot({ + data: { + url: monitor.url, + incidentId: newIncident[0].id, + kind: "incident", + }, + }); + } + } + await checkerAudit.publishAuditLog({ + id: `monitor:${monitorId}`, + action: "monitor.failed", + targets: [{ id: monitorId, type: "monitor" }], + metadata: { region, statusCode, message }, + }); + } catch { + console.log("incident was already created"); + } + break; + default: + console.log("should not happen"); + break; + } + } + + // if we are in error + return c.text("Ok", 200); +}); + +const payload = z.object({ + url: z.string().url(), + incidentId: z.number(), + kind: z.enum(["incident", "recovery"]), +}); + +const triggerScreenshot = async ({ + data, +}: { + data: z.infer; +}) => { + console.log(` 📸 taking screenshot for incident ${data.incidentId}`); + + const client = new Client({ token: env().QSTASH_TOKEN }); + + await client.publishJSON({ + url: env().SCREENSHOT_SERVICE_URL, + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": `Basic ${env().CRON_SECRET}`, + }, + body: { + url: data.url, + incidentId: data.incidentId, + kind: data.kind, + }, + }); +}; diff --git a/apps/workflows/src/checker/utils.ts b/apps/workflows/src/checker/utils.ts new file mode 100644 index 00000000..be6d123d --- /dev/null +++ b/apps/workflows/src/checker/utils.ts @@ -0,0 +1,97 @@ +import type { + Monitor, + Notification, + NotificationProvider, +} from "@openstatus/db/src/schema"; +import { + sendAlert as sendDiscordAlert, + sendDegraded as sendDiscordDegraded, + sendRecovery as sendDiscordRecovery, +} from "@openstatus/notification-discord"; +import { + sendAlert as sendEmailAlert, + sendDegraded as sendEmailDegraded, + sendRecovery as sendEmailRecovery, +} from "@openstatus/notification-emails"; +import { + sendAlert as sendSlackAlert, + sendDegraded as sendSlackDegraded, + sendRecovery as sendSlackRecovery, +} from "@openstatus/notification-slack"; +import { + sendAlert as sendSmsAlert, + sendDegraded as sendSmsDegraded, + sendRecovery as sendSmsRecovery, +} from "@openstatus/notification-twillio-sms"; + +import { + sendDegraded as sendPagerDutyDegraded, + sendRecovery as sendPagerDutyRecovery, + sendAlert as sendPagerdutyAlert, +} from "@openstatus/notification-pagerduty"; + +import type { Region } from "@openstatus/db/src/schema/constants"; +import { + sendAlert as sendOpsGenieAlert, + sendDegraded as sendOpsGenieDegraded, + sendRecovery as sendOpsGenieRecovery, +} from "@openstatus/notification-opsgenie"; + +type SendNotification = ({ + monitor, + notification, + statusCode, + message, + incidentId, + cronTimestamp, + latency, + region, +}: { + monitor: Monitor; + notification: Notification; + statusCode?: number; + message?: string; + incidentId?: string; + cronTimestamp: number; + latency?: number; + region?: Region; +}) => Promise; + +type Notif = { + sendAlert: SendNotification; + sendRecovery: SendNotification; + sendDegraded: SendNotification; +}; + +export const providerToFunction = { + email: { + sendAlert: sendEmailAlert, + sendRecovery: sendEmailRecovery, + sendDegraded: sendEmailDegraded, + }, + slack: { + sendAlert: sendSlackAlert, + sendRecovery: sendSlackRecovery, + sendDegraded: sendSlackDegraded, + }, + discord: { + sendAlert: sendDiscordAlert, + sendRecovery: sendDiscordRecovery, + sendDegraded: sendDiscordDegraded, + }, + sms: { + sendAlert: sendSmsAlert, + sendRecovery: sendSmsRecovery, + sendDegraded: sendSmsDegraded, + }, + opsgenie: { + sendAlert: sendOpsGenieAlert, + sendRecovery: sendOpsGenieRecovery, + sendDegraded: sendOpsGenieDegraded, + }, + pagerduty: { + sendAlert: sendPagerdutyAlert, + sendRecovery: sendPagerDutyRecovery, + sendDegraded: sendPagerDutyDegraded, + }, +} satisfies Record; diff --git a/apps/workflows/src/env.ts b/apps/workflows/src/env.ts index ebbcd300..d8153977 100644 --- a/apps/workflows/src/env.ts +++ b/apps/workflows/src/env.ts @@ -15,5 +15,9 @@ export const env = () => DATABASE_AUTH_TOKEN: z.string().default(""), RESEND_API_KEY: z.string().default(""), TINY_BIRD_API_KEY: z.string().default(""), + QSTASH_TOKEN: z.string().default(""), + SCREENSHOT_SERVICE_URL: z.string().default(""), + TWILLIO_AUTH_TOKEN: z.string().default(""), + TWILLIO_ACCOUNT_ID: z.string().default(""), }) .parse(process.env); diff --git a/apps/workflows/src/index.ts b/apps/workflows/src/index.ts index a58f570d..58b14ac0 100644 --- a/apps/workflows/src/index.ts +++ b/apps/workflows/src/index.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { showRoutes } from "hono/dev"; import { logger } from "hono/logger"; +import { checkerRoute } from "./checker"; import { cronRouter } from "./cron"; import { env } from "./env"; @@ -26,6 +27,8 @@ if (NODE_ENV === "development") { showRoutes(app, { verbose: true, colorize: true }); } +app.route("/", checkerRoute); + console.log(`Starting server on port ${PORT}`); const server = { port: PORT, fetch: app.fetch }; diff --git a/apps/workflows/src/utils/audit-log.ts b/apps/workflows/src/utils/audit-log.ts new file mode 100644 index 00000000..6ee3bdd8 --- /dev/null +++ b/apps/workflows/src/utils/audit-log.ts @@ -0,0 +1,7 @@ +import { AuditLog, Tinybird } from "@openstatus/tinybird"; + +import { env } from "../env"; + +const tb = new Tinybird({ token: env().TINY_BIRD_API_KEY }); + +export const checkerAudit = new AuditLog({ tb }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7d8e8f3..8eff5339 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,7 +95,7 @@ importers: version: 2.6.2 drizzle-orm: specifier: 0.35.3 - version: 0.35.3(@cloudflare/workers-types@4.20241230.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.5))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.7.0)(bun-types@1.2.4)(react@19.0.0) + version: 0.35.3(@cloudflare/workers-types@4.20250303.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.5))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.7.0)(bun-types@1.2.5)(react@19.0.0) hono: specifier: 4.5.3 version: 4.5.3 @@ -171,7 +171,7 @@ importers: version: link:../../packages/utils '@scalar/hono-api-reference': specifier: 0.5.131 - version: 0.5.131(postcss@8.5.2)(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)))(typescript@5.7.2) + version: 0.5.131(postcss@8.5.2)(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)))(typescript@5.7.2) '@t3-oss/env-core': specifier: 0.7.1 version: 0.7.1(typescript@5.7.2)(zod@3.23.8) @@ -489,12 +489,36 @@ importers: '@openstatus/emails': specifier: workspace:* version: link:../../packages/emails + '@openstatus/notification-discord': + specifier: workspace:* + version: link:../../packages/notifications/discord + '@openstatus/notification-emails': + specifier: workspace:* + version: link:../../packages/notifications/email + '@openstatus/notification-opsgenie': + specifier: workspace:* + version: link:../../packages/notifications/opsgenie + '@openstatus/notification-pagerduty': + specifier: workspace:* + version: link:../../packages/notifications/pagerduty + '@openstatus/notification-slack': + specifier: workspace:* + version: link:../../packages/notifications/slack + '@openstatus/notification-twillio-sms': + specifier: workspace:* + version: link:../../packages/notifications/twillio-sms + '@openstatus/tinybird': + specifier: workspace:* + version: link:../../packages/tinybird '@openstatus/upstash': specifier: workspace:* version: link:../../packages/upstash '@openstatus/utils': specifier: workspace:* version: link:../../packages/utils + '@upstash/qstash': + specifier: 2.6.2 + version: 2.6.2 hono: specifier: 4.5.3 version: 4.5.3 @@ -510,7 +534,7 @@ importers: version: link:../../packages/tsconfig '@types/bun': specifier: latest - version: 1.2.4 + version: 1.2.5 packages/analytics: dependencies: @@ -621,10 +645,10 @@ importers: version: 0.7.0(typescript@5.6.2)(zod@3.23.8) drizzle-orm: specifier: 0.35.3 - version: 0.35.3(@cloudflare/workers-types@4.20241230.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.4.0)(bun-types@1.2.4)(react@19.0.0) + version: 0.35.3(@cloudflare/workers-types@4.20250303.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.4.0)(bun-types@1.2.5)(react@19.0.0) drizzle-zod: specifier: 0.5.1 - version: 0.5.1(drizzle-orm@0.35.3(@cloudflare/workers-types@4.20241230.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.4.0)(bun-types@1.2.4)(react@19.0.0))(zod@3.23.8) + version: 0.5.1(drizzle-orm@0.35.3(@cloudflare/workers-types@4.20250303.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.4.0)(bun-types@1.2.5)(react@19.0.0))(zod@3.23.8) zod: specifier: 3.23.8 version: 3.23.8 @@ -1712,8 +1736,8 @@ packages: '@chronark/zod-bird@0.3.6': resolution: {integrity: sha512-hE8kCGLJK5ncH8F7uPaqPiOOqo68vUI66nusg7HO5X9BcyN8lXfeQliu6Ou1kOSq95OYshf9nB2fk2+LEvF4ng==} - '@cloudflare/workers-types@4.20241230.0': - resolution: {integrity: sha512-dtLD4jY35Lb750cCVyO1i/eIfdZJg2Z0i+B1RYX6BVeRPlgaHx/H18ImKAkYmy0g09Ow8R2jZy3hIxMgXun0WQ==} + '@cloudflare/workers-types@4.20250303.0': + resolution: {integrity: sha512-O7F7nRT4bbmwHf3gkRBLfJ7R6vHIJ/oZzWdby6obOiw2yavUfp/AIwS7aO2POu5Cv8+h3TXS3oHs3kKCZLraUA==} '@codemirror/autocomplete@6.17.0': resolution: {integrity: sha512-fdfj6e6ZxZf8yrkMHUSJJir7OJkHkZKaOZGzLWIYp2PZ3jd+d+UjG8zVPqJF6d3bKxkhvXTPan/UZ1t7Bqm0gA==} @@ -4884,8 +4908,8 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} - '@types/bun@1.2.4': - resolution: {integrity: sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA==} + '@types/bun@1.2.5': + resolution: {integrity: sha512-w2OZTzrZTVtbnJew1pdFmgV99H0/L+Pvw+z1P67HaR18MHOzYnTYOi6qzErhK8HyT+DB782ADVPPE92Xu2/Opg==} '@types/caseless@0.12.4': resolution: {integrity: sha512-2in/lrHRNmDvHPgyormtEralhPcN3An1gLjJzj2Bw145VBxkQ75JEXW6CTdMAwShiHQcYsl2d10IjQSdJSJz4g==} @@ -5630,8 +5654,8 @@ packages: bun-types@1.0.8: resolution: {integrity: sha512-2dNB+dBwAcFW7RSd4y5vKycRjouKVklSwPk4EjBKWvcMYUBOqZGGNzV7+b2tfKBG3BeRXnozbnegVKR1azuATg==} - bun-types@1.2.4: - resolution: {integrity: sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q==} + bun-types@1.2.5: + resolution: {integrity: sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg==} bundle-require@4.0.2: resolution: {integrity: sha512-jwzPOChofl67PSTW2SGubV9HBQAhhR2i6nskiOThauo9dzwDUgOWQScFVaJkjEfYX+UXiD+LEx8EblQMc2wIag==} @@ -6966,6 +6990,10 @@ packages: resolution: {integrity: sha512-r26WwwbKD3BAYdfB294knNnegNda7VfV1tVn66D9Kvl9WQTdrR+5eKdoeaQNHQcC3Gr0KBikzAtjd6VsRGVSaw==} engines: {node: '>=16.0.0'} + hono@4.7.4: + resolution: {integrity: sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -11339,7 +11367,7 @@ snapshots: dependencies: zod: 3.23.8 - '@cloudflare/workers-types@4.20241230.0': + '@cloudflare/workers-types@4.20250303.0': optional: true '@codemirror/autocomplete@6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.28.4)(@lezer/common@1.2.1)': @@ -12022,9 +12050,9 @@ snapshots: protobufjs: 7.2.5 yargs: 17.7.2 - '@headlessui/tailwindcss@0.2.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)))': + '@headlessui/tailwindcss@0.2.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)))': dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)) + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) '@headlessui/vue@1.7.22(vue@3.4.31(typescript@5.7.2))': dependencies: @@ -13655,9 +13683,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.34.8': optional: true - '@scalar/api-client@2.0.45(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)))(typescript@5.7.2)': + '@scalar/api-client@2.0.45(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)))(typescript@5.7.2)': dependencies: - '@headlessui/tailwindcss': 0.2.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2))) + '@headlessui/tailwindcss': 0.2.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2))) '@headlessui/vue': 1.7.22(vue@3.4.31(typescript@5.7.2)) '@scalar/components': 0.12.28(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(typescript@5.7.2) '@scalar/draggable': 0.1.4(typescript@5.7.2) @@ -13693,11 +13721,11 @@ snapshots: - typescript - vitest - '@scalar/api-reference@1.24.70(postcss@8.5.2)(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)))(typescript@5.7.2)': + '@scalar/api-reference@1.24.70(postcss@8.5.2)(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)))(typescript@5.7.2)': dependencies: '@floating-ui/vue': 1.1.1(vue@3.4.31(typescript@5.7.2)) '@headlessui/vue': 1.7.22(vue@3.4.31(typescript@5.7.2)) - '@scalar/api-client': 2.0.45(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)))(typescript@5.7.2) + '@scalar/api-client': 2.0.45(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)))(typescript@5.7.2) '@scalar/components': 0.12.28(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(typescript@5.7.2) '@scalar/oas-utils': 0.2.26(typescript@5.7.2) '@scalar/openapi-parser': 0.7.2 @@ -13782,10 +13810,10 @@ snapshots: transitivePeerDependencies: - typescript - '@scalar/hono-api-reference@0.5.131(postcss@8.5.2)(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)))(typescript@5.7.2)': + '@scalar/hono-api-reference@0.5.131(postcss@8.5.2)(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)))(typescript@5.7.2)': dependencies: - '@scalar/api-reference': 1.24.70(postcss@8.5.2)(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)))(typescript@5.7.2) - hono: 4.5.3 + '@scalar/api-reference': 1.24.70(postcss@8.5.2)(storybook@8.4.7(bufferutil@4.0.8)(prettier@3.4.2)(utf-8-validate@6.0.5))(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)))(typescript@5.7.2) + hono: 4.7.4 transitivePeerDependencies: - '@jest/globals' - '@types/bun' @@ -14740,9 +14768,9 @@ snapshots: dependencies: '@babel/types': 7.26.3 - '@types/bun@1.2.4': + '@types/bun@1.2.5': dependencies: - bun-types: 1.2.4 + bun-types: 1.2.5 '@types/caseless@0.12.4': {} @@ -15015,7 +15043,7 @@ snapshots: '@upstash/qstash@2.6.2': dependencies: crypto-js: 4.2.0 - jose: 5.2.4 + jose: 5.9.4 '@upstash/ratelimit@0.4.4(encoding@0.1.13)': dependencies: @@ -15732,7 +15760,7 @@ snapshots: bun-types@1.0.8: {} - bun-types@1.2.4: + bun-types@1.2.5: dependencies: '@types/node': 22.10.2 '@types/ws': 8.5.13 @@ -16258,35 +16286,35 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.35.3(@cloudflare/workers-types@4.20241230.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.4.0)(bun-types@1.2.4)(react@19.0.0): + drizzle-orm@0.35.3(@cloudflare/workers-types@4.20250303.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.4.0)(bun-types@1.2.5)(react@19.0.0): dependencies: '@libsql/client-wasm': 0.14.0 optionalDependencies: - '@cloudflare/workers-types': 4.20241230.0 + '@cloudflare/workers-types': 4.20250303.0 '@libsql/client': 0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3) '@opentelemetry/api': 1.9.0 '@types/pg': 8.11.10 '@types/react': 19.0.10 better-sqlite3: 11.4.0 - bun-types: 1.2.4 + bun-types: 1.2.5 react: 19.0.0 - drizzle-orm@0.35.3(@cloudflare/workers-types@4.20241230.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.5))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.7.0)(bun-types@1.2.4)(react@19.0.0): + drizzle-orm@0.35.3(@cloudflare/workers-types@4.20250303.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.5))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.7.0)(bun-types@1.2.5)(react@19.0.0): dependencies: '@libsql/client-wasm': 0.14.0 optionalDependencies: - '@cloudflare/workers-types': 4.20241230.0 + '@cloudflare/workers-types': 4.20250303.0 '@libsql/client': 0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.5) '@opentelemetry/api': 1.9.0 '@types/pg': 8.11.10 '@types/react': 19.0.10 better-sqlite3: 11.7.0 - bun-types: 1.2.4 + bun-types: 1.2.5 react: 19.0.0 - drizzle-zod@0.5.1(drizzle-orm@0.35.3(@cloudflare/workers-types@4.20241230.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.4.0)(bun-types@1.2.4)(react@19.0.0))(zod@3.23.8): + drizzle-zod@0.5.1(drizzle-orm@0.35.3(@cloudflare/workers-types@4.20250303.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.4.0)(bun-types@1.2.5)(react@19.0.0))(zod@3.23.8): dependencies: - drizzle-orm: 0.35.3(@cloudflare/workers-types@4.20241230.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.4.0)(bun-types@1.2.4)(react@19.0.0) + drizzle-orm: 0.35.3(@cloudflare/workers-types@4.20250303.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.3))(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@19.0.10)(better-sqlite3@11.4.0)(bun-types@1.2.5)(react@19.0.0) zod: 3.23.8 dset@3.1.4: {} @@ -17306,6 +17334,8 @@ snapshots: hono@4.5.3: {} + hono@4.7.4: {} + hookable@5.5.3: {} html-escaper@3.0.3: {} @@ -19152,21 +19182,21 @@ snapshots: postcss: 8.4.38 ts-node: 10.9.2(@types/node@22.10.2)(typescript@5.6.2) - postcss-load-config@4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)): + postcss-load-config@4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.6.2)): dependencies: lilconfig: 3.1.3 yaml: 2.6.1 optionalDependencies: postcss: 8.5.2 - ts-node: 10.9.2(@types/node@20.8.0)(typescript@5.7.2) + ts-node: 10.9.2(@types/node@22.10.2)(typescript@5.6.2) - postcss-load-config@4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.6.2)): + postcss-load-config@4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)): dependencies: lilconfig: 3.1.3 yaml: 2.6.1 optionalDependencies: postcss: 8.5.2 - ts-node: 10.9.2(@types/node@22.10.2)(typescript@5.6.2) + ts-node: 10.9.2(@types/node@22.10.2)(typescript@5.7.2) postcss-nested@6.0.1(postcss@8.5.2): dependencies: @@ -20379,7 +20409,7 @@ snapshots: dependencies: tailwindcss: 3.4.3(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.6.2)) - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)): + tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.6.2)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -20398,7 +20428,7 @@ snapshots: postcss: 8.5.2 postcss-import: 15.1.0(postcss@8.5.2) postcss-js: 4.0.1(postcss@8.5.2) - postcss-load-config: 4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2)) + postcss-load-config: 4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.6.2)) postcss-nested: 6.2.0(postcss@8.5.2) postcss-selector-parser: 6.1.2 resolve: 1.22.9 @@ -20406,7 +20436,7 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.6.2)): + tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -20425,7 +20455,7 @@ snapshots: postcss: 8.5.2 postcss-import: 15.1.0(postcss@8.5.2) postcss-js: 4.0.1(postcss@8.5.2) - postcss-load-config: 4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.6.2)) + postcss-load-config: 4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) postcss-nested: 6.2.0(postcss@8.5.2) postcss-selector-parser: 6.1.2 resolve: 1.22.9 @@ -20679,26 +20709,26 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@20.8.0)(typescript@5.7.2): + ts-node@10.9.2(@types/node@22.10.2)(typescript@5.6.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.8.0 + '@types/node': 22.10.2 acorn: 8.11.3 acorn-walk: 8.3.2 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.7.2 + typescript: 5.6.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optional: true - ts-node@10.9.2(@types/node@22.10.2)(typescript@5.6.2): + ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -20712,7 +20742,7 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.6.2 + typescript: 5.7.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optional: true