diff --git a/bun.lock b/bun.lock index d481de8e83..8e3c9b7452 100644 --- a/bun.lock +++ b/bun.lock @@ -107,7 +107,6 @@ "solid-js": "catalog:", "solid-list": "0.3.0", "solid-stripe": "0.8.1", - "svix": "1.92.2", "vite": "catalog:", "zod": "catalog:", }, @@ -2168,8 +2167,6 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], - "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="], "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="], @@ -3180,8 +3177,6 @@ "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], - "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], @@ -4656,8 +4651,6 @@ "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], - "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], - "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -4726,8 +4719,6 @@ "sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="], - "svix": ["svix@1.92.2", "", { "dependencies": { "standardwebhooks": "1.0.0" } }, "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ=="], - "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], diff --git a/infra/console.ts b/infra/console.ts index d92fcaa8e2..ab6502a8f8 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -1,5 +1,6 @@ import { domain } from "./stage" import { EMAILOCTOPUS_API_KEY } from "./app" +import { SECRET } from "./secret" //////////////// // DATABASE @@ -221,8 +222,6 @@ const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", { properties: { value: stripeWebhook.secret }, }) -const INCIDENT_WEBHOOK_SIGNING_SECRET = new sst.Secret("INCIDENT_WEBHOOK_SIGNING_SECRET") -const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL") const gatewayKv = new sst.cloudflare.Kv("GatewayKv") @@ -233,6 +232,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv") const bucket = new sst.cloudflare.Bucket("ZenData") const bucketNew = new sst.cloudflare.Bucket("ZenDataNew") +const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL") const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") @@ -254,8 +254,8 @@ new sst.cloudflare.x.SolidStart("Console", { database, AUTH_API_URL, STRIPE_WEBHOOK_SECRET, - INCIDENT_WEBHOOK_SIGNING_SECRET, DISCORD_INCIDENT_WEBHOOK_URL, + SECRET.HoneycombWebhookSecret, STRIPE_SECRET_KEY, EMAILOCTOPUS_API_KEY, AWS_SES_ACCESS_KEY_ID, diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 4fb7183a2f..4e22e3d812 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -1,318 +1,91 @@ -const displayName = (s: string) => - s - .split("-") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" ") - .replace(/(?<=\d) (?=\d)/g, ".") +import { SECRET } from "./secret" +import { domain } from "./stage" -const resourceName = (s: string) => displayName(s).replace(/[^a-zA-Z0-9]/g, "") - -const varSpec = (label: string, name: string) => - $jsonStringify({ - content: [ - { - content: [ - { - attrs: { - name, - label, - missing: false, - }, - type: "varSpec", - }, - ], - type: "paragraph", - }, - ], - type: "doc", - }) - -const fields = { - model: incident.getAlertAttributeOutput({ name: "Model" }), - product: incident.getAlertAttributeOutput({ name: "Product" }), -} - -const alertSource = new incident.AlertSource("HoneycombAlertSource", { - name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`, - sourceType: "honeycomb", - template: { - title: { - literal: varSpec("Payload -> Title", "title"), - }, - description: { - literal: varSpec("Payload -> Description", "description"), - }, - attributes: [ - { - alertAttributeId: fields.model.id, - binding: { - value: { - reference: 'expressions["model"]', - }, - mergeStrategy: "first_wins", - }, - }, - { - alertAttributeId: fields.product.id, - binding: { - value: { - reference: 'expressions["product"]', - }, - mergeStrategy: "first_wins", - }, - }, - ], - expressions: [ - { - label: "Model", - operations: [ - { - operationType: "parse", - parse: { - returns: { - array: false, - type: fields.model.type, - }, - source: "$['model']", - }, - }, - ], - reference: "model", - rootReference: "payload", - }, - { - label: "Product", - operations: [ - { - operationType: "parse", - parse: { - returns: { - array: false, - type: fields.product.type, - }, - source: "$['product']", - }, - }, - ], - reference: "product", - rootReference: "payload", - }, - ], - }, -}) - -const webhookRecipient = new honeycomb.WebhookRecipient(`IncidentWebhook`, { - name: $app.stage === "production" ? "Incident.io" : `Incident.io (${$app.stage})`, - url: alertSource.alertEventsUrl, - secret: alertSource.secretToken, +const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { + name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`, + url: `https://${domain}/honeycomb/webhook`, + secret: SECRET.HoneycombWebhookSecret.result, templates: [ { type: "trigger", - body: $jsonStringify({ - title: "{{ .Name }}", - description: "{{ .Description }}", - status: "{{ .Alert.Status }}", - deduplication_key: "{{ .Alert.InstanceID }}", - source_url: "{{ .Result.URL }}", - model: "{{ .Vars.model }}", - product: "{{ .Vars.product }}", - }), + body: `{ + "url": {{ .Result.URL | quote }}, + "type": {{ .Vars.type | quote }}, + "name": {{ .Name | quote }}, + "status": {{ .Alert.Status | quote }}, + "isTest": {{ .Alert.IsTest }}, + "groups": {{ .Result.GroupsTriggered | toJson }} + }`, }, ], variables: [ { - name: "model", - }, - { - name: "product", + name: "type", }, ], }) -new incident.AlertRoute("HoneycombAlertRoute", { - name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`, - enabled: true, - isPrivate: false, - alertSources: [ - { - alertSourceId: alertSource.id, - conditionGroups: [ - { - conditions: [ - { - subject: "alert.title", - operation: "is_set", - paramBindings: [], - }, - ], - }, - ], - }, - ], - conditionGroups: [ - { - conditions: [ - { - subject: "alert.title", - operation: "is_set", - paramBindings: [], - }, - ], - }, - ], - expressions: [], - escalationConfig: { - autoCancelEscalations: true, - escalationTargets: [], - }, - incidentConfig: { - autoDeclineEnabled: true, - enabled: true, - conditionGroups: [], - deferTimeSeconds: 0, - groupingKeys: [ +const modelHttpErrorsQuery = (product: "go" | "zen") => { + const filters = [ + { column: "model", op: "exists" }, + { column: "event_type", op: "=", value: "completions" }, + { column: "user_agent", op: "contains", value: "opencode" }, + { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, + ] + + return honeycomb.getQuerySpecificationOutput({ + breakdowns: ["model"], + calculatedFields: [ { - reference: $interpolate`alert.attributes.${fields.model.id}`, - }, - { - reference: $interpolate`alert.attributes.${fields.product.id}`, + name: "is_failed_http_status", + expression: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, }, ], - groupingWindowSeconds: 3600, - }, - incidentTemplate: { - name: { - value: { - literal: varSpec("Alert -> Title", "alert.title"), - }, - }, - summary: { - value: { - literal: varSpec("Alert -> Description", "alert.description"), - }, - }, - startInTriage: { - value: { - literal: "true", - }, - }, - severity: { - mergeStrategy: "first-wins", - }, - incidentMode: { - value: { - literal: $app.stage === "production" ? "standard" : "test", - }, - }, - }, -}) - -type Product = "go" | "zen" - -type Trigger = (opts: { model: string; product: Product }) => { - id: string - title: string - description: string - json: honeycomb.GetQuerySpecificationOutputArgs - threshold: { op: ">=" | "<="; value: number } -} - -type Model = { id: string; products: Product[]; triggers: Trigger[] } - -const httpErrors: Trigger = ({ model, product }) => ({ - id: "increased-http-errors", - title: `Increased HTTP Errors for ${displayName(model)} on ${displayName(product)}`, - description: `Detected increased rate of HTTP errors for ${displayName(model)} on OpenCode ${displayName(product)}`, - json: { calculations: [ - { - op: "COUNT", - name: "TOTAL", - filterCombination: "AND", - filters: [ - { column: "model", op: "=", value: model }, - { column: "event_type", op: "=", value: "completions" }, - { column: "user_agent", op: "contains", value: "opencode" }, - { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, - ], - }, - { - op: "COUNT", - name: "FAILED", - filterCombination: "AND", - filters: [ - { column: "model", op: "=", value: model }, - { column: "event_type", op: "=", value: "completions" }, - { column: "user_agent", op: "contains", value: "opencode" }, - { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, - { column: "status", op: ">=", value: "400" }, - { column: "status", op: "!=", value: "401" }, - ], - }, + { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, + { op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, ], - formulas: [{ name: "ERROR", expression: "$FAILED / $TOTAL" }], + formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 2500), DIV($FAILED, $TOTAL), 0)" }], timeRange: 900, - }, - threshold: { op: ">=", value: 0.8 }, + }).json +} + +const description = "Managed by SST (Don't edit in Honeycomb UI)" + +new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { + name: "Increased Model HTTP Errors [Go]", + description, + queryJson: modelHttpErrorsQuery("go"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + recipients: [ + // { + // id: webhookRecipient.id, + // notificationDetails: [ + // { + // variables: [{ name: "type", value: "model_http_errors" }], + // }, + // ], + // }, + ], }) -const models: Model[] = [ - { id: "kimi-k2.6", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "kimi-k2.5", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "deepseek-v4-flash", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "deepseek-v4-pro", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "glm-5.1", products: ["go", "zen"], triggers: [httpErrors] }, - // { id: "glm-5", products: ["go"], triggers: [httpErrors] }, - { id: "qwen3.6-plus", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "qwen3.5-plus", products: ["go"], triggers: [httpErrors] }, - { id: "minimax-m2.7", products: ["go", "zen"], triggers: [httpErrors] }, - // { id: "minimax-m2.5", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "mimo-v2.5-pro", products: ["go"], triggers: [httpErrors] }, - // { id: "mimo-v2.5", products: ["go"], triggers: [httpErrors] }, - // { id: "mimo-v2-omni", products: ["go"], triggers: [httpErrors] }, - // { id: "mimo-v2-pro", products: ["go"], triggers: [httpErrors] }, - { id: "claude-opus-4-7", products: ["zen"], triggers: [httpErrors] }, - // { id: "claude-opus-4-6", products: ["zen"], triggers: [httpErrors] }, - // { id: "claude-sonnet-4-6", products: ["zen"], triggers: [httpErrors] }, - { id: "gpt-5.5", products: ["zen"], triggers: [httpErrors] }, - { id: "big-pickle", products: ["zen"], triggers: [httpErrors] }, - // { id: "minimax-m2.5-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "hy3-preview-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "nemotron-3-super-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "trinity-large-preview-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "ling-2.6-flash-free", products: ["zen"], triggers: [httpErrors] }, -] - -if ($app.stage !== "production") { - models.splice(1) -} - -for (const model of models) { - for (const product of model.products) { - for (const trigger of model.triggers) { - const spec = trigger({ model: model.id, product }) - - new honeycomb.Trigger(resourceName(`${spec.id}-${product}-${model.id}`), { - name: spec.title, - description: spec.description, - queryJson: honeycomb.getQuerySpecificationOutput(spec.json).json, - alertType: "on_change", - frequency: 300, - thresholds: [{ ...spec.threshold, exceededLimit: 1 }], - recipients: [ - { - id: webhookRecipient.id, - notificationDetails: [ - { - variables: [ - { name: "model", value: model.id }, - { name: "product", value: product }, - ], - }, - ], - }, - ], - }) - } - } -} +new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { + name: "Increased Model HTTP Errors [Zen]", + description, + queryJson: modelHttpErrorsQuery("zen"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + recipients: [ + // { + // id: webhookRecipient.id, + // notificationDetails: [ + // { + // variables: [{ name: "type", value: "model_http_errors" }], + // }, + // ], + // }, + ], +}) diff --git a/infra/secret.ts b/infra/secret.ts index 0b1870fa15..d4e8b148fc 100644 --- a/infra/secret.ts +++ b/infra/secret.ts @@ -1,4 +1,11 @@ +sst.Linkable.wrap(random.RandomPassword, (resource) => ({ + properties: { + value: resource.result, + }, +})) + export const SECRET = { R2AccessKey: new sst.Secret("R2AccessKey", "unknown"), R2SecretKey: new sst.Secret("R2SecretKey", "unknown"), + HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }), } diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 71d37d1553..298ae4a8cf 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -31,7 +31,6 @@ "solid-js": "catalog:", "solid-list": "0.3.0", "solid-stripe": "0.8.1", - "svix": "1.92.2", "vite": "catalog:", "zod": "catalog:" }, diff --git a/packages/console/app/src/routes/honeycomb/webhook.ts b/packages/console/app/src/routes/honeycomb/webhook.ts new file mode 100644 index 0000000000..b4d5e4bf7e --- /dev/null +++ b/packages/console/app/src/routes/honeycomb/webhook.ts @@ -0,0 +1,81 @@ +import type { APIEvent } from "@solidjs/start/server" +import { z } from "zod" +import { Resource } from "@opencode-ai/console-resource" +import { safeEqual } from "@opencode-ai/console-core/util/crypto.js" + +const DISCORD_ALERT_ROLE_ID = "1501447160175136838" + +const basePayload = z.object({ + name: z.string().optional(), + status: z.string().optional(), + isTest: z.boolean().optional(), + url: z.string(), +}) + +const groups = z.object({ group: z.object({ key: z.string(), value: z.string() }).array() }).array() + +const honeycombWebhookPayload = z.discriminatedUnion("type", [ + basePayload.extend({ + type: z.literal("model_http_errors"), + groups, + }), + basePayload.extend({ + type: z.literal("provider_http_errors"), + groups, + }), +]) + +const postDiscordMessage = async (payload: z.infer) => { + const group = payload.type === "model_http_errors" ? "model" : "provider" + const names = (payload.groups ?? []).flatMap((item) => item.group.map((g) => g.value)) + + const content = [ + `[**${payload.isTest ? "[TEST] " : ""}${payload.name ?? "Honeycomb alert"}**](${payload.url})`, + names.length > 0 ? `Affected ${group}s:` : undefined, + ...names.map((name) => `- ${name}`), + "", + `<@&${DISCORD_ALERT_ROLE_ID}>`, + ] + .filter((line) => line !== undefined) + .join("\n") + + return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content, + allowed_mentions: { roles: [DISCORD_ALERT_ROLE_ID] }, + flags: 4, + }), + }) +} + +export async function POST(input: APIEvent) { + const token = input.request.headers.get("X-Honeycomb-Webhook-Token") + if (!safeEqual(token ?? "", Resource.HoneycombWebhookSecret.value)) { + console.debug("Invalid Honeycomb webhook token") + return Response.json({ message: "invalid token" }, { status: 401 }) + } + + const body = await input.request.json() + console.log(body, JSON.stringify(body, null, 2)) + + const parsed = honeycombWebhookPayload.safeParse(body) + + if (!parsed.success) { + console.error(parsed.error) + return Response.json({ message: "invalid payload" }, { status: 400 }) + } + + if (parsed.data.status !== "TRIGGERED") { + console.debug("Skipping resolved alert Honeycomb webhook") + return Response.json({ message: "ignored" }, { status: 200 }) + } + + const response = await postDiscordMessage(parsed.data) + if (!response.ok) { + return Response.json({ message: "discord webhook failed" }, { status: 502 }) + } + + return Response.json({ message: "sent" }, { status: 200 }) +} diff --git a/packages/console/app/src/routes/incident/webhook.ts b/packages/console/app/src/routes/incident/webhook.ts deleted file mode 100644 index ce7b0a0d9f..0000000000 --- a/packages/console/app/src/routes/incident/webhook.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { APIEvent } from "@solidjs/start/server" -import { Resource } from "@opencode-ai/console-resource" -import { Webhook } from "svix" - -const DISCORD_INCIDENT_ROLE_ID = "1501447160175136838" - -type Incident = { - mode?: "test" | "standard" - name?: string - permalink?: string - summary?: string -} - -type IncidentWebhookPayload = { - event_type?: string - "public_incident.incident_created_v2"?: Incident -} - -const verifyWebhook = async (request: Request) => { - const body = await request.text() - try { - return new Webhook(Resource.INCIDENT_WEBHOOK_SIGNING_SECRET.value).verify( - body, - Object.fromEntries(request.headers.entries()), - ) as IncidentWebhookPayload - } catch { - return undefined - } -} - -const postDiscordMessage = async (incident: Incident) => { - return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: [ - `**${incident.mode === "test" ? "[TEST] " : ""}${incident.name ?? "Incident has been created"}**`, - incident.summary, - "", - `<@&${DISCORD_INCIDENT_ROLE_ID}>`, - "", - incident.permalink, - ] - .filter((line) => line !== undefined) - .join("\n"), - allowed_mentions: { - roles: [DISCORD_INCIDENT_ROLE_ID], - }, - flags: 4, - }), - }) -} - -export async function POST(input: APIEvent) { - const payload = await verifyWebhook(input.request) - if (!payload) { - return Response.json({ message: "invalid signature" }, { status: 401 }) - } - - if (payload.event_type !== "public_incident.incident_created_v2") { - return Response.json({ message: "ignored event" }, { status: 200 }) - } - - const incident = payload["public_incident.incident_created_v2"] - if (!incident) { - return Response.json({ message: "missing incident" }, { status: 400 }) - } - - const response = await postDiscordMessage(incident) - if (!response.ok) { - return Response.json({ message: "discord webhook failed" }, { status: 502 }) - } - - return Response.json({ message: "sent" }, { status: 200 }) -} diff --git a/packages/console/core/src/util/crypto.ts b/packages/console/core/src/util/crypto.ts new file mode 100644 index 0000000000..46f53ae391 --- /dev/null +++ b/packages/console/core/src/util/crypto.ts @@ -0,0 +1,8 @@ +import { timingSafeEqual } from "node:crypto" + +export function safeEqual(a: string, b: string): boolean { + const encoder = new TextEncoder() + const aBytes = encoder.encode(a) + const bBytes = encoder.encode(b) + return aBytes.length === bBytes.length && timingSafeEqual(aBytes, bBytes) +} diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/sst-env.d.ts b/sst-env.d.ts index 52702acd7c..e75c54d056 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -114,8 +114,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "LogProcessor": { diff --git a/sst.config.ts b/sst.config.ts index a7e513ca0a..d82c7d18d9 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -11,15 +11,10 @@ export default $config({ stripe: { apiKey: process.env.STRIPE_SECRET_KEY!, }, + random: "4.19.2", planetscale: "0.4.1", - honeycomb: { - version: "0.49.0", - apiKey: process.env.HONEYCOMB_API_KEY!, - }, - incident: { - version: "5.35.0", - apiKey: process.env.INCIDENT_API_KEY!, - }, + honeycomb: "0.49.0", + incident: "5.35.0", }, } }, @@ -27,7 +22,7 @@ export default $config({ await import("./infra/app.js") await import("./infra/console.js") await import("./infra/enterprise.js") - if ($app.stage === "production") { + if ($app.stage === "production" || $app.stage === "vimtor") { await import("./infra/monitoring.js") } },