diff --git a/.server-changes/add-alerts-2-0-query-based-definitions.md b/.server-changes/add-alerts-2-0-query-based-definitions.md new file mode 100644 index 000000000..f7d0a5b18 --- /dev/null +++ b/.server-changes/add-alerts-2-0-query-based-definitions.md @@ -0,0 +1,14 @@ +--- +area: webapp +type: feature +--- + +Add Alerts 2.0: query-based alert definitions that evaluate TSQL queries against ClickHouse on a configurable schedule + +Key components: +- `AlertV2Definition` Postgres model: stores query, conditions (JSON), evaluation interval, channel IDs, and current state +- `alert_evaluations_v1` ClickHouse table: records every evaluation result with state, value, and duration (90-day TTL) +- `scheduleAlertEvaluations` cron job (every minute): finds due definitions and enqueues individual evaluation jobs +- `evaluateAlertDefinition` worker job: executes the TSQL query with tenant isolation, evaluates thresholds, writes to ClickHouse, fires notifications on state change +- `ALERT_V2_FIRING` / `ALERT_V2_RESOLVED` alert types delivered via existing Slack, Email, and Webhook channels +- Global concurrency limit via the alerts worker prevents overwhelming ClickHouse diff --git a/apps/webapp/app/v3/alertsWorker.server.ts b/apps/webapp/app/v3/alertsWorker.server.ts index 46670887a..07a09a346 100644 --- a/apps/webapp/app/v3/alertsWorker.server.ts +++ b/apps/webapp/app/v3/alertsWorker.server.ts @@ -7,6 +7,8 @@ import { singleton } from "~/utils/singleton"; import { DeliverAlertService } from "./services/alerts/deliverAlert.server"; import { PerformDeploymentAlertsService } from "./services/alerts/performDeploymentAlerts.server"; import { PerformTaskRunAlertsService } from "./services/alerts/performTaskRunAlerts.server"; +import { EvaluateAlertDefinitionService } from "./services/alerts/evaluateAlertDefinition.server"; +import { ScheduleAlertEvaluationsService } from "./services/alerts/scheduleAlertEvaluations.server"; function initializeWorker() { const redisOptions = { @@ -55,6 +57,26 @@ function initializeWorker() { }, logErrors: false, }, + "v3.scheduleAlertEvaluations": { + schema: z.object({}), + visibilityTimeoutMs: 60_000, + retry: { + maxAttempts: 1, + }, + logErrors: false, + // Run every minute to pick up definitions that are due for evaluation + cron: "* * * * *", + }, + "v3.evaluateAlertDefinition": { + schema: z.object({ + alertDefinitionId: z.string(), + }), + visibilityTimeoutMs: 120_000, + retry: { + maxAttempts: 2, + }, + logErrors: false, + }, }, concurrency: { workers: env.ALERTS_WORKER_CONCURRENCY_WORKERS, @@ -80,6 +102,14 @@ function initializeWorker() { const service = new PerformTaskRunAlertsService(); await service.call(payload.runId); }, + "v3.scheduleAlertEvaluations": async () => { + const service = new ScheduleAlertEvaluationsService(); + await service.call(); + }, + "v3.evaluateAlertDefinition": async ({ payload }) => { + const service = new EvaluateAlertDefinitionService(); + await service.call(payload.alertDefinitionId); + }, }, }); diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index 8b922f91e..1a1978fea 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -88,6 +88,17 @@ type FoundAlert = Prisma.Result< }; }; }; + alertV2Definition: { + select: { + id: true; + name: true; + description: true; + query: true; + conditions: true; + queryPeriod: true; + state: true; + }; + }; }; }, "findUniqueOrThrow" @@ -139,6 +150,17 @@ export class DeliverAlertService extends BaseService { }, }, }, + alertV2Definition: { + select: { + id: true, + name: true, + description: true, + query: true, + conditions: true, + queryPeriod: true, + state: true, + }, + }, }, }); @@ -319,6 +341,17 @@ export class DeliverAlertService extends BaseService { break; } + case "ALERT_V2_FIRING": + case "ALERT_V2_RESOLVED": { + // Email delivery for Alerts 2.0 is not yet implemented. + // These notifications are better served via Slack or Webhook channels. + logger.info("[DeliverAlert] Email delivery for Alert v2 not yet implemented", { + alertId: alert.id, + type: alert.type, + alertV2DefinitionId: alert.alertV2Definition?.id, + }); + break; + } default: { assertNever(alert.type); } @@ -657,6 +690,46 @@ export class DeliverAlertService extends BaseService { break; } + case "ALERT_V2_FIRING": + case "ALERT_V2_RESOLVED": { + if (alert.alertV2Definition) { + const payload = { + id: alert.id, + created: alert.createdAt, + webhookVersion: "v1", + type: + alert.type === "ALERT_V2_FIRING" ? "alert.v2.firing" : "alert.v2.resolved", + object: { + alert: { + id: alert.alertV2Definition.id, + name: alert.alertV2Definition.name, + description: alert.alertV2Definition.description ?? undefined, + query: alert.alertV2Definition.query, + queryPeriod: alert.alertV2Definition.queryPeriod, + conditions: alert.alertV2Definition.conditions, + state: alert.type === "ALERT_V2_FIRING" ? "firing" : "ok", + }, + project: { + id: alert.project.id, + ref: alert.project.externalRef, + slug: alert.project.slug, + name: alert.project.name, + }, + organization: { + id: alert.project.organizationId, + slug: alert.project.organization.slug, + name: alert.project.organization.title, + }, + }, + }; + + await this.#deliverWebhook(payload, webhookProperties.data); + } else { + logger.error("[DeliverAlert] Alert v2 definition not found", { alert }); + } + + break; + } default: { assertNever(alert.type); } @@ -913,6 +986,53 @@ export class DeliverAlertService extends BaseService { return; } } + case "ALERT_V2_FIRING": + case "ALERT_V2_RESOLVED": { + if (alert.alertV2Definition) { + const isFiring = alert.type === "ALERT_V2_FIRING"; + const stateEmoji = isFiring ? "🔴" : "🟢"; + const stateText = isFiring ? "FIRING" : "RESOLVED"; + const alertName = alert.alertV2Definition.name; + + await this.#postSlackMessage(integration, { + channel: slackProperties.data.channelId, + unfurl_links: false, + unfurl_media: false, + text: `${stateEmoji} Alert *${alertName}* is ${stateText}`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `${stateEmoji} *Alert ${stateText}: ${alertName}*`, + }, + }, + ...(alert.alertV2Definition.description + ? [ + { + type: "section" as const, + text: { + type: "mrkdwn", + text: alert.alertV2Definition.description, + }, + }, + ] + : []), + { + type: "section", + text: { + type: "mrkdwn", + text: `*Query period:* ${alert.alertV2Definition.queryPeriod}\n*Organization:* ${alert.project.organization.title}\n*Project:* ${alert.project.name}`, + }, + }, + ], + }); + } else { + logger.error("[DeliverAlert] Alert v2 definition not found", { alert }); + } + + break; + } default: { assertNever(alert.type); } diff --git a/apps/webapp/app/v3/services/alerts/evaluateAlertDefinition.server.ts b/apps/webapp/app/v3/services/alerts/evaluateAlertDefinition.server.ts new file mode 100644 index 000000000..45830edb0 --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/evaluateAlertDefinition.server.ts @@ -0,0 +1,329 @@ +import { type AlertV2State } from "@trigger.dev/database"; +import { z } from "zod"; +import { executeTSQL, type FieldMappings, type WhereClauseCondition } from "@internal/clickhouse"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { clickhouseClient, queryClickhouseClient } from "~/services/clickhouseInstance.server"; +import { logger } from "~/services/logger.server"; +import { alertsWorker } from "~/v3/alertsWorker.server"; +import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; +import { querySchemas } from "~/v3/querySchemas"; +import { BaseService } from "../baseService.server"; +import { DeliverAlertService } from "./deliverAlert.server"; +import parse from "parse-duration"; + +/** A single condition in an alert definition */ +const AlertConditionSchema = z.object({ + /** Column name from the query result to evaluate */ + field: z.string(), + /** Comparison operator */ + op: z.enum(["gt", "gte", "lt", "lte", "eq", "neq"]), + /** Threshold value */ + value: z.number(), +}); + +export type AlertCondition = z.infer; +export const AlertConditionsSchema = z.array(AlertConditionSchema); + +/** Evaluates a single AlertV2Definition and writes results to ClickHouse */ +export class EvaluateAlertDefinitionService extends BaseService { + public async call(alertDefinitionId: string) { + const definition = await this._prisma.alertV2Definition.findUnique({ + where: { id: alertDefinitionId }, + include: { + organization: { select: { id: true } }, + project: { select: { id: true, externalRef: true } }, + environment: { select: { id: true, slug: true } }, + }, + }); + + if (!definition) { + logger.warn("[EvaluateAlertDefinition] Definition not found", { alertDefinitionId }); + return; + } + + if (!definition.enabled) { + logger.debug("[EvaluateAlertDefinition] Definition is disabled, skipping", { + alertDefinitionId, + }); + return; + } + + const startTime = Date.now(); + let errorMessage = ""; + let queryValue: number | null = null; + let newState: AlertV2State = definition.state; + + // Parse conditions JSON + const conditionsResult = AlertConditionsSchema.safeParse(definition.conditions); + if (!conditionsResult.success) { + logger.error("[EvaluateAlertDefinition] Invalid conditions JSON", { + alertDefinitionId, + conditions: definition.conditions, + error: conditionsResult.error.message, + }); + return; + } + const conditions = conditionsResult.data; + + try { + // Build tenant isolation constraints + const scope = definition.scope; + const organizationId = definition.organizationId; + const projectId = definition.project?.id ?? ""; + const environmentId = definition.environment?.id ?? ""; + + // Find the time column for this query (same logic as executeQuery) + const matchedSchema = querySchemas.find((s) => + new RegExp(`\\bFROM\\s+${s.name}\\b`, "i").test(definition.query) + ); + const timeColumn = matchedSchema?.timeConstraint ?? "triggered_at"; + + // Convert queryPeriod string (e.g. "1h", "5m", "24h") to a from Date + const periodMs = parse(definition.queryPeriod) ?? 60 * 60 * 1000; // default 1h + const fromDate = new Date(Date.now() - periodMs); + const timeFallback: WhereClauseCondition = { op: "gte", value: fromDate }; + + // Enforce tenant isolation - always include organization_id + const enforcedWhereClause: Record = { + organization_id: { op: "eq", value: organizationId }, + project_id: + scope === "PROJECT" || scope === "ENVIRONMENT" + ? { op: "eq", value: projectId } + : undefined, + environment_id: scope === "ENVIRONMENT" ? { op: "eq", value: environmentId } : undefined, + [timeColumn]: { op: "gte", value: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) }, // Absolute max lookback safety limit + }; + + // Build field mappings for project_ref and environment slug translation + const projects = await prisma.project.findMany({ + where: { organizationId }, + select: { id: true, externalRef: true }, + }); + const environments = await prisma.runtimeEnvironment.findMany({ + where: { project: { organizationId } }, + select: { id: true, slug: true }, + }); + + const fieldMappings: FieldMappings = { + project: Object.fromEntries(projects.map((p) => [p.id, p.externalRef])), + environment: Object.fromEntries(environments.map((e) => [e.id, e.slug])), + }; + + // Execute the TSQL query against ClickHouse + const result = await executeTSQL(queryClickhouseClient.reader, { + query: definition.query, + schema: z.record(z.unknown()), + tableSchema: querySchemas, + enforcedWhereClause, + fieldMappings, + whereClauseFallback: { + [timeColumn]: timeFallback, + }, + clickhouseSettings: { + max_execution_time: env.QUERY_CLICKHOUSE_MAX_EXECUTION_TIME, + timeout_overflow_mode: "throw", + max_memory_usage: String(env.QUERY_CLICKHOUSE_MAX_MEMORY_USAGE), + max_ast_elements: String(env.QUERY_CLICKHOUSE_MAX_AST_ELEMENTS), + max_expanded_ast_elements: String(env.QUERY_CLICKHOUSE_MAX_EXPANDED_AST_ELEMENTS), + readonly: "1", + format_csv_allow_double_quotes: 0, + }, + querySettings: { + maxRows: 1000, + }, + }); + + if (result[0] !== null) { + // Query error + errorMessage = result[0].message ?? "Query execution failed"; + logger.warn("[EvaluateAlertDefinition] Query failed", { + alertDefinitionId, + error: errorMessage, + }); + } else { + const rows = result[1]?.data ?? []; + + // Extract numeric value from first row for display purposes + if (rows.length > 0) { + const firstRow = rows[0]; + for (const val of Object.values(firstRow)) { + const num = Number(val); + if (!isNaN(num) && val !== null && val !== "") { + queryValue = num; + break; + } + } + } + + // Evaluate all conditions (ALL must pass for alert to fire) + const allConditionsMet = + conditions.length > 0 && + conditions.every((condition) => { + // For empty result sets: treat all fields as 0 + const rawValue = rows.length > 0 ? rows[0][condition.field] : null; + const fieldValue = rawValue !== null && rawValue !== undefined ? Number(rawValue) : 0; + return evaluateCondition(fieldValue, condition.op, condition.value); + }); + + newState = allConditionsMet ? "FIRING" : "OK"; + } + } catch (error) { + errorMessage = error instanceof Error ? error.message : String(error); + logger.error("[EvaluateAlertDefinition] Unexpected error during evaluation", { + alertDefinitionId, + error: errorMessage, + }); + } + + const queryDurationMs = Date.now() - startTime; + const stateChanged = newState !== definition.state; + const evaluatedAt = new Date(); + + // Write evaluation result to ClickHouse + const [insertError] = await clickhouseClient.alertEvaluations.insert([ + { + alert_definition_id: definition.id, + organization_id: definition.organizationId, + project_id: definition.project?.id ?? "", + environment_id: definition.environment?.id ?? "", + evaluated_at: evaluatedAt.toISOString(), + state: newState === "FIRING" ? "firing" : "ok", + state_changed: stateChanged ? 1 : 0, + value: queryValue, + conditions: JSON.stringify(conditionsResult.success ? conditionsResult.data : []), + query_duration_ms: queryDurationMs, + error_message: errorMessage, + }, + ]); + + if (insertError) { + logger.error("[EvaluateAlertDefinition] Failed to write evaluation to ClickHouse", { + alertDefinitionId, + error: insertError, + }); + } + + // Update the definition's state, lastEvaluatedAt (and lastStateChangedAt if changed) + await this._prisma.alertV2Definition.update({ + where: { id: definition.id }, + data: { + state: newState, + lastEvaluatedAt: evaluatedAt, + ...(stateChanged ? { lastStateChangedAt: evaluatedAt } : {}), + }, + }); + + // If state changed, create alert notifications + if (stateChanged && definition.alertChannelIds.length > 0) { + await this.#notifyChannels(definition, newState, evaluatedAt); + } + + logger.debug("[EvaluateAlertDefinition] Evaluation complete", { + alertDefinitionId, + previousState: definition.state, + newState, + stateChanged, + queryValue, + queryDurationMs, + }); + } + + async #notifyChannels( + definition: { + id: string; + alertChannelIds: string[]; + organizationId: string; + projectId: string | null; + environmentId: string | null; + }, + newState: AlertV2State, + evaluatedAt: Date + ) { + const alertType = newState === "FIRING" ? "ALERT_V2_FIRING" : "ALERT_V2_RESOLVED"; + + const channels = await this._prisma.projectAlertChannel.findMany({ + where: { id: { in: definition.alertChannelIds }, enabled: true }, + select: { id: true, type: true, projectId: true }, + }); + + // We need a projectId and environmentId for the ProjectAlert record. + // Use the definition's project/env or fall back to the first channel's project. + const projectId = definition.projectId ?? channels[0]?.projectId; + if (!projectId) { + logger.warn("[EvaluateAlertDefinition] No projectId available for notification", { + alertDefinitionId: definition.id, + }); + return; + } + + // Find an environment for the alert record (required by ProjectAlert) + const environment = await this._prisma.runtimeEnvironment.findFirst({ + where: definition.environmentId + ? { id: definition.environmentId } + : { projectId, type: { not: "DEVELOPMENT" } }, + select: { id: true }, + }); + + if (!environment) { + logger.warn("[EvaluateAlertDefinition] No environment found for notification", { + alertDefinitionId: definition.id, + projectId, + }); + return; + } + + for (const channel of channels) { + await this._prisma.projectAlert + .create({ + data: { + friendlyId: generateFriendlyId("alert"), + channelId: channel.id, + projectId, + environmentId: environment.id, + status: "PENDING", + type: alertType, + alertV2DefinitionId: definition.id, + }, + }) + .then((alert) => DeliverAlertService.enqueue(alert.id)) + .catch((error) => { + logger.error("[EvaluateAlertDefinition] Failed to create/enqueue alert", { + alertDefinitionId: definition.id, + channelId: channel.id, + error, + }); + }); + } + } + + static async enqueue(alertDefinitionId: string, runAt?: Date) { + return await alertsWorker.enqueue({ + id: `evaluateAlertDefinition:${alertDefinitionId}`, + job: "v3.evaluateAlertDefinition", + payload: { alertDefinitionId }, + availableAt: runAt, + }); + } +} + +function evaluateCondition( + fieldValue: number, + op: AlertCondition["op"], + threshold: number +): boolean { + switch (op) { + case "gt": + return fieldValue > threshold; + case "gte": + return fieldValue >= threshold; + case "lt": + return fieldValue < threshold; + case "lte": + return fieldValue <= threshold; + case "eq": + return fieldValue === threshold; + case "neq": + return fieldValue !== threshold; + } +} diff --git a/apps/webapp/app/v3/services/alerts/scheduleAlertEvaluations.server.ts b/apps/webapp/app/v3/services/alerts/scheduleAlertEvaluations.server.ts new file mode 100644 index 000000000..866835af2 --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/scheduleAlertEvaluations.server.ts @@ -0,0 +1,52 @@ +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { EvaluateAlertDefinitionService } from "./evaluateAlertDefinition.server"; + +/** + * Finds all enabled AlertV2Definitions that are due for evaluation and enqueues + * individual evaluation jobs for each one. + * + * A definition is due if: + * - lastEvaluatedAt is null (never evaluated), OR + * - now >= lastEvaluatedAt + evaluationIntervalSeconds + */ +export class ScheduleAlertEvaluationsService { + public async call() { + const now = new Date(); + + // Use a raw query to efficiently find due definitions using SQL arithmetic. + // We compare now against lastEvaluatedAt + evaluationIntervalSeconds. + const dueDefs = await prisma.$queryRaw>` + SELECT id + FROM "public"."AlertV2Definition" + WHERE enabled = true + AND ( + "lastEvaluatedAt" IS NULL + OR "lastEvaluatedAt" + ("evaluationIntervalSeconds" * INTERVAL '1 second') <= ${now} + ) + LIMIT 1000 + `; + + if (dueDefs.length === 0) { + return; + } + + logger.debug("[ScheduleAlertEvaluations] Scheduling evaluations", { + count: dueDefs.length, + }); + + // Enqueue an evaluation job for each due definition. + // enqueue() uses a stable ID so duplicate scheduling is a no-op. + const results = await Promise.allSettled( + dueDefs.map((def) => EvaluateAlertDefinitionService.enqueue(def.id)) + ); + + const failed = results.filter((r) => r.status === "rejected"); + if (failed.length > 0) { + logger.error("[ScheduleAlertEvaluations] Some enqueues failed", { + failedCount: failed.length, + total: dueDefs.length, + }); + } + } +} diff --git a/internal-packages/clickhouse/schema/021_create_alert_evaluations_v1.sql b/internal-packages/clickhouse/schema/021_create_alert_evaluations_v1.sql new file mode 100644 index 000000000..88aa4c20d --- /dev/null +++ b/internal-packages/clickhouse/schema/021_create_alert_evaluations_v1.sql @@ -0,0 +1,40 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS trigger_dev.alert_evaluations_v1 +( + -- Alert definition reference + alert_definition_id String CODEC(ZSTD(1)), + + -- Tenant isolation + organization_id LowCardinality(String), + project_id LowCardinality(String) DEFAULT '', + environment_id String CODEC(ZSTD(1)) DEFAULT '', + + -- When the evaluation ran + evaluated_at DateTime64(3) CODEC(Delta(8), ZSTD(1)), + + -- Resulting state: 'ok' or 'firing' + state LowCardinality(String), + + -- Whether the state changed compared to the previous evaluation + state_changed UInt8 DEFAULT 0, + + -- The numeric value returned by the query (first numeric column of the first row) + value Nullable(Float64) CODEC(ZSTD(1)), + + -- JSON serialization of the conditions that were evaluated + conditions String CODEC(ZSTD(1)), + + -- How long the ClickHouse query took in milliseconds + query_duration_ms UInt32 DEFAULT 0, + + -- Error message if the evaluation failed (query error, etc.) + error_message String CODEC(ZSTD(1)) DEFAULT '' +) +ENGINE = MergeTree() +PARTITION BY toDate(evaluated_at) +ORDER BY (alert_definition_id, organization_id, evaluated_at) +TTL toDate(evaluated_at) + INTERVAL 90 DAY +SETTINGS ttl_only_drop_parts = 1; + +-- +goose Down +DROP TABLE IF EXISTS trigger_dev.alert_evaluations_v1; diff --git a/internal-packages/clickhouse/src/alertEvaluations.ts b/internal-packages/clickhouse/src/alertEvaluations.ts new file mode 100644 index 000000000..edef3bf91 --- /dev/null +++ b/internal-packages/clickhouse/src/alertEvaluations.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { ClickhouseWriter } from "./client/types.js"; + +export const AlertEvaluationV1Input = z.object({ + alert_definition_id: z.string(), + organization_id: z.string(), + project_id: z.string().default(""), + environment_id: z.string().default(""), + evaluated_at: z.string(), // ISO 8601 datetime string + state: z.enum(["ok", "firing"]), + state_changed: z.number().int().min(0).max(1).default(0), + value: z.number().nullable().default(null), + conditions: z.string(), // JSON serialized conditions + query_duration_ms: z.number().int().default(0), + error_message: z.string().default(""), +}); + +export type AlertEvaluationV1Input = z.input; + +export function insertAlertEvaluations(ch: ClickhouseWriter) { + return ch.insertUnsafe({ + name: "insertAlertEvaluations", + table: "trigger_dev.alert_evaluations_v1", + settings: {}, + }); +} diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index b66ce8e3e..a54ae4a1a 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -27,6 +27,7 @@ import { getLogsSearchListQueryBuilder, } from "./taskEvents.js"; import { insertMetrics } from "./metrics.js"; +import { insertAlertEvaluations } from "./alertEvaluations.js"; import { Logger, type LogLevel } from "@trigger.dev/core/logger"; import type { Agent as HttpAgent } from "http"; import type { Agent as HttpsAgent } from "https"; @@ -34,6 +35,7 @@ import type { Agent as HttpsAgent } from "https"; export type * from "./taskRuns.js"; export type * from "./taskEvents.js"; export type * from "./metrics.js"; +export type * from "./alertEvaluations.js"; export type * from "./client/queryBuilder.js"; // Re-export column constants, indices, and type-safe accessors @@ -214,6 +216,12 @@ export class ClickHouse { }; } + get alertEvaluations() { + return { + insert: insertAlertEvaluations(this.writer), + }; + } + get taskEventsV2() { return { insert: insertTaskEventsV2(this.writer), diff --git a/internal-packages/database/prisma/migrations/20260225000000_add_alert_v2_definitions/migration.sql b/internal-packages/database/prisma/migrations/20260225000000_add_alert_v2_definitions/migration.sql new file mode 100644 index 000000000..689c08f76 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260225000000_add_alert_v2_definitions/migration.sql @@ -0,0 +1,59 @@ +-- CreateEnum +CREATE TYPE "public"."AlertV2State" AS ENUM ('OK', 'FIRING'); + +-- AlterEnum +ALTER TYPE "public"."ProjectAlertType" ADD VALUE 'ALERT_V2_FIRING'; +ALTER TYPE "public"."ProjectAlertType" ADD VALUE 'ALERT_V2_RESOLVED'; + +-- AlterTable +ALTER TABLE "public"."ProjectAlert" ADD COLUMN "alertV2DefinitionId" TEXT; + +-- CreateTable +CREATE TABLE "public"."AlertV2Definition" ( + "id" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "query" TEXT NOT NULL, + "scope" "public"."CustomerQueryScope" NOT NULL, + "queryPeriod" TEXT NOT NULL DEFAULT '1h', + "conditions" JSONB NOT NULL, + "evaluationIntervalSeconds" INTEGER NOT NULL DEFAULT 300, + "state" "public"."AlertV2State" NOT NULL DEFAULT 'OK', + "lastEvaluatedAt" TIMESTAMP(3), + "lastStateChangedAt" TIMESTAMP(3), + "alertChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "enabled" BOOLEAN NOT NULL DEFAULT true, + "organizationId" TEXT NOT NULL, + "projectId" TEXT, + "environmentId" TEXT, + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AlertV2Definition_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AlertV2Definition_friendlyId_key" ON "public"."AlertV2Definition" ("friendlyId"); + +-- CreateIndex +CREATE INDEX "AlertV2Definition_enabled_lastEvaluatedAt_idx" ON "public"."AlertV2Definition" ("enabled", "lastEvaluatedAt"); + +-- CreateIndex +CREATE INDEX "AlertV2Definition_organizationId_createdAt_idx" ON "public"."AlertV2Definition" ("organizationId", "createdAt" DESC); + +-- AddForeignKey +ALTER TABLE "public"."ProjectAlert" ADD CONSTRAINT "ProjectAlert_alertV2DefinitionId_fkey" FOREIGN KEY ("alertV2DefinitionId") REFERENCES "public"."AlertV2Definition" ("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."AlertV2Definition" ADD CONSTRAINT "AlertV2Definition_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."AlertV2Definition" ADD CONSTRAINT "AlertV2Definition_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."AlertV2Definition" ADD CONSTRAINT "AlertV2Definition_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "public"."RuntimeEnvironment" ("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."AlertV2Definition" ADD CONSTRAINT "AlertV2Definition_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "public"."User" ("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index e28b951f0..80a65183a 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -64,6 +64,7 @@ model User { impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget") customerQueries CustomerQuery[] metricsDashboards MetricsDashboard[] + alertV2Definitions AlertV2Definition[] } model MfaBackupCode { @@ -225,6 +226,7 @@ model Organization { githubAppInstallations GithubAppInstallation[] customerQueries CustomerQuery[] metricsDashboards MetricsDashboard[] + alertV2Definitions AlertV2Definition[] } model OrgMember { @@ -343,6 +345,7 @@ model RuntimeEnvironment { waitpointTags WaitpointTag[] BulkActionGroup BulkActionGroup[] customerQueries CustomerQuery[] + alertV2Definitions AlertV2Definition[] @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@ -413,6 +416,7 @@ model Project { buildSettings Json? taskScheduleInstances TaskScheduleInstance[] metricsDashboards MetricsDashboard[] + alertV2Definitions AlertV2Definition[] } enum ProjectVersion { @@ -2057,6 +2061,9 @@ model ProjectAlert { workerDeployment WorkerDeployment? @relation(fields: [workerDeploymentId], references: [id], onDelete: Cascade, onUpdate: Cascade) workerDeploymentId String? + alertV2Definition AlertV2Definition? @relation(fields: [alertV2DefinitionId], references: [id], onDelete: Cascade, onUpdate: Cascade) + alertV2DefinitionId String? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -2067,6 +2074,10 @@ enum ProjectAlertType { TASK_RUN_ATTEMPT DEPLOYMENT_FAILURE DEPLOYMENT_SUCCESS + /// Alerts 2.0 - query-based alert fired + ALERT_V2_FIRING + /// Alerts 2.0 - query-based alert resolved + ALERT_V2_RESOLVED } enum ProjectAlertStatus { @@ -2572,3 +2583,71 @@ model MetricsDashboard { /// Fast lookup for the list @@index([projectId, createdAt(sort: Desc)]) } + +/// Alerts 2.0 - query-based alert definition +model AlertV2Definition { + id String @id @default(cuid()) + friendlyId String @unique + + name String + description String? + + /// The TSQL query to execute against ClickHouse (same format as CustomerQuery.query) + query String + + /// The scope of the query - determines tenant isolation + scope CustomerQueryScope + + /// The lookback window for the query (e.g. "5m", "1h", "24h") + queryPeriod String @default("1h") + + /// JSON array of conditions that cause the alert to fire. + /// Format: [{"field": "count", "op": "gt", "value": 10}] + /// Supported ops: gt, gte, lt, lte, eq, neq + /// All conditions must be met for the alert to fire. + conditions Json + + /// How often to evaluate in seconds (minimum 60) + evaluationIntervalSeconds Int @default(300) + + /// Current state of the alert + state AlertV2State @default(OK) + + /// When the last evaluation was run + lastEvaluatedAt DateTime? + + /// When the alert last changed state (OK -> FIRING or FIRING -> OK) + lastStateChangedAt DateTime? + + /// IDs of ProjectAlertChannel records to notify on state change + alertChannelIds String[] + + enabled Boolean @default(true) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String? + + environment RuntimeEnvironment? @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String? + + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull, onUpdate: Cascade) + createdById String? + + alerts ProjectAlert[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// For finding due evaluations efficiently + @@index([enabled, lastEvaluatedAt]) + /// For org-level list views + @@index([organizationId, createdAt(sort: Desc)]) +} + +enum AlertV2State { + OK + FIRING +}