mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: telemetry alert rules with webhook delivery
core.alert_rules (migration 00012) — heartbeat_silence and error_spike rules evaluated by a 60s backend ticker. Transition-based firing: alert.fired on breach, alert.resolved on recovery, both first-class webhook event types riding the signed dispatcher + realtime stream. Dashboard manages rules on the Integrations page. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
345cf7918b
commit
2f4de543a9
28 files changed
+1693
-10
No files matched your search
@@ -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.
|
- **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.
|
- **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`.
|
- **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 <slug> --filter … --order …`.
|
- **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 <slug> --filter … --order …`.
|
||||||
|
|
||||||
## [0.7.1] - 2026-09-23
|
## [0.7.1] - 2026-09-23
|
||||||
|
|||||||
+13
-2
@@ -307,6 +307,19 @@ Channel subscriptions over the Phase 7 SSE stream, in `@primora/client`.
|
|||||||
- Presence and client-broadcast deliberately deferred — protocol question,
|
- Presence and client-broadcast deliberately deferred — protocol question,
|
||||||
no consumer yet.
|
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
|
## Future phases — candidates
|
||||||
|
|
||||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
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
|
- **Phase 10 — Storage backends** — S3-compatible object storage behind
|
||||||
the existing bucket abstraction (deferred from Phase 6), plus
|
the existing bucket abstraction (deferred from Phase 6), plus
|
||||||
presigned-URL uploads for large files.
|
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
|
- **Inbound webhooks** — project-scoped ingest endpoints that turn
|
||||||
external events into domain events/jobs.
|
external events into domain events/jobs.
|
||||||
- **HA mode** — leader-elected scheduler + distributed locks so two
|
- **HA mode** — leader-elected scheduler + distributed locks so two
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) {
|
|||||||
return string(ns.CoreProjectRole), nil
|
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 {
|
type CoreApiKey struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Querier interface {
|
|||||||
CountOrganizations(ctx context.Context) (int64, error)
|
CountOrganizations(ctx context.Context) (int64, error)
|
||||||
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
|
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||||
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, 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)
|
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
|
||||||
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
|
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
|
||||||
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
|
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
|
||||||
@@ -35,6 +36,7 @@ type Querier interface {
|
|||||||
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
|
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
|
||||||
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
|
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
|
||||||
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, 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)
|
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
||||||
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||||
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
||||||
@@ -54,6 +56,7 @@ type Querier interface {
|
|||||||
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
|
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||||
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
||||||
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, 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)
|
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
||||||
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||||
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, 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)
|
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||||
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||||
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, 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)
|
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)
|
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
||||||
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
||||||
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, 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)
|
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
||||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
||||||
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
||||||
|
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||||
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, 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)
|
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
||||||
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
||||||
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) 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)
|
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
|
||||||
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
||||||
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
|
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
|
||||||
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
|
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
|
||||||
|
SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error
|
||||||
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
|
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
|
||||||
// Per-project retention sweeps. A retention of 0 days disables the sweep for
|
// 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
|
// 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)
|
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
||||||
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
||||||
TouchAPIKey(ctx context.Context, id uuid.UUID) 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)
|
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -121,6 +121,11 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
|
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
|
||||||
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
|
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
|
||||||
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
|
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("/projects/:projectID/realtime/stream", h.realtimeStream)
|
||||||
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
||||||
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ type PlatformService struct {
|
|||||||
enc *secrets.Encryptor
|
enc *secrets.Encryptor
|
||||||
dispatcher *WebhookDispatcher
|
dispatcher *WebhookDispatcher
|
||||||
scheduler *JobScheduler
|
scheduler *JobScheduler
|
||||||
|
alerts *alertEvaluator
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
@@ -230,6 +231,8 @@ func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalS
|
|||||||
s.dispatcher.Start(context.Background())
|
s.dispatcher.Start(context.Background())
|
||||||
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
|
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
|
||||||
s.scheduler.Start(context.Background())
|
s.scheduler.Start(context.Background())
|
||||||
|
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger)
|
||||||
|
s.alerts.Start(context.Background())
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -242,6 +245,9 @@ func (s *PlatformService) Close() {
|
|||||||
if s.scheduler != nil {
|
if s.scheduler != nil {
|
||||||
s.scheduler.Stop()
|
s.scheduler.Stop()
|
||||||
}
|
}
|
||||||
|
if s.alerts != nil {
|
||||||
|
s.alerts.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishEvent fans a domain event out to realtime SSE subscribers and to
|
// publishEvent fans a domain event out to realtime SSE subscribers and to
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const (
|
|||||||
WebhookEventIssueCreated = "issue.created"
|
WebhookEventIssueCreated = "issue.created"
|
||||||
WebhookEventDeployMarker = "deploy.marker"
|
WebhookEventDeployMarker = "deploy.marker"
|
||||||
WebhookEventTest = "webhook.test"
|
WebhookEventTest = "webhook.test"
|
||||||
|
WebhookEventAlertFired = "alert.fired"
|
||||||
|
WebhookEventAlertResolved = "alert.resolved"
|
||||||
WebhookEventDocumentCreated = "document.created"
|
WebhookEventDocumentCreated = "document.created"
|
||||||
WebhookEventDocumentUpdated = "document.updated"
|
WebhookEventDocumentUpdated = "document.updated"
|
||||||
WebhookEventDocumentDeleted = "document.deleted"
|
WebhookEventDocumentDeleted = "document.deleted"
|
||||||
@@ -43,6 +45,8 @@ var webhookEventTypes = map[string]bool{
|
|||||||
WebhookEventIssueCreated: true,
|
WebhookEventIssueCreated: true,
|
||||||
WebhookEventDeployMarker: true,
|
WebhookEventDeployMarker: true,
|
||||||
WebhookEventTest: true,
|
WebhookEventTest: true,
|
||||||
|
WebhookEventAlertFired: true,
|
||||||
|
WebhookEventAlertResolved: true,
|
||||||
WebhookEventJobRun: true,
|
WebhookEventJobRun: true,
|
||||||
WebhookEventDocumentCreated: true,
|
WebhookEventDocumentCreated: true,
|
||||||
WebhookEventDocumentUpdated: true,
|
WebhookEventDocumentUpdated: true,
|
||||||
|
|||||||
@@ -1941,6 +1941,106 @@ paths:
|
|||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
@@ -3512,7 +3612,7 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
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:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
has_secret:
|
has_secret:
|
||||||
@@ -3534,7 +3634,7 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
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:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
required: [url]
|
required: [url]
|
||||||
@@ -3551,7 +3651,7 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
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:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
WebhookCreateResponse:
|
WebhookCreateResponse:
|
||||||
@@ -3821,3 +3921,71 @@ components:
|
|||||||
value:
|
value:
|
||||||
type: string
|
type: string
|
||||||
required: [name, value]
|
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]
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { For, Show, createEffect, createSignal } from "solid-js";
|
import { For, Show, createEffect, createSignal } from "solid-js";
|
||||||
import {
|
import {
|
||||||
|
AlertsService,
|
||||||
CreateIntegrationRequest,
|
CreateIntegrationRequest,
|
||||||
IntegrationsService,
|
IntegrationsService,
|
||||||
|
UpsertAlertRuleRequest,
|
||||||
WebhooksService,
|
WebhooksService,
|
||||||
|
type AlertRule,
|
||||||
type Integration,
|
type Integration,
|
||||||
type IntegrationAnalytics,
|
type IntegrationAnalytics,
|
||||||
type Webhook,
|
type Webhook,
|
||||||
@@ -48,7 +51,7 @@ const statusVariant = (s?: string) =>
|
|||||||
? "warning"
|
? "warning"
|
||||||
: "neutral";
|
: "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) {
|
export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||||
const [tab, setTab] = createSignal<Tab>("integrations");
|
const [tab, setTab] = createSignal<Tab>("integrations");
|
||||||
@@ -59,8 +62,17 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
const [analyticsSite, setAnalyticsSite] = createSignal<Record<string, string>>({});
|
const [analyticsSite, setAnalyticsSite] = createSignal<Record<string, string>>({});
|
||||||
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
|
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
|
||||||
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
|
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
|
||||||
|
const [alertRules, setAlertRules] = createSignal<AlertRule[]>([]);
|
||||||
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
|
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
|
||||||
const [showCreateWebhook, setShowCreateWebhook] = 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 [integrationForm, setIntegrationForm] = createSignal({ name: "", base_url: "", api_key: "", site_id: "" });
|
||||||
const [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true });
|
const [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true });
|
||||||
const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" });
|
const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" });
|
||||||
@@ -75,12 +87,14 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [int, hooks] = await Promise.all([
|
const [int, hooks, rules] = await Promise.all([
|
||||||
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
|
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
|
||||||
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
|
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
|
||||||
|
props.demoMode ? Promise.resolve({ items: [] }) : AlertsService.listAlertRules({ projectId: props.projectID }),
|
||||||
]);
|
]);
|
||||||
setIntegrations(int.items ?? []);
|
setIntegrations(int.items ?? []);
|
||||||
setWebhooks(hooks.items ?? []);
|
setWebhooks(hooks.items ?? []);
|
||||||
|
setAlertRules(rules.items ?? []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(err(e));
|
setError(err(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -205,6 +219,31 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
"Webhook deleted",
|
"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) =>
|
const testWebhook = (id: string) =>
|
||||||
run(
|
run(
|
||||||
`test-hook-${id}`,
|
`test-hook-${id}`,
|
||||||
@@ -506,6 +545,84 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-flush mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card-header-title">Alert rules</div>
|
||||||
|
<div class="card-header-description">
|
||||||
|
Telemetry conditions that fire <code>alert.fired</code> / <code>alert.resolved</code> events —
|
||||||
|
subscribe a webhook above to receive them.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateAlert(true)}>
|
||||||
|
<IconPlus class="w-4 h-4" />
|
||||||
|
Add rule
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={alertRules().length > 0}
|
||||||
|
fallback={
|
||||||
|
<p class="text-text-2 text-sm p-4">
|
||||||
|
No alert rules yet. Watch for heartbeat silence or error spikes across your components.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Condition</th>
|
||||||
|
<th>Scope</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th style="text-align:right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={alertRules()}>
|
||||||
|
{(rule) => (
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{rule.name}</td>
|
||||||
|
<td class="text-text-2">
|
||||||
|
{rule.kind === "heartbeat_silence"
|
||||||
|
? `no heartbeat for ${rule.config.threshold_minutes ?? "?"}m`
|
||||||
|
: `≥${rule.config.threshold_count ?? "?"} errors in ${rule.config.window_minutes ?? 15}m`}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Show when={rule.config.component} fallback={<span class="text-text-2">all components</span>}>
|
||||||
|
<Badge variant="neutral">{rule.config.component}</Badge>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Show
|
||||||
|
when={rule.firing.length > 0}
|
||||||
|
fallback={<Badge variant={rule.enabled ? "success" : "neutral"}>{rule.enabled ? "watching" : "disabled"}</Badge>}
|
||||||
|
>
|
||||||
|
<Badge variant="error">firing: {rule.firing.join(", ")}</Badge>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;white-space:nowrap">
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void deleteAlertRule(rule.id)}
|
||||||
|
disabled={busy() === `del-alert-${rule.id}`}
|
||||||
|
>
|
||||||
|
<IconTrash class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Show when={props.canManage}>
|
<Show when={props.canManage}>
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -639,6 +756,58 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={showCreateAlert()} onClose={() => setShowCreateAlert(false)} title="Add alert rule" size="md">
|
||||||
|
<form onSubmit={createAlertRule} class="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
placeholder="api_down"
|
||||||
|
required
|
||||||
|
value={alertForm().name}
|
||||||
|
onInput={(e) => setAlertForm((c) => ({ ...c, name: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Condition"
|
||||||
|
value={alertForm().kind}
|
||||||
|
onChange={(e) => setAlertForm((c) => ({ ...c, kind: e.currentTarget.value as UpsertAlertRuleRequest.kind }))}
|
||||||
|
options={[
|
||||||
|
{ value: "heartbeat_silence", label: "Heartbeat silence — component stops reporting" },
|
||||||
|
{ value: "error_spike", label: "Error spike — too many errors in a window" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Component"
|
||||||
|
placeholder="empty = all components"
|
||||||
|
value={alertForm().component}
|
||||||
|
onInput={(e) => setAlertForm((c) => ({ ...c, component: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label={alertForm().kind === UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE ? "Silent for (minutes)" : "Errors at least"}
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={alertForm().threshold}
|
||||||
|
onInput={(e) => setAlertForm((c) => ({ ...c, threshold: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Show when={alertForm().kind === UpsertAlertRuleRequest.kind.ERROR_SPIKE}>
|
||||||
|
<Input
|
||||||
|
label="Window (minutes)"
|
||||||
|
type="number"
|
||||||
|
value={alertForm().window}
|
||||||
|
onInput={(e) => setAlertForm((c) => ({ ...c, window: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateAlert(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={busy() === "create-alert"}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export { OpenAPI } from './core/OpenAPI';
|
|||||||
export type { OpenAPIConfig } from './core/OpenAPI';
|
export type { OpenAPIConfig } from './core/OpenAPI';
|
||||||
|
|
||||||
export type { AcceptInvitationRequest } from './models/AcceptInvitationRequest';
|
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 { ApiKey } from './models/ApiKey';
|
||||||
export type { ApiKeyID } from './models/ApiKeyID';
|
export type { ApiKeyID } from './models/ApiKeyID';
|
||||||
export type { AuditAction } from './models/AuditAction';
|
export type { AuditAction } from './models/AuditAction';
|
||||||
@@ -130,6 +133,7 @@ export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRole
|
|||||||
export type { UpdateProjectRequest } from './models/UpdateProjectRequest';
|
export type { UpdateProjectRequest } from './models/UpdateProjectRequest';
|
||||||
export type { UpdateScheduledJobRequest } from './models/UpdateScheduledJobRequest';
|
export type { UpdateScheduledJobRequest } from './models/UpdateScheduledJobRequest';
|
||||||
export type { UpdateWebhookRequest } from './models/UpdateWebhookRequest';
|
export type { UpdateWebhookRequest } from './models/UpdateWebhookRequest';
|
||||||
|
export { UpsertAlertRuleRequest } from './models/UpsertAlertRuleRequest';
|
||||||
export type { UserID } from './models/UserID';
|
export type { UserID } from './models/UserID';
|
||||||
export type { UserSummary } from './models/UserSummary';
|
export type { UserSummary } from './models/UserSummary';
|
||||||
export type { Webhook } from './models/Webhook';
|
export type { Webhook } from './models/Webhook';
|
||||||
@@ -140,6 +144,9 @@ export type { WebhookID } from './models/WebhookID';
|
|||||||
export type { WebhookListResponse } from './models/WebhookListResponse';
|
export type { WebhookListResponse } from './models/WebhookListResponse';
|
||||||
|
|
||||||
export { $AcceptInvitationRequest } from './schemas/$AcceptInvitationRequest';
|
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 { $ApiKey } from './schemas/$ApiKey';
|
||||||
export { $ApiKeyID } from './schemas/$ApiKeyID';
|
export { $ApiKeyID } from './schemas/$ApiKeyID';
|
||||||
export { $AuditAction } from './schemas/$AuditAction';
|
export { $AuditAction } from './schemas/$AuditAction';
|
||||||
@@ -262,6 +269,7 @@ export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberR
|
|||||||
export { $UpdateProjectRequest } from './schemas/$UpdateProjectRequest';
|
export { $UpdateProjectRequest } from './schemas/$UpdateProjectRequest';
|
||||||
export { $UpdateScheduledJobRequest } from './schemas/$UpdateScheduledJobRequest';
|
export { $UpdateScheduledJobRequest } from './schemas/$UpdateScheduledJobRequest';
|
||||||
export { $UpdateWebhookRequest } from './schemas/$UpdateWebhookRequest';
|
export { $UpdateWebhookRequest } from './schemas/$UpdateWebhookRequest';
|
||||||
|
export { $UpsertAlertRuleRequest } from './schemas/$UpsertAlertRuleRequest';
|
||||||
export { $UserID } from './schemas/$UserID';
|
export { $UserID } from './schemas/$UserID';
|
||||||
export { $UserSummary } from './schemas/$UserSummary';
|
export { $UserSummary } from './schemas/$UserSummary';
|
||||||
export { $Webhook } from './schemas/$Webhook';
|
export { $Webhook } from './schemas/$Webhook';
|
||||||
@@ -271,6 +279,7 @@ export { $WebhookDeliveryListResponse } from './schemas/$WebhookDeliveryListResp
|
|||||||
export { $WebhookID } from './schemas/$WebhookID';
|
export { $WebhookID } from './schemas/$WebhookID';
|
||||||
export { $WebhookListResponse } from './schemas/$WebhookListResponse';
|
export { $WebhookListResponse } from './schemas/$WebhookListResponse';
|
||||||
|
|
||||||
|
export { AlertsService } from './services/AlertsService';
|
||||||
export { AutomationService } from './services/AutomationService';
|
export { AutomationService } from './services/AutomationService';
|
||||||
export { CollectionsService } from './services/CollectionsService';
|
export { CollectionsService } from './services/CollectionsService';
|
||||||
export { DatabasesService } from './services/DatabasesService';
|
export { DatabasesService } from './services/DatabasesService';
|
||||||
|
|||||||
@@ -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<string>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
export namespace AlertRule {
|
||||||
|
export enum kind {
|
||||||
|
HEARTBEAT_SILENCE = 'heartbeat_silence',
|
||||||
|
ERROR_SPIKE = 'error_spike',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<AlertRule>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ export type CreateWebhookRequest = {
|
|||||||
* HMAC key for X-Primora-Signature. Generated when omitted.
|
* HMAC key for X-Primora-Signature. Generated when omitted.
|
||||||
*/
|
*/
|
||||||
secret?: string;
|
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;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ export type UpdateWebhookRequest = {
|
|||||||
* Replace the signing secret. Omit to keep the current one.
|
* Replace the signing secret. Omit to keep the current one.
|
||||||
*/
|
*/
|
||||||
secret?: string;
|
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;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ export type Webhook = {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
url: 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<string>;
|
events: Array<string>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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<AlertRuleListResponse> {
|
||||||
|
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<AlertRule> {
|
||||||
|
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<AlertRule> {
|
||||||
|
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<void> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/projects/{projectID}/alerts/{ruleID}',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
'ruleID': ruleId,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
401: `Error response`,
|
||||||
|
404: `Error response`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user