diff --git a/CHANGELOG.md b/CHANGELOG.md index 9328929..91ad15b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac - **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values. - **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext. - **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`. +- **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components. - **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection --filter … --order …`. ## [0.7.1] - 2026-09-23 diff --git a/ROADMAP.md b/ROADMAP.md index 42c309c..1ff7f87 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -307,6 +307,19 @@ Channel subscriptions over the Phase 7 SSE stream, in `@primora/client`. - Presence and client-broadcast deliberately deferred — protocol question, no consumer yet. +## Notifications — alert rules (unreleased) + +Telemetry-driven alerts delivered through the existing webhook dispatcher. + +- `core.alert_rules` (migration 00012) — `heartbeat_silence` and + `error_spike` kinds, per-component or project-wide scope, evaluated by a + 60 s backend ticker. +- Transition-based firing: `alert.fired` once on breach, `alert.resolved` + on recovery — no notification storms. Both are webhook event types and + realtime events, so any existing subscriber receives them signed. +- CRUD via `/projects/:id/alerts`; Integrations page manages rules next to + webhooks and shows live `firing` state. + ## Future phases — candidates Ordered loosely by leverage. None committed; each gets scoped when picked. @@ -317,8 +330,6 @@ Ordered loosely by leverage. None committed; each gets scoped when picked. - **Phase 10 — Storage backends** — S3-compatible object storage behind the existing bucket abstraction (deferred from Phase 6), plus presigned-URL uploads for large files. -- **Notifications** — alert rules on telemetry (error-rate thresholds, - heartbeat silence) fanning out through the webhook dispatcher. - **Inbound webhooks** — project-scoped ingest endpoints that turn external events into domain events/jobs. - **HA mode** — leader-elected scheduler + distributed locks so two diff --git a/apps/backend/db/migrations/00012_alert_rules.sql b/apps/backend/db/migrations/00012_alert_rules.sql new file mode 100644 index 0000000..e4dc5b1 --- /dev/null +++ b/apps/backend/db/migrations/00012_alert_rules.sql @@ -0,0 +1,16 @@ +-- +goose Up +CREATE TABLE core.alert_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('heartbeat_silence','error_spike')), + config JSONB NOT NULL DEFAULT '{}'::jsonb, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + state JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, name) +); + +-- +goose Down +DROP TABLE IF EXISTS core.alert_rules; diff --git a/apps/backend/db/queries/alert_rules.sql b/apps/backend/db/queries/alert_rules.sql new file mode 100644 index 0000000..3e46d70 --- /dev/null +++ b/apps/backend/db/queries/alert_rules.sql @@ -0,0 +1,49 @@ +-- name: CreateAlertRule :one +INSERT INTO core.alert_rules (project_id, name, kind, config) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: ListAlertRules :many +SELECT * FROM core.alert_rules +WHERE project_id = $1 +ORDER BY name; + +-- name: ListEnabledAlertRules :many +SELECT * FROM core.alert_rules +WHERE enabled; + +-- name: GetAlertRule :one +SELECT * FROM core.alert_rules +WHERE id = $1 AND project_id = $2; + +-- name: UpdateAlertRule :one +UPDATE core.alert_rules +SET name = $3, config = $4, enabled = $5, updated_at = NOW() +WHERE id = $1 AND project_id = $2 +RETURNING *; + +-- name: SetAlertRuleState :exec +UPDATE core.alert_rules +SET state = $3, updated_at = NOW() +WHERE id = $1 AND project_id = $2; + +-- name: DeleteAlertRule :exec +DELETE FROM core.alert_rules +WHERE id = $1 AND project_id = $2; + +-- Per-component most recent heartbeat — heartbeat_silence detection. +-- name: LastHeartbeats :many +SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat +FROM core.components co +JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat' +WHERE co.project_id = $1 +GROUP BY co.name; + +-- Per-component error count inside a sliding window — error_spike detection. +-- name: RecentErrorCounts :many +SELECT co.name AS component_name, count(*) AS errors +FROM core.events e +JOIN core.components co ON co.id = e.component_id +WHERE e.project_id = $1 AND e.type = 'error' + AND e.ts > now() - make_interval(secs => $2::bigint) +GROUP BY co.name; diff --git a/apps/backend/internal/database/db/alert_rules.sql.go b/apps/backend/internal/database/db/alert_rules.sql.go new file mode 100644 index 0000000..5b1d4ca --- /dev/null +++ b/apps/backend/internal/database/db/alert_rules.sql.go @@ -0,0 +1,290 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: alert_rules.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createAlertRule = `-- name: CreateAlertRule :one +INSERT INTO core.alert_rules (project_id, name, kind, config) +VALUES ($1, $2, $3, $4) +RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at +` + +type CreateAlertRuleParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Config []byte `json:"config"` +} + +func (q *Queries) CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) { + row := q.db.QueryRow(ctx, createAlertRule, + arg.ProjectID, + arg.Name, + arg.Kind, + arg.Config, + ) + var i CoreAlertRule + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Kind, + &i.Config, + &i.Enabled, + &i.State, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAlertRule = `-- name: DeleteAlertRule :exec +DELETE FROM core.alert_rules +WHERE id = $1 AND project_id = $2 +` + +type DeleteAlertRuleParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error { + _, err := q.db.Exec(ctx, deleteAlertRule, arg.ID, arg.ProjectID) + return err +} + +const getAlertRule = `-- name: GetAlertRule :one +SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules +WHERE id = $1 AND project_id = $2 +` + +type GetAlertRuleParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) { + row := q.db.QueryRow(ctx, getAlertRule, arg.ID, arg.ProjectID) + var i CoreAlertRule + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Kind, + &i.Config, + &i.Enabled, + &i.State, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const lastHeartbeats = `-- name: LastHeartbeats :many +SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat +FROM core.components co +JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat' +WHERE co.project_id = $1 +GROUP BY co.name +` + +type LastHeartbeatsRow struct { + ComponentName string `json:"component_name"` + LastBeat pgtype.Timestamptz `json:"last_beat"` +} + +// Per-component most recent heartbeat — heartbeat_silence detection. +func (q *Queries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) { + rows, err := q.db.Query(ctx, lastHeartbeats, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LastHeartbeatsRow + for rows.Next() { + var i LastHeartbeatsRow + if err := rows.Scan(&i.ComponentName, &i.LastBeat); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAlertRules = `-- name: ListAlertRules :many +SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules +WHERE project_id = $1 +ORDER BY name +` + +func (q *Queries) ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) { + rows, err := q.db.Query(ctx, listAlertRules, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CoreAlertRule + for rows.Next() { + var i CoreAlertRule + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Kind, + &i.Config, + &i.Enabled, + &i.State, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEnabledAlertRules = `-- name: ListEnabledAlertRules :many +SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules +WHERE enabled +` + +func (q *Queries) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) { + rows, err := q.db.Query(ctx, listEnabledAlertRules) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CoreAlertRule + for rows.Next() { + var i CoreAlertRule + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Kind, + &i.Config, + &i.Enabled, + &i.State, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const recentErrorCounts = `-- name: RecentErrorCounts :many +SELECT co.name AS component_name, count(*) AS errors +FROM core.events e +JOIN core.components co ON co.id = e.component_id +WHERE e.project_id = $1 AND e.type = 'error' + AND e.ts > now() - make_interval(secs => $2::bigint) +GROUP BY co.name +` + +type RecentErrorCountsParams struct { + ProjectID uuid.UUID `json:"project_id"` + Column2 int64 `json:"column_2"` +} + +type RecentErrorCountsRow struct { + ComponentName string `json:"component_name"` + Errors int64 `json:"errors"` +} + +// Per-component error count inside a sliding window — error_spike detection. +func (q *Queries) RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) { + rows, err := q.db.Query(ctx, recentErrorCounts, arg.ProjectID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecentErrorCountsRow + for rows.Next() { + var i RecentErrorCountsRow + if err := rows.Scan(&i.ComponentName, &i.Errors); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setAlertRuleState = `-- name: SetAlertRuleState :exec +UPDATE core.alert_rules +SET state = $3, updated_at = NOW() +WHERE id = $1 AND project_id = $2 +` + +type SetAlertRuleStateParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + State []byte `json:"state"` +} + +func (q *Queries) SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error { + _, err := q.db.Exec(ctx, setAlertRuleState, arg.ID, arg.ProjectID, arg.State) + return err +} + +const updateAlertRule = `-- name: UpdateAlertRule :one +UPDATE core.alert_rules +SET name = $3, config = $4, enabled = $5, updated_at = NOW() +WHERE id = $1 AND project_id = $2 +RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at +` + +type UpdateAlertRuleParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Config []byte `json:"config"` + Enabled bool `json:"enabled"` +} + +func (q *Queries) UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) { + row := q.db.QueryRow(ctx, updateAlertRule, + arg.ID, + arg.ProjectID, + arg.Name, + arg.Config, + arg.Enabled, + ) + var i CoreAlertRule + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Kind, + &i.Config, + &i.Enabled, + &i.State, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index 9f97578..ac84b38 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) { return string(ns.CoreProjectRole), nil } +type CoreAlertRule struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Config []byte `json:"config"` + Enabled bool `json:"enabled"` + State []byte `json:"state"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type CoreApiKey struct { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index 902086a..8f67465 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -23,6 +23,7 @@ type Querier interface { CountOrganizations(ctx context.Context) (int64, error) CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error) + CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error) CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error) CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error) @@ -35,6 +36,7 @@ type Querier interface { CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error) + DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error) DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error) DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error @@ -54,6 +56,7 @@ type Querier interface { FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error) GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error) + GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error) GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error) GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error) @@ -80,7 +83,10 @@ type Querier interface { InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) + // Per-component most recent heartbeat — heartbeat_silence detection. + LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error) + ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error) ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error) ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error) @@ -90,6 +96,7 @@ type Querier interface { ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) + ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error) @@ -114,10 +121,13 @@ type Querier interface { MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error) MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error) PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error + // Per-component error count inside a sliding window — error_spike detection. + RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error) RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error) RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error) + SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error SweepExpiredAuditLogs(ctx context.Context) (int64, error) // Per-project retention sweeps. A retention of 0 days disables the sweep for // that stream. Pending webhook deliveries are never swept — they may still be @@ -128,6 +138,7 @@ type Querier interface { SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error) SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error) TouchAPIKey(ctx context.Context, id uuid.UUID) error + UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error) diff --git a/apps/backend/internal/handlers/alerts.go b/apps/backend/internal/handlers/alerts.go new file mode 100644 index 0000000..d0ee024 --- /dev/null +++ b/apps/backend/internal/handlers/alerts.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/tdvorak/primora/apps/backend/internal/middleware" + apperrors "github.com/tdvorak/primora/apps/backend/internal/response" + "github.com/tdvorak/primora/apps/backend/internal/services" +) + +func (h *HTTPHandler) alertError(c *gin.Context, err error) { + if errors.Is(err, pgx.ErrNoRows) { + apperrors.Abort(c, http.StatusNotFound, "not_found", "alert rule not found") + return + } + msg := err.Error() + if strings.Contains(msg, "invalid") || strings.Contains(msg, "must be") || strings.Contains(msg, "already exists") || strings.Contains(msg, "too long") { + apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg) + return + } + h.handleError(c, err) +} + +func (h *HTTPHandler) listAlertRules(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + result, err := h.Platform.ListAlertRules(c.Request.Context(), actor, projectID) + if err != nil { + h.alertError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": result}) +} + +func (h *HTTPHandler) createAlertRule(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + var body services.UpsertAlertRuleInput + if !h.bindAndValidate(c, &body) { + return + } + result, err := h.Platform.CreateAlertRule(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c)) + if err != nil { + h.alertError(c, err) + return + } + c.JSON(http.StatusCreated, result) +} + +func (h *HTTPHandler) updateAlertRule(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + ruleID, err := uuid.Parse(c.Param("ruleID")) + if err != nil { + apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id") + return + } + var body services.UpsertAlertRuleInput + if !h.bindAndValidate(c, &body) { + return + } + result, err := h.Platform.UpdateAlertRule(c.Request.Context(), actor, projectID, ruleID, body, middleware.RequestIDFromContext(c)) + if err != nil { + h.alertError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) deleteAlertRule(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + ruleID, err := uuid.Parse(c.Param("ruleID")) + if err != nil { + apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id") + return + } + if err := h.Platform.DeleteAlertRule(c.Request.Context(), actor, projectID, ruleID, middleware.RequestIDFromContext(c)); err != nil { + h.alertError(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index 672aa00..4deea1a 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -121,6 +121,11 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret) api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret) api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret) + + api.GET("/projects/:projectID/alerts", h.listAlertRules) + api.POST("/projects/:projectID/alerts", h.createAlertRule) + api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule) + api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule) api.GET("/projects/:projectID/realtime/stream", h.realtimeStream) api.GET("/buckets/:bucketID/objects", h.listObjects) api.POST("/buckets/:bucketID/objects", h.uploadObject) diff --git a/apps/backend/internal/services/alerting.go b/apps/backend/internal/services/alerting.go new file mode 100644 index 0000000..587a3d4 --- /dev/null +++ b/apps/backend/internal/services/alerting.go @@ -0,0 +1,368 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + db "github.com/tdvorak/primora/apps/backend/internal/database/db" + "github.com/tdvorak/primora/apps/backend/internal/models" +) + +// Alert rules detect conditions in telemetry (component heartbeat silence, +// error-rate spikes) and fire `alert.fired` / `alert.resolved` domain events. +// Delivery reuses the webhook dispatcher + realtime stream — the evaluator is +// the only new machinery. Rules fire on state transitions only, so a flaky +// component produces one fired + one resolved, not a storm. + +const ( + AlertEventFired = "alert.fired" + AlertEventResolved = "alert.resolved" + + AlertKindHeartbeatSilence = "heartbeat_silence" + AlertKindErrorSpike = "error_spike" +) + +// AlertRuleConfig is the JSONB config per rule kind. +// +// heartbeat_silence: component beats less often than ThresholdMinutes. +// error_spike: component logs more than ThresholdCount errors in +// WindowMinutes. +// Component empty = evaluate every component in the project. +type AlertRuleConfig struct { + Component string `json:"component,omitempty"` + ThresholdMinutes int `json:"threshold_minutes,omitempty"` + ThresholdCount int64 `json:"threshold_count,omitempty"` + WindowMinutes int `json:"window_minutes,omitempty"` +} + +type AlertRuleSummary struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Config AlertRuleConfig `json:"config"` + Enabled bool `json:"enabled"` + Firing []string `json:"firing"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UpsertAlertRuleInput struct { + Name string `json:"name" validate:"required"` + Kind string `json:"kind" validate:"required"` + Config AlertRuleConfig `json:"config"` + Enabled *bool `json:"enabled"` +} + +func (s *PlatformService) validateAlertRule(input UpsertAlertRuleInput) (AlertRuleConfig, error) { + if !projectSecretNameRe.MatchString(input.Name) { + return AlertRuleConfig{}, fmt.Errorf("invalid alert rule name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name) + } + cfg := input.Config + switch input.Kind { + case AlertKindHeartbeatSilence: + if cfg.ThresholdMinutes < 1 || cfg.ThresholdMinutes > 10080 { + return cfg, errors.New("threshold_minutes must be 1–10080") + } + case AlertKindErrorSpike: + if cfg.ThresholdCount < 1 { + return cfg, errors.New("threshold_count must be ≥ 1") + } + if cfg.WindowMinutes == 0 { + cfg.WindowMinutes = 15 + } + if cfg.WindowMinutes < 1 || cfg.WindowMinutes > 1440 { + return cfg, errors.New("window_minutes must be 1–1440") + } + default: + return cfg, fmt.Errorf("invalid kind %q — %s or %s", input.Kind, AlertKindHeartbeatSilence, AlertKindErrorSpike) + } + if cfg.Component != "" && len(cfg.Component) > 128 { + return cfg, errors.New("component name too long") + } + return cfg, nil +} + +func alertRuleSummary(row db.CoreAlertRule) AlertRuleSummary { + var cfg AlertRuleConfig + _ = json.Unmarshal(row.Config, &cfg) + firing := parseFiring(row.State) + return AlertRuleSummary{ + ID: row.ID.String(), + ProjectID: row.ProjectID.String(), + Name: row.Name, + Kind: row.Kind, + Config: cfg, + Enabled: row.Enabled, + Firing: firing.list(), + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } +} + +func (s *PlatformService) ListAlertRules(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]AlertRuleSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + rows, err := s.repo.Queries().ListAlertRules(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]AlertRuleSummary, len(rows)) + for i, r := range rows { + out[i] = alertRuleSummary(r) + } + return out, nil +} + +func (s *PlatformService) CreateAlertRule(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return AlertRuleSummary{}, err + } + cfg, err := s.validateAlertRule(input) + if err != nil { + return AlertRuleSummary{}, err + } + rawCfg, _ := json.Marshal(cfg) + row, err := s.repo.Queries().CreateAlertRule(ctx, db.CreateAlertRuleParams{ + ProjectID: projectID, + Name: input.Name, + Kind: input.Kind, + Config: rawCfg, + }) + if err != nil { + if strings.Contains(err.Error(), "alert_rules_project_id_name_key") { + return AlertRuleSummary{}, fmt.Errorf("an alert rule named %s already exists", input.Name) + } + return AlertRuleSummary{}, err + } + s.auditAlert(ctx, actor, projectID, requestID, "alert.created", row.ID, input.Name) + return alertRuleSummary(row), nil +} + +func (s *PlatformService) UpdateAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return AlertRuleSummary{}, err + } + if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil { + return AlertRuleSummary{}, err // pgx.ErrNoRows → 404 at the handler + } + cfg, err := s.validateAlertRule(input) + if err != nil { + return AlertRuleSummary{}, err + } + enabled := true + if input.Enabled != nil { + enabled = *input.Enabled + } + rawCfg, _ := json.Marshal(cfg) + row, err := s.repo.Queries().UpdateAlertRule(ctx, db.UpdateAlertRuleParams{ + ID: ruleID, ProjectID: projectID, Name: input.Name, Config: rawCfg, Enabled: enabled, + }) + if err != nil { + return AlertRuleSummary{}, err + } + s.auditAlert(ctx, actor, projectID, requestID, "alert.updated", row.ID, input.Name) + return alertRuleSummary(row), nil +} + +func (s *PlatformService) DeleteAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, requestID string) error { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return err + } + if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil { + return err + } + if err := s.repo.Queries().DeleteAlertRule(ctx, db.DeleteAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil { + return err + } + s.auditAlert(ctx, actor, projectID, requestID, "alert.deleted", ruleID, "") + return nil +} + +func (s *PlatformService) auditAlert(ctx context.Context, actor *models.Actor, projectID uuid.UUID, requestID, action string, ruleID uuid.UUID, name string) { + project, err := s.repo.Queries().GetProjectByID(ctx, projectID) + if err != nil { + return + } + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, action, "alert_rule", ruleID.String(), map[string]any{"name": name})) +} + +// --------------------------------------------------------------------------- +// evaluator — one ticker per backend, transition-based firing +// --------------------------------------------------------------------------- + +type firingSet map[string]bool + +func parseFiring(raw []byte) firingSet { + var state struct { + Firing map[string]bool `json:"firing"` + } + if err := json.Unmarshal(raw, &state); err != nil || state.Firing == nil { + return firingSet{} + } + return state.Firing +} + +func (f firingSet) list() []string { + out := make([]string, 0, len(f)) + for name, on := range f { + if on { + out = append(out, name) + } + } + return out +} + +// alertQueries is the slice of db.Queries the evaluator needs — narrow so +// tests can stub it without a database. +type alertQueries interface { + ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error) + LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error) + RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error) + SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error +} + +type alertEvaluator struct { + q alertQueries + publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) + logger *slog.Logger + tick time.Duration + quit chan struct{} + wg sync.WaitGroup + started sync.Once +} + +func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger) *alertEvaluator { + return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{})} +} + +func (e *alertEvaluator) Start(ctx context.Context) { + e.started.Do(func() { + e.wg.Add(1) + go func() { + defer e.wg.Done() + t := time.NewTicker(e.tick) + defer t.Stop() + for { + select { + case <-t.C: + e.scan(ctx) + case <-e.quit: + return + case <-ctx.Done(): + return + } + } + }() + }) +} + +func (e *alertEvaluator) Stop() { + close(e.quit) + e.wg.Wait() +} + +func (e *alertEvaluator) scan(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + rules, err := e.q.ListEnabledAlertRules(ctx) + if err != nil { + e.logger.Warn("alert scan: list rules", "error", err) + return + } + for _, rule := range rules { + if err := e.evaluate(ctx, rule); err != nil { + e.logger.Warn("alert scan: evaluate", "rule", rule.Name, "error", err) + } + } +} + +func (e *alertEvaluator) evaluate(ctx context.Context, rule db.CoreAlertRule) error { + var cfg AlertRuleConfig + if err := json.Unmarshal(rule.Config, &cfg); err != nil { + return nil // bad config can't be fixed by re-scanning; skip quietly + } + now := time.Now() + var breached map[string]float64 // component → measured value + var metric, unit string + + switch rule.Kind { + case AlertKindHeartbeatSilence: + rows, err := e.q.LastHeartbeats(ctx, rule.ProjectID) + if err != nil { + return err + } + breached = map[string]float64{} + for _, r := range rows { + silent := now.Sub(r.LastBeat.Time).Minutes() + if silent >= float64(cfg.ThresholdMinutes) { + breached[r.ComponentName] = silent + } + } + metric, unit = "silence", "minutes" + + case AlertKindErrorSpike: + rows, err := e.q.RecentErrorCounts(ctx, db.RecentErrorCountsParams{ + ProjectID: rule.ProjectID, + Column2: int64(cfg.WindowMinutes) * 60, + }) + if err != nil { + return err + } + breached = map[string]float64{} + for _, r := range rows { + if r.Errors >= cfg.ThresholdCount { + breached[r.ComponentName] = float64(r.Errors) + } + } + metric, unit = "errors", "count" + + default: + return nil + } + + firing := parseFiring(rule.State) + changed := false + // newly breached components → fired + for comp, value := range breached { + if cfg.Component != "" && comp != cfg.Component { + continue + } + if firing[comp] { + continue + } + firing[comp] = true + changed = true + e.publish(ctx, rule.ProjectID, AlertEventFired, map[string]any{ + "rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind, + "component": comp, "metric": metric, "value": value, "unit": unit, + }) + } + // recovered components → resolved + for comp, on := range firing { + _, stillBreached := breached[comp] + if !on || stillBreached { + continue + } + firing[comp] = false + changed = true + e.publish(ctx, rule.ProjectID, AlertEventResolved, map[string]any{ + "rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind, + "component": comp, + }) + } + if !changed { + return nil + } + raw, _ := json.Marshal(map[string]any{"firing": firing}) + return e.q.SetAlertRuleState(ctx, db.SetAlertRuleStateParams{ID: rule.ID, ProjectID: rule.ProjectID, State: raw}) +} diff --git a/apps/backend/internal/services/alerting_test.go b/apps/backend/internal/services/alerting_test.go new file mode 100644 index 0000000..6ab4e18 --- /dev/null +++ b/apps/backend/internal/services/alerting_test.go @@ -0,0 +1,162 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + db "github.com/tdvorak/primora/apps/backend/internal/database/db" +) + +type stubAlertQueries struct { + rules []db.CoreAlertRule + beats []db.LastHeartbeatsRow + errors []db.RecentErrorCountsRow + stateSaved map[uuid.UUID][]byte +} + +func (s *stubAlertQueries) ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error) { + return s.rules, nil +} +func (s *stubAlertQueries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error) { + return s.beats, nil +} +func (s *stubAlertQueries) RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error) { + return s.errors, nil +} +func (s *stubAlertQueries) SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error { + if s.stateSaved == nil { + s.stateSaved = map[uuid.UUID][]byte{} + } + s.stateSaved[arg.ID] = arg.State + // persist back so subsequent evaluations see the transition + for i, r := range s.rules { + if r.ID == arg.ID { + s.rules[i].State = arg.State + } + } + return nil +} + +func rule(t *testing.T, kind string, cfg AlertRuleConfig) db.CoreAlertRule { + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + return db.CoreAlertRule{ID: uuid.New(), ProjectID: uuid.New(), Name: "r1", Kind: kind, Config: raw, State: []byte(`{"firing":{}}`)} +} + +func collect() (func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), *[]string) { + events := &[]string{} + return func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) { + *events = append(*events, eventType+":"+data["component"].(string)) + }, events +} + +func TestAlertSilenceFiresOnceThenResolves(t *testing.T) { + r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{ThresholdMinutes: 5}) + stub := &stubAlertQueries{ + rules: []db.CoreAlertRule{r}, + beats: []db.LastHeartbeatsRow{{ + ComponentName: "web", + LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-10 * time.Minute), Valid: true}, + }}, + } + publish, events := collect() + e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()} + + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 1 || (*events)[0] != "alert.fired:web" { + t.Fatalf("want one alert.fired:web, got %v", *events) + } + + // second evaluation — still silent, must not refire + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 1 { + t.Fatalf("refired: %v", *events) + } + + // heartbeat resumes → resolved + stub.beats[0].LastBeat = pgtype.Timestamptz{Time: time.Now(), Valid: true} + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 2 || (*events)[1] != "alert.resolved:web" { + t.Fatalf("want alert.resolved:web, got %v", *events) + } +} + +func TestAlertErrorSpikeThreshold(t *testing.T) { + r := rule(t, AlertKindErrorSpike, AlertRuleConfig{ThresholdCount: 3, WindowMinutes: 15}) + stub := &stubAlertQueries{ + rules: []db.CoreAlertRule{r}, + errors: []db.RecentErrorCountsRow{{ComponentName: "api", Errors: 2}}, + } + publish, events := collect() + e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()} + + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 0 { + t.Fatalf("under threshold fired: %v", *events) + } + + stub.errors[0].Errors = 5 + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 1 || (*events)[0] != "alert.fired:api" { + t.Fatalf("want alert.fired:api, got %v", *events) + } +} + +func TestAlertComponentFilter(t *testing.T) { + r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{Component: "web", ThresholdMinutes: 5}) + stub := &stubAlertQueries{ + rules: []db.CoreAlertRule{r}, + beats: []db.LastHeartbeatsRow{ + {ComponentName: "web", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}}, + {ComponentName: "api", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}}, + }, + } + publish, events := collect() + e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()} + + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 1 || (*events)[0] != "alert.fired:web" { + t.Fatalf("filtered rule must fire only for web, got %v", *events) + } +} + +func TestValidateAlertRule(t *testing.T) { + s := &PlatformService{} + if _, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}}); err != nil { + t.Fatalf("valid silence rule rejected: %v", err) + } + cfg, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 3}}) + if err != nil || cfg.WindowMinutes != 15 { + t.Fatalf("window default: cfg=%+v err=%v", cfg, err) + } + for _, bad := range []UpsertAlertRuleInput{ + {Name: "bad name!", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}}, + {Name: "x", Kind: "bogus", Config: AlertRuleConfig{}}, + {Name: "x", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 0}}, + {Name: "x", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 0}}, + } { + if _, err := s.validateAlertRule(bad); err == nil { + t.Fatalf("accepted invalid rule %+v", bad) + } + } +} diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index b1273bd..6b3c6d5 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -38,6 +38,7 @@ type PlatformService struct { enc *secrets.Encryptor dispatcher *WebhookDispatcher scheduler *JobScheduler + alerts *alertEvaluator } type BootstrapInput struct { @@ -230,6 +231,8 @@ func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalS s.dispatcher.Start(context.Background()) s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent) s.scheduler.Start(context.Background()) + s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger) + s.alerts.Start(context.Background()) } return s } @@ -242,6 +245,9 @@ func (s *PlatformService) Close() { if s.scheduler != nil { s.scheduler.Stop() } + if s.alerts != nil { + s.alerts.Stop() + } } // publishEvent fans a domain event out to realtime SSE subscribers and to diff --git a/apps/backend/internal/services/webhooks.go b/apps/backend/internal/services/webhooks.go index f9f6925..a2944b5 100644 --- a/apps/backend/internal/services/webhooks.go +++ b/apps/backend/internal/services/webhooks.go @@ -31,6 +31,8 @@ const ( WebhookEventIssueCreated = "issue.created" WebhookEventDeployMarker = "deploy.marker" WebhookEventTest = "webhook.test" + WebhookEventAlertFired = "alert.fired" + WebhookEventAlertResolved = "alert.resolved" WebhookEventDocumentCreated = "document.created" WebhookEventDocumentUpdated = "document.updated" WebhookEventDocumentDeleted = "document.deleted" @@ -43,6 +45,8 @@ var webhookEventTypes = map[string]bool{ WebhookEventIssueCreated: true, WebhookEventDeployMarker: true, WebhookEventTest: true, + WebhookEventAlertFired: true, + WebhookEventAlertResolved: true, WebhookEventJobRun: true, WebhookEventDocumentCreated: true, WebhookEventDocumentUpdated: true, diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index d7b0629..f8445ee 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -1941,6 +1941,106 @@ paths: $ref: "#/components/responses/ErrorResponse" "404": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/alerts: + get: + tags: [Alerts] + operationId: listAlertRules + summary: Lists telemetry alert rules for the project. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Alert rules + content: + application/json: + schema: + $ref: "#/components/schemas/AlertRuleListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + post: + tags: [Alerts] + operationId: createAlertRule + summary: Creates an alert rule. Rules fire `alert.fired`/`alert.resolved` domain events on state transitions — subscribe a webhook to those event types to receive them. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertAlertRuleRequest" + responses: + "201": + description: Alert rule created + content: + application/json: + schema: + $ref: "#/components/schemas/AlertRule" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/alerts/{ruleID}: + put: + tags: [Alerts] + operationId: updateAlertRule + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: ruleID + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertAlertRuleRequest" + responses: + "200": + description: Alert rule updated + content: + application/json: + schema: + $ref: "#/components/schemas/AlertRule" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + delete: + tags: [Alerts] + operationId: deleteAlertRule + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: ruleID + in: path + required: true + schema: + type: string + format: uuid + responses: + "204": + description: Alert rule deleted + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" components: securitySchemes: bearerAuth: @@ -3512,7 +3612,7 @@ components: type: array items: type: string - description: Event filter (issue.created, deploy.marker, webhook.test, job.run, document.*, object.*). Empty means all events. + description: Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.*, object.*). Empty means all events. enabled: type: boolean has_secret: @@ -3534,7 +3634,7 @@ components: type: array items: type: string - enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] + enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] enabled: type: boolean required: [url] @@ -3551,7 +3651,7 @@ components: type: array items: type: string - enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] + enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] enabled: type: boolean WebhookCreateResponse: @@ -3821,3 +3921,71 @@ components: value: type: string required: [name, value] + AlertRuleConfig: + type: object + properties: + component: + type: string + description: Restrict the rule to one component name; empty = all components. + threshold_minutes: + type: integer + description: heartbeat_silence — fire when a component has not heartbeated for this many minutes (1–10080). + threshold_count: + type: integer + format: int64 + description: error_spike — fire when a component logs at least this many errors inside the window. + window_minutes: + type: integer + description: error_spike — sliding window size (default 15, 1–1440). + AlertRule: + type: object + properties: + id: + type: string + format: uuid + project_id: + type: string + format: uuid + name: + type: string + kind: + type: string + enum: [heartbeat_silence, error_spike] + config: + $ref: "#/components/schemas/AlertRuleConfig" + enabled: + type: boolean + firing: + type: array + items: + type: string + description: Component names currently in breached state. + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: [id, project_id, name, kind, config, enabled, firing, created_at, updated_at] + AlertRuleListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/AlertRule" + required: [items] + UpsertAlertRuleRequest: + type: object + properties: + name: + type: string + description: Env-var-safe name ([A-Za-z_][A-Za-z0-9_]*). + kind: + type: string + enum: [heartbeat_silence, error_spike] + config: + $ref: "#/components/schemas/AlertRuleConfig" + enabled: + type: boolean + required: [name, kind] diff --git a/apps/frontend/src/pages/IntegrationsPage.tsx b/apps/frontend/src/pages/IntegrationsPage.tsx index 9422a72..359c4b3 100644 --- a/apps/frontend/src/pages/IntegrationsPage.tsx +++ b/apps/frontend/src/pages/IntegrationsPage.tsx @@ -1,8 +1,11 @@ import { For, Show, createEffect, createSignal } from "solid-js"; import { + AlertsService, CreateIntegrationRequest, IntegrationsService, + UpsertAlertRuleRequest, WebhooksService, + type AlertRule, type Integration, type IntegrationAnalytics, type Webhook, @@ -48,7 +51,7 @@ const statusVariant = (s?: string) => ? "warning" : "neutral"; -const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test"] as const; +const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test", "alert.fired", "alert.resolved"] as const; export function IntegrationsPage(props: IntegrationsPageProps) { const [tab, setTab] = createSignal("integrations"); @@ -59,8 +62,17 @@ export function IntegrationsPage(props: IntegrationsPageProps) { const [analyticsSite, setAnalyticsSite] = createSignal>({}); const [expandedIntegration, setExpandedIntegration] = createSignal(); const [expandedWebhook, setExpandedWebhook] = createSignal(); + const [alertRules, setAlertRules] = createSignal([]); const [showCreateIntegration, setShowCreateIntegration] = createSignal(false); const [showCreateWebhook, setShowCreateWebhook] = createSignal(false); + const [showCreateAlert, setShowCreateAlert] = createSignal(false); + const [alertForm, setAlertForm] = createSignal({ + name: "", + kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE as UpsertAlertRuleRequest.kind, + component: "", + threshold: "5", + window: "15", + }); const [integrationForm, setIntegrationForm] = createSignal({ name: "", base_url: "", api_key: "", site_id: "" }); const [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true }); const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" }); @@ -75,12 +87,14 @@ export function IntegrationsPage(props: IntegrationsPageProps) { setLoading(true); setError(""); try { - const [int, hooks] = await Promise.all([ + const [int, hooks, rules] = await Promise.all([ intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }), hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }), + props.demoMode ? Promise.resolve({ items: [] }) : AlertsService.listAlertRules({ projectId: props.projectID }), ]); setIntegrations(int.items ?? []); setWebhooks(hooks.items ?? []); + setAlertRules(rules.items ?? []); } catch (e) { setError(err(e)); } finally { @@ -205,6 +219,31 @@ export function IntegrationsPage(props: IntegrationsPageProps) { "Webhook deleted", ); + const createAlertRule = (e: Event) => { + e.preventDefault(); + const form = alertForm(); + const threshold = parseInt(form.threshold, 10) || 0; + const config = + form.kind === UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE + ? { component: form.component.trim() || undefined, threshold_minutes: threshold } + : { component: form.component.trim() || undefined, threshold_count: threshold, window_minutes: parseInt(form.window, 10) || 15 }; + void run("create-alert", async () => { + await AlertsService.createAlertRule({ + projectId: props.projectID!, + requestBody: { name: form.name.trim(), kind: form.kind, config }, + }); + setShowCreateAlert(false); + setAlertForm({ name: "", kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE, component: "", threshold: "5", window: "15" }); + }, "Alert rule created"); + }; + + const deleteAlertRule = (id: string) => + run( + `del-alert-${id}`, + () => AlertsService.deleteAlertRule({ projectId: props.projectID!, ruleId: id }), + "Alert rule deleted", + ); + const testWebhook = (id: string) => run( `test-hook-${id}`, @@ -506,6 +545,84 @@ export function IntegrationsPage(props: IntegrationsPageProps) { +
+
+
+
Alert rules
+
+ Telemetry conditions that fire alert.fired / alert.resolved events — + subscribe a webhook above to receive them. +
+
+ + + +
+ 0} + fallback={ +

+ No alert rules yet. Watch for heartbeat silence or error spikes across your components. +

+ } + > +
+ + + + + + + + + + + + + {(rule) => ( + + + + + + + + )} + + +
NameConditionScopeStateActions
{rule.name} + {rule.kind === "heartbeat_silence" + ? `no heartbeat for ${rule.config.threshold_minutes ?? "?"}m` + : `≥${rule.config.threshold_count ?? "?"} errors in ${rule.config.window_minutes ?? 15}m`} + + all components}> + {rule.config.component} + + + 0} + fallback={{rule.enabled ? "watching" : "disabled"}} + > + firing: {rule.firing.join(", ")} + + + + + +
+
+
+
+
@@ -639,6 +756,58 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
+ + setShowCreateAlert(false)} title="Add alert rule" size="md"> +
+ setAlertForm((c) => ({ ...c, name: e.currentTarget.value }))} + /> + setAlertForm((c) => ({ ...c, component: e.currentTarget.value }))} + /> +
+ setAlertForm((c) => ({ ...c, threshold: e.currentTarget.value }))} + /> + + setAlertForm((c) => ({ ...c, window: e.currentTarget.value }))} + /> + +
+
+ + +
+
+
); } diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts index 18299f0..5003850 100644 --- a/packages/api-client/src/generated/index.ts +++ b/packages/api-client/src/generated/index.ts @@ -8,6 +8,9 @@ export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; export type { AcceptInvitationRequest } from './models/AcceptInvitationRequest'; +export { AlertRule } from './models/AlertRule'; +export type { AlertRuleConfig } from './models/AlertRuleConfig'; +export type { AlertRuleListResponse } from './models/AlertRuleListResponse'; export type { ApiKey } from './models/ApiKey'; export type { ApiKeyID } from './models/ApiKeyID'; export type { AuditAction } from './models/AuditAction'; @@ -130,6 +133,7 @@ export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRole export type { UpdateProjectRequest } from './models/UpdateProjectRequest'; export type { UpdateScheduledJobRequest } from './models/UpdateScheduledJobRequest'; export type { UpdateWebhookRequest } from './models/UpdateWebhookRequest'; +export { UpsertAlertRuleRequest } from './models/UpsertAlertRuleRequest'; export type { UserID } from './models/UserID'; export type { UserSummary } from './models/UserSummary'; export type { Webhook } from './models/Webhook'; @@ -140,6 +144,9 @@ export type { WebhookID } from './models/WebhookID'; export type { WebhookListResponse } from './models/WebhookListResponse'; export { $AcceptInvitationRequest } from './schemas/$AcceptInvitationRequest'; +export { $AlertRule } from './schemas/$AlertRule'; +export { $AlertRuleConfig } from './schemas/$AlertRuleConfig'; +export { $AlertRuleListResponse } from './schemas/$AlertRuleListResponse'; export { $ApiKey } from './schemas/$ApiKey'; export { $ApiKeyID } from './schemas/$ApiKeyID'; export { $AuditAction } from './schemas/$AuditAction'; @@ -262,6 +269,7 @@ export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberR export { $UpdateProjectRequest } from './schemas/$UpdateProjectRequest'; export { $UpdateScheduledJobRequest } from './schemas/$UpdateScheduledJobRequest'; export { $UpdateWebhookRequest } from './schemas/$UpdateWebhookRequest'; +export { $UpsertAlertRuleRequest } from './schemas/$UpsertAlertRuleRequest'; export { $UserID } from './schemas/$UserID'; export { $UserSummary } from './schemas/$UserSummary'; export { $Webhook } from './schemas/$Webhook'; @@ -271,6 +279,7 @@ export { $WebhookDeliveryListResponse } from './schemas/$WebhookDeliveryListResp export { $WebhookID } from './schemas/$WebhookID'; export { $WebhookListResponse } from './schemas/$WebhookListResponse'; +export { AlertsService } from './services/AlertsService'; export { AutomationService } from './services/AutomationService'; export { CollectionsService } from './services/CollectionsService'; export { DatabasesService } from './services/DatabasesService'; diff --git a/packages/api-client/src/generated/models/AlertRule.ts b/packages/api-client/src/generated/models/AlertRule.ts new file mode 100644 index 0000000..eae3976 --- /dev/null +++ b/packages/api-client/src/generated/models/AlertRule.ts @@ -0,0 +1,26 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AlertRuleConfig } from './AlertRuleConfig'; +export type AlertRule = { + id: string; + project_id: string; + name: string; + kind: AlertRule.kind; + config: AlertRuleConfig; + enabled: boolean; + /** + * Component names currently in breached state. + */ + firing: Array; + created_at: string; + updated_at: string; +}; +export namespace AlertRule { + export enum kind { + HEARTBEAT_SILENCE = 'heartbeat_silence', + ERROR_SPIKE = 'error_spike', + } +} + diff --git a/packages/api-client/src/generated/models/AlertRuleConfig.ts b/packages/api-client/src/generated/models/AlertRuleConfig.ts new file mode 100644 index 0000000..01732b4 --- /dev/null +++ b/packages/api-client/src/generated/models/AlertRuleConfig.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type AlertRuleConfig = { + /** + * Restrict the rule to one component name; empty = all components. + */ + component?: string; + /** + * heartbeat_silence — fire when a component has not heartbeated for this many minutes (1–10080). + */ + threshold_minutes?: number; + /** + * error_spike — fire when a component logs at least this many errors inside the window. + */ + threshold_count?: number; + /** + * error_spike — sliding window size (default 15, 1–1440). + */ + window_minutes?: number; +}; + diff --git a/packages/api-client/src/generated/models/AlertRuleListResponse.ts b/packages/api-client/src/generated/models/AlertRuleListResponse.ts new file mode 100644 index 0000000..80b4263 --- /dev/null +++ b/packages/api-client/src/generated/models/AlertRuleListResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AlertRule } from './AlertRule'; +export type AlertRuleListResponse = { + items: Array; +}; + diff --git a/packages/api-client/src/generated/models/CreateWebhookRequest.ts b/packages/api-client/src/generated/models/CreateWebhookRequest.ts index f6e3229..56f934a 100644 --- a/packages/api-client/src/generated/models/CreateWebhookRequest.ts +++ b/packages/api-client/src/generated/models/CreateWebhookRequest.ts @@ -8,7 +8,7 @@ export type CreateWebhookRequest = { * HMAC key for X-Primora-Signature. Generated when omitted. */ secret?: string; - events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; + events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'alert.fired' | 'alert.resolved' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; enabled?: boolean; }; diff --git a/packages/api-client/src/generated/models/UpdateWebhookRequest.ts b/packages/api-client/src/generated/models/UpdateWebhookRequest.ts index 1951e41..a642879 100644 --- a/packages/api-client/src/generated/models/UpdateWebhookRequest.ts +++ b/packages/api-client/src/generated/models/UpdateWebhookRequest.ts @@ -8,7 +8,7 @@ export type UpdateWebhookRequest = { * Replace the signing secret. Omit to keep the current one. */ secret?: string; - events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; + events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'alert.fired' | 'alert.resolved' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; enabled?: boolean; }; diff --git a/packages/api-client/src/generated/models/UpsertAlertRuleRequest.ts b/packages/api-client/src/generated/models/UpsertAlertRuleRequest.ts new file mode 100644 index 0000000..0dbcec9 --- /dev/null +++ b/packages/api-client/src/generated/models/UpsertAlertRuleRequest.ts @@ -0,0 +1,21 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AlertRuleConfig } from './AlertRuleConfig'; +export type UpsertAlertRuleRequest = { + /** + * Env-var-safe name ([A-Za-z_][A-Za-z0-9_]*). + */ + name: string; + kind: UpsertAlertRuleRequest.kind; + config?: AlertRuleConfig; + enabled?: boolean; +}; +export namespace UpsertAlertRuleRequest { + export enum kind { + HEARTBEAT_SILENCE = 'heartbeat_silence', + ERROR_SPIKE = 'error_spike', + } +} + diff --git a/packages/api-client/src/generated/models/Webhook.ts b/packages/api-client/src/generated/models/Webhook.ts index 34c5496..cbf89a2 100644 --- a/packages/api-client/src/generated/models/Webhook.ts +++ b/packages/api-client/src/generated/models/Webhook.ts @@ -7,7 +7,7 @@ export type Webhook = { project_id: string; url: string; /** - * Event filter (issue.created, deploy.marker, webhook.test, job.run, document.*, object.*). Empty means all events. + * Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.*, object.*). Empty means all events. */ events: Array; enabled: boolean; diff --git a/packages/api-client/src/generated/schemas/$AlertRule.ts b/packages/api-client/src/generated/schemas/$AlertRule.ts new file mode 100644 index 0000000..af2aacf --- /dev/null +++ b/packages/api-client/src/generated/schemas/$AlertRule.ts @@ -0,0 +1,51 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $AlertRule = { + properties: { + id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + project_id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + name: { + type: 'string', + isRequired: true, + }, + kind: { + type: 'Enum', + isRequired: true, + }, + config: { + type: 'AlertRuleConfig', + isRequired: true, + }, + enabled: { + type: 'boolean', + isRequired: true, + }, + firing: { + type: 'array', + contains: { + type: 'string', + }, + isRequired: true, + }, + created_at: { + type: 'string', + isRequired: true, + format: 'date-time', + }, + updated_at: { + type: 'string', + isRequired: true, + format: 'date-time', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$AlertRuleConfig.ts b/packages/api-client/src/generated/schemas/$AlertRuleConfig.ts new file mode 100644 index 0000000..4f559c4 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$AlertRuleConfig.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $AlertRuleConfig = { + properties: { + component: { + type: 'string', + description: `Restrict the rule to one component name; empty = all components.`, + }, + threshold_minutes: { + type: 'number', + description: `heartbeat_silence — fire when a component has not heartbeated for this many minutes (1–10080).`, + }, + threshold_count: { + type: 'number', + description: `error_spike — fire when a component logs at least this many errors inside the window.`, + format: 'int64', + }, + window_minutes: { + type: 'number', + description: `error_spike — sliding window size (default 15, 1–1440).`, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$AlertRuleListResponse.ts b/packages/api-client/src/generated/schemas/$AlertRuleListResponse.ts new file mode 100644 index 0000000..0f184de --- /dev/null +++ b/packages/api-client/src/generated/schemas/$AlertRuleListResponse.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $AlertRuleListResponse = { + properties: { + items: { + type: 'array', + contains: { + type: 'AlertRule', + }, + isRequired: true, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$UpsertAlertRuleRequest.ts b/packages/api-client/src/generated/schemas/$UpsertAlertRuleRequest.ts new file mode 100644 index 0000000..b73574f --- /dev/null +++ b/packages/api-client/src/generated/schemas/$UpsertAlertRuleRequest.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $UpsertAlertRuleRequest = { + properties: { + name: { + type: 'string', + description: `Env-var-safe name ([A-Za-z_][A-Za-z0-9_]*).`, + isRequired: true, + }, + kind: { + type: 'Enum', + isRequired: true, + }, + config: { + type: 'AlertRuleConfig', + }, + enabled: { + type: 'boolean', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/services/AlertsService.ts b/packages/api-client/src/generated/services/AlertsService.ts new file mode 100644 index 0000000..ab39f8e --- /dev/null +++ b/packages/api-client/src/generated/services/AlertsService.ts @@ -0,0 +1,112 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AlertRule } from '../models/AlertRule'; +import type { AlertRuleListResponse } from '../models/AlertRuleListResponse'; +import type { UpsertAlertRuleRequest } from '../models/UpsertAlertRuleRequest'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class AlertsService { + /** + * Lists telemetry alert rules for the project. + * @returns AlertRuleListResponse Alert rules + * @throws ApiError + */ + public static listAlertRules({ + projectId, + }: { + projectId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/projects/{projectID}/alerts', + path: { + 'projectID': projectId, + }, + errors: { + 401: `Error response`, + }, + }); + } + /** + * Creates an alert rule. Rules fire `alert.fired`/`alert.resolved` domain events on state transitions — subscribe a webhook to those event types to receive them. + * @returns AlertRule Alert rule created + * @throws ApiError + */ + public static createAlertRule({ + projectId, + requestBody, + }: { + projectId: string, + requestBody: UpsertAlertRuleRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/projects/{projectID}/alerts', + path: { + 'projectID': projectId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Error response`, + 401: `Error response`, + }, + }); + } + /** + * @returns AlertRule Alert rule updated + * @throws ApiError + */ + public static updateAlertRule({ + projectId, + ruleId, + requestBody, + }: { + projectId: string, + ruleId: string, + requestBody: UpsertAlertRuleRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/projects/{projectID}/alerts/{ruleID}', + path: { + 'projectID': projectId, + 'ruleID': ruleId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Error response`, + 401: `Error response`, + 404: `Error response`, + }, + }); + } + /** + * @returns void + * @throws ApiError + */ + public static deleteAlertRule({ + projectId, + ruleId, + }: { + projectId: string, + ruleId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/projects/{projectID}/alerts/{ruleID}', + path: { + 'projectID': projectId, + 'ruleID': ruleId, + }, + errors: { + 401: `Error response`, + 404: `Error response`, + }, + }); + } +}