From a187a7e1570fededaaa5e9a17c9971514eb91218 Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Fri, 31 Jul 2026 12:52:21 +0200
Subject: [PATCH 1/4] refactor(alerts): unify alert system
---
apps/api/src/app.ts | 4 +-
apps/api/src/mcp/tools/create-alert-rule.ts | 25 +-
apps/api/src/mcp/tools/get-alert-rule.ts | 11 -
apps/api/src/mcp/tools/update-alert-rule.ts | 18 +-
apps/api/src/routes/alerts.http.ts | 162 -
.../src/routes/v2/alert-deliveries.http.ts | 44 +
.../src/routes/v2/alert-destinations.http.ts | 32 -
apps/api/src/routes/v2/alert-rules.http.ts | 14 +-
apps/api/src/routes/v2/alerts.http.test.ts | 4 +
apps/api/src/routes/v2/v2-test-support.ts | 2 +
.../api/src/services/AlertDeliveryDispatch.ts | 83 +-
.../AlertDestinationHydration.test.ts | 7 +-
.../src/services/AlertDestinationHydration.ts | 9 -
apps/api/src/services/AlertsService.test.ts | 46 +-
apps/api/src/services/AlertsService.ts | 178 +-
.../alerting/notification-destinations.md | 14 +-
apps/web/src/components/ai-triage/breach.ts | 1 -
.../alerts/alert-create-page-content.test.ts | 20 +-
.../alerts/alert-create-page-content.tsx | 107 +-
.../components/alerts/destination-dialog.tsx | 115 +-
.../alerts/destination-provider.tsx | 37 +-
.../alerts/overview/settings-tab.tsx | 11 +-
.../alerts/signal-and-threshold-section.tsx | 4 +-
.../widgets/widget-actions-context.tsx | 9 +-
apps/web/src/lib/alerts/diagnosis.test.ts | 3 -
apps/web/src/lib/alerts/form-utils.ts | 126 +-
apps/web/src/lib/alerts/rule-status.test.ts | 3 -
apps/web/src/lib/alerts/widget-chart-param.ts | 3 +-
.../web/src/lib/alerts/widget-prefill.test.ts | 50 +-
apps/web/src/lib/alerts/widget-prefill.ts | 68 -
apps/web/src/lib/collections/alerts.test.ts | 11 +-
apps/web/src/lib/collections/alerts.ts | 11 -
.../alerts-overview-model.registry.test.ts | 3 -
.../lib/models/alerts-overview-model.test.ts | 3 -
.../src/lib/models/alerts-overview-model.ts | 15 +-
apps/web/src/routes/alerts/$ruleId.tsx | 11 +-
apps/web/src/routes/alerts/create.tsx | 6 +-
apps/web/src/routes/alerts/index.tsx | 6 +-
docs/api-v2.md | 47 +-
examples/alchemy-maple/README.md | 4 +-
examples/alchemy-maple/alchemy.run.ts | 13 +-
lib/alchemy-maple/README.md | 11 +-
lib/alchemy-maple/src/AlertDestination.ts | 31 +-
lib/alchemy-maple/src/AlertRule.ts | 8 +-
lib/alchemy-maple/src/index.ts | 10 +-
.../test/alert-destination-props.test.ts | 12 +-
lib/alchemy-maple/test/contract.test.ts | 12 -
packages/db/drizzle/0026_windy_bromley.sql | 103 +
packages/db/drizzle/meta/0026_snapshot.json | 7041 +++++++++++++++++
packages/db/drizzle/meta/_journal.json | 9 +-
packages/db/src/migrations.test.ts | 94 +-
packages/db/src/schema/alerts.ts | 7 +-
packages/domain/src/http/alerts.test.ts | 40 +-
packages/domain/src/http/alerts.ts | 220 -
packages/domain/src/http/api.ts | 2 -
.../domain/src/http/v2/alert-deliveries.ts | 69 +
.../domain/src/http/v2/alert-destinations.ts | 81 +-
packages/domain/src/http/v2/alert-rules.ts | 28 +-
packages/domain/src/http/v2/api.ts | 2 +
packages/domain/src/http/v2/index.ts | 1 +
packages/domain/src/http/v2/openapi.test.ts | 1 +
packages/domain/src/http/v2/resource-ids.ts | 4 +
.../domain/src/http/v2/v2-contract.test.ts | 11 +-
packages/domain/src/mcp-structured-types.ts | 3 -
packages/domain/src/query-engine.ts | 2 +
.../src/ch/queries/metrics.test.ts | 22 +-
.../query-engine/src/ch/queries/metrics.ts | 16 +-
.../src/query-builder/model.test.ts | 21 +
.../query-engine/src/query-builder/model.ts | 39 +-
.../query-engine/src/runtime/query-engine.ts | 81 +-
70 files changed, 7779 insertions(+), 1542 deletions(-)
delete mode 100644 apps/api/src/routes/alerts.http.ts
create mode 100644 apps/api/src/routes/v2/alert-deliveries.http.ts
create mode 100644 packages/db/drizzle/0026_windy_bromley.sql
create mode 100644 packages/db/drizzle/meta/0026_snapshot.json
create mode 100644 packages/domain/src/http/v2/alert-deliveries.ts
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 01d1be853..837e93b4f 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -6,10 +6,10 @@ import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
import { McpLive } from "./mcp/app"
import { HttpBillingLive, HttpBillingPublicLive } from "./routes/billing.http"
import { HttpAiTriageLive } from "./routes/ai-triage.http"
-import { HttpAlertsLive } from "./routes/alerts.http"
import { HttpAnomaliesLive } from "./routes/anomalies.http"
import { HttpErrorsLive } from "./routes/errors.http"
import { HttpApiKeysLive } from "./routes/api-keys.http"
+import { HttpV2AlertDeliveriesLive } from "./routes/v2/alert-deliveries.http"
import { HttpV2AlertDestinationsLive } from "./routes/v2/alert-destinations.http"
import { HttpV2AlertIncidentsLive } from "./routes/v2/alert-incidents.http"
import { HttpV2AlertRulesLive } from "./routes/v2/alert-rules.http"
@@ -306,7 +306,6 @@ const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe(
Layer.provide(Layer.mergeAll(HttpAiTriageLive, HttpAnomaliesLive, HttpChatLive, HttpInvestigationsLive)),
Layer.provide(HttpApiKeysLive),
Layer.provide(Layer.mergeAll(HttpBillingLive, HttpBillingPublicLive)),
- Layer.provide(HttpAlertsLive),
Layer.provide(HttpErrorsLive),
Layer.provide(HttpDashboardsLive),
Layer.provide(HttpDemoLive),
@@ -334,6 +333,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe(
Layer.mergeAll(
HttpV2ApiKeysLive,
HttpV2DashboardsLive,
+ HttpV2AlertDeliveriesLive,
HttpV2AlertRulesLive,
HttpV2AlertDestinationsLive,
HttpV2AlertIncidentsLive,
diff --git a/apps/api/src/mcp/tools/create-alert-rule.ts b/apps/api/src/mcp/tools/create-alert-rule.ts
index d03cd33ad..cdef8ab01 100644
--- a/apps/api/src/mcp/tools/create-alert-rule.ts
+++ b/apps/api/src/mcp/tools/create-alert-rule.ts
@@ -86,9 +86,6 @@ interface CreateAlertRuleParams {
consecutive_breaches?: number
consecutive_healthy?: number
renotify_interval_minutes?: number
- metric_name?: string
- metric_type?: string
- metric_aggregation?: string
apdex_threshold_ms?: number
query_builder_draft?: string
raw_query_sql?: string
@@ -139,14 +136,6 @@ function buildAlertRuleRequest(
// or, for builder_query, parses the draft JSON. Non-special signal types
// (error_rate, p95_latency, …) fall through with no extra checks.
const signalValidation = Match.value(signalType).pipe(
- Match.when("metric", (): { error: string } | { draft?: unknown } => {
- if (!params.metric_name || !params.metric_type || !params.metric_aggregation) {
- return {
- error: 'signal_type=metric requires metric_name, metric_type, and metric_aggregation. Use list_metrics to discover available metrics.\n\nExample:\n signal_type="metric" metric_name="http.server.duration" metric_type="histogram" metric_aggregation="avg"',
- }
- }
- return {}
- }),
Match.when("apdex", (): { error: string } | { draft?: unknown } => {
if (!params.apdex_threshold_ms && !templateDefaults.apdexThresholdMs) {
return {
@@ -219,11 +208,6 @@ function buildAlertRuleRequest(
if (params.renotify_interval_minutes !== undefined)
request.renotifyIntervalMinutes = params.renotify_interval_minutes
- // Metric-specific fields
- if (params.metric_name) request.metricName = params.metric_name
- if (params.metric_type) request.metricType = params.metric_type
- if (params.metric_aggregation) request.metricAggregation = params.metric_aggregation
-
// Apdex-specific fields
if (params.apdex_threshold_ms !== undefined) request.apdexThresholdMs = params.apdex_threshold_ms
@@ -280,7 +264,7 @@ export function registerCreateAlertRuleTool(server: McpToolRegistrar) {
enabled: optionalBooleanParam("Whether the rule is enabled (default: true)"),
// Custom-mode params (used when template is 'custom' or omitted)
signal_type: optionalStringParam(
- "Signal type (for custom): error_rate, p95_latency, p99_latency, apdex, throughput, metric, builder_query, raw_query",
+ "Signal type (for custom): error_rate, p95_latency, p99_latency, apdex, throughput, builder_query, raw_query. Use builder_query with a metrics draft for custom metrics.",
),
comparator: optionalStringParam(
"Comparison operator (for custom): gt (>), gte (>=), lt (<), lte (<=)",
@@ -296,13 +280,6 @@ export function registerCreateAlertRuleTool(server: McpToolRegistrar) {
renotify_interval_minutes: optionalNumberParam(
"Re-notification interval in minutes (default: 60)",
),
- metric_name: optionalStringParam("Metric name (required when signal_type=metric)"),
- metric_type: optionalStringParam(
- "Metric type: sum, gauge, histogram, exponential_histogram (required when signal_type=metric)",
- ),
- metric_aggregation: optionalStringParam(
- "Metric aggregation: avg, min, max, sum, count (required when signal_type=metric)",
- ),
apdex_threshold_ms: optionalNumberParam(
"Apdex threshold in milliseconds (required when signal_type=apdex)",
),
diff --git a/apps/api/src/mcp/tools/get-alert-rule.ts b/apps/api/src/mcp/tools/get-alert-rule.ts
index 73356153b..6fe6321dd 100644
--- a/apps/api/src/mcp/tools/get-alert-rule.ts
+++ b/apps/api/src/mcp/tools/get-alert-rule.ts
@@ -88,14 +88,6 @@ export function registerGetAlertRuleTool(server: McpToolRegistrar) {
lines.push(``)
// Signal-specific fields
- if (rule.metricName || rule.metricType || rule.metricAggregation) {
- lines.push(`### Metric Configuration`)
- if (rule.metricName) lines.push(`Metric Name: ${rule.metricName}`)
- if (rule.metricType) lines.push(`Metric Type: ${rule.metricType}`)
- if (rule.metricAggregation) lines.push(`Metric Aggregation: ${rule.metricAggregation}`)
- lines.push(``)
- }
-
if (rule.apdexThresholdMs) {
lines.push(`### Apdex Configuration`)
lines.push(`Apdex Threshold: ${rule.apdexThresholdMs}ms`)
@@ -171,9 +163,6 @@ export function registerGetAlertRuleTool(server: McpToolRegistrar) {
consecutiveBreachesRequired: rule.consecutiveBreachesRequired,
consecutiveHealthyRequired: rule.consecutiveHealthyRequired,
renotifyIntervalMinutes: rule.renotifyIntervalMinutes,
- metricName: rule.metricName,
- metricType: rule.metricType,
- metricAggregation: rule.metricAggregation,
apdexThresholdMs: rule.apdexThresholdMs,
queryBuilderDraft: rule.queryBuilderDraft,
rawQuerySql: rule.rawQuerySql,
diff --git a/apps/api/src/mcp/tools/update-alert-rule.ts b/apps/api/src/mcp/tools/update-alert-rule.ts
index 1c1171edb..82ce7a38b 100644
--- a/apps/api/src/mcp/tools/update-alert-rule.ts
+++ b/apps/api/src/mcp/tools/update-alert-rule.ts
@@ -45,9 +45,6 @@ interface UpdateAlertRuleParams {
consecutive_breaches?: number
consecutive_healthy?: number
renotify_interval_minutes?: number
- metric_name?: string
- metric_type?: string
- metric_aggregation?: string
apdex_threshold_ms?: number
query_builder_draft?: string
raw_query_sql?: string
@@ -85,9 +82,6 @@ function buildUpdatedRequest(
consecutiveBreachesRequired: current.consecutiveBreachesRequired,
consecutiveHealthyRequired: current.consecutiveHealthyRequired,
renotifyIntervalMinutes: current.renotifyIntervalMinutes,
- metricName: current.metricName,
- metricType: current.metricType,
- metricAggregation: current.metricAggregation,
apdexThresholdMs: current.apdexThresholdMs,
queryBuilderDraft: current.queryBuilderDraft,
rawQuerySql: current.rawQuerySql,
@@ -113,9 +107,6 @@ function buildUpdatedRequest(
request.consecutiveHealthyRequired = params.consecutive_healthy
if (params.renotify_interval_minutes !== undefined)
request.renotifyIntervalMinutes = params.renotify_interval_minutes
- if (params.metric_name !== undefined) request.metricName = params.metric_name
- if (params.metric_type !== undefined) request.metricType = params.metric_type
- if (params.metric_aggregation !== undefined) request.metricAggregation = params.metric_aggregation
if (params.apdex_threshold_ms !== undefined) request.apdexThresholdMs = params.apdex_threshold_ms
if (params.raw_query_sql !== undefined) request.rawQuerySql = params.raw_query_sql
if (params.raw_query_reducer !== undefined) request.rawQueryReducer = params.raw_query_reducer
@@ -168,7 +159,7 @@ export function registerUpdateAlertRuleTool(server: McpToolRegistrar) {
"Comma-separated destination IDs to notify (replaces the current destinations; use list_alert_rules to find IDs)",
),
signal_type: optionalStringParam(
- "Signal type: error_rate, p95_latency, p99_latency, apdex, throughput, metric, builder_query, raw_query",
+ "Signal type: error_rate, p95_latency, p99_latency, apdex, throughput, builder_query, raw_query. Use builder_query with a metrics draft for custom metrics.",
),
comparator: optionalStringParam("Comparison operator: gt (>), gte (>=), lt (<), lte (<=)"),
group_by: optionalStringParam(
@@ -179,13 +170,6 @@ export function registerUpdateAlertRuleTool(server: McpToolRegistrar) {
consecutive_breaches: optionalNumberParam("Consecutive breaches before alerting"),
consecutive_healthy: optionalNumberParam("Consecutive healthy evaluations before resolving"),
renotify_interval_minutes: optionalNumberParam("Re-notification interval in minutes"),
- metric_name: optionalStringParam("Metric name (for signal_type=metric)"),
- metric_type: optionalStringParam(
- "Metric type: sum, gauge, histogram, exponential_histogram (for signal_type=metric)",
- ),
- metric_aggregation: optionalStringParam(
- "Metric aggregation: avg, min, max, sum, count (for signal_type=metric)",
- ),
apdex_threshold_ms: optionalNumberParam(
"Apdex threshold in milliseconds (for signal_type=apdex)",
),
diff --git a/apps/api/src/routes/alerts.http.ts b/apps/api/src/routes/alerts.http.ts
deleted file mode 100644
index 18df2143c..000000000
--- a/apps/api/src/routes/alerts.http.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import { HttpApiBuilder } from "effect/unstable/httpapi"
-import { CurrentTenant, MapleApi } from "@maple/domain/http"
-import { Effect } from "effect"
-import { AlertsService } from "../services/AlertsService"
-
-export const HttpAlertsLive = HttpApiBuilder.group(MapleApi, "alerts", (handlers) =>
- Effect.gen(function* () {
- const alerts = yield* AlertsService
-
- return handlers
- .handle("listDestinations", () =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId })
- return yield* alerts.listDestinations(tenant.orgId)
- }).pipe(Effect.withSpan("alerts.listDestinations")),
- )
- .handle("createDestination", ({ payload }) =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({
- orgId: tenant.orgId,
- "tenant.userId": tenant.userId,
- })
- return yield* alerts.createDestination(tenant.orgId, tenant.userId, tenant.roles, payload)
- }).pipe(Effect.withSpan("alerts.createDestination")),
- )
- .handle("updateDestination", ({ params, payload }) =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({
- orgId: tenant.orgId,
- userId: tenant.userId,
- destinationId: params.destinationId,
- })
- return yield* alerts.updateDestination(
- tenant.orgId,
- tenant.userId,
- tenant.roles,
- params.destinationId,
- payload,
- )
- }).pipe(Effect.withSpan("alerts.updateDestination")),
- )
- .handle("deleteDestination", ({ params }) =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({
- orgId: tenant.orgId,
- destinationId: params.destinationId,
- })
- return yield* alerts.deleteDestination(tenant.orgId, tenant.roles, params.destinationId)
- }).pipe(Effect.withSpan("alerts.deleteDestination")),
- )
- .handle("testDestination", ({ params }) =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({
- orgId: tenant.orgId,
- userId: tenant.userId,
- destinationId: params.destinationId,
- })
- return yield* alerts.testDestination(
- tenant.orgId,
- tenant.userId,
- tenant.roles,
- params.destinationId,
- )
- }).pipe(Effect.withSpan("alerts.testDestination")),
- )
- .handle("listRules", () =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId })
- return yield* alerts.listRules(tenant.orgId)
- }).pipe(Effect.withSpan("alerts.listRules")),
- )
- .handle("createRule", ({ payload }) =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({
- orgId: tenant.orgId,
- "tenant.userId": tenant.userId,
- })
- return yield* alerts.createRule(tenant.orgId, tenant.userId, tenant.roles, payload)
- }).pipe(Effect.withSpan("alerts.createRule")),
- )
- .handle("updateRule", ({ params, payload }) =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({
- orgId: tenant.orgId,
- userId: tenant.userId,
- ruleId: params.ruleId,
- })
- return yield* alerts.updateRule(
- tenant.orgId,
- tenant.userId,
- tenant.roles,
- params.ruleId,
- payload,
- )
- }).pipe(Effect.withSpan("alerts.updateRule")),
- )
- .handle("deleteRule", ({ params }) =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, ruleId: params.ruleId })
- return yield* alerts.deleteRule(tenant.orgId, tenant.roles, params.ruleId)
- }).pipe(Effect.withSpan("alerts.deleteRule")),
- )
- .handle("testRule", ({ payload }) =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({
- orgId: tenant.orgId,
- "tenant.userId": tenant.userId,
- })
- return yield* alerts.testRule(
- tenant.orgId,
- tenant.userId,
- tenant.roles,
- payload.rule,
- payload.sendNotification ?? false,
- )
- }).pipe(Effect.withSpan("alerts.testRule")),
- )
- .handle("previewRule", ({ payload }) =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId })
- return yield* alerts.previewRule(tenant.orgId, tenant.roles, payload)
- }).pipe(Effect.withSpan("alerts.previewRule")),
- )
- .handle("listIncidents", () =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId })
- return yield* alerts.listIncidents(tenant.orgId)
- }).pipe(Effect.withSpan("alerts.listIncidents")),
- )
- .handle("listRuleChecks", ({ params, query }) =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, ruleId: params.ruleId })
- return yield* alerts.listRuleChecks(tenant.orgId, params.ruleId, {
- groupKey: query.groupKey,
- since: query.since,
- until: query.until,
- limit: query.limit ?? 500,
- })
- }).pipe(Effect.withSpan("alerts.listRuleChecks")),
- )
- .handle("listDeliveryEvents", () =>
- Effect.gen(function* () {
- const tenant = yield* CurrentTenant.Context
- yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId })
- return yield* alerts.listDeliveryEvents(tenant.orgId)
- }).pipe(Effect.withSpan("alerts.listDeliveryEvents")),
- )
- }),
-)
diff --git a/apps/api/src/routes/v2/alert-deliveries.http.ts b/apps/api/src/routes/v2/alert-deliveries.http.ts
new file mode 100644
index 000000000..53aa225a9
--- /dev/null
+++ b/apps/api/src/routes/v2/alert-deliveries.http.ts
@@ -0,0 +1,44 @@
+import { HttpApiBuilder } from "effect/unstable/httpapi"
+import type { AlertDeliveryEventDocument } from "@maple/domain/http"
+import { CurrentTenant } from "@maple/domain/http"
+import type { V2AlertDelivery } from "@maple/domain/http/v2"
+import { MapleApiV2, paginateArray, timestamp, timestampOrNull } from "@maple/domain/http/v2"
+import { Effect } from "effect"
+import { AlertsService } from "../../services/AlertsService"
+import { mapAlertError } from "./alerts-error-map"
+
+const toV2Delivery = (doc: AlertDeliveryEventDocument): V2AlertDelivery => ({
+ id: doc.id,
+ object: "alert_delivery",
+ incident_id: doc.incidentId,
+ rule_id: doc.ruleId,
+ destination_id: doc.destinationId,
+ destination_name: doc.destinationName,
+ destination_type: doc.destinationType,
+ delivery_key: doc.deliveryKey,
+ event_type: doc.eventType,
+ attempt_number: doc.attemptNumber,
+ status: doc.status,
+ scheduled_at: timestamp(doc.scheduledAt),
+ attempted_at: timestampOrNull(doc.attemptedAt),
+ provider_message: doc.providerMessage,
+ provider_reference: doc.providerReference,
+ response_code: doc.responseCode,
+ error_message: doc.errorMessage,
+})
+
+export const HttpV2AlertDeliveriesLive = HttpApiBuilder.group(MapleApiV2, "alertDeliveries", (handlers) =>
+ Effect.gen(function* () {
+ const alerts = yield* AlertsService
+ return handlers.handle("list", ({ query }) =>
+ Effect.gen(function* () {
+ const tenant = yield* CurrentTenant.Context
+ const response = yield* alerts
+ .listDeliveryEvents(tenant.orgId)
+ .pipe(mapAlertError("delivery_list"))
+ const page = yield* paginateArray(response.events.map(toV2Delivery), query)
+ return { object: "list" as const, ...page }
+ }),
+ )
+ }),
+)
diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts
index 81ac0e6c8..626f9ad7c 100644
--- a/apps/api/src/routes/v2/alert-destinations.http.ts
+++ b/apps/api/src/routes/v2/alert-destinations.http.ts
@@ -4,10 +4,8 @@ import {
CurrentTenant,
DiscordAlertDestinationConfig,
EmailAlertDestinationConfig,
- HazelAlertDestinationConfig,
HazelOAuthAlertDestinationConfig,
PagerDutyAlertDestinationConfig,
- SlackAlertDestinationConfig,
SlackBotAlertDestinationConfig,
WebhookAlertDestinationConfig,
} from "@maple/domain/http"
@@ -44,14 +42,6 @@ const toV2DestinationMutation = (doc: AlertDestinationDocument): V2AlertDestinat
const toCreateRequest = (params: V2AlertDestinationCreateParams) => {
switch (params.type) {
- case "slack":
- return new SlackAlertDestinationConfig({
- type: "slack",
- name: params.name,
- webhookUrl: params.webhook_url,
- ...(params.channel_label !== undefined ? { channelLabel: params.channel_label } : {}),
- ...(params.enabled !== undefined ? { enabled: params.enabled } : {}),
- })
case "slack-bot":
return new SlackBotAlertDestinationConfig({
type: "slack-bot",
@@ -75,14 +65,6 @@ const toCreateRequest = (params: V2AlertDestinationCreateParams) => {
...(params.signing_secret !== undefined ? { signingSecret: params.signing_secret } : {}),
...(params.enabled !== undefined ? { enabled: params.enabled } : {}),
})
- case "hazel":
- return new HazelAlertDestinationConfig({
- type: "hazel",
- name: params.name,
- webhookUrl: params.webhook_url,
- ...(params.signing_secret !== undefined ? { signingSecret: params.signing_secret } : {}),
- ...(params.enabled !== undefined ? { enabled: params.enabled } : {}),
- })
case "hazel-oauth":
return new HazelOAuthAlertDestinationConfig({
type: "hazel-oauth",
@@ -119,13 +101,6 @@ const toUpdateRequest = (params: V2AlertDestinationUpdateParams): AlertDestinati
...(params.enabled !== undefined ? { enabled: params.enabled } : {}),
}
switch (params.type) {
- case "slack":
- return {
- type: "slack",
- ...shared,
- ...(params.webhook_url !== undefined ? { webhookUrl: params.webhook_url } : {}),
- ...(params.channel_label !== undefined ? { channelLabel: params.channel_label } : {}),
- }
case "slack-bot":
return {
type: "slack-bot",
@@ -146,13 +121,6 @@ const toUpdateRequest = (params: V2AlertDestinationUpdateParams): AlertDestinati
...(params.url !== undefined ? { url: params.url } : {}),
...(params.signing_secret !== undefined ? { signingSecret: params.signing_secret } : {}),
}
- case "hazel":
- return {
- type: "hazel",
- ...shared,
- ...(params.webhook_url !== undefined ? { webhookUrl: params.webhook_url } : {}),
- ...(params.signing_secret !== undefined ? { signingSecret: params.signing_secret } : {}),
- }
case "hazel-oauth":
return {
type: "hazel-oauth",
diff --git a/apps/api/src/routes/v2/alert-rules.http.ts b/apps/api/src/routes/v2/alert-rules.http.ts
index 164d54b97..69a9cd3d9 100644
--- a/apps/api/src/routes/v2/alert-rules.http.ts
+++ b/apps/api/src/routes/v2/alert-rules.http.ts
@@ -85,9 +85,6 @@ const toV2Rule = (doc: AlertRuleDocument): V2AlertRule => ({
consecutive_breaches_required: doc.consecutiveBreachesRequired,
consecutive_healthy_required: doc.consecutiveHealthyRequired,
renotify_interval_minutes: doc.renotifyIntervalMinutes,
- metric_name: doc.metricName,
- metric_type: doc.metricType,
- metric_aggregation: doc.metricAggregation,
apdex_threshold_ms: doc.apdexThresholdMs,
query_builder_draft: doc.queryBuilderDraft,
raw_query_sql: doc.rawQuerySql,
@@ -189,11 +186,6 @@ const toUpsertRequest = (
...(params.renotify_interval_minutes !== undefined
? { renotifyIntervalMinutes: params.renotify_interval_minutes }
: {}),
- ...(params.metric_name !== undefined ? { metricName: params.metric_name } : {}),
- ...(params.metric_type !== undefined ? { metricType: params.metric_type } : {}),
- ...(params.metric_aggregation !== undefined
- ? { metricAggregation: params.metric_aggregation }
- : {}),
...(params.apdex_threshold_ms !== undefined
? { apdexThresholdMs: params.apdex_threshold_ms }
: {}),
@@ -204,7 +196,7 @@ const toUpsertRequest = (
})
/**
- * PATCH semantics over the v1 full-upsert `updateRule`: overlay the fields
+ * PATCH semantics over the domain full-upsert `updateRule`: overlay the fields
* present in the patch onto the rule's current state. Read-merge-write — no
* version check, mirroring the dashboard's behavior.
*/
@@ -264,10 +256,6 @@ const mergeUpsertRequest = (
patch.consecutive_breaches_required ?? doc.consecutiveBreachesRequired,
consecutiveHealthyRequired: patch.consecutive_healthy_required ?? doc.consecutiveHealthyRequired,
renotifyIntervalMinutes: patch.renotify_interval_minutes ?? doc.renotifyIntervalMinutes,
- metricName: patch.metric_name !== undefined ? patch.metric_name : doc.metricName,
- metricType: patch.metric_type !== undefined ? patch.metric_type : doc.metricType,
- metricAggregation:
- patch.metric_aggregation !== undefined ? patch.metric_aggregation : doc.metricAggregation,
apdexThresholdMs:
patch.apdex_threshold_ms !== undefined ? patch.apdex_threshold_ms : doc.apdexThresholdMs,
queryBuilderDraft,
diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts
index 2be8254cd..08bacba8a 100644
--- a/apps/api/src/routes/v2/alerts.http.test.ts
+++ b/apps/api/src/routes/v2/alerts.http.test.ts
@@ -252,6 +252,10 @@ describe("v2 alerts over HTTP", () => {
expect(incidents.status).toBe(200)
expect(incidents.body).toMatchObject({ object: "list", data: [] })
+ const deliveries = await harness.request("GET", "/v2/alerts/deliveries", key.secret)
+ expect(deliveries.status).toBe(200)
+ expect(deliveries.body).toMatchObject({ object: "list", data: [], has_more: false })
+
// A destination referenced by a rule cannot be deleted.
const conflicted = await harness.request("DELETE", `/v2/alerts/destinations/${destId}`, key.secret)
expect(conflicted.status).toBe(409)
diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts
index d2c722fa4..ae1490976 100644
--- a/apps/api/src/routes/v2/v2-test-support.ts
+++ b/apps/api/src/routes/v2/v2-test-support.ts
@@ -13,6 +13,7 @@ import { SetupAuditService } from "../../services/SetupAuditService"
import { ApiV2RateLimiter } from "../../services/ApiV2RateLimiter"
import { WarehouseQueryService } from "../../lib/WarehouseQueryService"
import { QueryEngineService } from "../../services/QueryEngineService"
+import { HttpV2AlertDeliveriesLive } from "./alert-deliveries.http"
import { HttpV2AlertDestinationsLive } from "./alert-destinations.http"
import { HttpV2AlertIncidentsLive } from "./alert-incidents.http"
import { HttpV2AlertRulesLive } from "./alert-rules.http"
@@ -48,6 +49,7 @@ export const AllV2GroupLayersLive = Layer.mergeAll(
HttpV2ApiKeysLive,
HttpV2SlackIntegrationsLive,
HttpV2DashboardsLive,
+ HttpV2AlertDeliveriesLive,
HttpV2AlertRulesLive,
HttpV2AlertDestinationsLive,
HttpV2AlertIncidentsLive,
diff --git a/apps/api/src/services/AlertDeliveryDispatch.ts b/apps/api/src/services/AlertDeliveryDispatch.ts
index 33621faa2..5e6113519 100644
--- a/apps/api/src/services/AlertDeliveryDispatch.ts
+++ b/apps/api/src/services/AlertDeliveryDispatch.ts
@@ -684,56 +684,10 @@ export const dispatchDelivery = (
Effect.gen(function* () {
return yield* Match.value(context.secretConfig).pipe(
Match.discriminatorsExhaustive("type")({
- slack: (config) =>
- Effect.gen(function* () {
- const templated = renderTitleBody(context, "slack", linkUrl, chatUrl)
- const blocks = templated
- ? buildSlackBlocksFromTemplate(
- templated.title,
- templated.body,
- context,
- linkUrl,
- chatUrl,
- )
- : buildSlackBlocks(context, linkUrl, chatUrl)
- const response = yield* runTimedFetch("slack", "Slack", fetchFn, timeoutMs, () =>
- safeFetch(config.webhookUrl, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- // No top-level `text`: alongside attachments (and no top-level
- // blocks) Slack renders it as a duplicate line above the color
- // bar. `fallback` carries the notification-preview one-liner.
- attachments: [
- {
- color: slackAttachmentColor(context.eventType, context.severity),
- fallback: templated?.title ?? buildSlackFallbackText(context),
- blocks,
- },
- ],
- }),
- fetchFn,
- }),
- )
- if (!response.ok) {
- const detail = yield* readErrorBody(response)
- return yield* Effect.fail(
- makeDeliveryError(
- `Slack delivery failed with ${response.status}${detail ? `: ${detail}` : ""}`,
- "slack",
- ),
- )
- }
- return {
- providerMessage: "Delivered to Slack",
- providerReference: null,
- responseCode: response.status,
- } as DispatchResult
- }),
"slack-bot": (config) =>
Effect.gen(function* () {
const botToken = yield* deps.resolveSlackBotToken(context.destination.orgId)
- const templated = renderTitleBody(context, "slack", linkUrl, chatUrl)
+ const templated = renderTitleBody(context, "slack-bot", linkUrl, chatUrl)
const blocks = templated
? buildSlackBlocksFromTemplate(
templated.title,
@@ -904,41 +858,6 @@ export const dispatchDelivery = (
responseCode: response.status,
} as DispatchResult
}),
- hazel: (config) =>
- Effect.gen(function* () {
- const headers: Record = {
- "content-type": "application/json",
- "x-maple-event-type": context.eventType,
- "x-maple-delivery-key": context.deliveryKey,
- }
- if (config.signingSecret) {
- headers["x-maple-signature"] = createHmac("sha256", config.signingSecret)
- .update(payloadJson)
- .digest("hex")
- }
- const response = yield* runTimedFetch("hazel", "Hazel", fetchFn, timeoutMs, () =>
- safeFetch(config.webhookUrl, {
- method: "POST",
- headers,
- body: payloadJson,
- fetchFn,
- }),
- )
- if (!response.ok) {
- const detail = yield* readErrorBody(response)
- return yield* Effect.fail(
- makeDeliveryError(
- `Hazel delivery failed with ${response.status}${detail ? `: ${detail}` : ""}`,
- "hazel",
- ),
- )
- }
- return {
- providerMessage: "Delivered to Hazel",
- providerReference: context.dedupeKey,
- responseCode: response.status,
- } as DispatchResult
- }),
"hazel-oauth": (config) =>
Effect.gen(function* () {
// Hazel exposes per-integration sibling endpoints under the same
diff --git a/apps/api/src/services/AlertDestinationHydration.test.ts b/apps/api/src/services/AlertDestinationHydration.test.ts
index 90cd6ef92..7dfeebd51 100644
--- a/apps/api/src/services/AlertDestinationHydration.test.ts
+++ b/apps/api/src/services/AlertDestinationHydration.test.ts
@@ -112,13 +112,14 @@ describe("hydrateDestinationRow", () => {
const testDb = createTestDb(createdDbs)
const id = asDestinationId("00000000-0000-4000-8000-000000000002")
const secretConfig: DestinationSecretConfig = {
- type: "slack",
- webhookUrl: "https://hooks.slack.com/services/T000/B000/xxx",
+ type: "slack-bot",
+ channelId: "C123",
+ channelName: "alerts",
}
return Effect.gen(function* () {
yield* seedDestination({
id,
- type: "slack",
+ type: "slack-bot",
publicConfig: {
summary: "#alerts",
channelLabel: "#alerts",
diff --git a/apps/api/src/services/AlertDestinationHydration.ts b/apps/api/src/services/AlertDestinationHydration.ts
index c6257f320..61f9fe97c 100644
--- a/apps/api/src/services/AlertDestinationHydration.ts
+++ b/apps/api/src/services/AlertDestinationHydration.ts
@@ -20,10 +20,6 @@ export const DestinationPublicConfigSchema = Schema.Struct({
})
const DestinationSecretConfigSchema = Schema.Union([
- Schema.Struct({
- type: Schema.Literal("slack"),
- webhookUrl: Schema.String,
- }),
Schema.Struct({
type: Schema.Literal("slack-bot"),
// No secret token here — the bot token is resolved from the org's
@@ -40,11 +36,6 @@ const DestinationSecretConfigSchema = Schema.Union([
url: Schema.String,
signingSecret: Schema.NullOr(Schema.String),
}),
- Schema.Struct({
- type: Schema.Literal("hazel"),
- webhookUrl: Schema.String,
- signingSecret: Schema.NullOr(Schema.String),
- }),
Schema.Struct({
type: Schema.Literal("hazel-oauth"),
hazelOrganizationId: Schema.String,
diff --git a/apps/api/src/services/AlertsService.test.ts b/apps/api/src/services/AlertsService.test.ts
index ac31c9f68..dd90f3128 100644
--- a/apps/api/src/services/AlertsService.test.ts
+++ b/apps/api/src/services/AlertsService.test.ts
@@ -701,7 +701,7 @@ describe("AlertsService", () => {
)
})
- it.effect("lowers the environment scope into a metric signal's compiled plan", () => {
+ it.effect("lowers a metrics query-builder draft into the compiled plan", () => {
const testDb = createTestDb(trackedDbs)
return Effect.gen(function* () {
@@ -716,11 +716,17 @@ describe("AlertsService", () => {
new AlertRuleUpsertRequest({
name: "Queue depth (prod)",
severity: "warning",
- environments: ["production"],
- signalType: "metric",
- metricName: "queue.depth",
- metricType: "gauge",
- metricAggregation: "avg",
+ signalType: "builder_query",
+ queryBuilderDraft: {
+ id: "alert-query",
+ name: "A",
+ dataSource: "metrics",
+ aggregation: "avg",
+ metricName: "queue.depth",
+ metricType: "gauge",
+ whereClause: 'deployment.environment = "production"',
+ groupBy: [],
+ },
comparator: "gt",
threshold: 100,
windowMinutes: 5,
@@ -1772,8 +1778,24 @@ describe("AlertsService", () => {
name: "Grouped metrics alert",
severity: "warning",
enabled: true,
- groupBy: ["attr.http.method", "attr.http.route"],
- signalType: "metric",
+ signalType: "builder_query",
+ queryBuilderDraft: {
+ id: "alert-query",
+ name: "A",
+ dataSource: "metrics",
+ aggregation: "avg",
+ metricName: "http.server.request.duration",
+ metricType: "histogram",
+ whereClause: "",
+ addOns: {
+ groupBy: true,
+ having: false,
+ orderBy: false,
+ limit: false,
+ legend: false,
+ },
+ groupBy: ["attr.http.method", "attr.http.route"],
+ },
comparator: "gt",
threshold: 100,
windowMinutes: 5,
@@ -1781,9 +1803,6 @@ describe("AlertsService", () => {
consecutiveBreachesRequired: 1,
consecutiveHealthyRequired: 1,
renotifyIntervalMinutes: 30,
- metricName: "http.server.request.duration",
- metricType: "histogram",
- metricAggregation: "avg",
destinationIds: [destination.id],
}),
)
@@ -1799,7 +1818,10 @@ describe("AlertsService", () => {
assert.isTrue(Exit.isFailure(exit))
assert.instanceOf(failure, AlertValidationError)
- assert.strictEqual(failure.message, "Metrics alerts support at most one attr.* groupBy dimension")
+ assert.strictEqual(
+ failure.message,
+ "Metrics queries support a single attr.* group by; ignoring attr.http.route",
+ )
})
})
diff --git a/apps/api/src/services/AlertsService.ts b/apps/api/src/services/AlertsService.ts
index e92271e80..23ba173ba 100644
--- a/apps/api/src/services/AlertsService.ts
+++ b/apps/api/src/services/AlertsService.ts
@@ -31,8 +31,6 @@ import {
AlertIncidentsListResponse,
AlertIncidentStatus,
AlertIncidentTransition as AlertIncidentTransitionSchema,
- AlertMetricAggregation as AlertMetricAggregationSchema,
- AlertMetricType as AlertMetricTypeSchema,
AlertNotFoundError,
AlertPersistenceError,
AlertRuleDeleteResponse,
@@ -54,8 +52,6 @@ import {
type AlertDestinationType,
type AlertDestinationUpdateRequest,
type AlertEventType as AlertEventTypeValue,
- type AlertMetricAggregation as AlertMetricAggregationValue,
- type AlertMetricType,
type AlertRuleUpsertRequest,
type QueryBuilderQueryDraftPayload,
type AlertSeverity,
@@ -165,9 +161,6 @@ interface NormalizedRule {
readonly consecutiveBreachesRequired: number
readonly consecutiveHealthyRequired: number
readonly renotifyIntervalMinutes: number
- readonly metricName: string | null
- readonly metricType: AlertMetricType | null
- readonly metricAggregation: AlertMetricAggregationValue | null
readonly apdexThresholdMs: number | null
readonly queryBuilderDraft: QueryBuilderQueryDraftPayload | null
readonly rawQuerySql: string | null
@@ -316,8 +309,6 @@ const decodeAlertSignalTypeSync = Schema.decodeUnknownSync(AlertSignalTypeSchema
const decodeAlertComparatorSync = Schema.decodeUnknownSync(AlertComparatorSchema)
const decodeAlertCheckStatusSync = Schema.decodeUnknownSync(AlertCheckStatusSchema)
const decodeAlertIncidentTransitionSync = Schema.decodeUnknownSync(AlertIncidentTransitionSchema)
-const decodeAlertMetricTypeSync = Schema.decodeUnknownSync(AlertMetricTypeSchema)
-const decodeAlertMetricAggregationSync = Schema.decodeUnknownSync(AlertMetricAggregationSchema)
const decodeAlertIncidentStatusSync = Schema.decodeUnknownSync(AlertIncidentStatus)
const decodeAlertEventTypeSync = Schema.decodeUnknownSync(AlertEventTypeSchema)
const decodeErrorIssueIdSync = Schema.decodeUnknownSync(AlertIncidentDocument.fields.errorIssueId)
@@ -610,10 +601,6 @@ const buildPublicConfig = (
): DestinationPublicConfig =>
Match.value(request).pipe(
Match.discriminatorsExhaustive("type")({
- slack: (r) => ({
- summary: r.channelLabel?.trim() || "Slack incoming webhook",
- channelLabel: normalizeOptionalString(r.channelLabel),
- }),
"slack-bot": (r) => ({
summary: r.channelName?.trim() ? `#${r.channelName.trim()}` : "Slack channel",
channelLabel: r.channelName?.trim() ? `#${r.channelName.trim()}` : null,
@@ -626,10 +613,6 @@ const buildPublicConfig = (
summary: summarizeWebhookUrl(r.url),
channelLabel: null,
}),
- hazel: (r) => ({
- summary: summarizeWebhookUrl(r.webhookUrl),
- channelLabel: null,
- }),
"hazel-oauth": (r) => ({
summary: `${r.hazelOrganizationName} · #${r.hazelChannelName}`,
channelLabel: `#${r.hazelChannelName}`,
@@ -654,10 +637,6 @@ const buildSecretConfig = (
): DestinationSecretConfig =>
Match.value(request).pipe(
Match.discriminatorsExhaustive("type")({
- slack: (r) => ({
- type: "slack" as const,
- webhookUrl: r.webhookUrl.trim(),
- }),
"slack-bot": (r) => ({
type: "slack-bot" as const,
channelId: r.channelId.trim(),
@@ -672,11 +651,6 @@ const buildSecretConfig = (
url: r.url.trim(),
signingSecret: normalizeOptionalString(r.signingSecret),
}),
- hazel: (r) => ({
- type: "hazel" as const,
- webhookUrl: r.webhookUrl.trim(),
- signingSecret: normalizeOptionalString(r.signingSecret),
- }),
discord: (r) => ({
type: "discord" as const,
webhookUrl: r.webhookUrl.trim(),
@@ -697,9 +671,6 @@ const compileRulePlan = Effect.fn("AlertsService.compileRulePlan")(function* (ru
readonly signalType: AlertSignalType
readonly serviceName: string | null
readonly environments: ReadonlyArray
- readonly metricName: string | null
- readonly metricType: AlertMetricType | null
- readonly metricAggregation: AlertMetricAggregationValue | null
readonly apdexThresholdMs: number | null
readonly queryBuilderDraft: QueryBuilderQueryDraftPayload | null
readonly rawQuerySql: string | null
@@ -709,7 +680,7 @@ const compileRulePlan = Effect.fn("AlertsService.compileRulePlan")(function* (ru
readonly groupBy: AlertGroupBy | null
}): Effect.fn.Return, AlertValidationError> {
const bucketSeconds = Math.max(rule.windowMinutes * 60, 60)
- // Rule-level scope shared by the trace built-ins and the `metric` signal.
+ // Rule-level scope shared by the trace built-ins.
// `environments` is empty for builder_query / raw_query — those carry their
// own filters — so it never reaches those branches.
const envFilter = rule.environments.length > 0 ? { environments: rule.environments } : {}
@@ -814,35 +785,6 @@ const compileRulePlan = Effect.fn("AlertsService.compileRulePlan")(function* (ru
filters,
})
sampleCountStrategy = "trace_count"
- } else if (rule.signalType === "metric") {
- if (rule.metricName == null || rule.metricType == null || rule.metricAggregation == null) {
- return yield* Effect.fail(
- makeValidationError("metric alerts require metricName, metricType, and metricAggregation"),
- )
- }
- const groupResolved = yield* resolveRuleGroupBy("metrics")
- const filters: Record = {
- metricName: rule.metricName,
- metricType: rule.metricType,
- ...(rule.serviceName == null ? {} : { serviceName: rule.serviceName }),
- ...envFilter,
- }
- if (groupResolved && groupResolved.attributeKeys.length > 0) {
- // Metrics group-by-attribute is single-key today; pick the first.
- filters.groupByAttributeKey = groupResolved.attributeKeys[0]
- }
- if (groupResolved && groupResolved.resourceAttributeKeys.length > 0) {
- filters.groupByResourceAttributeKey = groupResolved.resourceAttributeKeys[0]
- }
- query = decodeQuerySpecSync({
- kind: "timeseries",
- source: "metrics",
- metric: rule.metricAggregation,
- groupBy: groupResolved ? [...groupResolved.tokens] : ["none"],
- bucketSeconds,
- filters,
- })
- sampleCountStrategy = "metric_data_points"
} else if (rule.signalType === "builder_query") {
// Reuse the exact compiler that dashboard query-builder charts use, so
// an alert and a chart built from the same draft evaluate identically.
@@ -850,9 +792,12 @@ const compileRulePlan = Effect.fn("AlertsService.compileRulePlan")(function* (ru
return yield* Effect.fail(makeValidationError("builder_query alerts require a queryBuilderDraft"))
}
const built = buildTimeseriesQuerySpec(rule.queryBuilderDraft)
- if (built.error != null || built.query == null) {
+ if (built.error != null || built.query == null || built.warnings.length > 0) {
return yield* Effect.fail(
- makeValidationError(built.error ?? "Failed to build query builder spec", [...built.warnings]),
+ makeValidationError(
+ built.error ?? built.warnings[0] ?? "Failed to build query builder spec",
+ [...built.warnings],
+ ),
)
}
// Force the evaluation window's bucket size; the draft's stepInterval is
@@ -1011,10 +956,6 @@ const rowToRuleDocument = (
consecutiveBreachesRequired: row.consecutiveBreachesRequired,
consecutiveHealthyRequired: row.consecutiveHealthyRequired,
renotifyIntervalMinutes: row.renotifyIntervalMinutes,
- metricName: row.metricName,
- metricType: row.metricType != null ? decodeAlertMetricTypeSync(row.metricType) : null,
- metricAggregation:
- row.metricAggregation != null ? decodeAlertMetricAggregationSync(row.metricAggregation) : null,
apdexThresholdMs: row.apdexThresholdMs,
queryBuilderDraft: parseStoredQueryBuilderDraft(row.queryBuilderDraftJson),
rawQuerySql: row.signalType === "raw_query" ? (row.rawQuerySql ?? null) : null,
@@ -1414,12 +1355,6 @@ export class AlertsService extends Context.Service s.trim()).filter((s) => s.length > 0))]
: []
const tags = normalizeTags(request.tags)
- const metricName = normalizeOptionalString(request.metricName)
const groupBy = request.groupBy ?? null
// Dedupe while preserving selection order — a destination listed twice still
// notifies once, so we persist each id at most once. This is the authoritative
@@ -1487,13 +1421,6 @@ export class AlertsService extends Context.Service 0) {
details.push("groupBy is only supported when no service is specified")
}
@@ -1582,9 +1499,6 @@ export class AlertsService extends Context.Service 0
- ) {
- yield* validateDestinationUrl(request.webhookUrl, "webhookUrl")
- } else if (
- request.type === "webhook" &&
- request.url != null &&
- request.url.trim().length > 0
- ) {
+ if (request.type === "webhook" && request.url != null && request.url.trim().length > 0) {
yield* validateDestinationUrl(request.url, "url")
- } else if (
- request.type === "hazel" &&
- request.webhookUrl != null &&
- request.webhookUrl.trim().length > 0
- ) {
- yield* validateDestinationUrl(request.webhookUrl, "webhookUrl")
} else if (
request.type === "discord" &&
request.webhookUrl != null &&
@@ -2270,25 +2161,6 @@ export class AlertsService extends Context.Service
- Effect.succeed({
- nextPublicConfig: {
- summary:
- normalizeOptionalString(r.channelLabel) ??
- hydrated.publicConfig.summary,
- channelLabel:
- normalizeOptionalString(r.channelLabel) ??
- hydrated.publicConfig.channelLabel,
- } satisfies DestinationPublicConfig,
- nextSecretConfig: {
- type: "slack" as const,
- webhookUrl:
- normalizeOptionalString(r.webhookUrl) ??
- (hydrated.secretConfig.type === "slack"
- ? hydrated.secretConfig.webhookUrl
- : ""),
- } satisfies DestinationSecretConfig,
- }),
"slack-bot": (r) => {
const channelName = normalizeOptionalString(r.channelName)
return Effect.succeed({
@@ -2354,30 +2226,6 @@ export class AlertsService extends Context.Service
- Effect.succeed({
- nextPublicConfig: {
- summary:
- r.webhookUrl != null && r.webhookUrl.trim().length > 0
- ? summarizeWebhookUrl(r.webhookUrl)
- : hydrated.publicConfig.summary,
- channelLabel: null,
- } satisfies DestinationPublicConfig,
- nextSecretConfig: {
- type: "hazel" as const,
- webhookUrl:
- normalizeOptionalString(r.webhookUrl) ??
- (hydrated.secretConfig.type === "hazel"
- ? hydrated.secretConfig.webhookUrl
- : ""),
- signingSecret:
- r.signingSecret === undefined
- ? hydrated.secretConfig.type === "hazel"
- ? hydrated.secretConfig.signingSecret
- : null
- : normalizeOptionalString(r.signingSecret),
- } satisfies DestinationSecretConfig,
- }),
"hazel-oauth": (r) =>
Effect.gen(function* () {
const previousSecret =
@@ -2685,9 +2533,6 @@ export class AlertsService extends Context.Service
db
diff --git a/apps/landing/src/content/docs/alerting/notification-destinations.md b/apps/landing/src/content/docs/alerting/notification-destinations.md
index 194e6a41c..c5050512f 100644
--- a/apps/landing/src/content/docs/alerting/notification-destinations.md
+++ b/apps/landing/src/content/docs/alerting/notification-destinations.md
@@ -7,7 +7,7 @@ order: 0
A **notification destination** is where Maple delivers an alert when one of your rules fires. Add destinations once, then attach them to any number of alert rules — when a rule trips, Maple sends a `trigger`; when it recovers, a `resolve`.
-Destinations live under **Alerts** in the Maple dashboard. Open the **Destinations** section, click **Add destination**, pick a provider, and paste its credentials. Credentials are encrypted at rest and never returned to the browser after they're saved.
+Destinations live under **Alerts → Settings** in the Maple dashboard. Open the destinations section, click **Add destination**, and pick a provider. Provider credentials are encrypted at rest and never returned to the browser after they're saved.
## Sending a test
@@ -17,11 +17,12 @@ If a test fails, Maple surfaces the provider's own rejection reason in the toast
## Slack
-Post alerts to a Slack channel via an [incoming webhook](https://api.slack.com/messaging/webhooks).
+Install Maple's Slack app, then choose the channel where alerts should be delivered. Maple uses the
+installed bot connection; incoming-webhook destinations are not supported.
-1. Create a Slack app (or use an existing one) and enable **Incoming Webhooks**.
-2. Add a webhook to the channel you want alerts in.
-3. Copy the `https://hooks.slack.com/services/...` URL into the **Slack webhook URL** field.
+1. Open **Alerts → Settings** and choose **Add destination → Slack**.
+2. Install or reconnect the Maple Slack app when prompted.
+3. Pick a channel and save the destination.
## PagerDuty
@@ -63,4 +64,5 @@ POST a signed JSON payload to any HTTP endpoint you control — useful for custo
## Hazel
-Connect [Hazel](https://hazel.sh/docs/integrations/maple) via OAuth and pick a workspace channel to route alerts into, or paste a Hazel-issued webhook URL directly. See Hazel's [Maple integration guide](https://hazel.sh/docs/integrations/maple).
+Connect [Hazel](https://hazel.sh/docs/integrations/maple) through OAuth and pick a workspace channel
+to route alerts into. See Hazel's [Maple integration guide](https://hazel.sh/docs/integrations/maple).
diff --git a/apps/web/src/components/ai-triage/breach.ts b/apps/web/src/components/ai-triage/breach.ts
index 6a2621325..6187ce833 100644
--- a/apps/web/src/components/ai-triage/breach.ts
+++ b/apps/web/src/components/ai-triage/breach.ts
@@ -13,7 +13,6 @@ export function toAlertSignalType(value: string): AlertSignalType | null {
case "p99_latency":
case "apdex":
case "throughput":
- case "metric":
case "builder_query":
case "raw_query":
return value
diff --git a/apps/web/src/components/alerts/alert-create-page-content.test.ts b/apps/web/src/components/alerts/alert-create-page-content.test.ts
index 285efaf37..3a2613b4c 100644
--- a/apps/web/src/components/alerts/alert-create-page-content.test.ts
+++ b/apps/web/src/components/alerts/alert-create-page-content.test.ts
@@ -5,9 +5,8 @@ import { deriveInitialRuleDraft } from "./alert-create-page-content"
/**
* The starter-template deep link from the overview empty state. With no ruleId /
- * chart / dashboard params, `deriveInitialRuleDraft` reaches the template branch
- * before the rules/dashboards results ever matter, so `Result.initial()` stands
- * in for both.
+ * chart params, `deriveInitialRuleDraft` reaches the template branch before the
+ * rules result matters, so `Result.initial()` stands in for it.
*/
const loading = Result.initial()
@@ -19,7 +18,6 @@ describe("deriveInitialRuleDraft — template deep link", () => {
search: { template: "low_apdex" },
chartContext: undefined,
rulesResult: loading,
- dashboardsResult: loading,
})
expect(draft.form.signalType).toBe("apdex")
@@ -36,7 +34,6 @@ describe("deriveInitialRuleDraft — template deep link", () => {
search: { template: "not-a-real-template" },
chartContext: undefined,
rulesResult: loading,
- dashboardsResult: loading,
})
expect(draft.form.signalType).toBe("error_rate")
@@ -45,4 +42,17 @@ describe("deriveInitialRuleDraft — template deep link", () => {
expect(draft.showTemplatesInitially).toBe(true)
expect(draft.key).toBe("new:blank")
})
+
+ it("surfaces an invalid chart snapshot instead of using a second lookup path", () => {
+ const draft = deriveInitialRuleDraft({
+ search: { chart: "not-a-snapshot" },
+ chartContext: undefined,
+ rulesResult: loading,
+ })
+
+ expect(draft.form.signalType).toBe("error_rate")
+ expect(draft.prefillNotices[0]?.message).toContain("chart snapshot was invalid")
+ expect(draft.showTemplatesInitially).toBe(false)
+ expect(draft.key).toBe("invalid-chart-snapshot")
+ })
})
diff --git a/apps/web/src/components/alerts/alert-create-page-content.tsx b/apps/web/src/components/alerts/alert-create-page-content.tsx
index a4652a45b..573ee7e67 100644
--- a/apps/web/src/components/alerts/alert-create-page-content.tsx
+++ b/apps/web/src/components/alerts/alert-create-page-content.tsx
@@ -2,7 +2,6 @@ import { useSearch } from "@tanstack/react-router"
import { useMemo } from "react"
import type { AlertDestinationDocument, AlertRuleDocument } from "@maple/domain/http"
-import type { Dashboard } from "@/components/dashboard-builder/types"
import { Skeleton } from "@maple/ui/components/ui/skeleton"
import { cn } from "@maple/ui/utils"
@@ -14,20 +13,13 @@ import { useAutocompleteValuesContext } from "@/hooks/use-autocomplete-values"
import { defaultRuleForm, ruleToFormState, type RuleFormState } from "@/lib/alerts/form-utils"
import { ALERT_TEMPLATES, applyTemplate } from "@/lib/alerts/templates"
import { decodeAlertChartFromSearchParam, type AlertChartContext } from "@/lib/alerts/widget-chart-param"
-import {
- createWidgetAlertPrefill,
- resolveWidgetAlertPrefill,
- type WidgetAlertPrefillNotice,
-} from "@/lib/alerts/widget-prefill"
+import { createWidgetAlertPrefill, type WidgetAlertPrefillNotice } from "@/lib/alerts/widget-prefill"
import { useAlertDestinationsList, useAlertRulesList } from "@/hooks/use-alerts-list"
import { Result } from "@/lib/effect-atom"
-import { useDashboardsRead } from "@/hooks/use-dashboard-store"
type AlertCreateSearchValue = {
serviceName?: string
ruleId?: string
- dashboardId?: string
- widgetId?: string
chart?: string
template?: string
}
@@ -39,10 +31,9 @@ type InitialRuleDraft = {
editingRule: AlertRuleDocument | null
showTemplatesInitially: boolean
/**
- * The draft is a placeholder while the real rule (or its dashboard source) is
- * still loading. The form must not be shown yet — `form` is a blank default
- * that would read as "Create alert rule" until the fetch resolves and the
- * `key` change remounts it with the actual values.
+ * The draft is a placeholder while the real rule is loading. The form must
+ * not be shown yet — `form` is a blank default that would read as "Create
+ * alert rule" until the fetch resolves and the `key` change remounts it.
*/
loading?: boolean
}
@@ -55,20 +46,8 @@ export function AlertCreatePageContent() {
[search.chart],
)
- // The dashboards list is only needed for the legacy id-lookup fallback —
- // when the navigation carried a decodable widget snapshot, prefill is
- // synchronous and the fetch (plus its loading remount) is skipped entirely.
- const needsDashboards =
- !search.ruleId && chartContext == null && Boolean(search.dashboardId || search.widgetId)
-
const { result: destinationsResult } = useAlertDestinationsList()
const { result: rulesResult } = useAlertRulesList()
- const { dashboards, isLoading: dashboardsLoading, isError: dashboardsError } = useDashboardsRead()
- const dashboardsResult = useMemo(() => {
- if (!needsDashboards || dashboardsLoading) return Result.initial(dashboardsLoading)
- if (dashboardsError) return Result.fail(new Error("Dashboard sync failed"))
- return Result.success({ dashboards })
- }, [needsDashboards, dashboardsLoading, dashboardsError, dashboards])
const autocompleteValues = useAutocompleteValuesContext()
const serviceNameOptions = autocompleteValues.traces.services ?? []
@@ -86,9 +65,8 @@ export function AlertCreatePageContent() {
search,
chartContext,
rulesResult,
- dashboardsResult,
}),
- [search, chartContext, rulesResult, dashboardsResult],
+ [search, chartContext, rulesResult],
)
// Showing the blank default form here would paint a "Create alert rule" page
@@ -113,10 +91,9 @@ export function AlertCreatePageContent() {
}
/**
- * Placeholder shown while an existing rule (or a dashboard widget's prefill
- * source) loads. Mirrors the real surface's chrome — same breadcrumb, same
- * title, same two-column grid — so resolving the fetch swaps content into a
- * page that is already the right shape.
+ * Placeholder shown while an existing rule loads. Mirrors the real surface's
+ * chrome — same breadcrumb, same title, same two-column grid — so resolving the
+ * fetch swaps content into a page that is already the right shape.
*/
function AlertRuleFormSkeleton({ editing }: { editing: boolean }) {
return (
@@ -152,17 +129,10 @@ export function deriveInitialRuleDraft({
search,
chartContext,
rulesResult,
- dashboardsResult,
}: {
search: AlertCreateSearchValue
chartContext: AlertChartContext | undefined
rulesResult: Result.Result<{ rules: readonly AlertRuleDocument[] }, unknown>
- dashboardsResult: Result.Result<
- {
- dashboards: readonly Dashboard[]
- },
- unknown
- >
}): InitialRuleDraft {
const base = defaultRuleForm(search.serviceName)
@@ -201,9 +171,8 @@ export function deriveInitialRuleDraft({
}
}
- // Snapshot carried through navigation — synchronous prefill, no dashboards
- // fetch, immune to the autosave race. Garbage/oversized params decode to
- // undefined and fall through to the id-lookup path below.
+ // Snapshot carried through navigation: synchronous and immune to the
+ // dashboard autosave race.
if (chartContext) {
const result = createWidgetAlertPrefill(chartContext.widget, base)
return {
@@ -215,58 +184,18 @@ export function deriveInitialRuleDraft({
}
}
- if (search.dashboardId || search.widgetId) {
- if (!search.dashboardId || !search.widgetId) {
- const result = resolveWidgetAlertPrefill({
- dashboards: [],
- dashboardId: search.dashboardId,
- widgetId: search.widgetId,
- base,
- })
- return {
- key: `missing-chart-source:${search.dashboardId ?? "dashboard"}:${search.widgetId ?? "widget"}`,
- form: result.form,
- prefillNotices: result.notices,
- editingRule: null,
- showTemplatesInitially: false,
- }
- }
- if (Result.isSuccess(dashboardsResult)) {
- const result = resolveWidgetAlertPrefill({
- dashboards: dashboardsResult.value.dashboards,
- dashboardId: search.dashboardId,
- widgetId: search.widgetId,
- base,
- })
- return {
- key: `dashboard:${search.dashboardId}:widget:${search.widgetId}`,
- form: result.form,
- prefillNotices: result.notices,
- editingRule: null,
- showTemplatesInitially: false,
- }
- }
- if (Result.isFailure(dashboardsResult)) {
- return {
- key: `dashboard-load-failed:${search.dashboardId}:${search.widgetId}`,
- form: base,
- prefillNotices: [
- {
- severity: "warning",
- message: "Dashboards could not be loaded. Starting from a blank alert.",
- },
- ],
- editingRule: null,
- showTemplatesInitially: false,
- }
- }
+ if (search.chart) {
return {
- key: `loading-dashboard:${search.dashboardId}:${search.widgetId}`,
+ key: "invalid-chart-snapshot",
form: base,
- prefillNotices: [],
+ prefillNotices: [
+ {
+ severity: "warning",
+ message: "The source chart snapshot was invalid. Starting from a blank alert.",
+ },
+ ],
editingRule: null,
showTemplatesInitially: false,
- loading: true,
}
}
diff --git a/apps/web/src/components/alerts/destination-dialog.tsx b/apps/web/src/components/alerts/destination-dialog.tsx
index 4ec7e874e..761357bcd 100644
--- a/apps/web/src/components/alerts/destination-dialog.tsx
+++ b/apps/web/src/components/alerts/destination-dialog.tsx
@@ -682,27 +682,8 @@ function SlackBotFields({
{isEditing
? "Listing this workspace's Slack channels is limited to org admins, so the channel can't be changed here. Your other edits still save — ask an admin to move this destination to another channel."
- : "Listing this workspace's Slack channels is limited to org admins, so this destination can't be finished here. Ask an admin to create it, or use a Slack webhook destination — any member can set one up."}
+ : "Listing this workspace's Slack channels is limited to org admins, so this destination can't be finished here. Ask an admin to create it for you."}
- {!isEditing ? (
-
- onFormChange((current) => ({
- ...defaultDestinationForm("slack"),
- // Carry over what isn't Slack-bot-specific so switching
- // providers doesn't discard the user's typing.
- name: current.name,
- enabled: current.enabled,
- }))
- }
- >
- Use a Slack webhook instead
-
-
- ) : null}
)
}
@@ -887,49 +868,6 @@ export function DestinationDialog({
/>
- {form.type === "slack" && (
- <>
-
-
- Slack webhook URL
-
-
- onFormChange((current) => ({
- ...current,
- webhookUrl: event.target.value,
- }))
- }
- placeholder={
- isEditing
- ? "Leave blank to keep current webhook"
- : "https://hooks.slack.com/services/..."
- }
- className="font-mono text-xs"
- />
-
-
-
- Channel label
-
-
- onFormChange((current) => ({
- ...current,
- channelLabel: event.target.value,
- }))
- }
- placeholder="#ops-alerts"
- className="font-mono text-xs"
- />
-
- >
- )}
-
{form.type === "slack-bot" && (
)}
-
- {form.type === "hazel" && (
- <>
-
-
- Hazel webhook URL
-
-
- onFormChange((current) => ({
- ...current,
- hazelWebhookUrl: event.target.value,
- }))
- }
- placeholder={
- isEditing
- ? "Leave blank to keep current URL"
- : "https://api.hazel.sh/webhooks/incoming/{webhookId}/{token}/maple"
- }
- className="font-mono text-xs"
- />
-
- Create a Maple webhook in Hazel under Settings → Integrations →
- Maple, then paste the URL here.
-
-
-
-
- Signing secret
-
-
- onFormChange((current) => ({
- ...current,
- signingSecret: event.target.value,
- }))
- }
- placeholder={
- isEditing
- ? "Leave blank to keep current secret"
- : "Optional HMAC secret"
- }
- className="font-mono text-xs"
- />
-
- >
- )}
diff --git a/apps/web/src/components/alerts/destination-provider.tsx b/apps/web/src/components/alerts/destination-provider.tsx
index 5ced76244..eab9c111f 100644
--- a/apps/web/src/components/alerts/destination-provider.tsx
+++ b/apps/web/src/components/alerts/destination-provider.tsx
@@ -78,23 +78,6 @@ export type DestinationProvider = {
}
export const PROVIDERS: Record = {
- slack: {
- type: "slack",
- label: "Slack (webhook)",
- description: "Post alerts to a Slack channel via an incoming webhook you manage.",
- accent: SLACK_ACCENT,
- accentBg: SLACK_ACCENT_BG,
- accentText: SLACK_ACCENT_TEXT,
- // White clears both halves of the aubergine pair: 14.0:1 on #4A154B (light),
- // 4.65:1 on #AD51A7 (dark) — the dark stand-in was picked for this margin.
- accentOn: INK_ON_DARK_ACCENT,
- // Deliberately no `brandfetchDomain`: the bot entry renders the local
- // SlackIcon and the two rows sit next to each other — a remote CDN bitmap
- // here would make the same product look like two.
- fallbackIcon: ({ size = 22, className }) => ,
- docsUrl: "https://api.slack.com/messaging/webhooks",
- docsLabel: "Slack webhook docs",
- },
"slack-bot": {
type: "slack-bot",
label: "Slack (bot)",
@@ -102,7 +85,7 @@ export const PROVIDERS: Record = {
accent: SLACK_ACCENT,
accentBg: SLACK_ACCENT_BG,
accentText: SLACK_ACCENT_TEXT,
- // Same aubergine pair as the webhook row — 14.0:1 light / 4.65:1 dark.
+ // 14.0:1 light / 4.65:1 dark.
accentOn: INK_ON_DARK_ACCENT,
fallbackIcon: ({ size = 22, className }) => ,
docsUrl: "https://maple.dev/docs/integrations/slack",
@@ -137,19 +120,6 @@ export const PROVIDERS: Record = {
accentOn: INK_ON_BRIGHT_ACCENT,
fallbackIcon: ({ size = 22, className }) => ,
},
- hazel: {
- type: "hazel",
- label: "Hazel (webhook)",
- description: "Legacy webhook integration — paste the URL Hazel issues you.",
- accent: "#F46F0F",
- accentBg: "rgba(244,111,15,0.16)",
- accentText: HAZEL_ACCENT_TEXT,
- // White on Hazel's orange is 2.95:1; #1E1B17 is 5.82:1.
- accentOn: INK_ON_BRIGHT_ACCENT,
- fallbackIcon: ({ size = 22, className }) => ,
- docsUrl: "https://hazel.sh/docs/integrations/maple",
- docsLabel: "Hazel integration guide",
- },
"hazel-oauth": {
type: "hazel-oauth",
label: "Hazel",
@@ -157,7 +127,7 @@ export const PROVIDERS: Record = {
accent: "#F46F0F",
accentBg: "rgba(244,111,15,0.16)",
accentText: HAZEL_ACCENT_TEXT,
- // Same orange as the webhook row — 2.95:1 white, 5.82:1 on #1E1B17.
+ // 2.95:1 white, 5.82:1 on #1E1B17.
accentOn: INK_ON_BRIGHT_ACCENT,
fallbackIcon: ({ size = 22, className }) => ,
docsUrl: "https://hazel.sh/docs/integrations/maple",
@@ -195,16 +165,13 @@ export const PROVIDERS: Record = {
},
}
-// The recommended bot flow leads; the legacy webhook entry trails it.
export const DESTINATION_TYPES: ReadonlyArray = [
"slack-bot",
- "slack",
"discord",
"email",
"pagerduty",
"webhook",
"hazel-oauth",
- "hazel",
]
interface ProviderLogoProps {
diff --git a/apps/web/src/components/alerts/overview/settings-tab.tsx b/apps/web/src/components/alerts/overview/settings-tab.tsx
index 588173f54..75e6b5c8c 100644
--- a/apps/web/src/components/alerts/overview/settings-tab.tsx
+++ b/apps/web/src/components/alerts/overview/settings-tab.tsx
@@ -19,11 +19,11 @@ import {
formatAlertTime,
getExitErrorMessage,
groupDeliveryEventsByDay,
+ v2DeliveryToDocument,
type DestinationFormState,
} from "@/lib/alerts/form-utils"
import { v2ErrorInfo } from "@/lib/error-messages"
import { useAlertDestinationsList } from "@/hooks/use-alerts-list"
-import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client"
import { Result, useAtomSet, useAtomValue } from "@/lib/effect-atom"
import { Badge } from "@maple/ui/components/ui/badge"
@@ -186,17 +186,18 @@ export function useDestinationManager(): DestinationManager {
*/
export function AlertsSettingsTab({ manager, isAdmin }: { manager: DestinationManager; isAdmin: boolean }) {
const { result: destinationsResult } = useAlertDestinationsList()
- // TODO(v2): delivery events have no v2 endpoint (internal delivery-audit
- // schema); the proper follow-up is an Electric shape for alert_delivery_events.
const deliveryEventsResult = useAtomValue(
- MapleApiAtomClient.query("alerts", "listDeliveryEvents", { reactivityKeys: ["alertDeliveryEvents"] }),
+ MapleApiV2AtomClient.query("alertDeliveries", "list", {
+ query: { limit: 100 },
+ reactivityKeys: ["alertDeliveryEvents"],
+ }),
)
const destinations = Result.builder(destinationsResult)
.onSuccess((response) => [...response.destinations] as AlertDestinationDocument[])
.orElse(() => [])
const deliveryEvents = Result.builder(deliveryEventsResult)
- .onSuccess((response) => [...response.events] as AlertDeliveryEventDocument[])
+ .onSuccess((response) => response.data.map(v2DeliveryToDocument) as AlertDeliveryEventDocument[])
.orElse(() => [])
const deliveryEventGroups = groupDeliveryEventsByDay(deliveryEvents)
diff --git a/apps/web/src/components/alerts/signal-and-threshold-section.tsx b/apps/web/src/components/alerts/signal-and-threshold-section.tsx
index 127d9f05b..5203fe550 100644
--- a/apps/web/src/components/alerts/signal-and-threshold-section.tsx
+++ b/apps/web/src/components/alerts/signal-and-threshold-section.tsx
@@ -51,7 +51,7 @@ interface SignalAndThresholdSectionProps {
autocompleteValues: AutocompleteValuesContextType
}
-/* Eight signal types is too many for a single segmented bar — they wrap and
+/* Seven signal types is too many for a single segmented bar — they wrap and
every option looks equally weighted even though five of them are "I want a
common metric" and the other two are "I'll define my own". We split the
choice into two tiers:
@@ -64,7 +64,7 @@ interface SignalAndThresholdSectionProps {
type SignalKind = "builtin" | "builder_query" | "raw_query"
function signalTypeToKind(signalType: AlertSignalType): SignalKind {
- if (signalType === "builder_query" || signalType === "metric") return "builder_query"
+ if (signalType === "builder_query") return "builder_query"
if (signalType === "raw_query") return "raw_query"
return "builtin"
}
diff --git a/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx b/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx
index 2b7019c27..121df0c4b 100644
--- a/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx
+++ b/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx
@@ -74,8 +74,7 @@ export function WidgetActionsProvider({ widget, dataState, children }: WidgetAct
dashboardId && alertable
? () => {
// Carry the live widget (optimistic builder state) so the alert
- // page prefills without racing the dashboard autosave; the
- // id pair stays as the lookup fallback for oversized payloads.
+ // page prefills without racing dashboard autosave.
const chart = encodeAlertChartToSearchParam({
dashboardId,
widget: {
@@ -91,11 +90,7 @@ export function WidgetActionsProvider({ widget, dataState, children }: WidgetAct
})
navigate({
to: "/alerts/create",
- search: {
- dashboardId,
- widgetId: widget.id,
- ...(chart ? { chart } : {}),
- },
+ search: chart ? { chart } : {},
})
}
: undefined
diff --git a/apps/web/src/lib/alerts/diagnosis.test.ts b/apps/web/src/lib/alerts/diagnosis.test.ts
index 00dfb4a49..0b691b9de 100644
--- a/apps/web/src/lib/alerts/diagnosis.test.ts
+++ b/apps/web/src/lib/alerts/diagnosis.test.ts
@@ -32,9 +32,6 @@ function makeRule(overrides: Record = {}): AlertRuleDocument {
consecutiveBreachesRequired: 3,
consecutiveHealthyRequired: 2,
renotifyIntervalMinutes: 30,
- metricName: null,
- metricType: null,
- metricAggregation: null,
apdexThresholdMs: null,
queryBuilderDraft: null,
rawQuerySql: null,
diff --git a/apps/web/src/lib/alerts/form-utils.ts b/apps/web/src/lib/alerts/form-utils.ts
index 912ae24d2..9cd51e3be 100644
--- a/apps/web/src/lib/alerts/form-utils.ts
+++ b/apps/web/src/lib/alerts/form-utils.ts
@@ -1,5 +1,6 @@
import {
AlertCheckDocument,
+ AlertDeliveryEventDocument,
AlertDestinationDocument,
AlertIncidentDocument,
AlertRuleDocument,
@@ -12,19 +13,17 @@ import {
IsoDateTimeString,
UserId,
type AlertComparator,
- type AlertDeliveryEventDocument,
type AlertDeliveryStatus,
type AlertDestinationId,
type AlertDestinationType,
type AlertEventType,
- type AlertMetricAggregation,
- type AlertMetricType,
type AlertSeverity,
type AlertSignalType,
type QueryBuilderQueryDraftPayload,
} from "@maple/domain/http"
import type {
V2AlertCheck,
+ V2AlertDelivery,
V2AlertDestinationCreateParams,
V2AlertDestinationUpdateParams,
V2AlertRuleCreateParams,
@@ -32,6 +31,7 @@ import type {
V2AlertRuleTestParams,
} from "@maple/domain/http/v2"
import type { QueryEngineAlertReducer } from "@maple/query-engine"
+import type { QueryBuilderMetricType } from "@maple/query-engine/query-builder"
import { Cause, Exit, Option, Schema } from "effect"
import { v2ErrorInfo } from "@/lib/error-messages"
import { buildTimeseriesQuerySpec, createQueryDraft } from "@/lib/query-builder/model"
@@ -77,8 +77,7 @@ export type RuleFormState = {
consecutiveHealthyRequired: string
renotifyIntervalMinutes: string
metricName: string
- metricType: AlertMetricType
- metricAggregation: AlertMetricAggregation
+ metricType: QueryBuilderMetricType
apdexThresholdMs: string
/**
* Editing fields for the `builder_query` signal. They map 1:1 to a
@@ -108,7 +107,6 @@ export const signalLabels: Record = {
p99_latency: "P99 latency",
apdex: "Apdex",
throughput: "Throughput",
- metric: "Metric",
builder_query: "Query builder",
raw_query: "Raw SQL",
}
@@ -181,7 +179,6 @@ export function formatSignalValue(signalType: AlertSignalType, value: number | n
case "apdex":
return value.toFixed(3)
case "throughput":
- case "metric":
case "builder_query":
case "raw_query":
return formatNumber(value)
@@ -240,7 +237,6 @@ export function defaultRuleForm(serviceName?: string): RuleFormState {
renotifyIntervalMinutes: "30",
metricName: "",
metricType: "gauge",
- metricAggregation: "avg",
apdexThresholdMs: "500",
queryDataSource: "traces",
queryAggregation: "count",
@@ -254,29 +250,8 @@ export function defaultRuleForm(serviceName?: string): RuleFormState {
}
}
-function metricRuleToQueryBuilderDraft(rule: AlertRuleDocument): QueryBuilderQueryDraftPayload {
- return {
- ...createQueryDraft(0),
- dataSource: "metrics",
- aggregation: rule.metricAggregation ?? "avg",
- metricName: rule.metricName ?? "",
- metricType: rule.metricType ?? "gauge",
- isMonotonic: rule.metricType === "sum",
- groupBy: rule.groupBy ? [...rule.groupBy] : [],
- addOns: {
- groupBy: (rule.groupBy?.length ?? 0) > 0,
- having: false,
- orderBy: false,
- limit: false,
- legend: false,
- },
- }
-}
-
export function ruleToFormState(rule: AlertRuleDocument): RuleFormState {
- const queryBuilderDraft =
- rule.queryBuilderDraft ??
- (rule.signalType === "metric" ? metricRuleToQueryBuilderDraft(rule) : createQueryDraft(0))
+ const queryBuilderDraft = rule.queryBuilderDraft ?? createQueryDraft(0)
return {
name: rule.name,
notes: rule.notes ?? "",
@@ -287,7 +262,7 @@ export function ruleToFormState(rule: AlertRuleDocument): RuleFormState {
environments: rule.environments?.length > 0 ? [...rule.environments] : [],
tags: rule.tags?.length > 0 ? [...rule.tags] : [],
groupBy: rule.groupBy ? [...rule.groupBy] : [],
- signalType: rule.signalType === "metric" ? "builder_query" : rule.signalType,
+ signalType: rule.signalType,
comparator: rule.comparator,
threshold: domainThresholdToForm(rule.signalType, rule.threshold),
thresholdUpper:
@@ -297,9 +272,9 @@ export function ruleToFormState(rule: AlertRuleDocument): RuleFormState {
consecutiveBreachesRequired: String(rule.consecutiveBreachesRequired),
consecutiveHealthyRequired: String(rule.consecutiveHealthyRequired),
renotifyIntervalMinutes: String(rule.renotifyIntervalMinutes),
- metricName: rule.metricName ?? "",
- metricType: rule.metricType ?? "gauge",
- metricAggregation: rule.metricAggregation ?? "avg",
+ metricName: queryBuilderDraft.dataSource === "metrics" ? (queryBuilderDraft.metricName ?? "") : "",
+ metricType:
+ queryBuilderDraft.dataSource === "metrics" ? (queryBuilderDraft.metricType ?? "gauge") : "gauge",
apdexThresholdMs: rule.apdexThresholdMs == null ? "500" : String(rule.apdexThresholdMs),
queryDataSource: rule.queryBuilderDraft?.dataSource ?? "traces",
queryAggregation: rule.queryBuilderDraft?.aggregation ?? "count",
@@ -427,13 +402,6 @@ export function buildRuleCreateParamsV2(form: RuleFormState): V2AlertRuleCreateP
consecutive_breaches_required: parsePositiveNumber(form.consecutiveBreachesRequired, 2),
consecutive_healthy_required: parsePositiveNumber(form.consecutiveHealthyRequired, 2),
renotify_interval_minutes: parsePositiveNumber(form.renotifyIntervalMinutes, 30),
- // Always null: `ruleToFormState` rewrites a stored `metric` rule to
- // `builder_query` on load and nothing in the form can produce `"metric"`
- // again, so these can never be populated from here. A legacy metric rule
- // therefore converts to a builder_query rule the first time it is saved.
- metric_name: null,
- metric_type: null,
- metric_aggregation: null,
apdex_threshold_ms: signalType === "apdex" ? parsePositiveNumber(form.apdexThresholdMs, 500) : null,
query_builder_draft: signalType === "builder_query" ? buildQueryDraftFromForm(form) : null,
raw_query_sql: signalType === "raw_query" ? form.rawQuerySql.trim() || null : null,
@@ -478,8 +446,7 @@ export type DestinationFormState = {
type: AlertDestinationType
name: string
enabled: boolean
- channelLabel: string
- /** Slack and Discord both use this incoming-webhook URL field. */
+ /** Discord incoming-webhook URL. */
webhookUrl: string
/**
* Slack (bot) destination: the channel the installed Maple bot posts to.
@@ -492,7 +459,6 @@ export type DestinationFormState = {
integrationKey: string
url: string
signingSecret: string
- hazelWebhookUrl: string
hazelOrganizationId: string
hazelOrganizationName: string
hazelOrganizationLogoUrl: string | null
@@ -504,23 +470,18 @@ export type DestinationFormState = {
export const MAX_EMAIL_MEMBER_RECIPIENTS = 10
-/**
- * Defaults to `slack-bot` — the recommended flow, and the tile the dialog lists
- * first (`DESTINATION_TYPES`); the legacy `slack` webhook trails it.
- */
+/** Defaults to `slack-bot` — the tile the dialog lists first. */
export function defaultDestinationForm(type: AlertDestinationType = "slack-bot"): DestinationFormState {
return {
type,
name: "",
enabled: true,
- channelLabel: "",
webhookUrl: "",
slackChannelId: "",
slackChannelName: "",
integrationKey: "",
url: "",
signingSecret: "",
- hazelWebhookUrl: "",
hazelOrganizationId: "",
hazelOrganizationName: "",
hazelOrganizationLogoUrl: null,
@@ -535,7 +496,6 @@ export function destinationToFormState(destination: AlertDestinationDocument): D
type: destination.type,
name: destination.name,
enabled: destination.enabled,
- channelLabel: destination.channelLabel ?? "",
webhookUrl: "",
// slack-bot hydrates `channelLabel` as `#name`; keep the current channel
// visible on edit (its id isn't returned — an empty id keeps the stored one).
@@ -545,7 +505,6 @@ export function destinationToFormState(destination: AlertDestinationDocument): D
integrationKey: "",
url: "",
signingSecret: "",
- hazelWebhookUrl: "",
hazelOrganizationId: "",
hazelOrganizationName: "",
hazelOrganizationLogoUrl: null,
@@ -557,16 +516,6 @@ export function destinationToFormState(destination: AlertDestinationDocument): D
export function buildDestinationCreateParamsV2(form: DestinationFormState): V2AlertDestinationCreateParams {
switch (form.type) {
- case "slack": {
- const channelLabel = form.channelLabel.trim()
- return {
- type: "slack",
- name: form.name.trim(),
- enabled: form.enabled,
- webhook_url: form.webhookUrl.trim(),
- ...(channelLabel ? { channel_label: channelLabel } : {}),
- }
- }
case "slack-bot": {
const channelName = form.slackChannelName.trim()
return {
@@ -594,16 +543,6 @@ export function buildDestinationCreateParamsV2(form: DestinationFormState): V2Al
...(signingSecret ? { signing_secret: signingSecret } : {}),
}
}
- case "hazel": {
- const signingSecret = form.signingSecret.trim()
- return {
- type: "hazel",
- name: form.name.trim(),
- enabled: form.enabled,
- webhook_url: form.hazelWebhookUrl.trim(),
- ...(signingSecret ? { signing_secret: signingSecret } : {}),
- }
- }
case "hazel-oauth": {
const logoUrl = form.hazelOrganizationLogoUrl
return {
@@ -643,17 +582,6 @@ export function buildDestinationCreateParamsV2(form: DestinationFormState): V2Al
export function buildDestinationUpdateParamsV2(form: DestinationFormState): V2AlertDestinationUpdateParams {
const name = form.name.trim()
switch (form.type) {
- case "slack": {
- const channelLabel = form.channelLabel.trim()
- const webhookUrl = form.webhookUrl.trim()
- return {
- type: "slack",
- enabled: form.enabled,
- ...(name ? { name } : {}),
- ...(channelLabel ? { channel_label: channelLabel } : {}),
- ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
- }
- }
case "slack-bot": {
const channelId = form.slackChannelId.trim()
const channelName = form.slackChannelName.trim()
@@ -685,17 +613,6 @@ export function buildDestinationUpdateParamsV2(form: DestinationFormState): V2Al
...(signingSecret ? { signing_secret: signingSecret } : {}),
}
}
- case "hazel": {
- const webhookUrl = form.hazelWebhookUrl.trim()
- const signingSecret = form.signingSecret.trim()
- return {
- type: "hazel",
- enabled: form.enabled,
- ...(name ? { name } : {}),
- ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
- ...(signingSecret ? { signing_secret: signingSecret } : {}),
- }
- }
case "hazel-oauth": {
const organizationId = form.hazelOrganizationId.trim()
const organizationName = form.hazelOrganizationName.trim()
@@ -809,6 +726,27 @@ export function v2CheckToDocument(check: V2AlertCheck): AlertCheckDocument {
})
}
+export function v2DeliveryToDocument(delivery: V2AlertDelivery): AlertDeliveryEventDocument {
+ return new AlertDeliveryEventDocument({
+ id: delivery.id,
+ incidentId: delivery.incident_id,
+ ruleId: delivery.rule_id,
+ destinationId: delivery.destination_id,
+ destinationName: delivery.destination_name,
+ destinationType: delivery.destination_type,
+ deliveryKey: delivery.delivery_key,
+ eventType: delivery.event_type,
+ attemptNumber: delivery.attempt_number,
+ status: delivery.status,
+ scheduledAt: asIso(delivery.scheduled_at),
+ attemptedAt: asIsoOrNull(delivery.attempted_at),
+ providerMessage: delivery.provider_message,
+ providerReference: delivery.provider_reference,
+ responseCode: delivery.response_code,
+ errorMessage: delivery.error_message,
+ })
+}
+
/* -------------------------------------------------------------------------- */
/* Incident Stats */
/* -------------------------------------------------------------------------- */
diff --git a/apps/web/src/lib/alerts/rule-status.test.ts b/apps/web/src/lib/alerts/rule-status.test.ts
index 4b4839c4b..cacc2b402 100644
--- a/apps/web/src/lib/alerts/rule-status.test.ts
+++ b/apps/web/src/lib/alerts/rule-status.test.ts
@@ -31,9 +31,6 @@ function makeRule(overrides: Record = {}): AlertRuleDocument {
consecutiveBreachesRequired: 2,
consecutiveHealthyRequired: 2,
renotifyIntervalMinutes: 30,
- metricName: null,
- metricType: null,
- metricAggregation: null,
apdexThresholdMs: null,
queryBuilderDraft: null,
rawQuerySql: null,
diff --git a/apps/web/src/lib/alerts/widget-chart-param.ts b/apps/web/src/lib/alerts/widget-chart-param.ts
index df8eae26c..e78af468c 100644
--- a/apps/web/src/lib/alerts/widget-chart-param.ts
+++ b/apps/web/src/lib/alerts/widget-chart-param.ts
@@ -35,8 +35,7 @@ export type AlertChartContext = Schema.Schema.Type = {}) {
return {
@@ -158,51 +158,3 @@ describe("createWidgetAlertPrefill", () => {
)
})
})
-
-describe("resolveWidgetAlertPrefill", () => {
- it("returns a blank alert with a notice when the dashboard id is missing", () => {
- const result = resolveWidgetAlertPrefill({
- dashboards: [{ id: "dash", widgets: [] }],
- widgetId: "w1",
- base: defaultRuleForm(),
- })
-
- expect(result.form.signalType).toBe("error_rate")
- expect(result.notices[0]?.message).toContain("dashboard id was missing")
- })
-
- it("returns a blank alert with a notice when the widget id is missing", () => {
- const result = resolveWidgetAlertPrefill({
- dashboards: [{ id: "dash", widgets: [] }],
- dashboardId: "dash",
- base: defaultRuleForm(),
- })
-
- expect(result.form.signalType).toBe("error_rate")
- expect(result.notices[0]?.message).toContain("chart id was missing")
- })
-
- it("returns a blank alert with a notice when the dashboard is missing", () => {
- const result = resolveWidgetAlertPrefill({
- dashboards: [],
- dashboardId: "missing",
- widgetId: "w1",
- base: defaultRuleForm(),
- })
-
- expect(result.form.signalType).toBe("error_rate")
- expect(result.notices[0]?.message).toContain("dashboard could not be found")
- })
-
- it("returns a blank alert with a notice when the widget is missing", () => {
- const result = resolveWidgetAlertPrefill({
- dashboards: [{ id: "dash", widgets: [] }],
- dashboardId: "dash",
- widgetId: "missing",
- base: defaultRuleForm(),
- })
-
- expect(result.form.signalType).toBe("error_rate")
- expect(result.notices[0]?.message).toContain("source chart could not be found")
- })
-})
diff --git a/apps/web/src/lib/alerts/widget-prefill.ts b/apps/web/src/lib/alerts/widget-prefill.ts
index 68b954484..10b00803f 100644
--- a/apps/web/src/lib/alerts/widget-prefill.ts
+++ b/apps/web/src/lib/alerts/widget-prefill.ts
@@ -24,11 +24,6 @@ type AlertableDashboardWidget = {
display?: { title?: string }
}
-type DashboardWithWidgets = {
- id: string
- widgets: readonly AlertableDashboardWidget[]
-}
-
const QUERY_BUILDER_ENDPOINTS = new Set([
"custom_query_builder_timeseries",
"custom_query_builder_breakdown",
@@ -227,66 +222,3 @@ export function createWidgetAlertPrefill(
],
}
}
-
-export function resolveWidgetAlertPrefill({
- dashboards,
- dashboardId,
- widgetId,
- base,
-}: {
- dashboards: readonly DashboardWithWidgets[]
- dashboardId?: string
- widgetId?: string
- base: RuleFormState
-}): WidgetAlertPrefillResult {
- if (!dashboardId) {
- return {
- form: base,
- notices: [
- {
- severity: "warning",
- message: "The source dashboard id was missing. Starting from a blank alert.",
- },
- ],
- }
- }
- if (!widgetId) {
- return {
- form: base,
- notices: [
- {
- severity: "warning",
- message: "The source chart id was missing. Starting from a blank alert.",
- },
- ],
- }
- }
-
- const dashboard = dashboards.find((candidate) => candidate.id === dashboardId)
- if (!dashboard) {
- return {
- form: base,
- notices: [
- {
- severity: "warning",
- message: "The source dashboard could not be found. Starting from a blank alert.",
- },
- ],
- }
- }
-
- const widget = dashboard.widgets.find((candidate) => candidate.id === widgetId)
- if (!widget) {
- return {
- form: base,
- notices: [
- {
- severity: "warning",
- message: "The source chart could not be found. Starting from a blank alert.",
- },
- ],
- }
- }
-
- return createWidgetAlertPrefill(widget, base)
-}
diff --git a/apps/web/src/lib/collections/alerts.test.ts b/apps/web/src/lib/collections/alerts.test.ts
index 66baeae61..a36bdb862 100644
--- a/apps/web/src/lib/collections/alerts.test.ts
+++ b/apps/web/src/lib/collections/alerts.test.ts
@@ -44,9 +44,6 @@ const ruleRow: AlertRuleRow = {
consecutive_breaches_required: 2,
consecutive_healthy_required: 2,
renotify_interval_minutes: 30,
- metric_name: null,
- metric_type: null,
- metric_aggregation: null,
apdex_threshold_ms: null,
query_builder_draft_json: null,
raw_query_sql: null,
@@ -191,11 +188,11 @@ describe("rowToAlertDestinationDocument", () => {
id: DEST_ID,
org_id: "org_1",
name: "Ops Slack",
- type: "slack",
+ type: "slack-bot",
enabled: true,
// Only the public config the browser renders — no secrets (those live in
// the excluded encrypted columns, which the shape never projects).
- config_json: { summary: "Slack incoming webhook", channelLabel: "#ops" },
+ config_json: { summary: "Slack channel #ops", channelLabel: "#ops" },
last_tested_at: "2026-07-04T00:00:00.000Z",
last_test_error: null,
created_at: "2026-06-01T00:00:00.000Z",
@@ -206,9 +203,9 @@ describe("rowToAlertDestinationDocument", () => {
const doc = rowToAlertDestinationDocument(base)
assert.strictEqual(doc.id, DEST_ID)
assert.strictEqual(doc.name, "Ops Slack")
- assert.strictEqual(doc.type, "slack")
+ assert.strictEqual(doc.type, "slack-bot")
assert.strictEqual(doc.enabled, true)
- assert.strictEqual(doc.summary, "Slack incoming webhook")
+ assert.strictEqual(doc.summary, "Slack channel #ops")
assert.strictEqual(doc.channelLabel, "#ops")
assert.strictEqual(doc.lastTestedAt, "2026-07-04T00:00:00.000Z")
assert.strictEqual(doc.lastTestError, null)
diff --git a/apps/web/src/lib/collections/alerts.ts b/apps/web/src/lib/collections/alerts.ts
index e55ce9cbc..3164fd764 100644
--- a/apps/web/src/lib/collections/alerts.ts
+++ b/apps/web/src/lib/collections/alerts.ts
@@ -7,8 +7,6 @@ import {
AlertGroupBy,
AlertIncidentDocument,
AlertIncidentStatus,
- AlertMetricAggregation,
- AlertMetricType,
AlertNotificationTemplate,
AlertRuleDocument,
AlertRuleId,
@@ -35,8 +33,6 @@ const asDestinationType = Schema.decodeUnknownSync(AlertDestinationType)
const asSeverity = Schema.decodeUnknownSync(AlertSeverity)
const asSignalType = Schema.decodeUnknownSync(AlertSignalType)
const asComparator = Schema.decodeUnknownSync(AlertComparator)
-const asMetricType = Schema.decodeUnknownSync(AlertMetricType)
-const asMetricAggregation = Schema.decodeUnknownSync(AlertMetricAggregation)
const asIncidentStatus = Schema.decodeUnknownSync(AlertIncidentStatus)
const asEventType = Schema.decodeUnknownSync(AlertEventType)
const asReducer = Schema.decodeUnknownSync(QueryEngineAlertReducer)
@@ -88,9 +84,6 @@ export const AlertRuleRowSchema = Schema.Struct({
consecutive_breaches_required: Schema.Number,
consecutive_healthy_required: Schema.Number,
renotify_interval_minutes: Schema.Number,
- metric_name: Schema.NullOr(Schema.String),
- metric_type: Schema.NullOr(Schema.String),
- metric_aggregation: Schema.NullOr(Schema.String),
apdex_threshold_ms: Schema.NullOr(Schema.Number),
query_builder_draft_json: Schema.NullOr(Schema.Unknown),
raw_query_sql: Schema.NullOr(Schema.String),
@@ -217,10 +210,6 @@ export const rowToAlertRuleDocument = (
consecutiveBreachesRequired: row.consecutive_breaches_required,
consecutiveHealthyRequired: row.consecutive_healthy_required,
renotifyIntervalMinutes: row.renotify_interval_minutes,
- metricName: row.metric_name,
- metricType: row.metric_type != null ? asMetricType(row.metric_type) : null,
- metricAggregation:
- row.metric_aggregation != null ? asMetricAggregation(row.metric_aggregation) : null,
apdexThresholdMs: row.apdex_threshold_ms,
queryBuilderDraft:
row.query_builder_draft_json == null
diff --git a/apps/web/src/lib/models/alerts-overview-model.registry.test.ts b/apps/web/src/lib/models/alerts-overview-model.registry.test.ts
index 3d8a1485a..934b1f3a2 100644
--- a/apps/web/src/lib/models/alerts-overview-model.registry.test.ts
+++ b/apps/web/src/lib/models/alerts-overview-model.registry.test.ts
@@ -55,9 +55,6 @@ const makeRule = (overrides: Record = {}): AlertRuleDocument =>
consecutiveBreachesRequired: 2,
consecutiveHealthyRequired: 2,
renotifyIntervalMinutes: 30,
- metricName: null,
- metricType: null,
- metricAggregation: null,
apdexThresholdMs: null,
queryBuilderDraft: null,
rawQuerySql: null,
diff --git a/apps/web/src/lib/models/alerts-overview-model.test.ts b/apps/web/src/lib/models/alerts-overview-model.test.ts
index 49d788cd9..64c5bca2c 100644
--- a/apps/web/src/lib/models/alerts-overview-model.test.ts
+++ b/apps/web/src/lib/models/alerts-overview-model.test.ts
@@ -36,9 +36,6 @@ function makeRule(overrides: Record = {}): AlertRuleDocument {
consecutiveBreachesRequired: 2,
consecutiveHealthyRequired: 2,
renotifyIntervalMinutes: 30,
- metricName: null,
- metricType: null,
- metricAggregation: null,
apdexThresholdMs: null,
queryBuilderDraft: null,
rawQuerySql: null,
diff --git a/apps/web/src/lib/models/alerts-overview-model.ts b/apps/web/src/lib/models/alerts-overview-model.ts
index 3cfeaaa23..511c9d045 100644
--- a/apps/web/src/lib/models/alerts-overview-model.ts
+++ b/apps/web/src/lib/models/alerts-overview-model.ts
@@ -32,7 +32,7 @@ import {
} from "@/lib/collections/alerts"
import { getOrgCollections } from "@/lib/collections/org-collections"
import { collectionFailureMessage, makeOrgCollectionsKey, orgIdOf } from "@/lib/models/org-collections-key"
-import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
+import { v2DeliveryToDocument } from "@/lib/alerts/form-utils"
import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client"
const DAY_MS = 24 * 60 * 60 * 1000
@@ -231,16 +231,17 @@ export class AlertsOverviewModel extends Model.Service()("m
(key) => getOrgCollections(orgIdOf(key)).alertIncidents,
)
- // Delivery events stay an HTTP read (no Electric shape) — refetched on
- // org/generation change via the dependency store, manually via `refresh`.
- // TODO(v2): also the last v1 alerts endpoint the web app calls — no v2
- // equivalent exists; the follow-up is an alert_delivery_events Electric shape.
+ // Delivery events stay a capped HTTP read (no Electric shape) — refetched
+ // on org/generation change via the dependency store, manually via `refresh`.
const deliveryEvents = yield* Query.make({
stores: { orgKey },
handler: () =>
Effect.gen(function* () {
- const client = yield* MapleApiAtomClient
- return yield* client.alerts.listDeliveryEvents()
+ const client = yield* MapleApiV2AtomClient
+ const response = yield* client.alertDeliveries.list({
+ query: { limit: 100 },
+ })
+ return { events: response.data.map(v2DeliveryToDocument) }
}),
})
diff --git a/apps/web/src/routes/alerts/$ruleId.tsx b/apps/web/src/routes/alerts/$ruleId.tsx
index 9a7e9a456..9053e3253 100644
--- a/apps/web/src/routes/alerts/$ruleId.tsx
+++ b/apps/web/src/routes/alerts/$ruleId.tsx
@@ -6,7 +6,6 @@ import { useCallback, useMemo, useState } from "react"
import { toast } from "sonner"
import { DashboardLayout } from "@/components/layout/dashboard-layout"
-import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client"
import { useAlertRuleChecks } from "@/hooks/use-alert-rule-checks"
import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range"
@@ -31,6 +30,7 @@ import {
computeIncidentStats,
getExitErrorMessage,
v2CheckToDocument,
+ v2DeliveryToDocument,
} from "@/lib/alerts/form-utils"
import { RuleDiagnosisPanel } from "@/components/alerts/rule-detail/rule-diagnosis-panel"
import { useAlertRuleStates } from "@/hooks/use-alert-rule-states"
@@ -132,10 +132,9 @@ function RuleDetailContent() {
const { result: incidentsResult, refresh: refreshIncidents } = useAlertIncidentsList()
const ruleStates = useAlertRuleStates(ruleId)
const { result: destinationsResult } = useAlertDestinationsList()
- // TODO(v2): delivery events have no v2 endpoint (internal delivery-audit
- // schema); the proper follow-up is an Electric shape for alert_delivery_events.
const deliveryEventsResult = useAtomValue(
- MapleApiAtomClient.query("alerts", "listDeliveryEvents", {
+ MapleApiV2AtomClient.query("alertDeliveries", "list", {
+ query: { limit: 100 },
reactivityKeys: ["alertDeliveryEvents"],
}),
)
@@ -188,7 +187,9 @@ function RuleDetailContent() {
const ruleDeliveryEvents = useMemo(
() =>
Result.builder(deliveryEventsResult)
- .onSuccess((response) => response.events.filter((event) => event.ruleId === ruleId))
+ .onSuccess((response) =>
+ response.data.map(v2DeliveryToDocument).filter((event) => event.ruleId === ruleId),
+ )
.orElse(() => []),
[deliveryEventsResult, ruleId],
)
diff --git a/apps/web/src/routes/alerts/create.tsx b/apps/web/src/routes/alerts/create.tsx
index c5f5a6c7f..666312787 100644
--- a/apps/web/src/routes/alerts/create.tsx
+++ b/apps/web/src/routes/alerts/create.tsx
@@ -8,13 +8,9 @@ const AlertCreateSearch = Schema.Struct({
ruleId: Schema.optional(Schema.String),
/** Starter-template id from the overview empty state — pre-applies that preset. */
template: Schema.optional(Schema.String),
- /** Set by the "Create alert" action on a dashboard chart widget. */
- dashboardId: Schema.optional(Schema.String),
- widgetId: Schema.optional(Schema.String),
/**
* Base64url-encoded snapshot of the source widget (see widget-chart-param.ts).
- * Carries the live builder state so prefill doesn't race the dashboard
- * autosave; dashboardId/widgetId remain as the lookup fallback.
+ * Carries the live builder state so prefill doesn't race dashboard autosave.
*/
chart: Schema.optional(Schema.String),
})
diff --git a/apps/web/src/routes/alerts/index.tsx b/apps/web/src/routes/alerts/index.tsx
index fdba19d98..37d81d0a1 100644
--- a/apps/web/src/routes/alerts/index.tsx
+++ b/apps/web/src/routes/alerts/index.tsx
@@ -15,11 +15,7 @@ import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs"
type AlertsTab = "overview" | "settings"
const AlertsSearch = Schema.Struct({
- /**
- * Accepts any string so legacy deep links (`tab=monitor`, `tab=rules`) keep
- * resolving — anything that isn't "settings" lands on the overview.
- */
- tab: Schema.optional(Schema.String),
+ tab: Schema.optional(Schema.Literals(["overview", "settings"])),
serviceName: Schema.optional(Schema.String),
createdBy: Schema.optional(Schema.String),
/** Health-summary filter over the rules list. */
diff --git a/docs/api-v2.md b/docs/api-v2.md
index b7677a2c9..d2e29e857 100644
--- a/docs/api-v2.md
+++ b/docs/api-v2.md
@@ -44,7 +44,7 @@ POST /v2/traces/search complex reads are POST .../search
Every v2 object has a prefixed public ID (`key_4CzLmR…`, `dash_…`, `alrt_…`). Public IDs are opaque; internally they are a reversible base58 encoding of the internal ID, computed at the API boundary (`packages/domain/src/http/v2/public-id.ts` — the prefix registry lives there and is the single source of truth). No database migration: rows keep their raw UUIDs / internal strings.
-Prefixes: `key` (API key), `ingk` (ingest key), `dash` (dashboard), `dbv` (dashboard version), `dtpl` (dashboard template), `alrt` (alert rule), `dest` (alert destination), `inc` (alert incident), `einc` (error incident), `iss` (error issue), `inv` (investigation), `anom` (anomaly incident), `scrp` (scrape target), `rec` (recommendation), `amap` (attribute mapping), `srep` (session replay), and `log` (synthetic log identity); `evt` and `we` are reserved for events/webhooks.
+Prefixes: `key` (API key), `ingk` (ingest key), `dash` (dashboard), `dbv` (dashboard version), `dtpl` (dashboard template), `alrt` (alert rule), `dest` (alert destination), `inc` (alert incident), `evt` (alert delivery event), `einc` (error incident), `iss` (error issue), `inv` (investigation), `anom` (anomaly incident), `scrp` (scrape target), `rec` (recommendation), `amap` (attribute mapping), `srep` (session replay), and `log` (synthetic log identity); `we` is reserved for webhooks.
Exception: Clerk-issued `org_…` / `user_…` IDs are already prefixed public IDs and pass through unchanged.
@@ -138,29 +138,30 @@ Stripe-style `expand[]` is deliberately omitted: responses embed the small, alwa
Implemented in phases; the pilot (`api_keys`) ships first and proves every convention.
-| Resource | Endpoints | Backing v1 group / service |
-| ------------------------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------------- |
-| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `apiKeys` / `ApiKeysService` |
-| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `ingestKeys` |
+| Resource | Endpoints | Backing service |
+| ------------------------------------ | -------------------------------------------------------------------------------------------------- | ---------------------------------------- |
+| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `apiKeys` / `ApiKeysService` |
+| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `ingestKeys` |
| `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/preview/instantiate) + Perses import | `dashboards` |
-| `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `alerts` |
-| `alerts/destinations` ✅ | CRUD + `test` | `alerts` |
-| `alerts/incidents` ✅ | list/retrieve | `alerts` |
-| `error_issues` 🟡 | list/retrieve ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` |
-| `investigations` ✅ | list/retrieve/create/status | `investigations` |
-| `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue + `PATCH` settings | `anomalies` |
-| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `recommendationIssues` |
-| `instrumentation/audit` ✅ | retrieve (singleton report, recomputed per request) | `SetupAuditService` |
-| `scrape_targets` ✅ | CRUD + `probe` + `checks` | `scrapeTargets` |
-| `attribute_mappings` ✅ | CRUD | `ingestAttributeMappings` |
-| `integrations/slack` ✅ | status + admin-only install/uninstall/`channels` (channel ids for `slack-bot` destinations) | `SlackIntegrationService` |
-| `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` |
-| `organization` 🟡 | retrieve (GET only shipped); update settings (incl. ClickHouse BYOC) + delete deferred | `organizations`, `orgClickHouseSettings` |
-| `traces` ✅ | search/timeseries/breakdown + direct trace/span reads | `queryEngine`, `observability` |
-| `logs` ✅ | search/timeseries/breakdown + direct log reads | `queryEngine` |
-| `metrics` ✅ | catalog + timeseries/breakdown | `queryEngine` |
-| `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}` | `queryEngine` |
-| `service_map` ✅ | `GET /v2/service_map` | `queryEngine` |
+| `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `AlertsService` |
+| `alerts/destinations` ✅ | CRUD + `test` | `AlertsService` |
+| `alerts/incidents` ✅ | list/retrieve | `AlertsService` |
+| `alerts/deliveries` ✅ | list delivery attempts | `AlertsService` |
+| `error_issues` 🟡 | list/retrieve ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` |
+| `investigations` ✅ | list/retrieve/create/status | `investigations` |
+| `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue + `PATCH` settings | `anomalies` |
+| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `recommendationIssues` |
+| `instrumentation/audit` ✅ | retrieve (singleton report, recomputed per request) | `SetupAuditService` |
+| `scrape_targets` ✅ | CRUD + `probe` + `checks` | `scrapeTargets` |
+| `attribute_mappings` ✅ | CRUD | `ingestAttributeMappings` |
+| `integrations/slack` ✅ | status + admin-only install/uninstall/`channels` (channel ids for `slack-bot` destinations) | `SlackIntegrationService` |
+| `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` |
+| `organization` 🟡 | retrieve (GET only shipped); update settings (incl. ClickHouse BYOC) + delete deferred | `organizations`, `orgClickHouseSettings` |
+| `traces` ✅ | search/timeseries/breakdown + direct trace/span reads | `queryEngine`, `observability` |
+| `logs` ✅ | search/timeseries/breakdown + direct log reads | `queryEngine` |
+| `metrics` ✅ | catalog + timeseries/breakdown | `queryEngine` |
+| `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}` | `queryEngine` |
+| `service_map` ✅ | `GET /v2/service_map` | `queryEngine` |
The long tail of ~40 query-engine RPC endpoints (facets, infra hosts/pods/nodes/workloads, Cloudflare/PlanetScale infra) starts in the internal RPC tier and is promoted into `/v2` individually as shapes stabilize.
diff --git a/examples/alchemy-maple/README.md b/examples/alchemy-maple/README.md
index 6d3c83559..842a00478 100644
--- a/examples/alchemy-maple/README.md
+++ b/examples/alchemy-maple/README.md
@@ -6,7 +6,7 @@ A Cloudflare Worker and the Maple resources that observe it, in one stack — vi
| | |
| ---------------------------------- | -------------------------------------------------------------------------- |
| [`src/Api.ts`](src/Api.ts) | The Worker: Alchemy's Effect-native style, instrumented with the Maple SDK |
-| [`alchemy.run.ts`](alchemy.run.ts) | A Slack destination, two alert rules, a dashboard, a scoped API key |
+| [`alchemy.run.ts`](alchemy.run.ts) | A PagerDuty destination, two alert rules, a dashboard, a scoped API key |
## The seam
@@ -35,7 +35,7 @@ bun run --cwd ../../lib/alchemy-maple build && bun run --cwd ../../lib/effect-sd
```
```bash
-MAPLE_API_KEY=maple_ak_… CLOUDFLARE_ACCOUNT_ID=… SLACK_WEBHOOK_URL=https://hooks.slack.com/… bun alchemy deploy
+MAPLE_API_KEY=maple_ak_… CLOUDFLARE_ACCOUNT_ID=… PAGERDUTY_ROUTING_KEY=… bun alchemy deploy
```
Needs an org-admin `maple_ak_…` key (alert rules and API keys require one); set `MAPLE_API_URL` to
diff --git a/examples/alchemy-maple/alchemy.run.ts b/examples/alchemy-maple/alchemy.run.ts
index c3bc2bc8c..e65cf598b 100644
--- a/examples/alchemy-maple/alchemy.run.ts
+++ b/examples/alchemy-maple/alchemy.run.ts
@@ -27,11 +27,10 @@ export default Alchemy.Stack(
const api = yield* Api
// Where alerts go. Channel secrets are write-only server-side.
- const slack = yield* Maple.AlertDestination("oncall-slack", {
- type: "slack",
- name: "On-call Slack",
- webhook_url: process.env.SLACK_WEBHOOK_URL ?? "https://hooks.slack.com/services/CHANGE/ME/PLEASE",
- channel_label: "#incidents",
+ const oncall = yield* Maple.AlertDestination("oncall-pagerduty", {
+ type: "pagerduty",
+ name: "On-call PagerDuty",
+ integration_key: process.env.PAGERDUTY_ROUTING_KEY ?? "CHANGE_ME",
})
// Alerts on the service name the Worker reports as — `SERVICE_NAME` is the
@@ -46,7 +45,7 @@ export default Alchemy.Stack(
threshold: 0.05, // error rates are 0–1 ratios, not percentages
window_minutes: 5,
service_names: [SERVICE_NAME],
- destination_ids: [slack.destinationId],
+ destination_ids: [oncall.destinationId],
})
yield* Maple.AlertRule("checkout-p95", {
@@ -57,7 +56,7 @@ export default Alchemy.Stack(
threshold: 750,
window_minutes: 10,
service_names: [SERVICE_NAME],
- destination_ids: [slack.destinationId],
+ destination_ids: [oncall.destinationId],
})
yield* Maple.Dashboard("service-health", {
diff --git a/lib/alchemy-maple/README.md b/lib/alchemy-maple/README.md
index 9c73576fc..884db460b 100644
--- a/lib/alchemy-maple/README.md
+++ b/lib/alchemy-maple/README.md
@@ -22,11 +22,10 @@ export default Alchemy.Stack(
{ providers: Layer.mergeAll(Cloudflare.providers(), Maple.providers()) },
Effect.gen(function* () {
// A notification channel…
- const slack = yield* Maple.AlertDestination("oncall", {
- type: "slack",
- name: "On-call Slack",
- webhook_url: process.env.SLACK_WEBHOOK_URL!,
- channel_label: "#incidents",
+ const oncall = yield* Maple.AlertDestination("oncall", {
+ type: "pagerduty",
+ name: "On-call PagerDuty",
+ integration_key: process.env.PAGERDUTY_ROUTING_KEY!,
})
// …an alert rule that delivers to it (dependency resolved automatically)…
@@ -37,7 +36,7 @@ export default Alchemy.Stack(
comparator: "gt",
threshold: 0.05, // error rates are 0–1 ratios
window_minutes: 5,
- destination_ids: [slack.destinationId],
+ destination_ids: [oncall.destinationId],
})
// …a dashboard…
diff --git a/lib/alchemy-maple/src/AlertDestination.ts b/lib/alchemy-maple/src/AlertDestination.ts
index 7828e4483..ed64e8a96 100644
--- a/lib/alchemy-maple/src/AlertDestination.ts
+++ b/lib/alchemy-maple/src/AlertDestination.ts
@@ -24,10 +24,8 @@ interface DestinationBaseProps {
* prop changes only.
*/
export type AlertDestinationProps =
- | (DestinationBaseProps & { type: "slack"; webhook_url: SecretInput; channel_label?: string })
| (DestinationBaseProps & { type: "pagerduty"; integration_key: SecretInput })
| (DestinationBaseProps & { type: "webhook"; url: string; signing_secret?: SecretInput })
- | (DestinationBaseProps & { type: "hazel"; webhook_url: SecretInput; signing_secret?: SecretInput })
| (DestinationBaseProps & { type: "discord"; webhook_url: SecretInput })
| (DestinationBaseProps & { type: "email"; member_user_ids: string[] })
@@ -46,16 +44,16 @@ export type AlertDestination = Resource<
>
/**
- * A notification channel (Slack, PagerDuty, webhook, Hazel, Discord, or
- * workspace-member email) that `Maple.AlertRule`s deliver to.
+ * A notification channel (PagerDuty, webhook, Discord, or workspace-member
+ * email) that `Maple.AlertRule`s deliver to. Slack and Hazel destinations use
+ * their installed integrations and are managed in Maple.
*
* @example
* ```typescript
- * const slack = yield* Maple.AlertDestination("oncall", {
- * type: "slack",
- * name: "On-call Slack",
- * webhook_url: Redacted.make(process.env.SLACK_WEBHOOK_URL!),
- * channel_label: "#incidents",
+ * const oncall = yield* Maple.AlertDestination("oncall", {
+ * type: "pagerduty",
+ * name: "On-call PagerDuty",
+ * integration_key: Redacted.make(process.env.PAGERDUTY_ROUTING_KEY!),
* })
* ```
*/
@@ -90,10 +88,6 @@ const desiredBody = (props: AlertDestinationProps): Record => {
const body: Record = { type: props.type, name: props.name }
if (props.enabled !== undefined) body.enabled = props.enabled
switch (props.type) {
- case "slack":
- body.webhook_url = unwrap(props.webhook_url)
- if (props.channel_label !== undefined) body.channel_label = props.channel_label
- break
case "pagerduty":
body.integration_key = unwrap(props.integration_key)
break
@@ -101,10 +95,6 @@ const desiredBody = (props: AlertDestinationProps): Record => {
body.url = props.url
if (props.signing_secret !== undefined) body.signing_secret = unwrap(props.signing_secret)
break
- case "hazel":
- body.webhook_url = unwrap(props.webhook_url)
- if (props.signing_secret !== undefined) body.signing_secret = unwrap(props.signing_secret)
- break
case "discord":
body.webhook_url = unwrap(props.webhook_url)
break
@@ -122,12 +112,7 @@ const desiredBody = (props: AlertDestinationProps): Record => {
const drifted = (
props: AlertDestinationProps,
observed: Schema.Schema.Type,
-): boolean =>
- props.name !== observed.name ||
- (props.enabled ?? true) !== observed.enabled ||
- (props.type === "slack" &&
- props.channel_label !== undefined &&
- props.channel_label !== (observed.channel_label ?? undefined))
+): boolean => props.name !== observed.name || (props.enabled ?? true) !== observed.enabled
const toAttributes = (observed: Schema.Schema.Type) => ({
destinationId: observed.id,
diff --git a/lib/alchemy-maple/src/AlertRule.ts b/lib/alchemy-maple/src/AlertRule.ts
index 78315a6ec..c462a96f6 100644
--- a/lib/alchemy-maple/src/AlertRule.ts
+++ b/lib/alchemy-maple/src/AlertRule.ts
@@ -12,7 +12,6 @@ export type AlertSignalType =
| "p99_latency"
| "apdex"
| "throughput"
- | "metric"
| "builder_query"
| "raw_query"
@@ -20,8 +19,8 @@ export type AlertComparator = "gt" | "gte" | "lt" | "lte" | "eq" | "neq" | "betw
/**
* Alert rule props, authored in the v2 wire shape — mirrors
- * `POST /v2/alerts/rules`. Signal-specific fields (`metric_*`,
- * `apdex_threshold_ms`, `query_builder_draft`, `raw_query_*`) are validated
+ * `POST /v2/alerts/rules`. Signal-specific fields (`apdex_threshold_ms`,
+ * `query_builder_draft`, `raw_query_*`) are validated
* server-side against `signal_type`.
*/
export interface AlertRuleProps {
@@ -46,9 +45,6 @@ export interface AlertRuleProps {
consecutive_breaches_required?: number
consecutive_healthy_required?: number
renotify_interval_minutes?: number
- metric_name?: string | null
- metric_type?: "sum" | "gauge" | "histogram" | "exponential_histogram" | null
- metric_aggregation?: "avg" | "min" | "max" | "sum" | "count" | null
apdex_threshold_ms?: number | null
/** Opaque query-builder draft for `builder_query` rules (verbatim passthrough). */
query_builder_draft?: Record | null
diff --git a/lib/alchemy-maple/src/index.ts b/lib/alchemy-maple/src/index.ts
index 89a6f0fab..f90aa5bf3 100644
--- a/lib/alchemy-maple/src/index.ts
+++ b/lib/alchemy-maple/src/index.ts
@@ -14,10 +14,10 @@
* export default Alchemy.Stack("my-app", {
* providers: Maple.providers(),
* }, Effect.gen(function* () {
- * const slack = yield* Maple.AlertDestination("oncall", {
- * type: "slack",
- * name: "On-call Slack",
- * webhook_url: process.env.SLACK_WEBHOOK_URL!,
+ * const oncall = yield* Maple.AlertDestination("oncall", {
+ * type: "pagerduty",
+ * name: "On-call PagerDuty",
+ * integration_key: process.env.PAGERDUTY_ROUTING_KEY!,
* })
* yield* Maple.AlertRule("checkout-errors", {
* name: "Checkout error rate",
@@ -26,7 +26,7 @@
* comparator: "gt",
* threshold: 0.05,
* window_minutes: 5,
- * destination_ids: [slack.destinationId],
+ * destination_ids: [oncall.destinationId],
* })
* }))
* ```
diff --git a/lib/alchemy-maple/test/alert-destination-props.test.ts b/lib/alchemy-maple/test/alert-destination-props.test.ts
index 249e9c748..80e847622 100644
--- a/lib/alchemy-maple/test/alert-destination-props.test.ts
+++ b/lib/alchemy-maple/test/alert-destination-props.test.ts
@@ -18,29 +18,25 @@ import {
// Every channel type is constructible with its own fields.
export const accepts = Effect.gen(function* () {
- yield* AlertDestination("slack", { type: "slack", name: "s", webhook_url: "u", channel_label: "#c" })
yield* AlertDestination("pagerduty", { type: "pagerduty", name: "p", integration_key: "k" })
yield* AlertDestination("webhook", { type: "webhook", name: "w", url: "https://x", signing_secret: "s" })
- yield* AlertDestination("hazel", { type: "hazel", name: "h", webhook_url: "u" })
yield* AlertDestination("discord", { type: "discord", name: "d", webhook_url: "u" })
yield* AlertDestination("email", { type: "email", name: "e", member_user_ids: ["u_1"] })
})
// …and only with its own fields.
export const rejects = Effect.gen(function* () {
- // @ts-expect-error slack requires webhook_url
- yield* AlertDestination("a", { type: "slack", name: "no secret" })
- // @ts-expect-error integration_key belongs to pagerduty
- yield* AlertDestination("b", { type: "slack", name: "x", webhook_url: "u", integration_key: "k" })
+ // @ts-expect-error pagerduty requires integration_key
+ yield* AlertDestination("a", { type: "pagerduty", name: "no secret" })
+ // @ts-expect-error url belongs to webhook
+ yield* AlertDestination("b", { type: "pagerduty", name: "x", integration_key: "k", url: "u" })
// @ts-expect-error email requires member_user_ids
yield* AlertDestination("c", { type: "email", name: "x" })
})
const channels: ReadonlyArray<[string, AlertDestinationProps, string]> = [
- ["slack", { type: "slack", name: "s", webhook_url: "u" }, "webhook_url"],
["pagerduty", { type: "pagerduty", name: "p", integration_key: "k" }, "integration_key"],
["webhook", { type: "webhook", name: "w", url: "https://x" }, "url"],
- ["hazel", { type: "hazel", name: "h", webhook_url: "u" }, "webhook_url"],
["discord", { type: "discord", name: "d", webhook_url: "u" }, "webhook_url"],
["email", { type: "email", name: "e", member_user_ids: ["u_1"] }, "member_user_ids"],
]
diff --git a/lib/alchemy-maple/test/contract.test.ts b/lib/alchemy-maple/test/contract.test.ts
index 37dc9706c..9ce33b9ba 100644
--- a/lib/alchemy-maple/test/contract.test.ts
+++ b/lib/alchemy-maple/test/contract.test.ts
@@ -42,13 +42,6 @@ describe("provider request bodies decode against the real v2 create-param schema
it("alert destination create bodies (each channel type)", () => {
const bodies = [
- _alertDestinationCreateBody({
- type: "slack",
- name: "On-call Slack",
- webhook_url: "https://hooks.slack.com/services/T000/B000/XXXX",
- channel_label: "#incidents",
- enabled: true,
- }),
_alertDestinationCreateBody({ type: "pagerduty", name: "PD", integration_key: "key" }),
_alertDestinationCreateBody({
type: "webhook",
@@ -56,11 +49,6 @@ describe("provider request bodies decode against the real v2 create-param schema
url: "https://example.com/hooks/maple",
signing_secret: "shh",
}),
- _alertDestinationCreateBody({
- type: "hazel",
- name: "Hazel",
- webhook_url: "https://hazel.example.com/hook",
- }),
_alertDestinationCreateBody({
type: "discord",
name: "Discord",
diff --git a/packages/db/drizzle/0026_windy_bromley.sql b/packages/db/drizzle/0026_windy_bromley.sql
new file mode 100644
index 000000000..b52dd443b
--- /dev/null
+++ b/packages/db/drizzle/0026_windy_bromley.sql
@@ -0,0 +1,103 @@
+UPDATE "alert_rules"
+SET
+ "query_builder_draft_json" = jsonb_build_object(
+ 'id', 'alert-query',
+ 'name', 'A',
+ 'enabled', true,
+ 'hidden', false,
+ 'dataSource', 'metrics',
+ 'signalSource', 'default',
+ 'metricName', "metric_name",
+ 'metricType', "metric_type",
+ 'isMonotonic', "metric_type" = 'sum',
+ 'aggregation', "metric_aggregation",
+ 'whereClause', concat_ws(
+ ' AND ',
+ CASE
+ WHEN jsonb_typeof("service_names_json") = 'array'
+ AND jsonb_array_length("service_names_json") > 0
+ THEN 'service.name = ' || to_json(
+ (
+ SELECT string_agg(value, ',' ORDER BY ordinal)
+ FROM jsonb_array_elements_text("service_names_json")
+ WITH ORDINALITY AS services(value, ordinal)
+ )
+ )::text
+ END,
+ CASE
+ WHEN jsonb_typeof("environments_json") = 'array'
+ AND jsonb_array_length("environments_json") > 0
+ THEN 'deployment.environment = ' || to_json(
+ (
+ SELECT string_agg(value, ',')
+ FROM jsonb_array_elements_text("environments_json")
+ )
+ )::text
+ END
+ ),
+ 'stepInterval', '',
+ 'orderByDirection', 'desc',
+ 'addOns', jsonb_build_object(
+ 'groupBy',
+ "group_by" IS NOT NULL
+ OR (
+ jsonb_typeof("service_names_json") = 'array'
+ AND jsonb_array_length("service_names_json") > 1
+ ),
+ 'having', false,
+ 'orderBy', false,
+ 'limit', false,
+ 'legend', false
+ ),
+ 'groupBy',
+ CASE
+ WHEN jsonb_typeof("service_names_json") = 'array'
+ AND jsonb_array_length("service_names_json") > 1
+ THEN jsonb_build_array('service.name')
+ ELSE COALESCE("group_by"::jsonb, '[]'::jsonb)
+ END,
+ 'having', '',
+ 'orderBy', '',
+ 'limit', '',
+ 'legend', ''
+ ),
+ "signal_type" = 'builder_query',
+ "service_names_json" = NULL,
+ "environments_json" = NULL,
+ "group_by" = NULL
+WHERE "signal_type" = 'metric';--> statement-breakpoint
+UPDATE "alert_rules" AS rules
+SET "destination_ids_json" = COALESCE(
+ (
+ SELECT jsonb_agg(destination_id)
+ FROM jsonb_array_elements_text(rules."destination_ids_json") AS ids(destination_id)
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM "alert_destinations" AS destinations
+ WHERE destinations."id" = destination_id
+ AND destinations."type" IN ('slack', 'hazel')
+ )
+ ),
+ '[]'::jsonb
+)
+WHERE EXISTS (
+ SELECT 1
+ FROM jsonb_array_elements_text(rules."destination_ids_json") AS ids(destination_id)
+ JOIN "alert_destinations" AS destinations ON destinations."id" = destination_id
+ WHERE destinations."type" IN ('slack', 'hazel')
+);--> statement-breakpoint
+UPDATE "alert_rules"
+SET "enabled" = false
+WHERE "enabled" = true
+ AND jsonb_array_length("destination_ids_json") = 0;--> statement-breakpoint
+DELETE FROM "alert_delivery_events"
+WHERE "destination_id" IN (
+ SELECT "id"
+ FROM "alert_destinations"
+ WHERE "type" IN ('slack', 'hazel')
+);--> statement-breakpoint
+DELETE FROM "alert_destinations"
+WHERE "type" IN ('slack', 'hazel');--> statement-breakpoint
+ALTER TABLE "alert_rules" DROP COLUMN "metric_name";--> statement-breakpoint
+ALTER TABLE "alert_rules" DROP COLUMN "metric_type";--> statement-breakpoint
+ALTER TABLE "alert_rules" DROP COLUMN "metric_aggregation";
diff --git a/packages/db/drizzle/meta/0026_snapshot.json b/packages/db/drizzle/meta/0026_snapshot.json
new file mode 100644
index 000000000..e1fa843ec
--- /dev/null
+++ b/packages/db/drizzle/meta/0026_snapshot.json
@@ -0,0 +1,7041 @@
+{
+ "id": "cd402582-5321-4ca1-84cd-34369d5c8b97",
+ "prevId": "ed9b467d-665c-4ec2-a3d9-ae5d9ec7fe9f",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.ai_triage_runs": {
+ "name": "ai_triage_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "incident_kind": {
+ "name": "incident_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "incident_id": {
+ "name": "incident_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "context_json": {
+ "name": "context_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "result_json": {
+ "name": "result_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "ai_triage_runs_incident_idx": {
+ "name": "ai_triage_runs_incident_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "incident_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "incident_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ai_triage_runs_org_issue_idx": {
+ "name": "ai_triage_runs_org_issue_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ai_triage_runs_org_created_idx": {
+ "name": "ai_triage_runs_org_created_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ai_triage_settings": {
+ "name": "ai_triage_settings",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "max_runs_per_day": {
+ "name": "max_runs_per_day",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_delivery_events": {
+ "name": "alert_delivery_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "incident_id": {
+ "name": "incident_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination_id": {
+ "name": "destination_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "delivery_key": {
+ "name": "delivery_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attempt_number": {
+ "name": "attempt_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "claimed_at": {
+ "name": "claimed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "claim_expires_at": {
+ "name": "claim_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "claimed_by": {
+ "name": "claimed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attempted_at": {
+ "name": "attempted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_message": {
+ "name": "provider_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_reference": {
+ "name": "provider_reference",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_code": {
+ "name": "response_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "alert_delivery_events_org_idx": {
+ "name": "alert_delivery_events_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_delivery_events_org_incident_idx": {
+ "name": "alert_delivery_events_org_incident_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "incident_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_delivery_events_due_idx": {
+ "name": "alert_delivery_events_due_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scheduled_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_delivery_events_claim_idx": {
+ "name": "alert_delivery_events_claim_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "claim_expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scheduled_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_delivery_events_delivery_attempt_idx": {
+ "name": "alert_delivery_events_delivery_attempt_idx",
+ "columns": [
+ {
+ "expression": "delivery_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "attempt_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_destinations": {
+ "name": "alert_destinations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "config_json": {
+ "name": "config_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_ciphertext": {
+ "name": "secret_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_iv": {
+ "name": "secret_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_tag": {
+ "name": "secret_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_tested_at": {
+ "name": "last_tested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_test_error": {
+ "name": "last_test_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "alert_destinations_org_idx": {
+ "name": "alert_destinations_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_destinations_org_enabled_idx": {
+ "name": "alert_destinations_org_enabled_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_destinations_org_name_idx": {
+ "name": "alert_destinations_org_name_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_incidents": {
+ "name": "alert_incidents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "incident_key": {
+ "name": "incident_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rule_name": {
+ "name": "rule_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_key": {
+ "name": "group_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signal_type": {
+ "name": "signal_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comparator": {
+ "name": "comparator",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "threshold": {
+ "name": "threshold",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "threshold_upper": {
+ "name": "threshold_upper",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_triggered_at": {
+ "name": "first_triggered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_triggered_at": {
+ "name": "last_triggered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_observed_value": {
+ "name": "last_observed_value",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sample_count": {
+ "name": "last_sample_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_evaluated_at": {
+ "name": "last_evaluated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dedupe_key": {
+ "name": "dedupe_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_delivered_event_type": {
+ "name": "last_delivered_event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_notified_at": {
+ "name": "last_notified_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_issue_id": {
+ "name": "error_issue_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "alert_incidents_org_idx": {
+ "name": "alert_incidents_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_incidents_org_status_idx": {
+ "name": "alert_incidents_org_status_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_incidents_org_rule_idx": {
+ "name": "alert_incidents_org_rule_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "rule_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_incidents_org_issue_idx": {
+ "name": "alert_incidents_org_issue_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "error_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_incidents_incident_key_idx": {
+ "name": "alert_incidents_incident_key_idx",
+ "columns": [
+ {
+ "expression": "incident_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_rule_states": {
+ "name": "alert_rule_states",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_key": {
+ "name": "group_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'__total__'"
+ },
+ "consecutive_breaches": {
+ "name": "consecutive_breaches",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "consecutive_healthy": {
+ "name": "consecutive_healthy",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_value": {
+ "name": "last_value",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sample_count": {
+ "name": "last_sample_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_evaluated_at": {
+ "name": "last_evaluated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "alert_rule_states_org_idx": {
+ "name": "alert_rule_states_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "alert_rule_states_org_id_rule_id_group_key_pk": {
+ "name": "alert_rule_states_org_id_rule_id_group_key_pk",
+ "columns": ["org_id", "rule_id", "group_key"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_rules": {
+ "name": "alert_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notification_template_json": {
+ "name": "notification_template_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "service_names_json": {
+ "name": "service_names_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "exclude_service_names_json": {
+ "name": "exclude_service_names_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "environments_json": {
+ "name": "environments_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags_json": {
+ "name": "tags_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signal_type": {
+ "name": "signal_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comparator": {
+ "name": "comparator",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "threshold": {
+ "name": "threshold",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "threshold_upper": {
+ "name": "threshold_upper",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "window_minutes": {
+ "name": "window_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "minimum_sample_count": {
+ "name": "minimum_sample_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "consecutive_breaches_required": {
+ "name": "consecutive_breaches_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 2
+ },
+ "consecutive_healthy_required": {
+ "name": "consecutive_healthy_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 2
+ },
+ "renotify_interval_minutes": {
+ "name": "renotify_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 30
+ },
+ "apdex_threshold_ms": {
+ "name": "apdex_threshold_ms",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "query_builder_draft_json": {
+ "name": "query_builder_draft_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw_query_sql": {
+ "name": "raw_query_sql",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "group_by": {
+ "name": "group_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "destination_ids_json": {
+ "name": "destination_ids_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "query_spec_json": {
+ "name": "query_spec_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reducer": {
+ "name": "reducer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sample_count_strategy": {
+ "name": "sample_count_strategy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "no_data_behavior": {
+ "name": "no_data_behavior",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_scheduled_at": {
+ "name": "last_scheduled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "alert_rules_org_idx": {
+ "name": "alert_rules_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_rules_org_enabled_idx": {
+ "name": "alert_rules_org_enabled_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "alert_rules_org_name_idx": {
+ "name": "alert_rules_org_name_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anomaly_detector_settings": {
+ "name": "anomaly_detector_settings",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "sensitivity": {
+ "name": "sensitivity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'normal'"
+ },
+ "muted_signals_json": {
+ "name": "muted_signals_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "last_tick_at": {
+ "name": "last_tick_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anomaly_detector_states": {
+ "name": "anomaly_detector_states",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detector_key": {
+ "name": "detector_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "signal_type": {
+ "name": "signal_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "service_name": {
+ "name": "service_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deployment_env": {
+ "name": "deployment_env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "fingerprint_hash": {
+ "name": "fingerprint_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consecutive_breaches": {
+ "name": "consecutive_breaches",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "consecutive_healthy": {
+ "name": "consecutive_healthy",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_value": {
+ "name": "last_value",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "baseline_median": {
+ "name": "baseline_median",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sample_count": {
+ "name": "last_sample_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_evaluated_at": {
+ "name": "last_evaluated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_incident_id": {
+ "name": "open_incident_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_resolved_at": {
+ "name": "last_resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_incident_id": {
+ "name": "last_incident_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "anomaly_detector_states_open_incident_idx": {
+ "name": "anomaly_detector_states_open_incident_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "open_incident_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anomaly_detector_states_evaluated_idx": {
+ "name": "anomaly_detector_states_evaluated_idx",
+ "columns": [
+ {
+ "expression": "last_evaluated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "anomaly_detector_states_org_id_detector_key_pk": {
+ "name": "anomaly_detector_states_org_id_detector_key_pk",
+ "columns": ["org_id", "detector_key"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anomaly_incidents": {
+ "name": "anomaly_incidents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detector_key": {
+ "name": "detector_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "signal_type": {
+ "name": "signal_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "service_name": {
+ "name": "service_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deployment_env": {
+ "name": "deployment_env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "fingerprint_hash": {
+ "name": "fingerprint_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_issue_id": {
+ "name": "error_issue_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "opened_value": {
+ "name": "opened_value",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "baseline_median": {
+ "name": "baseline_median",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "baseline_sigma": {
+ "name": "baseline_sigma",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "threshold_value": {
+ "name": "threshold_value",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_observed_value": {
+ "name": "last_observed_value",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_sample_count": {
+ "name": "last_sample_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "first_triggered_at": {
+ "name": "first_triggered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_triggered_at": {
+ "name": "last_triggered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolve_reason": {
+ "name": "resolve_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "triage_status": {
+ "name": "triage_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "dedupe_key": {
+ "name": "dedupe_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fingerprints_json": {
+ "name": "fingerprints_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "reopen_count": {
+ "name": "reopen_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_reopened_at": {
+ "name": "last_reopened_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "anomaly_incidents_org_status_idx": {
+ "name": "anomaly_incidents_org_status_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anomaly_incidents_org_triggered_idx": {
+ "name": "anomaly_incidents_org_triggered_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_triggered_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anomaly_incidents_org_detector_idx": {
+ "name": "anomaly_incidents_org_detector_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "detector_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anomaly_incidents_org_issue_idx": {
+ "name": "anomaly_incidents_org_issue_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "error_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_keys": {
+ "name": "api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_prefix": {
+ "name": "key_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_json": {
+ "name": "metadata_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'standard'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_email": {
+ "name": "created_by_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "api_keys_key_hash_unique": {
+ "name": "api_keys_key_hash_unique",
+ "columns": [
+ {
+ "expression": "key_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "api_keys_org_id_idx": {
+ "name": "api_keys_org_id_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cloudflare_analytics_state": {
+ "name": "cloudflare_analytics_state",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dataset": {
+ "name": "dataset",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "zone_id": {
+ "name": "zone_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "zone_name": {
+ "name": "zone_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "watermark_at": {
+ "name": "watermark_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "backfill_at": {
+ "name": "backfill_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "settings_json": {
+ "name": "settings_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "settings_fetched_at": {
+ "name": "settings_fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "quantiles_available": {
+ "name": "quantiles_available",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "live_scripts_json": {
+ "name": "live_scripts_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_success_at": {
+ "name": "last_success_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error_at": {
+ "name": "last_error_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "cf_analytics_state_org_dataset_zone_idx": {
+ "name": "cf_analytics_state_org_dataset_zone_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "dataset",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "zone_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cf_analytics_state_org_idx": {
+ "name": "cf_analytics_state_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cloudflare_hyperdrive_configs": {
+ "name": "cloudflare_hyperdrive_configs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config_id": {
+ "name": "config_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_host": {
+ "name": "origin_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin_port": {
+ "name": "origin_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin_scheme": {
+ "name": "origin_scheme",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_database": {
+ "name": "origin_database",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_user": {
+ "name": "origin_user",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "cloudflare_hyperdrive_configs_org_config_idx": {
+ "name": "cloudflare_hyperdrive_configs_org_config_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "config_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cloudflare_hyperdrive_configs_org_idx": {
+ "name": "cloudflare_hyperdrive_configs_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cloudflare_logpush_connectors": {
+ "name": "cloudflare_logpush_connectors",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "zone_name": {
+ "name": "zone_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "service_name": {
+ "name": "service_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dataset": {
+ "name": "dataset",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'http_requests'"
+ },
+ "secret_ciphertext": {
+ "name": "secret_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_iv": {
+ "name": "secret_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_tag": {
+ "name": "secret_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_hash": {
+ "name": "secret_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "last_received_at": {
+ "name": "last_received_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "secret_rotated_at": {
+ "name": "secret_rotated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "cloudflare_logpush_connectors_org_idx": {
+ "name": "cloudflare_logpush_connectors_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cloudflare_logpush_connectors_org_enabled_idx": {
+ "name": "cloudflare_logpush_connectors_org_enabled_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cloudflare_logpush_connectors_secret_hash_unique": {
+ "name": "cloudflare_logpush_connectors_secret_hash_unique",
+ "columns": [
+ {
+ "expression": "secret_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cli_device_authorizations": {
+ "name": "cli_device_authorizations",
+ "schema": "",
+ "columns": {
+ "device_code_hash": {
+ "name": "device_code_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_code_hash": {
+ "name": "user_code_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_name": {
+ "name": "device_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "approved_org_id": {
+ "name": "approved_org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_user_id": {
+ "name": "approved_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_roles": {
+ "name": "approved_roles",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_user_email": {
+ "name": "approved_user_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_key_id": {
+ "name": "api_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_ciphertext": {
+ "name": "token_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_iv": {
+ "name": "token_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_tag": {
+ "name": "token_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_at": {
+ "name": "approved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "denied_at": {
+ "name": "denied_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "cli_device_authorizations_user_code_unique": {
+ "name": "cli_device_authorizations_user_code_unique",
+ "columns": [
+ {
+ "expression": "user_code_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cli_device_authorizations_expires_idx": {
+ "name": "cli_device_authorizations_expires_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_oauth_authorizations": {
+ "name": "mcp_oauth_authorizations",
+ "schema": "",
+ "columns": {
+ "request_id_hash": {
+ "name": "request_id_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uri": {
+ "name": "redirect_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_challenge": {
+ "name": "code_challenge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "authorization_code_hash": {
+ "name": "authorization_code_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_org_id": {
+ "name": "approved_org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_user_id": {
+ "name": "approved_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_roles": {
+ "name": "approved_roles",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_user_email": {
+ "name": "approved_user_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_at": {
+ "name": "approved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "denied_at": {
+ "name": "denied_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "mcp_oauth_authorizations_code_unique": {
+ "name": "mcp_oauth_authorizations_code_unique",
+ "columns": [
+ {
+ "expression": "authorization_code_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "mcp_oauth_authorizations_expires_idx": {
+ "name": "mcp_oauth_authorizations_expires_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_oauth_clients": {
+ "name": "mcp_oauth_clients",
+ "schema": "",
+ "columns": {
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_uri": {
+ "name": "client_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_oauth_refresh_tokens": {
+ "name": "mcp_oauth_refresh_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "family_id": {
+ "name": "family_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "roles": {
+ "name": "roles",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_email": {
+ "name": "user_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_key_id": {
+ "name": "access_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "replaced_by_id": {
+ "name": "replaced_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "mcp_oauth_refresh_tokens_hash_unique": {
+ "name": "mcp_oauth_refresh_tokens_hash_unique",
+ "columns": [
+ {
+ "expression": "token_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "mcp_oauth_refresh_tokens_family_idx": {
+ "name": "mcp_oauth_refresh_tokens_family_idx",
+ "columns": [
+ {
+ "expression": "family_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "mcp_oauth_refresh_tokens_expires_idx": {
+ "name": "mcp_oauth_refresh_tokens_expires_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.dashboard_versions": {
+ "name": "dashboard_versions",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dashboard_id": {
+ "name": "dashboard_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snapshot_json": {
+ "name": "snapshot_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "change_kind": {
+ "name": "change_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "change_summary": {
+ "name": "change_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_version_id": {
+ "name": "source_version_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "dashboard_versions_org_dashboard_idx": {
+ "name": "dashboard_versions_org_dashboard_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "dashboard_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "dashboard_versions_org_dashboard_version_unq": {
+ "name": "dashboard_versions_org_dashboard_version_unq",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "dashboard_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "dashboard_versions_org_id_id_pk": {
+ "name": "dashboard_versions_org_id_id_pk",
+ "columns": ["org_id", "id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.dashboards": {
+ "name": "dashboards",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "dashboards_org_updated_idx": {
+ "name": "dashboards_org_updated_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "dashboards_org_name_idx": {
+ "name": "dashboards_org_name_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "dashboards_org_id_id_pk": {
+ "name": "dashboards_org_id_id_pk",
+ "columns": ["org_id", "id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.digest_subscriptions": {
+ "name": "digest_subscriptions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "day_of_week": {
+ "name": "day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'UTC'"
+ },
+ "last_sent_at": {
+ "name": "last_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_attempted_at": {
+ "name": "last_attempted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "digest_subscriptions_org_user_idx": {
+ "name": "digest_subscriptions_org_user_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "digest_subscriptions_org_enabled_idx": {
+ "name": "digest_subscriptions_org_enabled_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.actors": {
+ "name": "actors",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_name": {
+ "name": "agent_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "capabilities_json": {
+ "name": "capabilities_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_active_at": {
+ "name": "last_active_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "actors_org_user_idx": {
+ "name": "actors_org_user_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "actors_org_agent_name_idx": {
+ "name": "actors_org_agent_name_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "actors_org_type_idx": {
+ "name": "actors_org_type_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.error_incidents": {
+ "name": "error_incidents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "first_triggered_at": {
+ "name": "first_triggered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_triggered_at": {
+ "name": "last_triggered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "occurrence_count": {
+ "name": "occurrence_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "error_incidents_org_issue_idx": {
+ "name": "error_incidents_org_issue_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "error_incidents_org_status_idx": {
+ "name": "error_incidents_org_status_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.error_issue_events": {
+ "name": "error_issue_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_state": {
+ "name": "from_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_state": {
+ "name": "to_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "error_issue_events_issue_idx": {
+ "name": "error_issue_events_issue_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "error_issue_events_actor_idx": {
+ "name": "error_issue_events_actor_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "actor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "error_issue_events_type_idx": {
+ "name": "error_issue_events_type_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.error_issue_states": {
+ "name": "error_issue_states",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_observed_occurrence_at": {
+ "name": "last_observed_occurrence_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_evaluated_at": {
+ "name": "last_evaluated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_incident_id": {
+ "name": "open_incident_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "error_issue_states_org_id_issue_id_pk": {
+ "name": "error_issue_states_org_id_issue_id_pk",
+ "columns": ["org_id", "issue_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.error_issues": {
+ "name": "error_issues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'error'"
+ },
+ "source_ref_json": {
+ "name": "source_ref_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fingerprint_hash": {
+ "name": "fingerprint_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "service_name": {
+ "name": "service_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "exception_type": {
+ "name": "exception_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "exception_message": {
+ "name": "exception_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error_label": {
+ "name": "error_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "top_frame": {
+ "name": "top_frame",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workflow_state": {
+ "name": "workflow_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'triage'"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "severity_source": {
+ "name": "severity_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assigned_actor_id": {
+ "name": "assigned_actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_holder_actor_id": {
+ "name": "lease_holder_actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_expires_at": {
+ "name": "lease_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "claimed_at": {
+ "name": "claimed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "occurrence_count": {
+ "name": "occurrence_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by_actor_id": {
+ "name": "resolved_by_actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "snooze_until": {
+ "name": "snooze_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "error_issues_org_fp_idx": {
+ "name": "error_issues_org_fp_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "fingerprint_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "error_issues_org_workflow_idx": {
+ "name": "error_issues_org_workflow_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "workflow_state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "error_issues_org_severity_idx": {
+ "name": "error_issues_org_severity_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "severity",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "error_issues_org_last_seen_idx": {
+ "name": "error_issues_org_last_seen_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_seen_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "error_issues_org_assignee_idx": {
+ "name": "error_issues_org_assignee_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "assigned_actor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "error_issues_lease_expiry_idx": {
+ "name": "error_issues_lease_expiry_idx",
+ "columns": [
+ {
+ "expression": "lease_expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "error_issues_org_archived_idx": {
+ "name": "error_issues_org_archived_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "archived_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"error_issues\".\"archived_at\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.error_notification_policies": {
+ "name": "error_notification_policies",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "destination_ids_json": {
+ "name": "destination_ids_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "notify_on_first_seen": {
+ "name": "notify_on_first_seen",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "notify_on_regression": {
+ "name": "notify_on_regression",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "notify_on_resolve": {
+ "name": "notify_on_resolve",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "notify_on_transition_in_review": {
+ "name": "notify_on_transition_in_review",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "notify_on_transition_done": {
+ "name": "notify_on_transition_done",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "notify_on_claim": {
+ "name": "notify_on_claim",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "min_occurrence_count": {
+ "name": "min_occurrence_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'warning'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_escalation_policies": {
+ "name": "issue_escalation_policies",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "rules_json": {
+ "name": "rules_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_escalations": {
+ "name": "issue_escalations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "investigation_id": {
+ "name": "investigation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "delivery_results_json": {
+ "name": "delivery_results_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "dedupe_key": {
+ "name": "dedupe_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "issue_escalations_dedupe_idx": {
+ "name": "issue_escalations_dedupe_idx",
+ "columns": [
+ {
+ "expression": "dedupe_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_escalations_due_idx": {
+ "name": "issue_escalations_due_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_escalations_org_issue_idx": {
+ "name": "issue_escalations_org_issue_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.investigations": {
+ "name": "investigations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'investigating'"
+ },
+ "seeded_by": {
+ "name": "seeded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'user'"
+ },
+ "subject_json": {
+ "name": "subject_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snapshot_json": {
+ "name": "snapshot_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "incident_kind": {
+ "name": "incident_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "incident_id": {
+ "name": "incident_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "report_json": {
+ "name": "report_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confidence": {
+ "name": "confidence",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "autonomous_turns": {
+ "name": "autonomous_turns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "diagnosed_at": {
+ "name": "diagnosed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "investigations_incident_idx": {
+ "name": "investigations_incident_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "incident_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "incident_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"investigations\".\"incident_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "investigations_org_created_idx": {
+ "name": "investigations_org_created_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "investigations_org_issue_idx": {
+ "name": "investigations_org_issue_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "investigations_org_status_idx": {
+ "name": "investigations_org_status_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_auth_states": {
+ "name": "oauth_auth_states",
+ "schema": "",
+ "columns": {
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "initiated_by_user_id": {
+ "name": "initiated_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uri": {
+ "name": "redirect_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "return_to": {
+ "name": "return_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "code_verifier": {
+ "name": "code_verifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "oauth_auth_states_expires_idx": {
+ "name": "oauth_auth_states_expires_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_connections": {
+ "name": "oauth_connections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_user_id": {
+ "name": "external_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_user_email": {
+ "name": "external_user_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_account_name": {
+ "name": "external_account_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connected_by_user_id": {
+ "name": "connected_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "access_token_ciphertext": {
+ "name": "access_token_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token_iv": {
+ "name": "access_token_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token_tag": {
+ "name": "access_token_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token_ciphertext": {
+ "name": "refresh_token_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_iv": {
+ "name": "refresh_token_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_tag": {
+ "name": "refresh_token_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "oauth_connections_org_provider_idx": {
+ "name": "oauth_connections_org_provider_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_connections_org_idx": {
+ "name": "oauth_connections_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_onboarding_state": {
+ "name": "org_onboarding_state",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "demo_data_requested": {
+ "name": "demo_data_requested",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "checklist_dismissed_at": {
+ "name": "checklist_dismissed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_data_received_at": {
+ "name": "first_data_received_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "welcome_email_sent_at": {
+ "name": "welcome_email_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connect_nudge_email_sent_at": {
+ "name": "connect_nudge_email_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stalled_email_sent_at": {
+ "name": "stalled_email_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "activation_email_sent_at": {
+ "name": "activation_email_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_ingest_attribute_mappings": {
+ "name": "org_ingest_attribute_mappings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_context": {
+ "name": "source_context",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_key": {
+ "name": "target_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operation": {
+ "name": "operation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "org_ingest_attribute_mappings_org_idx": {
+ "name": "org_ingest_attribute_mappings_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_recommendation_issues": {
+ "name": "org_recommendation_issues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "number": {
+ "name": "number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recommendation_key": {
+ "name": "recommendation_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canonical_key": {
+ "name": "canonical_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "usage_count": {
+ "name": "usage_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "opened_at": {
+ "name": "opened_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "org_recommendation_issues_org_idx": {
+ "name": "org_recommendation_issues_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "org_recommendation_issues_org_key_idx": {
+ "name": "org_recommendation_issues_org_key_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "recommendation_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_ingest_keys": {
+ "name": "org_ingest_keys",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "public_key_hash": {
+ "name": "public_key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "private_key_ciphertext": {
+ "name": "private_key_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "private_key_iv": {
+ "name": "private_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "private_key_tag": {
+ "name": "private_key_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "private_key_hash": {
+ "name": "private_key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "public_rotated_at": {
+ "name": "public_rotated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "private_rotated_at": {
+ "name": "private_rotated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "org_ingest_keys_public_key_unique": {
+ "name": "org_ingest_keys_public_key_unique",
+ "columns": [
+ {
+ "expression": "public_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "org_ingest_keys_public_key_hash_unique": {
+ "name": "org_ingest_keys_public_key_hash_unique",
+ "columns": [
+ {
+ "expression": "public_key_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "org_ingest_keys_private_key_hash_unique": {
+ "name": "org_ingest_keys_private_key_hash_unique",
+ "columns": [
+ {
+ "expression": "private_key_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "org_ingest_keys_org_id_pk": {
+ "name": "org_ingest_keys_org_id_pk",
+ "columns": ["org_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_ingest_sampling_policies": {
+ "name": "org_ingest_sampling_policies",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "trace_sample_ratio": {
+ "name": "trace_sample_ratio",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "always_keep_error_spans": {
+ "name": "always_keep_error_spans",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "always_keep_slow_spans_ms": {
+ "name": "always_keep_slow_spans_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_clickhouse_settings": {
+ "name": "org_clickhouse_settings",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ch_url": {
+ "name": "ch_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ch_user": {
+ "name": "ch_user",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ch_password_ciphertext": {
+ "name": "ch_password_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ch_password_iv": {
+ "name": "ch_password_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ch_password_tag": {
+ "name": "ch_password_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ch_database": {
+ "name": "ch_database",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sync_status": {
+ "name": "sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sync_error": {
+ "name": "last_sync_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "schema_version": {
+ "name": "schema_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "org_clickhouse_settings_org_id_pk": {
+ "name": "org_clickhouse_settings_org_id_pk",
+ "columns": ["org_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_spend_limits": {
+ "name": "org_spend_limits",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "monthly_limit_cents": {
+ "name": "monthly_limit_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enforcement_mode": {
+ "name": "enforcement_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'notify'"
+ },
+ "alert_threshold_percents": {
+ "name": "alert_threshold_percents",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[80,100]'::jsonb"
+ },
+ "feature_caps": {
+ "name": "feature_caps",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "last_evaluated_at": {
+ "name": "last_evaluated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "evaluated_spend_cents": {
+ "name": "evaluated_spend_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "breached_at": {
+ "name": "breached_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_at": {
+ "name": "paused_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_features": {
+ "name": "paused_features",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "notified_thresholds": {
+ "name": "notified_thresholds",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "cycle_started_at": {
+ "name": "cycle_started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_clickhouse_schema_apply_runs": {
+ "name": "org_clickhouse_schema_apply_runs",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workflow_instance_id": {
+ "name": "workflow_instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "phase": {
+ "name": "phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_migration": {
+ "name": "current_migration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "steps_total": {
+ "name": "steps_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "steps_done": {
+ "name": "steps_done",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applied_versions": {
+ "name": "applied_versions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "skipped": {
+ "name": "skipped",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "org_clickhouse_schema_apply_runs_org_id_pk": {
+ "name": "org_clickhouse_schema_apply_runs_org_id_pk",
+ "columns": ["org_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.planetscale_connections": {
+ "name": "planetscale_connections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ps_organization": {
+ "name": "ps_organization",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_by_user_id": {
+ "name": "connected_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scrape_target_id": {
+ "name": "scrape_target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "webhook_secret_ciphertext": {
+ "name": "webhook_secret_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "webhook_secret_iv": {
+ "name": "webhook_secret_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "webhook_secret_tag": {
+ "name": "webhook_secret_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "detected_permissions_json": {
+ "name": "detected_permissions_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_inventory_at": {
+ "name": "last_inventory_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_inventory_error": {
+ "name": "last_inventory_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "planetscale_connections_org_idx": {
+ "name": "planetscale_connections_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.planetscale_databases": {
+ "name": "planetscale_databases",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "database_id": {
+ "name": "database_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'mysql'"
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plan": {
+ "name": "plan",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "branches_json": {
+ "name": "branches_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "planetscale_databases_org_db_idx": {
+ "name": "planetscale_databases_org_db_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "database_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "planetscale_databases_org_idx": {
+ "name": "planetscale_databases_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.planetscale_poll_state": {
+ "name": "planetscale_poll_state",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dataset": {
+ "name": "dataset",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "database_id": {
+ "name": "database_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "watermark_at": {
+ "name": "watermark_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_success_at": {
+ "name": "last_success_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error_at": {
+ "name": "last_error_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "planetscale_poll_state_org_dataset_db_idx": {
+ "name": "planetscale_poll_state_org_dataset_db_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "dataset",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "database_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "planetscale_poll_state_org_idx": {
+ "name": "planetscale_poll_state_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scrape_target_checks": {
+ "name": "scrape_target_checks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "scrape_target_checks_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sub_target_key": {
+ "name": "sub_target_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "checked_at": {
+ "name": "checked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "samples_scraped": {
+ "name": "samples_scraped",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "samples_post_relabel": {
+ "name": "samples_post_relabel",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "scrape_target_checks_target_checked_idx": {
+ "name": "scrape_target_checks_target_checked_idx",
+ "columns": [
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "checked_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "scrape_target_checks_target_id_scrape_targets_id_fk": {
+ "name": "scrape_target_checks_target_id_scrape_targets_id_fk",
+ "tableFrom": "scrape_target_checks",
+ "tableTo": "scrape_targets",
+ "columnsFrom": ["target_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scrape_targets": {
+ "name": "scrape_targets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "service_name": {
+ "name": "service_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'prometheus'"
+ },
+ "discovery_config_json": {
+ "name": "discovery_config_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scrape_interval_seconds": {
+ "name": "scrape_interval_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "labels_json": {
+ "name": "labels_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "managed_by": {
+ "name": "managed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "auth_credentials_ciphertext": {
+ "name": "auth_credentials_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "auth_credentials_iv": {
+ "name": "auth_credentials_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "auth_credentials_tag": {
+ "name": "auth_credentials_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "last_scrape_at": {
+ "name": "last_scrape_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_scrape_error": {
+ "name": "last_scrape_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "scrape_targets_org_idx": {
+ "name": "scrape_targets_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scrape_targets_org_enabled_idx": {
+ "name": "scrape_targets_org_enabled_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.slack_workspaces": {
+ "name": "slack_workspaces",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "team_id": {
+ "name": "team_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "team_name": {
+ "name": "team_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bot_user_id": {
+ "name": "bot_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bot_token_ciphertext": {
+ "name": "bot_token_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bot_token_iv": {
+ "name": "bot_token_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bot_token_tag": {
+ "name": "bot_token_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_key_id": {
+ "name": "api_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_key_secret_ciphertext": {
+ "name": "api_key_secret_ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_key_secret_iv": {
+ "name": "api_key_secret_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_key_secret_tag": {
+ "name": "api_key_secret_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installed_by_user_id": {
+ "name": "installed_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_reason": {
+ "name": "revoked_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "slack_workspaces_team_id_idx": {
+ "name": "slack_workspaces_team_id_idx",
+ "columns": [
+ {
+ "expression": "team_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "slack_workspaces_org_idx": {
+ "name": "slack_workspaces_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "slack_workspaces_active_org_idx": {
+ "name": "slack_workspaces_active_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"slack_workspaces\".\"revoked_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.vcs_commits": {
+ "name": "vcs_commits",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "repository_id": {
+ "name": "repository_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sha": {
+ "name": "sha",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_name": {
+ "name": "author_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_email": {
+ "name": "author_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_login": {
+ "name": "author_login",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_avatar_url": {
+ "name": "author_avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authored_at": {
+ "name": "authored_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "committed_at": {
+ "name": "committed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "html_url": {
+ "name": "html_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "vcs_commits_repo_sha_idx": {
+ "name": "vcs_commits_repo_sha_idx",
+ "columns": [
+ {
+ "expression": "repository_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sha",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "vcs_commits_org_sha_idx": {
+ "name": "vcs_commits_org_sha_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sha",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.vcs_installations": {
+ "name": "vcs_installations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_installation_id": {
+ "name": "external_installation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_login": {
+ "name": "account_login",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_type": {
+ "name": "account_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_account_id": {
+ "name": "external_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_avatar_url": {
+ "name": "account_avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "repository_selection": {
+ "name": "repository_selection",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'all'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "suspended_at": {
+ "name": "suspended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installed_by_user_id": {
+ "name": "installed_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "vcs_installations_provider_external_idx": {
+ "name": "vcs_installations_provider_external_idx",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_installation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "vcs_installations_org_idx": {
+ "name": "vcs_installations_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.vcs_repositories": {
+ "name": "vcs_repositories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "installation_id": {
+ "name": "installation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_repo_id": {
+ "name": "external_repo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner": {
+ "name": "owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "full_name": {
+ "name": "full_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "default_branch": {
+ "name": "default_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'main'"
+ },
+ "tracked_branch": {
+ "name": "tracked_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "html_url": {
+ "name": "html_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_private": {
+ "name": "is_private",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "sync_status": {
+ "name": "sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sync_error": {
+ "name": "last_sync_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "vcs_repositories_org_repo_idx": {
+ "name": "vcs_repositories_org_repo_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_repo_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "vcs_repositories_org_idx": {
+ "name": "vcs_repositories_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "vcs_repositories_installation_idx": {
+ "name": "vcs_repositories_installation_idx",
+ "columns": [
+ {
+ "expression": "installation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.vcs_repository_branches": {
+ "name": "vcs_repository_branches",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "repository_id": {
+ "name": "repository_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "head_sha": {
+ "name": "head_sha",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "vcs_repository_branches_repo_name_idx": {
+ "name": "vcs_repository_branches_repo_name_idx",
+ "columns": [
+ {
+ "expression": "repository_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "vcs_repository_branches_org_idx": {
+ "name": "vcs_repository_branches_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 31c6a8c15..900ef406c 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -183,6 +183,13 @@
"when": 1785360784276,
"tag": "0025_majestic_randall_flagg",
"breakpoints": true
+ },
+ {
+ "idx": 26,
+ "version": "7",
+ "when": 1785489583849,
+ "tag": "0026_windy_bromley",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/packages/db/src/migrations.test.ts b/packages/db/src/migrations.test.ts
index fc26f1351..ed4b0fdd6 100644
--- a/packages/db/src/migrations.test.ts
+++ b/packages/db/src/migrations.test.ts
@@ -1,4 +1,4 @@
-import { readFileSync } from "node:fs"
+import { readdirSync, readFileSync } from "node:fs"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { PGlite } from "@electric-sql/pglite"
@@ -18,6 +18,15 @@ const readJournal = (): MigrationJournal => {
return JSON.parse(readFileSync(path, "utf8")) as MigrationJournal
}
+const migrationsDir = () => resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle")
+
+const readMigrationSqlBefore = (tag: string): string =>
+ readdirSync(migrationsDir())
+ .filter((file) => file.endsWith(".sql") && file < `${tag}.sql`)
+ .sort()
+ .map((file) => readFileSync(resolve(migrationsDir(), file), "utf8"))
+ .join("\n")
+
describe("drizzle migrations", () => {
it("keeps journal timestamps increasing in migration order", () => {
const { entries } = readJournal()
@@ -97,4 +106,87 @@ describe("bundled migrations", () => {
expect(row.relreplident, `${row.relname} replica identity`).toBe("d")
}
}, 30_000)
+
+ it("converts metric rules and removes retired destinations without runtime compatibility", async () => {
+ const pg = new PGlite()
+ await pg.exec(readMigrationSqlBefore("0026_windy_bromley"))
+
+ const now = "2026-07-31T00:00:00.000Z"
+ await pg.query(
+ `INSERT INTO alert_destinations (
+ id, org_id, name, type, config_json, secret_ciphertext, secret_iv, secret_tag,
+ created_at, updated_at, created_by, updated_by
+ ) VALUES
+ ('dest_slack', 'org_1', 'Old Slack', 'slack', '{}', 'x', 'x', 'x', $1, $1, 'user_1', 'user_1'),
+ ('dest_webhook', 'org_1', 'Webhook', 'webhook', '{}', 'x', 'x', 'x', $1, $1, 'user_1', 'user_1')`,
+ [now],
+ )
+ await pg.query(
+ `INSERT INTO alert_rules (
+ id, org_id, name, severity, service_names_json, environments_json, signal_type,
+ comparator, threshold, window_minutes, metric_name, metric_type, metric_aggregation,
+ destination_ids_json, query_spec_json, reducer, no_data_behavior,
+ created_at, updated_at, created_by, updated_by
+ ) VALUES
+ (
+ 'rule_multi', 'org_1', 'Multi-service metric', 'warning',
+ '["api", "checkout"]', '["prod", "staging"]', 'metric',
+ 'gt', 10, 5, 'http.requests', 'sum', 'rate',
+ '["dest_slack", "dest_webhook"]', '{"preserved": true}', 'max', 'skip',
+ $1, $1, 'user_1', 'user_1'
+ ),
+ (
+ 'rule_orphaned', 'org_1', 'Only retired destination', 'warning',
+ NULL, NULL, 'error_rate', 'gt', 0.1, 5, NULL, NULL, NULL,
+ '["dest_slack"]', '{}', 'max', 'skip',
+ $1, $1, 'user_1', 'user_1'
+ )`,
+ [now],
+ )
+
+ await pg.exec(readFileSync(resolve(migrationsDir(), "0026_windy_bromley.sql"), "utf8"))
+
+ const migrated = await pg.query<{
+ signal_type: string
+ service_names_json: unknown
+ environments_json: unknown
+ group_by: string | null
+ destination_ids_json: string[]
+ query_spec_json: { preserved?: boolean }
+ query_builder_draft_json: {
+ whereClause: string
+ groupBy: string[]
+ addOns: { groupBy: boolean }
+ }
+ }>("SELECT * FROM alert_rules WHERE id = 'rule_multi'")
+ expect(migrated.rows[0]).toMatchObject({
+ signal_type: "builder_query",
+ service_names_json: null,
+ environments_json: null,
+ group_by: null,
+ destination_ids_json: ["dest_webhook"],
+ query_spec_json: { preserved: true },
+ query_builder_draft_json: {
+ whereClause: 'service.name = "api,checkout" AND deployment.environment = "prod,staging"',
+ groupBy: ["service.name"],
+ addOns: { groupBy: true },
+ },
+ })
+
+ const orphaned = await pg.query<{ enabled: boolean; destination_ids_json: string[] }>(
+ "SELECT enabled, destination_ids_json FROM alert_rules WHERE id = 'rule_orphaned'",
+ )
+ expect(orphaned.rows[0]).toEqual({ enabled: false, destination_ids_json: [] })
+
+ const retired = await pg.query("SELECT id FROM alert_destinations WHERE type IN ('slack', 'hazel')")
+ expect(retired.rows).toEqual([])
+
+ const columns = await pg.query<{ column_name: string }>(
+ `SELECT column_name
+ FROM information_schema.columns
+ WHERE table_name = 'alert_rules'
+ AND column_name IN ('metric_name', 'metric_type', 'metric_aggregation')`,
+ )
+ expect(columns.rows).toEqual([])
+ }, 30_000)
})
diff --git a/packages/db/src/schema/alerts.ts b/packages/db/src/schema/alerts.ts
index dffcc2f66..29384cc9f 100644
--- a/packages/db/src/schema/alerts.ts
+++ b/packages/db/src/schema/alerts.ts
@@ -59,8 +59,8 @@ export const alertRules = pgTable(
excludeServiceNamesJson: jsonb("exclude_service_names_json").$type>(),
/**
* Deployment environments the rule is scoped to. Null/empty means every
- * environment (the historical behaviour). Applies to the built-in trace
- * signals and `metric`; `builder_query` / `raw_query` carry their own
+ * environment. Applies to the built-in trace signals;
+ * `builder_query` / `raw_query` carry their own
* filters, so it is always empty for those.
*/
environmentsJson: jsonb("environments_json").$type>(),
@@ -75,9 +75,6 @@ export const alertRules = pgTable(
consecutiveBreachesRequired: integer("consecutive_breaches_required").notNull().default(2),
consecutiveHealthyRequired: integer("consecutive_healthy_required").notNull().default(2),
renotifyIntervalMinutes: integer("renotify_interval_minutes").notNull().default(30),
- metricName: text("metric_name"),
- metricType: text("metric_type"),
- metricAggregation: text("metric_aggregation"),
apdexThresholdMs: doublePrecision("apdex_threshold_ms"),
queryBuilderDraftJson: jsonb("query_builder_draft_json").$type(),
rawQuerySql: text("raw_query_sql"),
diff --git a/packages/domain/src/http/alerts.test.ts b/packages/domain/src/http/alerts.test.ts
index f642c6464..15ab3db11 100644
--- a/packages/domain/src/http/alerts.test.ts
+++ b/packages/domain/src/http/alerts.test.ts
@@ -5,7 +5,7 @@ import {
AlertNotificationTemplate,
AlertRuleUpsertRequest,
PagerDutyAlertDestinationConfig,
- SlackAlertDestinationConfig,
+ SlackBotAlertDestinationConfig,
WebhookAlertDestinationConfig,
} from "./alerts"
@@ -17,23 +17,23 @@ describe("AlertDestinationCreateRequest", () => {
// side and produces the plain wire-format object on the output side.
// These tests assert the encoded wire shape matches what HTTP clients
// see on the wire.
- it("encodes slack destination instances to the plain wire shape", () => {
+ it("encodes slack bot destination instances to the plain wire shape", () => {
expect(
encode(
- new SlackAlertDestinationConfig({
- type: "slack",
+ new SlackBotAlertDestinationConfig({
+ type: "slack-bot",
name: "Ops Slack",
enabled: true,
- webhookUrl: "https://hooks.slack.com/services/T/B/X",
- channelLabel: "#ops-alerts",
+ channelId: "C123",
+ channelName: "ops-alerts",
}),
),
).toEqual({
- type: "slack",
+ type: "slack-bot",
name: "Ops Slack",
enabled: true,
- webhookUrl: "https://hooks.slack.com/services/T/B/X",
- channelLabel: "#ops-alerts",
+ channelId: "C123",
+ channelName: "ops-alerts",
})
})
@@ -78,22 +78,22 @@ describe("AlertDestinationCreateRequest", () => {
// Decode goes the other direction: plain wire-format objects in, class
// instances out. The union discriminates on `type`.
- it("decodes a slack wire object into a SlackAlertDestinationConfig instance", () => {
+ it("decodes a slack bot wire object into a SlackBotAlertDestinationConfig instance", () => {
const decoded = decode({
- type: "slack",
+ type: "slack-bot",
name: "Ops Slack",
enabled: true,
- webhookUrl: "https://hooks.slack.com/services/T/B/X",
- channelLabel: "#ops-alerts",
+ channelId: "C123",
+ channelName: "ops-alerts",
})
- expect(decoded).toBeInstanceOf(SlackAlertDestinationConfig)
+ expect(decoded).toBeInstanceOf(SlackBotAlertDestinationConfig)
expect(decoded).toMatchObject({
- type: "slack",
+ type: "slack-bot",
name: "Ops Slack",
enabled: true,
- webhookUrl: "https://hooks.slack.com/services/T/B/X",
- channelLabel: "#ops-alerts",
+ channelId: "C123",
+ channelName: "ops-alerts",
})
})
@@ -141,12 +141,12 @@ describe("AlertDestinationCreateRequest", () => {
expect(Exit.isFailure(result)).toBe(true)
})
- it("fails to decode a slack destination missing the required webhookUrl", () => {
+ it("fails to decode a slack bot destination missing the required channelId", () => {
const result = decodeExit({
- type: "slack",
+ type: "slack-bot",
name: "Ops Slack",
enabled: true,
- channelLabel: "#ops-alerts",
+ channelName: "ops-alerts",
})
expect(Exit.isFailure(result)).toBe(true)
diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts
index dadcc3ca7..28f6bd144 100644
--- a/packages/domain/src/http/alerts.ts
+++ b/packages/domain/src/http/alerts.ts
@@ -1,4 +1,3 @@
-import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { Schema } from "effect"
import { QueryEngineAlertReducer, QueryEngineNoDataBehavior } from "../query-engine"
import {
@@ -14,16 +13,12 @@ import {
RoleName,
UserId,
} from "../primitives"
-import { Authorization } from "./current-tenant"
import { QueryBuilderQueryDraftSchema } from "./query-engine"
-import { warehouseHttpErrors } from "./warehouse"
export const AlertDestinationType = Schema.Literals([
- "slack",
"slack-bot",
"pagerduty",
"webhook",
- "hazel",
"hazel-oauth",
"discord",
"email",
@@ -45,7 +40,6 @@ export const AlertSignalType = Schema.Literals([
"p99_latency",
"apdex",
"throughput",
- "metric",
"builder_query",
"raw_query",
]).annotate({
@@ -104,23 +98,6 @@ export const isRangeComparator = (c: AlertComparator): c is "between" | "not_bet
*/
export const UNGROUPED_GROUP_KEY = "__total__"
-export const AlertMetricType = Schema.Literals([
- "sum",
- "gauge",
- "histogram",
- "exponential_histogram",
-]).annotate({
- identifier: "@maple/AlertMetricType",
- title: "Alert Metric Type",
-})
-export type AlertMetricType = Schema.Schema.Type
-
-export const AlertMetricAggregation = Schema.Literals(["avg", "min", "max", "sum", "count"]).annotate({
- identifier: "@maple/AlertMetricAggregation",
- title: "Alert Metric Aggregation",
-})
-export type AlertMetricAggregation = Schema.Schema.Type
-
export const AlertIncidentStatus = Schema.Literals(["open", "resolved"]).annotate({
identifier: "@maple/AlertIncidentStatus",
title: "Alert Incident Status",
@@ -176,16 +153,6 @@ export const AlertWindowMinutes = PositiveInt.pipe(
Schema.check(Schema.isLessThanOrEqualTo(MAX_ALERT_WINDOW_MINUTES)),
)
-export class SlackAlertDestinationConfig extends Schema.Class(
- "SlackAlertDestinationConfig",
-)({
- type: Schema.Literal("slack"),
- name: ChannelLabel,
- webhookUrl: NonEmptyString,
- channelLabel: OptionalNonEmptyString,
- enabled: Schema.optionalKey(Schema.Boolean),
-}) {}
-
export class SlackBotAlertDestinationConfig extends Schema.Class(
"SlackBotAlertDestinationConfig",
)({
@@ -217,16 +184,6 @@ export class WebhookAlertDestinationConfig extends Schema.Class(
- "HazelAlertDestinationConfig",
-)({
- type: Schema.Literal("hazel"),
- name: ChannelLabel,
- webhookUrl: NonEmptyString,
- signingSecret: Schema.optionalKey(Schema.String),
- enabled: Schema.optionalKey(Schema.Boolean),
-}) {}
-
export class HazelOAuthAlertDestinationConfig extends Schema.Class(
"HazelOAuthAlertDestinationConfig",
)({
@@ -271,26 +228,15 @@ export class EmailAlertDestinationConfig extends Schema.Class
-export class UpdateSlackAlertDestinationConfig extends Schema.Class(
- "UpdateSlackAlertDestinationConfig",
-)({
- name: OptionalNonEmptyString,
- webhookUrl: Schema.optionalKey(Schema.String),
- channelLabel: OptionalNonEmptyString,
- enabled: Schema.optionalKey(Schema.Boolean),
-}) {}
-
export class UpdateSlackBotAlertDestinationConfig extends Schema.Class(
"UpdateSlackBotAlertDestinationConfig",
)({
@@ -317,15 +263,6 @@ export class UpdateWebhookAlertDestinationConfig extends Schema.Class(
- "UpdateHazelAlertDestinationConfig",
-)({
- name: OptionalNonEmptyString,
- webhookUrl: Schema.optionalKey(Schema.String),
- signingSecret: Schema.optionalKey(Schema.String),
- enabled: Schema.optionalKey(Schema.Boolean),
-}) {}
-
export class UpdateHazelOAuthAlertDestinationConfig extends Schema.Class(
"UpdateHazelOAuthAlertDestinationConfig",
)({
@@ -355,10 +292,6 @@ export class UpdateEmailAlertDestinationConfig extends Schema.Class("AlertRul
consecutiveBreachesRequired: PositiveInt,
consecutiveHealthyRequired: PositiveInt,
renotifyIntervalMinutes: PositiveInt,
- metricName: Schema.NullOr(Schema.String),
- metricType: Schema.NullOr(AlertMetricType),
- metricAggregation: Schema.NullOr(AlertMetricAggregation),
apdexThresholdMs: Schema.NullOr(PositiveFloat),
queryBuilderDraft: Schema.NullOr(QueryBuilderQueryDraftSchema),
rawQuerySql: Schema.NullOr(Schema.String),
@@ -562,9 +488,6 @@ export class AlertRuleUpsertRequest extends Schema.Class
consecutiveBreachesRequired: Schema.optionalKey(PositiveInt),
consecutiveHealthyRequired: Schema.optionalKey(PositiveInt),
renotifyIntervalMinutes: Schema.optionalKey(PositiveInt),
- metricName: Schema.optionalKey(Schema.NullOr(Schema.String)),
- metricType: Schema.optionalKey(Schema.NullOr(AlertMetricType)),
- metricAggregation: Schema.optionalKey(Schema.NullOr(AlertMetricAggregation)),
apdexThresholdMs: Schema.optionalKey(Schema.NullOr(PositiveFloat)),
queryBuilderDraft: Schema.optionalKey(Schema.NullOr(QueryBuilderQueryDraftSchema)),
rawQuerySql: Schema.optionalKey(Schema.NullOr(Schema.String)),
@@ -823,146 +746,3 @@ export const ListRuleChecksQuery = Schema.Struct({
Schema.NumberFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 2000 })),
),
})
-
-export class AlertsApiGroup extends HttpApiGroup.make("alerts")
- .add(
- HttpApiEndpoint.get("listDestinations", "/destinations", {
- success: AlertDestinationsListResponse,
- error: AlertPersistenceError,
- }),
- )
- .add(
- HttpApiEndpoint.post("createDestination", "/destinations", {
- payload: AlertDestinationCreateRequest,
- success: AlertDestinationDocument,
- error: [AlertForbiddenError, AlertValidationError, AlertPersistenceError],
- }),
- )
- .add(
- HttpApiEndpoint.patch("updateDestination", "/destinations/:destinationId", {
- params: {
- destinationId: AlertDestinationId,
- },
- payload: AlertDestinationUpdateRequest,
- success: AlertDestinationDocument,
- error: [AlertForbiddenError, AlertValidationError, AlertPersistenceError, AlertNotFoundError],
- }),
- )
- .add(
- HttpApiEndpoint.delete("deleteDestination", "/destinations/:destinationId", {
- params: {
- destinationId: AlertDestinationId,
- },
- success: AlertDestinationDeleteResponse,
- error: [
- AlertForbiddenError,
- AlertPersistenceError,
- AlertNotFoundError,
- AlertDestinationInUseError,
- ],
- }),
- )
- .add(
- HttpApiEndpoint.post("testDestination", "/destinations/:destinationId/test", {
- params: {
- destinationId: AlertDestinationId,
- },
- success: AlertDestinationTestResponse,
- error: [
- AlertForbiddenError,
- AlertValidationError,
- AlertPersistenceError,
- AlertNotFoundError,
- AlertDeliveryError,
- ],
- }),
- )
- .add(
- HttpApiEndpoint.get("listRules", "/rules", {
- success: AlertRulesListResponse,
- error: AlertPersistenceError,
- }),
- )
- .add(
- HttpApiEndpoint.post("createRule", "/rules", {
- payload: AlertRuleUpsertRequest,
- success: AlertRuleDocument,
- error: [AlertForbiddenError, AlertValidationError, AlertPersistenceError, AlertNotFoundError],
- }),
- )
- .add(
- HttpApiEndpoint.patch("updateRule", "/rules/:ruleId", {
- params: {
- ruleId: AlertRuleId,
- },
- payload: AlertRuleUpsertRequest,
- success: AlertRuleDocument,
- error: [AlertForbiddenError, AlertValidationError, AlertPersistenceError, AlertNotFoundError],
- }),
- )
- .add(
- HttpApiEndpoint.delete("deleteRule", "/rules/:ruleId", {
- params: {
- ruleId: AlertRuleId,
- },
- success: AlertRuleDeleteResponse,
- error: [AlertForbiddenError, AlertPersistenceError, AlertNotFoundError],
- }),
- )
- .add(
- HttpApiEndpoint.post("testRule", "/rules/test", {
- payload: AlertRuleTestRequest,
- success: AlertEvaluationResult,
- error: [
- AlertForbiddenError,
- AlertValidationError,
- AlertPersistenceError,
- AlertNotFoundError,
- AlertDeliveryError,
- ...warehouseHttpErrors,
- ],
- }),
- )
- .add(
- HttpApiEndpoint.post("previewRule", "/rules/preview", {
- payload: AlertRulePreviewRequest,
- success: AlertRulePreviewResponse,
- // Preview is read-only (unlike testRule, which can send notifications) and
- // so is not admin-gated in general. The one exception is a raw_query rule:
- // replaying it executes user-authored ClickHouse against the org's
- // warehouse, which is the same capability createRule/testRule gate behind
- // org-admin — hence AlertForbiddenError is reachable here.
- error: [
- AlertValidationError,
- AlertForbiddenError,
- AlertPersistenceError,
- AlertNotFoundError,
- AlertDeliveryError,
- ...warehouseHttpErrors,
- ],
- }),
- )
- .add(
- HttpApiEndpoint.get("listIncidents", "/incidents", {
- success: AlertIncidentsListResponse,
- error: AlertPersistenceError,
- }),
- )
- .add(
- HttpApiEndpoint.get("listRuleChecks", "/rules/:ruleId/checks", {
- params: {
- ruleId: AlertRuleId,
- },
- query: ListRuleChecksQuery,
- success: AlertChecksListResponse,
- error: [AlertPersistenceError, AlertNotFoundError],
- }),
- )
- .add(
- HttpApiEndpoint.get("listDeliveryEvents", "/delivery-events", {
- success: AlertDeliveryEventsListResponse,
- error: AlertPersistenceError,
- }),
- )
- .prefix("/api/alerts")
- .middleware(Authorization) {}
diff --git a/packages/domain/src/http/api.ts b/packages/domain/src/http/api.ts
index cf2a37421..9e5ad4df6 100644
--- a/packages/domain/src/http/api.ts
+++ b/packages/domain/src/http/api.ts
@@ -2,7 +2,6 @@ import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { AiTriageApiGroup } from "./ai-triage"
import { AnomaliesApiGroup } from "./anomalies"
import { ApiKeysApiGroup } from "./api-keys"
-import { AlertsApiGroup } from "./alerts"
import { AuthApiGroup, AuthPublicApiGroup } from "./auth"
import { BillingApiGroup, BillingPublicApiGroup } from "./billing"
import { ChatApiGroup } from "./chat"
@@ -29,7 +28,6 @@ export class MapleApi extends HttpApi.make("MapleApi")
.add(AiTriageApiGroup)
.add(AnomaliesApiGroup)
.add(ApiKeysApiGroup)
- .add(AlertsApiGroup)
.add(BillingApiGroup)
.add(BillingPublicApiGroup)
.add(ChatApiGroup)
diff --git a/packages/domain/src/http/v2/alert-deliveries.ts b/packages/domain/src/http/v2/alert-deliveries.ts
new file mode 100644
index 000000000..13a6f8c09
--- /dev/null
+++ b/packages/domain/src/http/v2/alert-deliveries.ts
@@ -0,0 +1,69 @@
+import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { Schema } from "effect"
+import { AlertDeliveryStatus, AlertDestinationType, AlertEventType } from "../alerts"
+import { AuthorizationV2, V2SchemaErrors } from "./auth"
+import { ListOf, ListQuery, Timestamp } from "./envelopes"
+import { V2InvalidRequestError, V2ServiceUnavailableError } from "./errors"
+import {
+ AlertDeliveryEventPublicId,
+ AlertDestinationPublicId,
+ AlertIncidentPublicId,
+ AlertRulePublicId,
+} from "./resource-ids"
+
+/** A single attempt to deliver an alert notification to a destination. */
+export const V2AlertDelivery = Schema.Struct({
+ id: AlertDeliveryEventPublicId,
+ object: Schema.Literal("alert_delivery"),
+ incident_id: Schema.NullOr(AlertIncidentPublicId),
+ rule_id: AlertRulePublicId,
+ destination_id: AlertDestinationPublicId,
+ destination_name: Schema.String,
+ destination_type: AlertDestinationType,
+ delivery_key: Schema.String,
+ event_type: AlertEventType,
+ attempt_number: Schema.Number.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThan(0))),
+ status: AlertDeliveryStatus,
+ scheduled_at: Timestamp,
+ attempted_at: Schema.NullOr(Timestamp),
+ provider_message: Schema.NullOr(Schema.String),
+ provider_reference: Schema.NullOr(Schema.String),
+ response_code: Schema.NullOr(Schema.Number),
+ error_message: Schema.NullOr(Schema.String),
+}).annotate({
+ identifier: "AlertDelivery",
+ title: "Alert Delivery",
+ description:
+ "One notification delivery attempt produced by an alert rule. This is the canonical delivery-history resource used by both the dashboard and API clients.",
+})
+export type V2AlertDelivery = Schema.Schema.Type
+
+const AlertDeliveryList = ListOf(V2AlertDelivery).annotate({
+ identifier: "AlertDeliveryList",
+ title: "Alert delivery list",
+})
+
+export class V2AlertDeliveriesApiGroup extends HttpApiGroup.make("alertDeliveries")
+ .add(
+ HttpApiEndpoint.get("list", "/", {
+ query: ListQuery,
+ success: AlertDeliveryList,
+ error: [V2InvalidRequestError, V2ServiceUnavailableError],
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "listAlertDeliveries",
+ summary: "List alert deliveries",
+ description:
+ "Returns the organization's most recent alert notification attempts, newest first. Requires the `alerts:read` scope.",
+ }),
+ ),
+ )
+ .prefix("/v2/alerts/deliveries")
+ .middleware(AuthorizationV2)
+ .middleware(V2SchemaErrors)
+ .annotateMerge(
+ OpenApi.annotations({
+ title: "Alert Deliveries",
+ description: "Read-only notification delivery history for alert rules.",
+ }),
+ ) {}
diff --git a/packages/domain/src/http/v2/alert-destinations.ts b/packages/domain/src/http/v2/alert-destinations.ts
index e06f09f63..66d444076 100644
--- a/packages/domain/src/http/v2/alert-destinations.ts
+++ b/packages/domain/src/http/v2/alert-destinations.ts
@@ -1,12 +1,6 @@
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Schema } from "effect"
-import {
- AlertDestinationId,
- HazelChannelId,
- HazelOrganizationId,
- PostgresTransactionId,
- UserId,
-} from "../../primitives"
+import { HazelChannelId, HazelOrganizationId, PostgresTransactionId, UserId } from "../../primitives"
import { AlertDestinationType, MAX_EMAIL_RECIPIENTS } from "../alerts"
import { AuthorizationV2, V2SchemaErrors } from "./auth"
import { ListOf, ListQuery, Timestamp } from "./envelopes"
@@ -18,13 +12,12 @@ import {
V2ServiceUnavailableError,
V2UpstreamError,
} from "./errors"
-import { PublicId, PublicIdPrefixes } from "./public-id"
+import { AlertDestinationPublicId } from "./resource-ids"
/** See api-keys.ts: examples are authored in wire (encoded) shape. */
const wireExample = (example: object): A => example as A
-/** `dest_…` public ID ⇄ internal `AlertDestinationId` (raw UUID). */
-export const AlertDestinationPublicId = PublicId(PublicIdPrefixes.alertDestination, AlertDestinationId)
+export { AlertDestinationPublicId } from "./resource-ids"
const NonEmptyString = Schema.String.pipe(Schema.check(Schema.isMinLength(1), Schema.isTrimmed()))
@@ -44,9 +37,9 @@ const alertDestinationExample = {
id: "dest_oybbpTBhtSFGShMjjLiCrh",
object: "alert_destination",
name: "On-call Slack",
- type: "slack",
+ type: "slack-bot",
enabled: true,
- summary: "Slack webhook → #incidents",
+ summary: "Slack bot → #incidents",
channel_label: "#incidents",
member_user_ids: null,
last_tested_at: "2026-07-15T09:12:00.000Z",
@@ -69,8 +62,8 @@ export const V2AlertDestination = Schema.Struct({
}),
type: AlertDestinationType.annotate({
description:
- "The delivery channel: `slack`, `slack-bot`, `pagerduty`, `webhook`, `hazel`, `hazel-oauth`, `discord`, or `email`. Immutable after creation.",
- examples: ["slack"],
+ "The delivery channel: `slack-bot`, `pagerduty`, `webhook`, `hazel-oauth`, `discord`, or `email`. Immutable after creation.",
+ examples: ["slack-bot"],
}),
enabled: Schema.Boolean.annotate({
description:
@@ -80,7 +73,7 @@ export const V2AlertDestination = Schema.Struct({
summary: Schema.String.annotate({
description:
"Redacted, human-readable summary of the destination's configuration. Secrets (webhook URLs, integration keys, signing secrets) are write-only — they are never returned by the API.",
- examples: ["Slack webhook → #incidents"],
+ examples: ["Slack bot → #incidents"],
}),
channel_label: Schema.NullOr(Schema.String).annotate({
description: "Optional display label for the target channel (Slack destinations), or `null`.",
@@ -103,7 +96,7 @@ export const V2AlertDestination = Schema.Struct({
identifier: "AlertDestination",
title: "Alert Destination",
description:
- "A notification channel that alert rules deliver to (Slack, PagerDuty, generic webhook, Hazel, Discord, or workspace-member email). Channel secrets are write-only: responses carry a redacted `summary` instead.",
+ "A notification channel that alert rules deliver to (Slack bot, PagerDuty, generic webhook, Hazel OAuth, Discord, or workspace-member email). Channel secrets are write-only: responses carry a redacted `summary` instead.",
examples: [wireExample(alertDestinationExample)],
})
export type V2AlertDestination = Schema.Schema.Type
@@ -133,22 +126,6 @@ const nameField = NonEmptyString.annotate({
examples: ["On-call Slack"],
})
-const V2SlackDestinationCreateParams = Schema.Struct({
- type: Schema.Literal("slack"),
- name: nameField,
- webhook_url: NonEmptyString.annotate({
- description: "The Slack incoming-webhook URL. Write-only — never returned.",
- examples: ["https://hooks.slack.com/services/T000/B000/XXXX"],
- }),
- channel_label: Schema.optionalKey(
- NonEmptyString.annotate({
- description: "Optional display label for the target channel, e.g. `#incidents`.",
- examples: ["#incidents"],
- }),
- ),
- enabled: enabledField,
-}).annotate({ identifier: "AlertDestinationCreateSlack", title: "Slack destination" })
-
const V2SlackBotDestinationCreateParams = Schema.Struct({
type: Schema.Literal("slack-bot"),
name: nameField,
@@ -189,20 +166,6 @@ const V2WebhookDestinationCreateParams = Schema.Struct({
enabled: enabledField,
}).annotate({ identifier: "AlertDestinationCreateWebhook", title: "Webhook destination" })
-const V2HazelDestinationCreateParams = Schema.Struct({
- type: Schema.Literal("hazel"),
- name: nameField,
- webhook_url: NonEmptyString.annotate({
- description: "The Hazel inbound-webhook URL. Write-only — never returned.",
- }),
- signing_secret: Schema.optionalKey(
- Schema.String.annotate({
- description: "Optional secret used to sign payloads. Write-only — never returned.",
- }),
- ),
- enabled: enabledField,
-}).annotate({ identifier: "AlertDestinationCreateHazel", title: "Hazel destination" })
-
const V2HazelOAuthDestinationCreateParams = Schema.Struct({
type: Schema.Literal("hazel-oauth"),
name: nameField,
@@ -245,11 +208,9 @@ const V2EmailDestinationCreateParams = Schema.Struct({
}).annotate({ identifier: "AlertDestinationCreateEmail", title: "Email destination" })
export const V2AlertDestinationCreateParams = Schema.Union([
- V2SlackDestinationCreateParams,
V2SlackBotDestinationCreateParams,
V2PagerDutyDestinationCreateParams,
V2WebhookDestinationCreateParams,
- V2HazelDestinationCreateParams,
V2HazelOAuthDestinationCreateParams,
V2DiscordDestinationCreateParams,
V2EmailDestinationCreateParams,
@@ -260,10 +221,10 @@ export const V2AlertDestinationCreateParams = Schema.Union([
"Request body for creating an alert destination, discriminated on `type`. Channel secrets are accepted here but never returned by any read endpoint.",
examples: [
wireExample({
- type: "slack",
+ type: "slack-bot",
name: "On-call Slack",
- webhook_url: "https://hooks.slack.com/services/T000/B000/XXXX",
- channel_label: "#incidents",
+ channel_id: "C0789CHAN",
+ channel_name: "incidents",
enabled: true,
}),
],
@@ -277,13 +238,6 @@ const optionalNameField = Schema.optionalKey(
)
export const V2AlertDestinationUpdateParams = Schema.Union([
- Schema.Struct({
- type: Schema.Literal("slack"),
- name: optionalNameField,
- webhook_url: Schema.optionalKey(Schema.String),
- channel_label: OptionalNonEmptyString,
- enabled: Schema.optionalKey(Schema.Boolean),
- }).annotate({ identifier: "AlertDestinationUpdateSlack", title: "Slack destination update" }),
Schema.Struct({
type: Schema.Literal("slack-bot"),
name: optionalNameField,
@@ -304,13 +258,6 @@ export const V2AlertDestinationUpdateParams = Schema.Union([
signing_secret: Schema.optionalKey(Schema.String),
enabled: Schema.optionalKey(Schema.Boolean),
}).annotate({ identifier: "AlertDestinationUpdateWebhook", title: "Webhook destination update" }),
- Schema.Struct({
- type: Schema.Literal("hazel"),
- name: optionalNameField,
- webhook_url: Schema.optionalKey(Schema.String),
- signing_secret: Schema.optionalKey(Schema.String),
- enabled: Schema.optionalKey(Schema.Boolean),
- }).annotate({ identifier: "AlertDestinationUpdateHazel", title: "Hazel destination update" }),
Schema.Struct({
type: Schema.Literal("hazel-oauth"),
name: optionalNameField,
@@ -341,7 +288,7 @@ export const V2AlertDestinationUpdateParams = Schema.Union([
title: "Alert destination update parameters",
description:
"Request body for updating an alert destination. `type` must match the destination's existing (immutable) type and selects which config fields apply; omitted fields are left unchanged.",
- examples: [wireExample({ type: "slack", enabled: false })],
+ examples: [wireExample({ type: "slack-bot", enabled: false })],
})
export type V2AlertDestinationUpdateParams = Schema.Schema.Type
@@ -496,6 +443,6 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina
OpenApi.annotations({
title: "Alert Destinations",
description:
- "Notification channels for alert rules — Slack, PagerDuty, generic webhooks, Hazel, Discord, and workspace-member email. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.",
+ "Notification channels for alert rules — Slack bot, PagerDuty, generic webhooks, Hazel OAuth, Discord, and workspace-member email. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.",
}),
) {}
diff --git a/packages/domain/src/http/v2/alert-rules.ts b/packages/domain/src/http/v2/alert-rules.ts
index d9674273d..0be939aa6 100644
--- a/packages/domain/src/http/v2/alert-rules.ts
+++ b/packages/domain/src/http/v2/alert-rules.ts
@@ -7,8 +7,6 @@ import {
AlertComparator,
AlertEvaluationStatus,
AlertIncidentTransition,
- AlertMetricAggregation,
- AlertMetricType,
AlertNotificationTemplate,
AlertSeverity,
AlertSignalType,
@@ -76,9 +74,6 @@ const alertRuleExample = {
consecutive_breaches_required: 2,
consecutive_healthy_required: 3,
renotify_interval_minutes: 60,
- metric_name: null,
- metric_type: null,
- metric_aggregation: null,
apdex_threshold_ms: null,
query_builder_draft: null,
raw_query_sql: null,
@@ -143,7 +138,7 @@ export const V2AlertRule = Schema.Struct({
}),
signal_type: AlertSignalType.annotate({
description:
- "What the rule measures: `error_rate`, `p95_latency`, `p99_latency`, `apdex`, `throughput`, `metric`, `builder_query`, or `raw_query`.",
+ "What the rule measures: `error_rate`, `p95_latency`, `p99_latency`, `apdex`, `throughput`, `builder_query`, or `raw_query`. Metrics use `builder_query` with a metrics draft.",
examples: ["error_rate"],
}),
comparator: AlertComparator.annotate({
@@ -179,17 +174,6 @@ export const V2AlertRule = Schema.Struct({
description: "How often an open incident re-notifies its destinations, in minutes.",
examples: [60],
}),
- metric_name: Schema.NullOr(Schema.String).annotate({
- description: "For `metric` rules: the metric to evaluate. `null` for other signal types.",
- }),
- metric_type: Schema.NullOr(AlertMetricType).annotate({
- description:
- "For `metric` rules: the metric's type (`sum`, `gauge`, `histogram`, `exponential_histogram`).",
- }),
- metric_aggregation: Schema.NullOr(AlertMetricAggregation).annotate({
- description:
- "For `metric` rules: how samples aggregate in the window (`avg`, `min`, `max`, `sum`, `count`).",
- }),
apdex_threshold_ms: Schema.NullOr(PositiveFloat).annotate({
description: "For `apdex` rules: the satisfied-latency threshold in milliseconds.",
}),
@@ -227,7 +211,7 @@ export const V2AlertRule = Schema.Struct({
identifier: "AlertRule",
title: "Alert Rule",
description:
- "A monitor that evaluates a signal (error rate, latency, Apdex, throughput, a metric, a query-builder query, or raw SQL) over a rolling window and opens incidents when the threshold condition holds for enough consecutive checks. Notifications are delivered to the referenced alert destinations.",
+ "A monitor that evaluates a built-in signal, query-builder query, or raw SQL over a rolling window and opens incidents when the threshold condition holds for enough consecutive checks. Metrics use the same query-builder path as metric charts. Notifications are delivered to the referenced alert destinations.",
examples: [wireExample(alertRuleExample)],
})
export type V2AlertRule = Schema.Schema.Type
@@ -320,9 +304,6 @@ const createParamsFields = {
consecutive_breaches_required: Schema.optionalKey(PositiveInt),
consecutive_healthy_required: Schema.optionalKey(PositiveInt),
renotify_interval_minutes: Schema.optionalKey(PositiveInt),
- metric_name: Schema.optionalKey(Schema.NullOr(Schema.String)),
- metric_type: Schema.optionalKey(Schema.NullOr(AlertMetricType)),
- metric_aggregation: Schema.optionalKey(Schema.NullOr(AlertMetricAggregation)),
apdex_threshold_ms: Schema.optionalKey(Schema.NullOr(PositiveFloat)),
query_builder_draft: Schema.optionalKey(Schema.NullOr(QueryBuilderDraftPassthrough)),
raw_query_sql: Schema.optionalKey(Schema.NullOr(Schema.String)),
@@ -336,7 +317,7 @@ export const V2AlertRuleCreateParams = Schema.Struct(createParamsFields).annotat
identifier: "AlertRuleCreateParams",
title: "Alert rule create parameters",
description:
- "Request body for creating an alert rule. Signal-specific fields (`metric_*`, `apdex_threshold_ms`, `query_builder_draft`, `raw_query_*`) are required by their respective `signal_type` and validated server-side.",
+ "Request body for creating an alert rule. Signal-specific fields (`apdex_threshold_ms`, `query_builder_draft`, `raw_query_*`) are required by their respective `signal_type` and validated server-side.",
examples: [
wireExample({
name: "Checkout error rate",
@@ -374,9 +355,6 @@ export const V2AlertRuleUpdateParams = Schema.Struct({
consecutive_breaches_required: createParamsFields.consecutive_breaches_required,
consecutive_healthy_required: createParamsFields.consecutive_healthy_required,
renotify_interval_minutes: createParamsFields.renotify_interval_minutes,
- metric_name: createParamsFields.metric_name,
- metric_type: createParamsFields.metric_type,
- metric_aggregation: createParamsFields.metric_aggregation,
apdex_threshold_ms: createParamsFields.apdex_threshold_ms,
query_builder_draft: createParamsFields.query_builder_draft,
raw_query_sql: createParamsFields.raw_query_sql,
diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts
index 71930449a..64f2e98f3 100644
--- a/packages/domain/src/http/v2/api.ts
+++ b/packages/domain/src/http/v2/api.ts
@@ -1,4 +1,5 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
+import { V2AlertDeliveriesApiGroup } from "./alert-deliveries"
import { V2AlertDestinationsApiGroup } from "./alert-destinations"
import { V2AnomaliesApiGroup } from "./anomalies"
import { V2AlertIncidentsApiGroup } from "./alert-incidents"
@@ -64,6 +65,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2")
.add(V2ApiKeysApiGroup)
.add(V2DashboardsApiGroup)
.add(V2AlertRulesApiGroup)
+ .add(V2AlertDeliveriesApiGroup)
.add(V2AlertDestinationsApiGroup)
.add(V2AlertIncidentsApiGroup)
.add(V2IngestKeysApiGroup)
diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts
index e6fa4544d..a10b8fe7d 100644
--- a/packages/domain/src/http/v2/index.ts
+++ b/packages/domain/src/http/v2/index.ts
@@ -1,3 +1,4 @@
+export * from "./alert-deliveries"
export * from "./alert-destinations"
export * from "./alert-incidents"
export * from "./alert-rules"
diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts
index afd1ee51e..e99949676 100644
--- a/packages/domain/src/http/v2/openapi.test.ts
+++ b/packages/domain/src/http/v2/openapi.test.ts
@@ -70,6 +70,7 @@ describe("MapleApiV2 OpenAPI", () => {
"DELETE /v2/dashboards/{id}",
"DELETE /v2/integrations/slack",
"DELETE /v2/scrape_targets/{id}",
+ "GET /v2/alerts/deliveries",
"GET /v2/alerts/destinations",
"GET /v2/alerts/destinations/{id}",
"GET /v2/alerts/incidents",
diff --git a/packages/domain/src/http/v2/resource-ids.ts b/packages/domain/src/http/v2/resource-ids.ts
index 2729d51aa..ee01ca3b6 100644
--- a/packages/domain/src/http/v2/resource-ids.ts
+++ b/packages/domain/src/http/v2/resource-ids.ts
@@ -1,5 +1,7 @@
import {
ActorId,
+ AlertDeliveryEventId,
+ AlertDestinationId,
AlertIncidentId,
AlertRuleId,
AnomalyIncidentId,
@@ -11,7 +13,9 @@ import { PublicId, PublicIdPrefixes } from "./public-id"
/** Canonical shared public-ID codecs for resources referenced across v2 groups. */
export const AlertRulePublicId = PublicId(PublicIdPrefixes.alertRule, AlertRuleId)
+export const AlertDestinationPublicId = PublicId(PublicIdPrefixes.alertDestination, AlertDestinationId)
export const AlertIncidentPublicId = PublicId(PublicIdPrefixes.alertIncident, AlertIncidentId)
+export const AlertDeliveryEventPublicId = PublicId(PublicIdPrefixes.event, AlertDeliveryEventId)
export const ActorPublicId = PublicId(PublicIdPrefixes.actor, ActorId)
export const AnomalyIncidentPublicId = PublicId(PublicIdPrefixes.anomalyIncident, AnomalyIncidentId)
export const ErrorIncidentPublicId = PublicId(PublicIdPrefixes.errorIncident, ErrorIncidentId)
diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts
index 9575c1a1b..ce15aced8 100644
--- a/packages/domain/src/http/v2/v2-contract.test.ts
+++ b/packages/domain/src/http/v2/v2-contract.test.ts
@@ -262,9 +262,6 @@ describe("V2 alerts wire format", () => {
consecutive_breaches_required: 2,
consecutive_healthy_required: 3,
renotify_interval_minutes: 60,
- metric_name: null,
- metric_type: null,
- metric_aggregation: null,
apdex_threshold_ms: null,
query_builder_draft: { queries: [{ signalType: "traces", attributeKey: "service.name" }] },
raw_query_sql: null,
@@ -310,12 +307,12 @@ describe("V2 alerts wire format", () => {
it("decodes destination create params per union arm and rejects mismatched configs", () => {
const slack = Schema.decodeUnknownSync(V2AlertDestinationCreateParams)({
- type: "slack",
+ type: "slack-bot",
name: "On-call",
- webhook_url: "https://hooks.slack.com/services/T/B/X",
- channel_label: "#incidents",
+ channel_id: "C123",
+ channel_name: "incidents",
})
- expect(slack.type).toBe("slack")
+ expect(slack.type).toBe("slack-bot")
const email = Schema.decodeUnknownSync(V2AlertDestinationCreateParams)({
type: "email",
diff --git a/packages/domain/src/mcp-structured-types.ts b/packages/domain/src/mcp-structured-types.ts
index 312718a43..80fa86311 100644
--- a/packages/domain/src/mcp-structured-types.ts
+++ b/packages/domain/src/mcp-structured-types.ts
@@ -326,9 +326,6 @@ export interface AlertRuleDetailRow extends AlertRuleRow {
consecutiveBreachesRequired: number
consecutiveHealthyRequired: number
renotifyIntervalMinutes: number
- metricName: string | null
- metricType: string | null
- metricAggregation: string | null
apdexThresholdMs: number | null
queryBuilderDraft: Record | null
rawQuerySql: string | null
diff --git a/packages/domain/src/query-engine.ts b/packages/domain/src/query-engine.ts
index 83ff05e70..6206d8435 100644
--- a/packages/domain/src/query-engine.ts
+++ b/packages/domain/src/query-engine.ts
@@ -119,6 +119,8 @@ export const MetricsFilters = Schema.Struct({
metricNames: Schema.optional(Schema.Array(MetricName)),
metricType: MetricType,
serviceName: Schema.optional(ServiceName),
+ /** Multi-value service scope. When present it supersedes `serviceName`. */
+ serviceNames: Schema.optional(Schema.Array(ServiceName)),
// Metrics tables have no pre-extracted DeploymentEnv column, so this lowers to
// a predicate on `ResourceAttributes['deployment.environment']` (see
// `metricsTimeseriesQuery`). Same field name as TracesFilters/LogsFilters.
diff --git a/packages/query-engine/src/ch/queries/metrics.test.ts b/packages/query-engine/src/ch/queries/metrics.test.ts
index 002e54f0c..9d8c08ce9 100644
--- a/packages/query-engine/src/ch/queries/metrics.test.ts
+++ b/packages/query-engine/src/ch/queries/metrics.test.ts
@@ -45,6 +45,17 @@ describe("metricsTimeseriesQuery", () => {
expect(sql).toContain("ResourceAttributes['deployment.environment'] IN ('production')")
})
+ it("uses a multi-service IN predicate instead of the scalar service filter", () => {
+ const q = metricsTimeseriesQuery({
+ metricType: "sum",
+ serviceName: "ignored",
+ serviceNames: ["api", "checkout"],
+ })
+ const { sql } = compileCH(q, baseParams)
+ expect(sql).toContain("ServiceName IN ('api', 'checkout')")
+ expect(sql).not.toContain("ServiceName = 'ignored'")
+ })
+
it("omits the environment predicate when no environments are selected", () => {
const q = metricsTimeseriesQuery({ metricType: "sum", environments: [] })
const { sql } = compileCH(q, baseParams)
@@ -173,6 +184,13 @@ describe("metricsTimeseriesRateQuery", () => {
expect(sql).toContain("ServiceName = 'api'")
})
+ it("applies a multi-service filter in the CTE", () => {
+ const q = metricsTimeseriesRateQuery({ serviceName: "ignored", serviceNames: ["api", "checkout"] })
+ const { sql } = compileCH(q, baseParams)
+ expect(sql).toContain("ServiceName IN ('api', 'checkout')")
+ expect(sql).not.toContain("ServiceName = 'ignored'")
+ })
+
it("applies attributeKey filter", () => {
const q = metricsTimeseriesRateQuery({
attributeKey: "region",
@@ -257,9 +275,7 @@ describe("metricsTimeseriesRateQuery", () => {
it("applies an environment filter in the CTE", () => {
const q = metricsTimeseriesRateQuery({ environments: ["production", "staging"] })
const { sql } = compileCH(q, baseParams)
- expect(sql).toContain(
- "ResourceAttributes['deployment.environment'] IN ('production', 'staging')",
- )
+ expect(sql).toContain("ResourceAttributes['deployment.environment'] IN ('production', 'staging')")
})
it("falls back to raw metrics_sum when an environment filter is set", () => {
diff --git a/packages/query-engine/src/ch/queries/metrics.ts b/packages/query-engine/src/ch/queries/metrics.ts
index 21fef51bd..39f5c7c0c 100644
--- a/packages/query-engine/src/ch/queries/metrics.ts
+++ b/packages/query-engine/src/ch/queries/metrics.ts
@@ -44,6 +44,8 @@ const DEPLOYMENT_ENV_KEY = "deployment.environment"
interface MetricsQueryOpts {
metricType: MetricType
serviceName?: string
+ /** Multi-service scope. When non-empty it supersedes `serviceName`. */
+ serviceNames?: readonly string[]
/** Deployment environments to scope to. Empty/undefined means all. */
environments?: readonly string[]
groupByAttributeKey?: string
@@ -114,7 +116,9 @@ export function metricsTimeseriesQuery(opts: MetricsTimeseriesOpts) {
$.OrgId.eq(param.string("orgId")),
$.TimeUnix.gte(param.dateTime("startTime")),
$.TimeUnix.lte(param.dateTime("endTime")),
- CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
+ opts.serviceNames?.length
+ ? CH.inList($.ServiceName, opts.serviceNames)
+ : CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
CH.when(opts.attributeKey, (k: string) => $.Attributes.get(k).eq(opts.attributeValue ?? "")),
opts.environments?.length
? CH.inList($.ResourceAttributes.get(DEPLOYMENT_ENV_KEY), opts.environments)
@@ -143,6 +147,8 @@ export interface MetricsRateTimeseriesOpts {
metricNames?: ReadonlyArray
bucketSeconds?: number
serviceName?: string
+ /** Multi-service scope. When non-empty it supersedes `serviceName`. */
+ serviceNames?: ReadonlyArray
/** Deployment environments to scope to. Empty/undefined means all. */
environments?: readonly string[]
groupByAttributeKey?: string
@@ -230,7 +236,9 @@ function metricsTimeseriesRateFromSpanMetricsCallsHourly(
$.MetricName.eq(param.string("metricName")),
$.Hour.gte(previousBucket),
$.Hour.lte(endBucket),
- CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
+ opts.serviceNames?.length
+ ? CH.inList($.ServiceName, opts.serviceNames)
+ : CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
CH.when(opts.attributeKey === "span.kind" ? opts.attributeValue : undefined, (v: string) =>
$.SpanKind.eq(v),
),
@@ -382,7 +390,9 @@ export function metricsTimeseriesRateQuery(
CH.dynamicColumn("IsMonotonic").eq(1),
$.TimeUnix.gte(CH.intervalSub(param.dateTime("startTime"), param.int("bucketSeconds"))),
$.TimeUnix.lte(param.dateTime("endTime")),
- CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
+ opts.serviceNames?.length
+ ? CH.inList($.ServiceName, opts.serviceNames)
+ : CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
CH.when(opts.attributeKey, (k: string) => $.Attributes.get(k).eq(opts.attributeValue ?? "")),
opts.environments?.length
? CH.inList($.ResourceAttributes.get(DEPLOYMENT_ENV_KEY), opts.environments)
diff --git a/packages/query-engine/src/query-builder/model.test.ts b/packages/query-engine/src/query-builder/model.test.ts
index 1224827ff..a0e878211 100644
--- a/packages/query-engine/src/query-builder/model.test.ts
+++ b/packages/query-engine/src/query-builder/model.test.ts
@@ -192,6 +192,15 @@ describe("metrics resource.* support", () => {
expect(filters?.serviceName).toBe("api")
})
+ it("lowers a comma-separated service scope to serviceNames", () => {
+ const { warnings, filters } = metricsFiltersOf({
+ whereClause: 'service.name = "api,checkout"',
+ })
+ expect(warnings).toEqual([])
+ expect(filters?.serviceNames).toEqual(["api", "checkout"])
+ expect(filters).not.toHaveProperty("serviceName")
+ })
+
it("warns (blocking) when the 5 resource-filter cap is exceeded", () => {
const clause = ["a", "b", "c", "d", "e", "f"].map((k) => `resource.${k} = "1"`).join(" AND ")
const { warnings, filters } = metricsFiltersOf({ whereClause: clause })
@@ -209,6 +218,18 @@ describe("metrics resource.* support", () => {
expect(filters?.groupByResourceAttributeKey).toBe("k8s.pod.name")
})
+ it("keeps the first attr.* group-by and warns about later dimensions", () => {
+ const { warnings, groupBy, filters } = metricsFiltersOf({
+ addOns: { groupBy: true, having: false, orderBy: false, limit: false, legend: false },
+ groupBy: ["attr.http.method", "attr.http.route"],
+ })
+ expect(groupBy).toEqual(["attribute"])
+ expect(filters?.groupByAttributeKey).toBe("http.method")
+ expect(warnings).toEqual([
+ "Metrics queries support a single attr.* group by; ignoring attr.http.route",
+ ])
+ })
+
it("keeps the first dimension and warns when attr.* and resource.* group-bys are combined", () => {
const { warnings, groupBy, filters } = metricsFiltersOf({
addOns: { groupBy: true, having: false, orderBy: false, limit: false, legend: false },
diff --git a/packages/query-engine/src/query-builder/model.ts b/packages/query-engine/src/query-builder/model.ts
index b12f08ce1..fd3594ec6 100644
--- a/packages/query-engine/src/query-builder/model.ts
+++ b/packages/query-engine/src/query-builder/model.ts
@@ -457,6 +457,8 @@ interface MetricsFilterAccumulator {
metricName: string
metricType: QueryBuilderMetricType
serviceName?: string
+ serviceNames?: string[]
+ environments?: string[]
groupByAttributeKey?: string
groupByResourceAttributeKey?: string
attributeFilters: AccumulatedAttributeFilter[]
@@ -512,7 +514,19 @@ function applyMetricsClause(
}
return Match.value(key).pipe(
- Match.when("service.name", () => ({ ...filters, serviceName: clause.value })),
+ Match.when("service.name", () => {
+ const serviceNames = splitCsv(clause.value)
+ if (serviceNames.length > 1) {
+ const { serviceName: _serviceName, ...rest } = filters
+ return { ...rest, serviceNames }
+ }
+ const { serviceNames: _serviceNames, ...rest } = filters
+ return { ...rest, serviceName: serviceNames[0] ?? clause.value }
+ }),
+ Match.when("deployment.environment", () => ({
+ ...filters,
+ environments: splitCsv(clause.value),
+ })),
Match.when("metric.type", () => {
if (QUERY_BUILDER_METRIC_TYPES.includes(clause.value as QueryBuilderMetricType)) {
return { ...filters, metricType: clause.value as QueryBuilderMetricType }
@@ -603,6 +617,15 @@ function resolveMetricsGroupByToken(
warnings.push("Invalid attr.* group by ignored")
return null
}
+ if (
+ metricsFilters.groupByAttributeKey !== undefined &&
+ metricsFilters.groupByAttributeKey !== attributeKey
+ ) {
+ warnings.push(
+ `Metrics queries support a single attr.* group by; ignoring attr.${attributeKey}`,
+ )
+ return null
+ }
metricsFilters.groupByAttributeKey = attributeKey
return "attribute" as const
}
@@ -612,6 +635,15 @@ function resolveMetricsGroupByToken(
warnings.push("Invalid resource.* group by ignored")
return null
}
+ if (
+ metricsFilters.groupByResourceAttributeKey !== undefined &&
+ metricsFilters.groupByResourceAttributeKey !== resourceKey
+ ) {
+ warnings.push(
+ `Metrics queries support a single resource.* group by; ignoring resource.${resourceKey}`,
+ )
+ return null
+ }
metricsFilters.groupByResourceAttributeKey = resourceKey
return "resource_attribute" as const
}
@@ -1131,7 +1163,10 @@ export function formatFiltersAsWhereClause(params: Record): str
const clauses: string[] = []
- if (typeof filters.serviceName === "string" && filters.serviceName.trim()) {
+ if (Array.isArray(filters.serviceNames) && filters.serviceNames.length > 0) {
+ const val = filters.serviceNames.filter((item): item is string => typeof item === "string").join(",")
+ if (val) clauses.push(`service.name = "${val}"`)
+ } else if (typeof filters.serviceName === "string" && filters.serviceName.trim()) {
clauses.push(`service.name = "${filters.serviceName.trim()}"`)
}
diff --git a/packages/query-engine/src/runtime/query-engine.ts b/packages/query-engine/src/runtime/query-engine.ts
index cffda4c90..3dc286482 100644
--- a/packages/query-engine/src/runtime/query-engine.ts
+++ b/packages/query-engine/src/runtime/query-engine.ts
@@ -1272,6 +1272,7 @@ export const makeQueryEngineExecute = (warehouse: QueryEn
metricNames: request.query.filters.metricNames,
bucketSeconds: bucketSeconds!,
serviceName: request.query.filters.serviceName,
+ serviceNames: request.query.filters.serviceNames,
environments: request.query.filters.environments,
groupByAttributeKey,
groupByResourceAttributeKey,
@@ -1322,6 +1323,7 @@ export const makeQueryEngineExecute = (warehouse: QueryEn
CH.metricsTimeseriesQuery({
metricType: request.query.filters.metricType,
serviceName: request.query.filters.serviceName,
+ serviceNames: request.query.filters.serviceNames,
environments: request.query.filters.environments,
groupByAttributeKey,
groupByResourceAttributeKey,
@@ -1987,7 +1989,6 @@ export const computeAlertBuckets = Effect.fnUntraced(function*
}
-
if (query.source === "traces") {
const opts = extractTracesOpts(query.filters as Record)
const rows = yield* executeCHQuery(
@@ -2057,25 +2058,55 @@ export const computeAlertBuckets = Effect.fnUntraced(function* 0 ? metricsAggregateValueForMetric(query.metric, row) : null
@@ -2197,9 +2228,7 @@ const computeRawSqlBuckets = Effect.fnUntraced(function*
// by `Date.parse`, which would read that space-separated form as local
// time rather than UTC.
bucket: normalizeBucket(
- typeof row.bucket === "string" || row.bucket instanceof Date
- ? row.bucket
- : range.startTime,
+ typeof row.bucket === "string" || row.bucket instanceof Date ? row.bucket : range.startTime,
),
groupKey,
value,
@@ -2220,10 +2249,7 @@ export const reduceAlertBuckets = (
obs: ReadonlyArray,
reducer: QueryEngineAlertReducer,
): ReadonlyArray => {
- const byGroup = new Map<
- string,
- Array<{ value: number | null; sampleCount: number; hasData: boolean }>
- >()
+ const byGroup = new Map>()
for (const o of obs) {
const entry = { value: o.value, sampleCount: o.sampleCount, hasData: o.sampleCount > 0 }
const list = byGroup.get(o.groupKey)
@@ -2236,7 +2262,6 @@ export const reduceAlertBuckets = (
return reducePerGroupObservations(byGroup, reducer)
}
-
/**
* Annotate, validate and resolve the bucket size for one alert evaluation.
* Shared by `evaluate` and `evaluateSeries` so the two can never drift on which
From 14a33937631f78a67fcad52271520e6244f936dd Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Fri, 31 Jul 2026 13:30:15 +0200
Subject: [PATCH 2/4] stff
---
.../src/routes/v2/alert-deliveries.http.ts | 12 +-
apps/api/src/routes/v2/alerts.http.test.ts | 101 +++++++++++++++-
.../services/AlertDeliveryDispatch.test.ts | 31 +++++
.../api/src/services/AlertDeliveryDispatch.ts | 17 ++-
apps/api/src/services/AlertsService.test.ts | 69 +++++++++++
apps/api/src/services/AlertsService.ts | 73 +++++++++---
.../alert-templating/renderer.test.ts | 20 ++++
.../src/services/alert-templating/renderer.ts | 9 +-
.../alerts/alert-create-page-content.test.ts | 3 +
.../alerts/alert-create-page-content.tsx | 93 +++++++++++++--
.../widgets/widget-actions-context.tsx | 9 +-
apps/web/src/lib/alerts/form-utils.ts | 4 +-
apps/web/src/lib/alerts/widget-chart-param.ts | 4 +-
.../web/src/lib/alerts/widget-prefill.test.ts | 45 +++++++-
apps/web/src/lib/alerts/widget-prefill.ts | 68 +++++++++++
apps/web/src/lib/collections/alerts.ts | 4 +-
apps/web/src/routes/alerts/create.tsx | 6 +-
apps/web/src/routes/alerts/index.tsx | 6 +-
packages/db/drizzle/0026_windy_bromley.sql | 6 +
packages/db/src/external-columns.test.ts | 109 ++++++++++++++++++
packages/db/src/migrations.test.ts | 10 +-
packages/domain/src/http/alerts.test.ts | 9 ++
packages/domain/src/http/alerts.ts | 18 ++-
.../domain/src/http/v2/alert-incidents.ts | 4 +-
packages/domain/src/http/v2/alert-rules.ts | 3 +-
25 files changed, 672 insertions(+), 61 deletions(-)
create mode 100644 packages/db/src/external-columns.test.ts
diff --git a/apps/api/src/routes/v2/alert-deliveries.http.ts b/apps/api/src/routes/v2/alert-deliveries.http.ts
index 53aa225a9..0c4241439 100644
--- a/apps/api/src/routes/v2/alert-deliveries.http.ts
+++ b/apps/api/src/routes/v2/alert-deliveries.http.ts
@@ -2,7 +2,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"
import type { AlertDeliveryEventDocument } from "@maple/domain/http"
import { CurrentTenant } from "@maple/domain/http"
import type { V2AlertDelivery } from "@maple/domain/http/v2"
-import { MapleApiV2, paginateArray, timestamp, timestampOrNull } from "@maple/domain/http/v2"
+import { MapleApiV2, paginateOffsetQuery, timestamp, timestampOrNull } from "@maple/domain/http/v2"
import { Effect } from "effect"
import { AlertsService } from "../../services/AlertsService"
import { mapAlertError } from "./alerts-error-map"
@@ -33,10 +33,12 @@ export const HttpV2AlertDeliveriesLive = HttpApiBuilder.group(MapleApiV2, "alert
return handlers.handle("list", ({ query }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
- const response = yield* alerts
- .listDeliveryEvents(tenant.orgId)
- .pipe(mapAlertError("delivery_list"))
- const page = yield* paginateArray(response.events.map(toV2Delivery), query)
+ const page = yield* paginateOffsetQuery(query, ({ limit, offset }) =>
+ alerts.listDeliveryEvents(tenant.orgId, { limit, offset }).pipe(
+ mapAlertError("delivery_list"),
+ Effect.map((response) => response.events.map(toV2Delivery)),
+ ),
+ )
return { object: "list" as const, ...page }
}),
)
diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts
index 08bacba8a..1ea3a77dc 100644
--- a/apps/api/src/routes/v2/alerts.http.test.ts
+++ b/apps/api/src/routes/v2/alerts.http.test.ts
@@ -8,7 +8,7 @@ import { BucketCacheService, EdgeCacheService } from "@maple/query-engine/cachin
import { CacheBackendLive } from "../../lib/CacheBackendLive"
import { EmailService } from "../../lib/EmailService"
import { Env } from "../../lib/Env"
-import { cleanupTestDbs, createTestDb, type TestDb } from "../../lib/test-pglite"
+import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "../../lib/test-pglite"
import type { WarehouseQueryServiceShape } from "../../lib/WarehouseQueryService"
import { WarehouseQueryService } from "../../lib/WarehouseQueryService"
import { ApiAuthorizationV2Layer } from "../../services/ApiAuthorizationV2Layer"
@@ -154,6 +154,7 @@ const makeHarness = (warehouseService: WarehouseQueryServiceShape = warehouseStu
return {
bootstrapKey,
request,
+ testDb,
dispose: async () => {
await disposeHandler()
await runtime.dispose()
@@ -162,6 +163,27 @@ const makeHarness = (warehouseService: WarehouseQueryServiceShape = warehouseStu
}
describe("v2 alerts over HTTP", () => {
+ const createWebhookAndRule = async (
+ harness: ReturnType,
+ token: string,
+ ) => {
+ const destination = await harness.request("POST", "/v2/alerts/destinations", token, {
+ type: "webhook",
+ name: "Ops hook",
+ url: "https://example.com/hooks/maple",
+ })
+ const rule = await harness.request("POST", "/v2/alerts/rules", token, {
+ name: "Checkout error rate",
+ severity: "critical",
+ signal_type: "error_rate",
+ comparator: "gt",
+ threshold: 0.05,
+ window_minutes: 5,
+ destination_ids: [destination.body.id],
+ })
+ return { destination, rule }
+ }
+
it("supports destination + rule CRUD with v2 wire conventions", async () => {
const harness = makeHarness()
const key = await harness.bootstrapKey(["alerts:write"])
@@ -280,6 +302,83 @@ describe("v2 alerts over HTTP", () => {
await harness.dispose()
})
+ it("reads retained metric incidents after metric rule migration", async () => {
+ const harness = makeHarness()
+ const key = await harness.bootstrapKey(["alerts:write"])
+ await createWebhookAndRule(harness, key.secret)
+
+ await executeSql(
+ harness.testDb,
+ `insert into alert_incidents (
+ id, org_id, rule_id, incident_key, rule_name, group_key, signal_type,
+ severity, status, comparator, threshold, first_triggered_at,
+ last_triggered_at, dedupe_key, created_at, updated_at
+ )
+ select
+ '00000000-0000-4000-8000-000000000901', org_id, id,
+ 'legacy-metric-incident', name, null, 'metric', 'critical', 'resolved',
+ 'gt', 10, timestamp '2026-07-01 00:00:00+00',
+ timestamp '2026-07-01 00:05:00+00', 'legacy-metric-dedupe',
+ timestamp '2026-07-01 00:00:00+00', timestamp '2026-07-01 00:05:00+00'
+ from alert_rules where org_id = 'org_alerts_e2e' limit 1`,
+ )
+
+ const incidents = await harness.request("GET", "/v2/alerts/incidents", key.secret)
+ expect(incidents.status).toBe(200)
+ expect(incidents.body.data).toHaveLength(1)
+ expect(incidents.body.data[0].signal_type).toBe("metric")
+
+ await harness.dispose()
+ })
+
+ it("paginates delivery history beyond the first 100 rows", async () => {
+ const harness = makeHarness()
+ const key = await harness.bootstrapKey(["alerts:write"])
+ await createWebhookAndRule(harness, key.secret)
+
+ await executeSql(
+ harness.testDb,
+ `insert into alert_delivery_events (
+ id, org_id, rule_id, destination_id, delivery_key, event_type,
+ attempt_number, status, scheduled_at, payload_json, created_at, updated_at
+ )
+ select
+ '00000000-0000-4000-8000-' || lpad(series::text, 12, '0'),
+ rules.org_id, rules.id, destinations.id, 'delivery-' || series,
+ 'trigger', 1, 'success',
+ timestamp '2026-07-31 12:00:00+00' - series * interval '1 second',
+ '{}'::jsonb,
+ timestamp '2026-07-31 12:00:00+00' - series * interval '1 second',
+ timestamp '2026-07-31 12:00:00+00' - series * interval '1 second'
+ from generate_series(1, 105) as series
+ cross join lateral (
+ select id, org_id from alert_rules where org_id = 'org_alerts_e2e' limit 1
+ ) as rules
+ cross join lateral (
+ select id from alert_destinations where org_id = 'org_alerts_e2e' limit 1
+ ) as destinations`,
+ )
+
+ const first = await harness.request("GET", "/v2/alerts/deliveries?limit=60", key.secret)
+ expect(first.status).toBe(200)
+ expect(first.body.data).toHaveLength(60)
+ expect(first.body.has_more).toBe(true)
+ expect(first.body.next_cursor).toEqual(expect.any(String))
+
+ const second = await harness.request(
+ "GET",
+ `/v2/alerts/deliveries?limit=60&cursor=${encodeURIComponent(first.body.next_cursor)}`,
+ key.secret,
+ )
+ expect(second.status).toBe(200)
+ expect(second.body.data).toHaveLength(45)
+ expect(second.body.has_more).toBe(false)
+ expect(second.body.next_cursor).toBeNull()
+ expect(new Set([...first.body.data, ...second.body.data].map((item) => item.id)).size).toBe(105)
+
+ await harness.dispose()
+ })
+
it("clears stored raw SQL when a rule switches to a structured signal", async () => {
const harness = makeHarness()
const key = await harness.bootstrapKey(["alerts:write"])
diff --git a/apps/api/src/services/AlertDeliveryDispatch.test.ts b/apps/api/src/services/AlertDeliveryDispatch.test.ts
index 4fa07285d..e6926a70b 100644
--- a/apps/api/src/services/AlertDeliveryDispatch.test.ts
+++ b/apps/api/src/services/AlertDeliveryDispatch.test.ts
@@ -426,6 +426,37 @@ describe("dispatchDelivery", () => {
}),
)
+ it.effect("slack-bot: applies stored legacy slack template overrides", () =>
+ Effect.gen(function* () {
+ let postedBody = ""
+ const fetchFn: typeof fetch = async (_input, init) => {
+ postedBody = String(init?.body)
+ return new Response(JSON.stringify({ ok: true, ts: "1700000000.000200" }), {
+ status: 200,
+ })
+ }
+
+ yield* dispatchDelivery(
+ {
+ ...slackBotContext,
+ template: {
+ body: "top-level body",
+ overrides: { slack: { body: "legacy Slack body" } },
+ },
+ },
+ "{}",
+ fetchFn,
+ 5_000,
+ LINK,
+ CHAT,
+ slackTokenDeps(),
+ )
+
+ assert.include(postedBody, "legacy Slack body")
+ assert.notInclude(postedBody, "top-level body")
+ }),
+ )
+
it.effect("slack-bot: surfaces a not_in_channel logical error with an actionable message", () =>
Effect.gen(function* () {
const fetchFn: typeof fetch = async () =>
diff --git a/apps/api/src/services/AlertDeliveryDispatch.ts b/apps/api/src/services/AlertDeliveryDispatch.ts
index 5e6113519..dc7f87631 100644
--- a/apps/api/src/services/AlertDeliveryDispatch.ts
+++ b/apps/api/src/services/AlertDeliveryDispatch.ts
@@ -4,8 +4,8 @@ import {
type AlertComparator,
type AlertDestinationType,
type AlertEventType,
+ type HistoricalAlertSignalType,
type AlertSeverity,
- type AlertSignalType,
} from "@maple/domain/http"
import type { AlertDestinationRow } from "@maple/db"
import { Clock, Duration, Effect, Match, Option, Schema } from "effect"
@@ -40,7 +40,7 @@ export interface DispatchContext {
readonly ruleId: string
readonly ruleName: string
readonly groupKey: string | null
- readonly signalType: AlertSignalType
+ readonly signalType: HistoricalAlertSignalType
readonly severity: AlertSeverity
readonly comparator: AlertComparator
readonly threshold: number
@@ -78,7 +78,7 @@ export interface ChatUrlContext {
readonly incidentId: string | null
readonly dedupeKey: string
readonly eventType: AlertEventType
- readonly signalType: AlertSignalType
+ readonly signalType: HistoricalAlertSignalType
readonly severity: AlertSeverity
readonly comparator: AlertComparator
readonly threshold: number
@@ -600,8 +600,9 @@ const renderTitleBody = (
destinationType: AlertDestinationType,
linkUrl: string,
chatUrl: string,
+ legacyDestinationTypes: ReadonlyArray = [],
): { title: string; body: string } | null => {
- const resolved = resolveTemplate(context.template, destinationType)
+ const resolved = resolveTemplate(context.template, destinationType, legacyDestinationTypes)
if (!hasCustomTemplate(resolved)) return null
try {
const templateCtx = buildTemplateContext(context, linkUrl, chatUrl)
@@ -687,7 +688,13 @@ export const dispatchDelivery = (
"slack-bot": (config) =>
Effect.gen(function* () {
const botToken = yield* deps.resolveSlackBotToken(context.destination.orgId)
- const templated = renderTitleBody(context, "slack-bot", linkUrl, chatUrl)
+ const templated = renderTitleBody(
+ context,
+ "slack-bot",
+ linkUrl,
+ chatUrl,
+ ["slack"],
+ )
const blocks = templated
? buildSlackBlocksFromTemplate(
templated.title,
diff --git a/apps/api/src/services/AlertsService.test.ts b/apps/api/src/services/AlertsService.test.ts
index dd90f3128..17bb3ef92 100644
--- a/apps/api/src/services/AlertsService.test.ts
+++ b/apps/api/src/services/AlertsService.test.ts
@@ -656,6 +656,75 @@ describe("AlertsService", () => {
)
})
+ it.effect("recompiles an explicitly invalidated migrated metric plan from its builder draft", () => {
+ const testDb = createTestDb(trackedDbs)
+
+ return Effect.gen(function* () {
+ yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS)
+ const alerts = yield* AlertsService
+ const orgId = asOrgId("org_migrated_metric_plan")
+ const userId = asUserId("user_migrated_metric_plan")
+ const destination = yield* createWebhookDestination(alerts, orgId, userId)
+ const rule = yield* alerts.createRule(
+ orgId,
+ userId,
+ adminRoles,
+ new AlertRuleUpsertRequest({
+ name: "Migrated request rate",
+ severity: "warning",
+ enabled: true,
+ signalType: "builder_query",
+ queryBuilderDraft: {
+ id: "alert-query",
+ name: "A",
+ dataSource: "metrics",
+ signalSource: "default",
+ metricName: "http.requests",
+ metricType: "sum",
+ isMonotonic: true,
+ aggregation: "rate",
+ whereClause:
+ 'service.name = "api,checkout" AND deployment.environment = "prod,staging"',
+ addOns: {
+ groupBy: true,
+ having: false,
+ orderBy: false,
+ limit: false,
+ legend: false,
+ },
+ groupBy: ["service.name"],
+ },
+ comparator: "gt",
+ threshold: 10,
+ windowMinutes: 5,
+ destinationIds: [destination.id],
+ }),
+ )
+
+ // Migration 0026 writes this null marker after replacing the legacy
+ // metric draft. A stale non-null plan must never be used.
+ yield* Effect.promise(() =>
+ executeSql(
+ testDb,
+ `update alert_rules
+ set query_spec_json = null, reducer = 'identity', sample_count_strategy = 'metric_data_points'
+ where id = $1`,
+ [rule.id],
+ ),
+ )
+
+ const tick = yield* alerts.runSchedulerTick()
+ assert.strictEqual(tick.evaluatedCount, 1)
+ assert.strictEqual(tick.evaluationFailureCount, 0)
+ }).pipe(
+ Effect.provide(
+ makeLayer(testDb, makeWarehouseStub({ metricsAggregateRows: emptyWarehouseRows }), {
+ fetch: okFetch,
+ }),
+ ),
+ )
+ })
+
it.effect("lowers the environment scope into a built-in signal's compiled plan", () => {
const testDb = createTestDb(trackedDbs)
diff --git a/apps/api/src/services/AlertsService.ts b/apps/api/src/services/AlertsService.ts
index 23ba173ba..51547a59c 100644
--- a/apps/api/src/services/AlertsService.ts
+++ b/apps/api/src/services/AlertsService.ts
@@ -43,6 +43,7 @@ import {
AlertRulesListResponse,
AlertSeverity as AlertSeveritySchema,
AlertSignalType as AlertSignalTypeSchema,
+ HistoricalAlertSignalType as HistoricalAlertSignalTypeSchema,
AlertValidationError,
QueryBuilderQueryDraftSchema,
AlertNotificationTemplate,
@@ -56,6 +57,7 @@ import {
type QueryBuilderQueryDraftPayload,
type AlertSeverity,
type AlertSignalType,
+ type HistoricalAlertSignalType,
type AlertGroupBy,
UNGROUPED_GROUP_KEY,
OrgId,
@@ -197,7 +199,7 @@ interface DispatchContext {
readonly ruleId: AlertRuleId
readonly ruleName: string
readonly groupKey: string | null
- readonly signalType: AlertSignalType
+ readonly signalType: HistoricalAlertSignalType
readonly severity: AlertSeverity
readonly comparator: AlertComparator
readonly threshold: number
@@ -228,7 +230,7 @@ interface DeliveryPayloadContext {
readonly ruleId: AlertRuleId
readonly ruleName: string
readonly groupKey: string | null
- readonly signalType: AlertSignalType
+ readonly signalType: HistoricalAlertSignalType
readonly severity: AlertSeverity
readonly comparator: AlertComparator
readonly threshold: number
@@ -306,6 +308,7 @@ const decodeUserIdSync = Schema.decodeUnknownSync(UserIdSchema)
const decodeAlertDestinationTypeSync = Schema.decodeUnknownSync(AlertDestinationTypeSchema)
const decodeAlertSeveritySync = Schema.decodeUnknownSync(AlertSeveritySchema)
const decodeAlertSignalTypeSync = Schema.decodeUnknownSync(AlertSignalTypeSchema)
+const decodeHistoricalAlertSignalTypeSync = Schema.decodeUnknownSync(HistoricalAlertSignalTypeSchema)
const decodeAlertComparatorSync = Schema.decodeUnknownSync(AlertComparatorSchema)
const decodeAlertCheckStatusSync = Schema.decodeUnknownSync(AlertCheckStatusSchema)
const decodeAlertIncidentTransitionSync = Schema.decodeUnknownSync(AlertIncidentTransitionSchema)
@@ -982,7 +985,7 @@ const rowToIncidentDocument = (row: AlertIncidentRow) =>
ruleId: decodeAlertRuleIdSync(row.ruleId),
ruleName: row.ruleName,
groupKey: row.groupKey,
- signalType: decodeAlertSignalTypeSync(row.signalType),
+ signalType: decodeHistoricalAlertSignalTypeSync(row.signalType),
severity: decodeAlertSeveritySync(row.severity),
status: decodeAlertIncidentStatusSync(row.status),
comparator: decodeAlertComparatorSync(row.comparator),
@@ -1137,6 +1140,7 @@ export interface AlertsServiceShape {
) => Effect.Effect
readonly listDeliveryEvents: (
orgId: OrgId,
+ options?: ListAlertDeliveryEventsOptions,
) => Effect.Effect
readonly runSchedulerTick: () => Effect.Effect<
{
@@ -1186,6 +1190,11 @@ export interface ListAlertIncidentsOptions {
readonly offset?: number
}
+export interface ListAlertDeliveryEventsOptions {
+ readonly limit?: number
+ readonly offset?: number
+}
+
export class AlertsService extends Context.Service()(
"@maple/api/services/AlertsService",
{
@@ -1334,20 +1343,47 @@ export class AlertsService extends Context.Service {
const serviceNames = serviceNamesFromRow(row)
+ const serviceName = serviceNames.length === 1 ? serviceNames[0] : null
+ const environments = environmentsFromRow(row)
+ const groupBy = parseStoredGroupBy(row.groupBy)
+ const signalType = decodeAlertSignalTypeSync(row.signalType)
+ const comparator = decodeAlertComparatorSync(row.comparator)
+ const queryBuilderDraft = parseStoredQueryBuilderDraft(row.queryBuilderDraftJson)
+ const rawQuerySql = row.rawQuerySql ?? null
+ const rawQueryReducer =
+ row.signalType === "raw_query" ? decodeQueryEngineAlertReducerSync(row.reducer) : null
+ // Migration 0026 invalidates legacy metric plans after translating them
+ // into builder drafts. Recompile only that explicit null-plan case;
+ // ordinary stored plans remain the evaluation authority.
+ const compiledPlan =
+ signalType === "builder_query" && row.querySpecJson == null
+ ? yield* compileRulePlan({
+ signalType,
+ serviceName,
+ environments,
+ apdexThresholdMs: row.apdexThresholdMs,
+ queryBuilderDraft,
+ rawQuerySql,
+ rawQueryReducer,
+ comparator,
+ windowMinutes: row.windowMinutes,
+ groupBy,
+ })
+ : yield* parseCompiledPlan(row)
return {
id: decodeAlertRuleIdSync(row.id),
name: row.name,
notificationTemplate: parseStoredNotificationTemplate(row.notificationTemplateJson),
enabled: row.enabled,
severity: decodeAlertSeveritySync(row.severity),
- serviceName: serviceNames.length === 1 ? serviceNames[0] : null,
+ serviceName,
serviceNames,
excludeServiceNames: excludeServiceNamesFromRow(row),
- environments: environmentsFromRow(row),
+ environments,
tags: tagsFromRow(row),
- groupBy: parseStoredGroupBy(row.groupBy),
- signalType: decodeAlertSignalTypeSync(row.signalType),
- comparator: decodeAlertComparatorSync(row.comparator),
+ groupBy,
+ signalType,
+ comparator,
threshold: row.threshold,
thresholdUpper: row.thresholdUpper,
windowMinutes: row.windowMinutes,
@@ -1356,14 +1392,11 @@ export class AlertsService extends Context.Service
db
.select()
.from(alertDeliveryEvents)
.where(eq(alertDeliveryEvents.orgId, orgId))
- .orderBy(desc(alertDeliveryEvents.createdAt))
- .limit(100),
+ .orderBy(desc(alertDeliveryEvents.createdAt), desc(alertDeliveryEvents.id))
+ .limit(options.limit ?? 100)
+ .offset(options.offset ?? 0),
)
const destinationRows = yield* dbExecute((db) =>
@@ -3647,7 +3682,7 @@ export class AlertsService extends Context.Service {
expect(resolveTemplate(config, "discord")).toEqual({ title: "top title", body: "top body" })
})
+ it("uses a legacy override alias after the current destination key", () => {
+ expect(resolveTemplate(config, "slack-bot", ["slack"])).toEqual({
+ title: "top title",
+ body: "slack body",
+ })
+ expect(
+ resolveTemplate(
+ {
+ ...config,
+ overrides: {
+ slack: { body: "legacy body" },
+ "slack-bot": { body: "current body" },
+ },
+ },
+ "slack-bot",
+ ["slack"],
+ ),
+ ).toEqual({ title: "top title", body: "current body" })
+ })
+
it("treats blank strings as unset (→ null, i.e. built-in default)", () => {
expect(resolveTemplate({ title: " ", body: "" }, "slack")).toEqual({
title: null,
diff --git a/apps/api/src/services/alert-templating/renderer.ts b/apps/api/src/services/alert-templating/renderer.ts
index 7ab7d1ad3..60fb7c1b4 100644
--- a/apps/api/src/services/alert-templating/renderer.ts
+++ b/apps/api/src/services/alert-templating/renderer.ts
@@ -87,12 +87,15 @@ const firstNonEmpty = (...values: ReadonlyArray): str
export const resolveTemplate = (
config: NotificationTemplateConfig | null | undefined,
destinationType: string,
+ legacyDestinationTypes: ReadonlyArray = [],
): ResolvedTemplate => {
if (config == null) return { title: null, body: null }
- const override = config.overrides?.[destinationType]
+ const overrides = [destinationType, ...legacyDestinationTypes].map(
+ (type) => config.overrides?.[type],
+ )
return {
- title: firstNonEmpty(override?.title, config.title),
- body: firstNonEmpty(override?.body, config.body),
+ title: firstNonEmpty(...overrides.map((override) => override?.title), config.title),
+ body: firstNonEmpty(...overrides.map((override) => override?.body), config.body),
}
}
diff --git a/apps/web/src/components/alerts/alert-create-page-content.test.ts b/apps/web/src/components/alerts/alert-create-page-content.test.ts
index 3a2613b4c..5059aecd2 100644
--- a/apps/web/src/components/alerts/alert-create-page-content.test.ts
+++ b/apps/web/src/components/alerts/alert-create-page-content.test.ts
@@ -18,6 +18,7 @@ describe("deriveInitialRuleDraft — template deep link", () => {
search: { template: "low_apdex" },
chartContext: undefined,
rulesResult: loading,
+ dashboardsResult: loading,
})
expect(draft.form.signalType).toBe("apdex")
@@ -34,6 +35,7 @@ describe("deriveInitialRuleDraft — template deep link", () => {
search: { template: "not-a-real-template" },
chartContext: undefined,
rulesResult: loading,
+ dashboardsResult: loading,
})
expect(draft.form.signalType).toBe("error_rate")
@@ -48,6 +50,7 @@ describe("deriveInitialRuleDraft — template deep link", () => {
search: { chart: "not-a-snapshot" },
chartContext: undefined,
rulesResult: loading,
+ dashboardsResult: loading,
})
expect(draft.form.signalType).toBe("error_rate")
diff --git a/apps/web/src/components/alerts/alert-create-page-content.tsx b/apps/web/src/components/alerts/alert-create-page-content.tsx
index 573ee7e67..1cd65823b 100644
--- a/apps/web/src/components/alerts/alert-create-page-content.tsx
+++ b/apps/web/src/components/alerts/alert-create-page-content.tsx
@@ -2,6 +2,7 @@ import { useSearch } from "@tanstack/react-router"
import { useMemo } from "react"
import type { AlertDestinationDocument, AlertRuleDocument } from "@maple/domain/http"
+import type { Dashboard } from "@/components/dashboard-builder/types"
import { Skeleton } from "@maple/ui/components/ui/skeleton"
import { cn } from "@maple/ui/utils"
@@ -13,13 +14,20 @@ import { useAutocompleteValuesContext } from "@/hooks/use-autocomplete-values"
import { defaultRuleForm, ruleToFormState, type RuleFormState } from "@/lib/alerts/form-utils"
import { ALERT_TEMPLATES, applyTemplate } from "@/lib/alerts/templates"
import { decodeAlertChartFromSearchParam, type AlertChartContext } from "@/lib/alerts/widget-chart-param"
-import { createWidgetAlertPrefill, type WidgetAlertPrefillNotice } from "@/lib/alerts/widget-prefill"
+import {
+ createWidgetAlertPrefill,
+ resolveWidgetAlertPrefill,
+ type WidgetAlertPrefillNotice,
+} from "@/lib/alerts/widget-prefill"
import { useAlertDestinationsList, useAlertRulesList } from "@/hooks/use-alerts-list"
import { Result } from "@/lib/effect-atom"
+import { useDashboardsRead } from "@/hooks/use-dashboard-store"
type AlertCreateSearchValue = {
serviceName?: string
ruleId?: string
+ dashboardId?: string
+ widgetId?: string
chart?: string
template?: string
}
@@ -31,9 +39,9 @@ type InitialRuleDraft = {
editingRule: AlertRuleDocument | null
showTemplatesInitially: boolean
/**
- * The draft is a placeholder while the real rule is loading. The form must
- * not be shown yet — `form` is a blank default that would read as "Create
- * alert rule" until the fetch resolves and the `key` change remounts it.
+ * The draft is a placeholder while the real rule or dashboard source is
+ * loading. The form must not be shown yet — `form` is a blank default that
+ * would read as "Create alert rule" until the fetch resolves.
*/
loading?: boolean
}
@@ -45,9 +53,17 @@ export function AlertCreatePageContent() {
() => (search.chart ? decodeAlertChartFromSearchParam(search.chart) : undefined),
[search.chart],
)
+ const needsDashboards =
+ !search.ruleId && chartContext == null && Boolean(search.dashboardId || search.widgetId)
const { result: destinationsResult } = useAlertDestinationsList()
const { result: rulesResult } = useAlertRulesList()
+ const { dashboards, isLoading: dashboardsLoading, isError: dashboardsError } = useDashboardsRead()
+ const dashboardsResult = useMemo(() => {
+ if (!needsDashboards || dashboardsLoading) return Result.initial(dashboardsLoading)
+ if (dashboardsError) return Result.fail(new Error("Dashboard sync failed"))
+ return Result.success({ dashboards })
+ }, [needsDashboards, dashboardsLoading, dashboardsError, dashboards])
const autocompleteValues = useAutocompleteValuesContext()
const serviceNameOptions = autocompleteValues.traces.services ?? []
@@ -65,8 +81,9 @@ export function AlertCreatePageContent() {
search,
chartContext,
rulesResult,
+ dashboardsResult,
}),
- [search, chartContext, rulesResult],
+ [search, chartContext, rulesResult, dashboardsResult],
)
// Showing the blank default form here would paint a "Create alert rule" page
@@ -91,9 +108,8 @@ export function AlertCreatePageContent() {
}
/**
- * Placeholder shown while an existing rule loads. Mirrors the real surface's
- * chrome — same breadcrumb, same title, same two-column grid — so resolving the
- * fetch swaps content into a page that is already the right shape.
+ * Placeholder shown while an existing rule or dashboard widget source loads.
+ * It mirrors the real surface so resolving the fetch swaps into the right shape.
*/
function AlertRuleFormSkeleton({ editing }: { editing: boolean }) {
return (
@@ -129,10 +145,12 @@ export function deriveInitialRuleDraft({
search,
chartContext,
rulesResult,
+ dashboardsResult,
}: {
search: AlertCreateSearchValue
chartContext: AlertChartContext | undefined
rulesResult: Result.Result<{ rules: readonly AlertRuleDocument[] }, unknown>
+ dashboardsResult: Result.Result<{ dashboards: readonly Dashboard[] }, unknown>
}): InitialRuleDraft {
const base = defaultRuleForm(search.serviceName)
@@ -171,8 +189,8 @@ export function deriveInitialRuleDraft({
}
}
- // Snapshot carried through navigation: synchronous and immune to the
- // dashboard autosave race.
+ // Snapshot carried through navigation — synchronous and immune to the
+ // dashboard autosave race. Invalid snapshots fall through to id lookup.
if (chartContext) {
const result = createWidgetAlertPrefill(chartContext.widget, base)
return {
@@ -184,6 +202,61 @@ export function deriveInitialRuleDraft({
}
}
+ if (search.dashboardId || search.widgetId) {
+ if (!search.dashboardId || !search.widgetId) {
+ const result = resolveWidgetAlertPrefill({
+ dashboards: [],
+ dashboardId: search.dashboardId,
+ widgetId: search.widgetId,
+ base,
+ })
+ return {
+ key: `missing-chart-source:${search.dashboardId ?? "dashboard"}:${search.widgetId ?? "widget"}`,
+ form: result.form,
+ prefillNotices: result.notices,
+ editingRule: null,
+ showTemplatesInitially: false,
+ }
+ }
+ if (Result.isSuccess(dashboardsResult)) {
+ const result = resolveWidgetAlertPrefill({
+ dashboards: dashboardsResult.value.dashboards,
+ dashboardId: search.dashboardId,
+ widgetId: search.widgetId,
+ base,
+ })
+ return {
+ key: `dashboard:${search.dashboardId}:widget:${search.widgetId}`,
+ form: result.form,
+ prefillNotices: result.notices,
+ editingRule: null,
+ showTemplatesInitially: false,
+ }
+ }
+ if (Result.isFailure(dashboardsResult)) {
+ return {
+ key: `dashboard-load-failed:${search.dashboardId}:${search.widgetId}`,
+ form: base,
+ prefillNotices: [
+ {
+ severity: "warning",
+ message: "Dashboards could not be loaded. Starting from a blank alert.",
+ },
+ ],
+ editingRule: null,
+ showTemplatesInitially: false,
+ }
+ }
+ return {
+ key: `loading-dashboard:${search.dashboardId}:${search.widgetId}`,
+ form: base,
+ prefillNotices: [],
+ editingRule: null,
+ showTemplatesInitially: false,
+ loading: true,
+ }
+ }
+
if (search.chart) {
return {
key: "invalid-chart-snapshot",
diff --git a/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx b/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx
index 121df0c4b..bb7e22168 100644
--- a/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx
+++ b/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx
@@ -74,7 +74,8 @@ export function WidgetActionsProvider({ widget, dataState, children }: WidgetAct
dashboardId && alertable
? () => {
// Carry the live widget (optimistic builder state) so the alert
- // page prefills without racing dashboard autosave.
+ // page prefills without racing dashboard autosave. The ids remain
+ // as the lookup fallback when the snapshot exceeds URL limits.
const chart = encodeAlertChartToSearchParam({
dashboardId,
widget: {
@@ -90,7 +91,11 @@ export function WidgetActionsProvider({ widget, dataState, children }: WidgetAct
})
navigate({
to: "/alerts/create",
- search: chart ? { chart } : {},
+ search: {
+ dashboardId,
+ widgetId: widget.id,
+ ...(chart ? { chart } : {}),
+ },
})
}
: undefined
diff --git a/apps/web/src/lib/alerts/form-utils.ts b/apps/web/src/lib/alerts/form-utils.ts
index 9cd51e3be..4559b1063 100644
--- a/apps/web/src/lib/alerts/form-utils.ts
+++ b/apps/web/src/lib/alerts/form-utils.ts
@@ -18,6 +18,7 @@ import {
type AlertDestinationType,
type AlertEventType,
type AlertSeverity,
+ type HistoricalAlertSignalType,
type AlertSignalType,
type QueryBuilderQueryDraftPayload,
} from "@maple/domain/http"
@@ -167,7 +168,7 @@ export function getExitErrorMessage(exit: Exit.Exit, fallback:
return fallback
}
-export function formatSignalValue(signalType: AlertSignalType, value: number | null): string {
+export function formatSignalValue(signalType: HistoricalAlertSignalType, value: number | null): string {
if (value == null || Number.isNaN(value)) return "n/a"
switch (signalType) {
@@ -179,6 +180,7 @@ export function formatSignalValue(signalType: AlertSignalType, value: number | n
case "apdex":
return value.toFixed(3)
case "throughput":
+ case "metric":
case "builder_query":
case "raw_query":
return formatNumber(value)
diff --git a/apps/web/src/lib/alerts/widget-chart-param.ts b/apps/web/src/lib/alerts/widget-chart-param.ts
index e78af468c..58145f99a 100644
--- a/apps/web/src/lib/alerts/widget-chart-param.ts
+++ b/apps/web/src/lib/alerts/widget-chart-param.ts
@@ -34,9 +34,7 @@ export type AlertChartContext = Schema.Schema.Type = {}) {
return {
@@ -158,3 +158,46 @@ describe("createWidgetAlertPrefill", () => {
)
})
})
+
+describe("resolveWidgetAlertPrefill", () => {
+ it("reloads the source widget when an oversized snapshot falls back to ids", () => {
+ const result = resolveWidgetAlertPrefill({
+ dashboards: [
+ {
+ id: "dash",
+ widgets: [
+ {
+ id: "w1",
+ dataSource: {
+ endpoint: "raw_sql_chart",
+ params: { sql: "SELECT count() AS value FROM traces WHERE $__orgFilter" },
+ },
+ },
+ ],
+ },
+ ],
+ dashboardId: "dash",
+ widgetId: "w1",
+ base: defaultRuleForm(),
+ })
+
+ expect(result.form.signalType).toBe("raw_query")
+ expect(result.form.rawQuerySql).toContain("$__orgFilter")
+ })
+
+ it("returns a clear notice when either fallback id is missing", () => {
+ const missingDashboard = resolveWidgetAlertPrefill({
+ dashboards: [],
+ widgetId: "w1",
+ base: defaultRuleForm(),
+ })
+ const missingWidget = resolveWidgetAlertPrefill({
+ dashboards: [],
+ dashboardId: "dash",
+ base: defaultRuleForm(),
+ })
+
+ expect(missingDashboard.notices[0]?.message).toContain("dashboard id was missing")
+ expect(missingWidget.notices[0]?.message).toContain("chart id was missing")
+ })
+})
diff --git a/apps/web/src/lib/alerts/widget-prefill.ts b/apps/web/src/lib/alerts/widget-prefill.ts
index 10b00803f..68b954484 100644
--- a/apps/web/src/lib/alerts/widget-prefill.ts
+++ b/apps/web/src/lib/alerts/widget-prefill.ts
@@ -24,6 +24,11 @@ type AlertableDashboardWidget = {
display?: { title?: string }
}
+type DashboardWithWidgets = {
+ id: string
+ widgets: readonly AlertableDashboardWidget[]
+}
+
const QUERY_BUILDER_ENDPOINTS = new Set([
"custom_query_builder_timeseries",
"custom_query_builder_breakdown",
@@ -222,3 +227,66 @@ export function createWidgetAlertPrefill(
],
}
}
+
+export function resolveWidgetAlertPrefill({
+ dashboards,
+ dashboardId,
+ widgetId,
+ base,
+}: {
+ dashboards: readonly DashboardWithWidgets[]
+ dashboardId?: string
+ widgetId?: string
+ base: RuleFormState
+}): WidgetAlertPrefillResult {
+ if (!dashboardId) {
+ return {
+ form: base,
+ notices: [
+ {
+ severity: "warning",
+ message: "The source dashboard id was missing. Starting from a blank alert.",
+ },
+ ],
+ }
+ }
+ if (!widgetId) {
+ return {
+ form: base,
+ notices: [
+ {
+ severity: "warning",
+ message: "The source chart id was missing. Starting from a blank alert.",
+ },
+ ],
+ }
+ }
+
+ const dashboard = dashboards.find((candidate) => candidate.id === dashboardId)
+ if (!dashboard) {
+ return {
+ form: base,
+ notices: [
+ {
+ severity: "warning",
+ message: "The source dashboard could not be found. Starting from a blank alert.",
+ },
+ ],
+ }
+ }
+
+ const widget = dashboard.widgets.find((candidate) => candidate.id === widgetId)
+ if (!widget) {
+ return {
+ form: base,
+ notices: [
+ {
+ severity: "warning",
+ message: "The source chart could not be found. Starting from a blank alert.",
+ },
+ ],
+ }
+ }
+
+ return createWidgetAlertPrefill(widget, base)
+}
diff --git a/apps/web/src/lib/collections/alerts.ts b/apps/web/src/lib/collections/alerts.ts
index 3164fd764..3ac968b89 100644
--- a/apps/web/src/lib/collections/alerts.ts
+++ b/apps/web/src/lib/collections/alerts.ts
@@ -5,6 +5,7 @@ import {
AlertDestinationType,
AlertEventType,
AlertGroupBy,
+ HistoricalAlertSignalType,
AlertIncidentDocument,
AlertIncidentStatus,
AlertNotificationTemplate,
@@ -32,6 +33,7 @@ const asDestinationType = Schema.decodeUnknownSync(AlertDestinationType)
const asSeverity = Schema.decodeUnknownSync(AlertSeverity)
const asSignalType = Schema.decodeUnknownSync(AlertSignalType)
+const asHistoricalSignalType = Schema.decodeUnknownSync(HistoricalAlertSignalType)
const asComparator = Schema.decodeUnknownSync(AlertComparator)
const asIncidentStatus = Schema.decodeUnknownSync(AlertIncidentStatus)
const asEventType = Schema.decodeUnknownSync(AlertEventType)
@@ -239,7 +241,7 @@ export const rowToAlertIncidentDocument = (row: AlertIncidentRow): AlertIncident
ruleId: asAlertRuleId(row.rule_id),
ruleName: row.rule_name,
groupKey: row.group_key,
- signalType: asSignalType(row.signal_type),
+ signalType: asHistoricalSignalType(row.signal_type),
severity: asSeverity(row.severity),
status: asIncidentStatus(row.status),
comparator: asComparator(row.comparator),
diff --git a/apps/web/src/routes/alerts/create.tsx b/apps/web/src/routes/alerts/create.tsx
index 666312787..16c42a83a 100644
--- a/apps/web/src/routes/alerts/create.tsx
+++ b/apps/web/src/routes/alerts/create.tsx
@@ -8,9 +8,13 @@ const AlertCreateSearch = Schema.Struct({
ruleId: Schema.optional(Schema.String),
/** Starter-template id from the overview empty state — pre-applies that preset. */
template: Schema.optional(Schema.String),
+ /** Dashboard/widget lookup fallback when the live chart snapshot is too large. */
+ dashboardId: Schema.optional(Schema.String),
+ widgetId: Schema.optional(Schema.String),
/**
* Base64url-encoded snapshot of the source widget (see widget-chart-param.ts).
- * Carries the live builder state so prefill doesn't race dashboard autosave.
+ * Carries the live builder state so prefill doesn't race dashboard autosave;
+ * dashboardId/widgetId remain as the oversized-payload fallback.
*/
chart: Schema.optional(Schema.String),
})
diff --git a/apps/web/src/routes/alerts/index.tsx b/apps/web/src/routes/alerts/index.tsx
index 37d81d0a1..bf3e65640 100644
--- a/apps/web/src/routes/alerts/index.tsx
+++ b/apps/web/src/routes/alerts/index.tsx
@@ -15,7 +15,11 @@ import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs"
type AlertsTab = "overview" | "settings"
const AlertsSearch = Schema.Struct({
- tab: Schema.optional(Schema.Literals(["overview", "settings"])),
+ /**
+ * Accept any string so legacy deep links (`tab=monitor`, `tab=rules`) still
+ * resolve; the component normalizes every value except settings to overview.
+ */
+ tab: Schema.optional(Schema.String),
serviceName: Schema.optional(Schema.String),
createdBy: Schema.optional(Schema.String),
/** Health-summary filter over the rules list. */
diff --git a/packages/db/drizzle/0026_windy_bromley.sql b/packages/db/drizzle/0026_windy_bromley.sql
index b52dd443b..36274520c 100644
--- a/packages/db/drizzle/0026_windy_bromley.sql
+++ b/packages/db/drizzle/0026_windy_bromley.sql
@@ -62,6 +62,12 @@ SET
'legend', ''
),
"signal_type" = 'builder_query',
+ -- The legacy compiled metric plan does not contain the draft's new
+ -- multi-service/grouping semantics. Null explicitly marks it for the API's
+ -- builder-query recompilation fallback on first read/evaluation.
+ "query_spec_json" = NULL,
+ "reducer" = 'identity',
+ "sample_count_strategy" = 'metric_data_points',
"service_names_json" = NULL,
"environments_json" = NULL,
"group_by" = NULL
diff --git a/packages/db/src/external-columns.test.ts b/packages/db/src/external-columns.test.ts
new file mode 100644
index 000000000..cfa083473
--- /dev/null
+++ b/packages/db/src/external-columns.test.ts
@@ -0,0 +1,109 @@
+import { PGlite } from "@electric-sql/pglite"
+import { describe, expect, it } from "vitest"
+import { readBundledMigrationsSql } from "./migrate"
+
+/**
+ * Columns consumed by an external, read-only system.
+ *
+ * That system reads this database directly and lives outside this repository,
+ * so a rename or drop here is invisible to it until it breaks at runtime — and
+ * because it reads on a background schedule, the failure surfaces long after
+ * the deploy that caused it. This test is the only mechanism that catches such
+ * a change *in the PR that makes it*.
+ *
+ * If a change here is intentional, update this list in the same PR and treat it
+ * as a coordinated change: the consumer has to ship its side first. Adding a
+ * column is always safe; renaming, dropping, or changing the type of one listed
+ * below is not.
+ *
+ * Scope is deliberately narrow — only columns actually read. Do not add a table
+ * here "for completeness"; every entry is a constraint on future migrations.
+ */
+const EXTERNALLY_CONSUMED: Readonly>> = {
+ // Lifecycle state. The four `*_email_sent_at` columns are listed because the
+ // consumer's one-time backfill reads them; they can be dropped once that
+ // backfill is retired, but not before.
+ org_onboarding_state: [
+ "org_id",
+ "user_id",
+ "email",
+ "created_at",
+ "first_data_received_at",
+ "onboarding_completed_at",
+ "welcome_email_sent_at",
+ "connect_nudge_email_sent_at",
+ "stalled_email_sent_at",
+ "activation_email_sent_at",
+ ],
+ // The only per-user contact record with a delivery preference.
+ digest_subscriptions: ["org_id", "user_id", "email", "enabled", "last_sent_at"],
+ // Revenue proxy + churn/expansion triggers, refreshed hourly by a cron.
+ org_spend_limits: [
+ "org_id",
+ "monthly_limit_cents",
+ "evaluated_spend_cents",
+ "breached_at",
+ "paused_at",
+ "last_evaluated_at",
+ ],
+ // Integration breadth — read as booleans ("has this org connected X"), so
+ // only the join key matters.
+ slack_workspaces: ["org_id"],
+ planetscale_connections: ["org_id"],
+ oauth_connections: ["org_id", "provider"],
+ dashboards: ["org_id"],
+ alert_rules: ["org_id"],
+}
+
+interface ColumnRow {
+ readonly table_name: string
+ readonly column_name: string
+}
+
+/**
+ * One PGlite boot for the whole suite — replaying every migration is ~5s, well
+ * over vitest's default timeout, so this mirrors the 30s bound in
+ * migrations.test.ts rather than booting per assertion.
+ */
+describe("externally consumed columns", () => {
+ it("still exist after every migration is applied", async () => {
+ const pg = new PGlite()
+ await pg.exec(readBundledMigrationsSql())
+
+ const tables = Object.keys(EXTERNALLY_CONSUMED)
+ const result = await pg.query(
+ `select table_name, column_name
+ from information_schema.columns
+ where table_schema = 'public' and table_name = any($1)`,
+ [tables],
+ )
+
+ const present = new Map>()
+ for (const row of result.rows) {
+ const columns = present.get(row.table_name) ?? new Set()
+ columns.add(row.column_name)
+ present.set(row.table_name, columns)
+ }
+
+ // Collect every missing column before failing, so a rename that touches
+ // several tables reports all of them in one run instead of one per fix.
+ const missing: string[] = []
+ for (const [table, columns] of Object.entries(EXTERNALLY_CONSUMED)) {
+ const actual = present.get(table)
+ if (!actual) {
+ missing.push(`${table} (table missing entirely)`)
+ continue
+ }
+ for (const column of columns) {
+ if (!actual.has(column)) missing.push(`${table}.${column}`)
+ }
+ }
+
+ expect(
+ missing,
+ `Missing columns consumed by an external read-only system:\n ${missing.join("\n ")}\n\n` +
+ "If this rename or drop is intentional, the external consumer must ship its side\n" +
+ "first, then this list is updated in the same PR as the migration.",
+ ).toEqual([])
+ }, 30_000)
+})
diff --git a/packages/db/src/migrations.test.ts b/packages/db/src/migrations.test.ts
index ed4b0fdd6..555d02aef 100644
--- a/packages/db/src/migrations.test.ts
+++ b/packages/db/src/migrations.test.ts
@@ -107,7 +107,7 @@ describe("bundled migrations", () => {
}
}, 30_000)
- it("converts metric rules and removes retired destinations without runtime compatibility", async () => {
+ it("converts metric rules, invalidates stale plans, and removes retired destinations", async () => {
const pg = new PGlite()
await pg.exec(readMigrationSqlBefore("0026_windy_bromley"))
@@ -152,7 +152,9 @@ describe("bundled migrations", () => {
environments_json: unknown
group_by: string | null
destination_ids_json: string[]
- query_spec_json: { preserved?: boolean }
+ query_spec_json: unknown
+ reducer: string
+ sample_count_strategy: string | null
query_builder_draft_json: {
whereClause: string
groupBy: string[]
@@ -165,7 +167,9 @@ describe("bundled migrations", () => {
environments_json: null,
group_by: null,
destination_ids_json: ["dest_webhook"],
- query_spec_json: { preserved: true },
+ query_spec_json: null,
+ reducer: "identity",
+ sample_count_strategy: "metric_data_points",
query_builder_draft_json: {
whereClause: 'service.name = "api,checkout" AND deployment.environment = "prod,staging"',
groupBy: ["service.name"],
diff --git a/packages/domain/src/http/alerts.test.ts b/packages/domain/src/http/alerts.test.ts
index 15ab3db11..cdafd5856 100644
--- a/packages/domain/src/http/alerts.test.ts
+++ b/packages/domain/src/http/alerts.test.ts
@@ -4,11 +4,20 @@ import {
AlertDestinationCreateRequest,
AlertNotificationTemplate,
AlertRuleUpsertRequest,
+ AlertSignalType,
+ HistoricalAlertSignalType,
PagerDutyAlertDestinationConfig,
SlackBotAlertDestinationConfig,
WebhookAlertDestinationConfig,
} from "./alerts"
+describe("alert signal compatibility", () => {
+ it("keeps metric readable in history while rejecting it for new rules", () => {
+ expect(Exit.isFailure(Schema.decodeUnknownExit(AlertSignalType)("metric"))).toBe(true)
+ expect(Schema.decodeUnknownSync(HistoricalAlertSignalType)("metric")).toBe("metric")
+ })
+})
+
describe("AlertDestinationCreateRequest", () => {
const encode = Schema.encodeUnknownSync(AlertDestinationCreateRequest)
diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts
index 28f6bd144..2ab71d49e 100644
--- a/packages/domain/src/http/alerts.ts
+++ b/packages/domain/src/http/alerts.ts
@@ -48,6 +48,20 @@ export const AlertSignalType = Schema.Literals([
})
export type AlertSignalType = Schema.Schema.Type
+/**
+ * Signal values that may still exist in immutable alert history or queued
+ * delivery payloads. New and updated rules intentionally use
+ * {@link AlertSignalType}, which excludes the retired `metric` rule shape.
+ */
+export const HistoricalAlertSignalType = Schema.Union([
+ AlertSignalType,
+ Schema.Literal("metric"),
+]).annotate({
+ identifier: "@maple/HistoricalAlertSignalType",
+ title: "Historical Alert Signal Type",
+})
+export type HistoricalAlertSignalType = Schema.Schema.Type
+
export const AlertGroupByDimension = Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed()).annotate({
identifier: "@maple/AlertGroupByDimension",
title: "Alert Group By Dimension",
@@ -586,7 +600,7 @@ export class AlertIncidentDocument extends Schema.Class("
ruleId: AlertRuleId,
ruleName: Schema.String,
groupKey: Schema.NullOr(Schema.String),
- signalType: AlertSignalType,
+ signalType: HistoricalAlertSignalType,
severity: AlertSeverity,
status: AlertIncidentStatus,
comparator: AlertComparator,
@@ -712,7 +726,7 @@ export class AlertCheckDocument extends Schema.Class("AlertC
timestamp: IsoDateTimeString,
groupKey: Schema.String,
status: AlertCheckStatus,
- signalType: AlertSignalType,
+ signalType: HistoricalAlertSignalType,
comparator: AlertComparator,
threshold: Schema.Number,
thresholdUpper: Schema.NullOr(Schema.Number),
diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts
index e16499148..2d682d10c 100644
--- a/packages/domain/src/http/v2/alert-incidents.ts
+++ b/packages/domain/src/http/v2/alert-incidents.ts
@@ -5,7 +5,7 @@ import {
AlertEventType,
AlertIncidentStatus,
AlertSeverity,
- AlertSignalType,
+ HistoricalAlertSignalType,
} from "../alerts"
import { AuthorizationV2, V2SchemaErrors } from "./auth"
import { ListOf, ListQuery, Timestamp } from "./envelopes"
@@ -56,7 +56,7 @@ export const V2AlertIncident = Schema.Struct({
group_key: Schema.NullOr(Schema.String).annotate({
description: "The breaching group for grouped rules, or `null` for ungrouped rules.",
}),
- signal_type: AlertSignalType.annotate({
+ signal_type: HistoricalAlertSignalType.annotate({
description: "The signal the rule measures.",
examples: ["error_rate"],
}),
diff --git a/packages/domain/src/http/v2/alert-rules.ts b/packages/domain/src/http/v2/alert-rules.ts
index 0be939aa6..185c7dad3 100644
--- a/packages/domain/src/http/v2/alert-rules.ts
+++ b/packages/domain/src/http/v2/alert-rules.ts
@@ -7,6 +7,7 @@ import {
AlertComparator,
AlertEvaluationStatus,
AlertIncidentTransition,
+ HistoricalAlertSignalType,
AlertNotificationTemplate,
AlertSeverity,
AlertSignalType,
@@ -538,7 +539,7 @@ export const V2AlertCheck = Schema.Struct({
description: "`breached`, `healthy`, `skipped`, or `error` (the evaluation query failed).",
examples: ["breached"],
}),
- signal_type: AlertSignalType,
+ signal_type: HistoricalAlertSignalType,
comparator: AlertComparator,
threshold: Schema.Number,
threshold_upper: Schema.NullOr(Schema.Number),
From 5e9ca12b0ffb69a85719d0f4e494ff076566f7fa Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Fri, 31 Jul 2026 14:44:19 +0200
Subject: [PATCH 3/4] stuff
---
apps/api/src/routes/v2/alerts.http.test.ts | 50 ++--
.../services/AlertDeliveryDispatch.test.ts | 31 ---
.../api/src/services/AlertDeliveryDispatch.ts | 17 +-
.../src/services/AlertDestinationHydration.ts | 11 +-
apps/api/src/services/AlertsService.test.ts | 217 +++--------------
apps/api/src/services/AlertsService.ts | 132 ++--------
.../alert-templating/renderer.test.ts | 28 +--
.../src/services/alert-templating/renderer.ts | 11 +-
apps/mobile/lib/alert-context.ts | 10 -
.../alerts/alert-create-form-surface.tsx | 19 +-
.../alerts/alert-create-page-content.test.ts | 19 +-
.../alerts/alert-create-page-content.tsx | 44 ++--
.../alerts/overview/settings-tab.tsx | 4 +-
.../src/components/alerts/overview/shared.tsx | 4 +-
.../src/components/alerts/scope-section.tsx | 47 ++--
.../alerts/signal-and-threshold-section.tsx | 112 ++++-----
apps/web/src/components/chat/alert-context.ts | 2 -
.../widgets/widget-actions-context.tsx | 4 +-
apps/web/src/lib/alerts/form-utils.test.ts | 87 +++++++
apps/web/src/lib/alerts/form-utils.ts | 120 ++++-----
.../src/lib/alerts/widget-chart-param.test.ts | 4 +-
apps/web/src/lib/alerts/widget-chart-param.ts | 5 +-
.../web/src/lib/alerts/widget-prefill.test.ts | 57 +++--
apps/web/src/lib/alerts/widget-prefill.ts | 17 +-
apps/web/src/lib/collections/alerts.ts | 4 +-
apps/web/src/routes/alerts/create.tsx | 6 +-
apps/web/src/routes/alerts/index.tsx | 4 +-
lib/alchemy-maple/README.md | 2 +-
.../test/alert-destination-props.test.ts | 29 +--
lib/alchemy-maple/test/contract.test.ts | 2 +-
packages/db/drizzle/0026_windy_bromley.sql | 86 +------
packages/db/src/migrations.test.ts | 68 +++---
packages/domain/src/http/alerts.test.ts | 4 +-
packages/domain/src/http/alerts.ts | 18 +-
.../domain/src/http/v2/alert-incidents.ts | 4 +-
packages/domain/src/http/v2/alert-rules.ts | 3 +-
packages/domain/src/query-engine.ts | 2 -
.../src/ch/queries/metrics.test.ts | 22 +-
.../query-engine/src/ch/queries/metrics.ts | 16 +-
.../src/query-builder/model.test.ts | 9 +-
.../query-engine/src/query-builder/model.ts | 16 +-
.../runtime/metrics-alert-evaluation.test.ts | 131 ++++++++++
.../query-engine/src/runtime/query-engine.ts | 227 ++++++++----------
43 files changed, 708 insertions(+), 997 deletions(-)
create mode 100644 packages/query-engine/src/runtime/metrics-alert-evaluation.test.ts
diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts
index 1ea3a77dc..75af6d2c3 100644
--- a/apps/api/src/routes/v2/alerts.http.test.ts
+++ b/apps/api/src/routes/v2/alerts.http.test.ts
@@ -274,10 +274,6 @@ describe("v2 alerts over HTTP", () => {
expect(incidents.status).toBe(200)
expect(incidents.body).toMatchObject({ object: "list", data: [] })
- const deliveries = await harness.request("GET", "/v2/alerts/deliveries", key.secret)
- expect(deliveries.status).toBe(200)
- expect(deliveries.body).toMatchObject({ object: "list", data: [], has_more: false })
-
// A destination referenced by a rule cannot be deleted.
const conflicted = await harness.request("DELETE", `/v2/alerts/destinations/${destId}`, key.secret)
expect(conflicted.status).toBe(409)
@@ -302,35 +298,6 @@ describe("v2 alerts over HTTP", () => {
await harness.dispose()
})
- it("reads retained metric incidents after metric rule migration", async () => {
- const harness = makeHarness()
- const key = await harness.bootstrapKey(["alerts:write"])
- await createWebhookAndRule(harness, key.secret)
-
- await executeSql(
- harness.testDb,
- `insert into alert_incidents (
- id, org_id, rule_id, incident_key, rule_name, group_key, signal_type,
- severity, status, comparator, threshold, first_triggered_at,
- last_triggered_at, dedupe_key, created_at, updated_at
- )
- select
- '00000000-0000-4000-8000-000000000901', org_id, id,
- 'legacy-metric-incident', name, null, 'metric', 'critical', 'resolved',
- 'gt', 10, timestamp '2026-07-01 00:00:00+00',
- timestamp '2026-07-01 00:05:00+00', 'legacy-metric-dedupe',
- timestamp '2026-07-01 00:00:00+00', timestamp '2026-07-01 00:05:00+00'
- from alert_rules where org_id = 'org_alerts_e2e' limit 1`,
- )
-
- const incidents = await harness.request("GET", "/v2/alerts/incidents", key.secret)
- expect(incidents.status).toBe(200)
- expect(incidents.body.data).toHaveLength(1)
- expect(incidents.body.data[0].signal_type).toBe("metric")
-
- await harness.dispose()
- })
-
it("paginates delivery history beyond the first 100 rows", async () => {
const harness = makeHarness()
const key = await harness.bootstrapKey(["alerts:write"])
@@ -358,12 +325,26 @@ describe("v2 alerts over HTTP", () => {
select id from alert_destinations where org_id = 'org_alerts_e2e' limit 1
) as destinations`,
)
+ await executeSql(
+ harness.testDb,
+ `insert into alert_delivery_events (
+ id, org_id, rule_id, destination_id, delivery_key, event_type,
+ attempt_number, status, scheduled_at, payload_json, created_at, updated_at
+ ) values (
+ '00000000-0000-4000-9000-000000000001', 'org_other',
+ '00000000-0000-4000-9000-000000000002', '00000000-0000-4000-9000-000000000003',
+ 'foreign-delivery', 'trigger', 1, 'success', timestamp '2026-08-01 12:00:00+00',
+ '{}'::jsonb, timestamp '2026-08-01 12:00:00+00', timestamp '2026-08-01 12:00:00+00'
+ )`,
+ )
const first = await harness.request("GET", "/v2/alerts/deliveries?limit=60", key.secret)
expect(first.status).toBe(200)
expect(first.body.data).toHaveLength(60)
expect(first.body.has_more).toBe(true)
expect(first.body.next_cursor).toEqual(expect.any(String))
+ expect(first.body.data[0].id).toMatch(/^evt_/)
+ expect(first.body.data[0].scheduled_at > first.body.data[1].scheduled_at).toBe(true)
const second = await harness.request(
"GET",
@@ -375,6 +356,9 @@ describe("v2 alerts over HTTP", () => {
expect(second.body.has_more).toBe(false)
expect(second.body.next_cursor).toBeNull()
expect(new Set([...first.body.data, ...second.body.data].map((item) => item.id)).size).toBe(105)
+ expect([...first.body.data, ...second.body.data].some((item) => item.delivery_key === "foreign-delivery")).toBe(
+ false,
+ )
await harness.dispose()
})
diff --git a/apps/api/src/services/AlertDeliveryDispatch.test.ts b/apps/api/src/services/AlertDeliveryDispatch.test.ts
index e6926a70b..4fa07285d 100644
--- a/apps/api/src/services/AlertDeliveryDispatch.test.ts
+++ b/apps/api/src/services/AlertDeliveryDispatch.test.ts
@@ -426,37 +426,6 @@ describe("dispatchDelivery", () => {
}),
)
- it.effect("slack-bot: applies stored legacy slack template overrides", () =>
- Effect.gen(function* () {
- let postedBody = ""
- const fetchFn: typeof fetch = async (_input, init) => {
- postedBody = String(init?.body)
- return new Response(JSON.stringify({ ok: true, ts: "1700000000.000200" }), {
- status: 200,
- })
- }
-
- yield* dispatchDelivery(
- {
- ...slackBotContext,
- template: {
- body: "top-level body",
- overrides: { slack: { body: "legacy Slack body" } },
- },
- },
- "{}",
- fetchFn,
- 5_000,
- LINK,
- CHAT,
- slackTokenDeps(),
- )
-
- assert.include(postedBody, "legacy Slack body")
- assert.notInclude(postedBody, "top-level body")
- }),
- )
-
it.effect("slack-bot: surfaces a not_in_channel logical error with an actionable message", () =>
Effect.gen(function* () {
const fetchFn: typeof fetch = async () =>
diff --git a/apps/api/src/services/AlertDeliveryDispatch.ts b/apps/api/src/services/AlertDeliveryDispatch.ts
index dc7f87631..f01c4d3e4 100644
--- a/apps/api/src/services/AlertDeliveryDispatch.ts
+++ b/apps/api/src/services/AlertDeliveryDispatch.ts
@@ -4,7 +4,7 @@ import {
type AlertComparator,
type AlertDestinationType,
type AlertEventType,
- type HistoricalAlertSignalType,
+ type AlertSignalType,
type AlertSeverity,
} from "@maple/domain/http"
import type { AlertDestinationRow } from "@maple/db"
@@ -40,7 +40,7 @@ export interface DispatchContext {
readonly ruleId: string
readonly ruleName: string
readonly groupKey: string | null
- readonly signalType: HistoricalAlertSignalType
+ readonly signalType: AlertSignalType
readonly severity: AlertSeverity
readonly comparator: AlertComparator
readonly threshold: number
@@ -78,7 +78,7 @@ export interface ChatUrlContext {
readonly incidentId: string | null
readonly dedupeKey: string
readonly eventType: AlertEventType
- readonly signalType: HistoricalAlertSignalType
+ readonly signalType: AlertSignalType
readonly severity: AlertSeverity
readonly comparator: AlertComparator
readonly threshold: number
@@ -600,9 +600,8 @@ const renderTitleBody = (
destinationType: AlertDestinationType,
linkUrl: string,
chatUrl: string,
- legacyDestinationTypes: ReadonlyArray = [],
): { title: string; body: string } | null => {
- const resolved = resolveTemplate(context.template, destinationType, legacyDestinationTypes)
+ const resolved = resolveTemplate(context.template, destinationType)
if (!hasCustomTemplate(resolved)) return null
try {
const templateCtx = buildTemplateContext(context, linkUrl, chatUrl)
@@ -688,13 +687,7 @@ export const dispatchDelivery = (
"slack-bot": (config) =>
Effect.gen(function* () {
const botToken = yield* deps.resolveSlackBotToken(context.destination.orgId)
- const templated = renderTitleBody(
- context,
- "slack-bot",
- linkUrl,
- chatUrl,
- ["slack"],
- )
+ const templated = renderTitleBody(context, "slack-bot", linkUrl, chatUrl)
const blocks = templated
? buildSlackBlocksFromTemplate(
templated.title,
diff --git a/apps/api/src/services/AlertDestinationHydration.ts b/apps/api/src/services/AlertDestinationHydration.ts
index 61f9fe97c..acd3e01c0 100644
--- a/apps/api/src/services/AlertDestinationHydration.ts
+++ b/apps/api/src/services/AlertDestinationHydration.ts
@@ -78,20 +78,23 @@ export interface HydratedDestination {
const parsePublicConfig = (
row: AlertDestinationRow,
- onError: () => E,
+ onError: (cause: unknown) => E,
): Effect.Effect =>
Schema.decodeUnknownEffect(DestinationPublicConfigSchema)(row.configJson).pipe(Effect.mapError(onError))
-const parseSecretConfig = (json: string, onError: () => E): Effect.Effect =>
+const parseSecretConfig = (
+ json: string,
+ onError: (cause: unknown) => E,
+): Effect.Effect =>
Schema.decodeUnknownEffect(SecretConfigFromJson)(json).pipe(Effect.mapError(onError))
export const hydrateDestinationRow = (
row: AlertDestinationRow,
encryptionKey: Buffer,
errors: {
- onPublicConfigInvalid: () => E
+ onPublicConfigInvalid: (cause: unknown) => E
onDecryptFailure: () => E
- onSecretConfigInvalid: () => E
+ onSecretConfigInvalid: (cause: unknown) => E
},
): Effect.Effect =>
Effect.gen(function* () {
diff --git a/apps/api/src/services/AlertsService.test.ts b/apps/api/src/services/AlertsService.test.ts
index 17bb3ef92..5c3152798 100644
--- a/apps/api/src/services/AlertsService.test.ts
+++ b/apps/api/src/services/AlertsService.test.ts
@@ -656,75 +656,6 @@ describe("AlertsService", () => {
)
})
- it.effect("recompiles an explicitly invalidated migrated metric plan from its builder draft", () => {
- const testDb = createTestDb(trackedDbs)
-
- return Effect.gen(function* () {
- yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS)
- const alerts = yield* AlertsService
- const orgId = asOrgId("org_migrated_metric_plan")
- const userId = asUserId("user_migrated_metric_plan")
- const destination = yield* createWebhookDestination(alerts, orgId, userId)
- const rule = yield* alerts.createRule(
- orgId,
- userId,
- adminRoles,
- new AlertRuleUpsertRequest({
- name: "Migrated request rate",
- severity: "warning",
- enabled: true,
- signalType: "builder_query",
- queryBuilderDraft: {
- id: "alert-query",
- name: "A",
- dataSource: "metrics",
- signalSource: "default",
- metricName: "http.requests",
- metricType: "sum",
- isMonotonic: true,
- aggregation: "rate",
- whereClause:
- 'service.name = "api,checkout" AND deployment.environment = "prod,staging"',
- addOns: {
- groupBy: true,
- having: false,
- orderBy: false,
- limit: false,
- legend: false,
- },
- groupBy: ["service.name"],
- },
- comparator: "gt",
- threshold: 10,
- windowMinutes: 5,
- destinationIds: [destination.id],
- }),
- )
-
- // Migration 0026 writes this null marker after replacing the legacy
- // metric draft. A stale non-null plan must never be used.
- yield* Effect.promise(() =>
- executeSql(
- testDb,
- `update alert_rules
- set query_spec_json = null, reducer = 'identity', sample_count_strategy = 'metric_data_points'
- where id = $1`,
- [rule.id],
- ),
- )
-
- const tick = yield* alerts.runSchedulerTick()
- assert.strictEqual(tick.evaluatedCount, 1)
- assert.strictEqual(tick.evaluationFailureCount, 0)
- }).pipe(
- Effect.provide(
- makeLayer(testDb, makeWarehouseStub({ metricsAggregateRows: emptyWarehouseRows }), {
- fetch: okFetch,
- }),
- ),
- )
- })
-
it.effect("lowers the environment scope into a built-in signal's compiled plan", () => {
const testDb = createTestDb(trackedDbs)
@@ -770,57 +701,6 @@ describe("AlertsService", () => {
)
})
- it.effect("lowers a metrics query-builder draft into the compiled plan", () => {
- const testDb = createTestDb(trackedDbs)
-
- return Effect.gen(function* () {
- const alerts = yield* AlertsService
- const orgId = asOrgId("org_env_metric")
- const userId = asUserId("user_env_metric")
- const destination = yield* createWebhookDestination(alerts, orgId, userId)
- yield* alerts.createRule(
- orgId,
- userId,
- adminRoles,
- new AlertRuleUpsertRequest({
- name: "Queue depth (prod)",
- severity: "warning",
- signalType: "builder_query",
- queryBuilderDraft: {
- id: "alert-query",
- name: "A",
- dataSource: "metrics",
- aggregation: "avg",
- metricName: "queue.depth",
- metricType: "gauge",
- whereClause: 'deployment.environment = "production"',
- groupBy: [],
- },
- comparator: "gt",
- threshold: 100,
- windowMinutes: 5,
- destinationIds: [destination.id],
- }),
- )
-
- const row = yield* Effect.promise(() =>
- queryFirstRow<{ querySpecJson: unknown }>(
- testDb,
- `select query_spec_json as "querySpecJson" from alert_rules limit 1`,
- ),
- )
-
- const spec = row?.querySpecJson as {
- source: string
- filters: { metricName: string; environments: ReadonlyArray }
- }
- assert.strictEqual(spec.source, "metrics")
- assert.deepStrictEqual(spec.filters.environments, ["production"])
- }).pipe(
- Effect.provide(makeLayer(testDb, makeWarehouseStub({ tracesAggregateRows: emptyWarehouseRows }))),
- )
- })
-
it.effect("drops the environment scope for builder_query rules", () => {
const testDb = createTestDb(trackedDbs)
@@ -1829,69 +1709,52 @@ describe("AlertsService", () => {
})
})
- it.effect("rejects metrics alerts with multiple attr groupBy dimensions", () => {
+ it.effect("accepts a builder query when lowering emits a warning", () => {
const testDb = createTestDb(trackedDbs)
return Effect.gen(function* () {
- const exit = yield* Effect.gen(function* () {
- const alerts = yield* AlertsService
- const orgId = asOrgId("org_metrics_group_validation")
- const userId = asUserId("user_metrics_group_validation")
- const destination = yield* createWebhookDestination(alerts, orgId, userId)
+ const alerts = yield* AlertsService
+ const orgId = asOrgId("org_builder_warning")
+ const userId = asUserId("user_builder_warning")
+ const destination = yield* createWebhookDestination(alerts, orgId, userId)
- return yield* alerts.createRule(
- orgId,
- userId,
- adminRoles,
- new AlertRuleUpsertRequest({
- name: "Grouped metrics alert",
- severity: "warning",
- enabled: true,
- signalType: "builder_query",
- queryBuilderDraft: {
- id: "alert-query",
- name: "A",
- dataSource: "metrics",
- aggregation: "avg",
- metricName: "http.server.request.duration",
- metricType: "histogram",
- whereClause: "",
- addOns: {
- groupBy: true,
- having: false,
- orderBy: false,
- limit: false,
- legend: false,
- },
- groupBy: ["attr.http.method", "attr.http.route"],
+ const rule = yield* alerts.createRule(
+ orgId,
+ userId,
+ adminRoles,
+ new AlertRuleUpsertRequest({
+ name: "Grouped metrics alert",
+ severity: "warning",
+ signalType: "builder_query",
+ queryBuilderDraft: {
+ id: "alert-query",
+ name: "A",
+ dataSource: "metrics",
+ aggregation: "avg",
+ metricName: "http.server.request.duration",
+ metricType: "histogram",
+ whereClause: "",
+ addOns: {
+ groupBy: true,
+ having: false,
+ orderBy: false,
+ limit: false,
+ legend: false,
},
- comparator: "gt",
- threshold: 100,
- windowMinutes: 5,
- minimumSampleCount: 1,
- consecutiveBreachesRequired: 1,
- consecutiveHealthyRequired: 1,
- renotifyIntervalMinutes: 30,
- destinationIds: [destination.id],
- }),
- )
- })
- .pipe(
- Effect.provide(
- makeLayer(testDb, makeWarehouseStub({ metricsAggregateRows: emptyWarehouseRows })),
- ),
- )
- .pipe(Effect.exit)
-
- const failure = getError(exit)
-
- assert.isTrue(Exit.isFailure(exit))
- assert.instanceOf(failure, AlertValidationError)
- assert.strictEqual(
- failure.message,
- "Metrics queries support a single attr.* group by; ignoring attr.http.route",
+ groupBy: ["attr.http.method", "attr.http.route"],
+ },
+ comparator: "gt",
+ threshold: 100,
+ windowMinutes: 5,
+ destinationIds: [destination.id],
+ }),
)
- })
+
+ assert.strictEqual(rule.signalType, "builder_query")
+ assert.deepStrictEqual(rule.queryBuilderDraft?.groupBy, ["attr.http.method", "attr.http.route"])
+ }).pipe(
+ Effect.provide(makeLayer(testDb, makeWarehouseStub({ metricsAggregateRows: emptyWarehouseRows }))),
+ )
})
const VALID_PD_KEY = "e93facc04764012d7bfb002500d5d1a6" // 32 hex chars
diff --git a/apps/api/src/services/AlertsService.ts b/apps/api/src/services/AlertsService.ts
index 51547a59c..7a6297dd3 100644
--- a/apps/api/src/services/AlertsService.ts
+++ b/apps/api/src/services/AlertsService.ts
@@ -43,7 +43,6 @@ import {
AlertRulesListResponse,
AlertSeverity as AlertSeveritySchema,
AlertSignalType as AlertSignalTypeSchema,
- HistoricalAlertSignalType as HistoricalAlertSignalTypeSchema,
AlertValidationError,
QueryBuilderQueryDraftSchema,
AlertNotificationTemplate,
@@ -57,7 +56,6 @@ import {
type QueryBuilderQueryDraftPayload,
type AlertSeverity,
type AlertSignalType,
- type HistoricalAlertSignalType,
type AlertGroupBy,
UNGROUPED_GROUP_KEY,
OrgId,
@@ -107,7 +105,6 @@ import { probeLiveness } from "../lib/telemetry-liveness"
import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment"
import type { TenantContext } from "./AuthService"
import {
- decryptAes256Gcm,
encryptAes256Gcm,
parseBase64Aes256GcmKey,
type EncryptedValue,
@@ -119,6 +116,8 @@ import {
dispatchDelivery as dispatchDeliveryImpl,
formatComparator,
PAGERDUTY_ROUTING_KEY_PATTERN,
+ type DispatchContext as DeliveryDispatchContext,
+ type DispatchResult,
verifyPagerDutyRoutingKey,
} from "./AlertDeliveryDispatch"
import { EmailService } from "../lib/EmailService"
@@ -133,7 +132,7 @@ import { validateExternalUrl } from "../lib/url-validator"
import type { AlertChecksRow } from "@maple/domain/tinybird"
import {
DestinationPublicConfigSchema,
- SecretConfigFromJson,
+ hydrateDestinationRow,
type DestinationPublicConfig,
type DestinationSecretConfig,
type EnrichedDestinationSecretConfig,
@@ -191,57 +190,21 @@ interface EvaluatedRule {
readonly derivedFromNoData: boolean
}
-interface DispatchContext {
- readonly deliveryKey: string
- readonly destination: AlertDestinationRow
- readonly publicConfig: DestinationPublicConfig
- readonly secretConfig: EnrichedDestinationSecretConfig
+type DispatchContext = Omit<
+ DeliveryDispatchContext,
+ "ruleId" | "incidentId" | "incidentStatus" | "sentAtMs"
+> & {
readonly ruleId: AlertRuleId
- readonly ruleName: string
- readonly groupKey: string | null
- readonly signalType: HistoricalAlertSignalType
- readonly severity: AlertSeverity
- readonly comparator: AlertComparator
- readonly threshold: number
- readonly thresholdUpper: number | null
- readonly eventType: AlertEventTypeValue
readonly incidentId: AlertIncidentId | null
readonly incidentStatus: Schema.Schema.Type
- readonly dedupeKey: string
- readonly windowMinutes: number
- readonly value: number | null
- readonly sampleCount: number | null
readonly linkUrl: string
readonly sentAtMs: number
- readonly template?: AlertNotificationTemplate | null
}
-interface DispatchResult {
- readonly providerMessage: string | null
- readonly providerReference: string | null
- readonly responseCode: number | null
-}
-
-interface DeliveryPayloadContext {
- readonly eventType: AlertEventTypeValue
- readonly incidentId: AlertIncidentId | null
- readonly incidentStatus: Schema.Schema.Type
- readonly dedupeKey: string
- readonly ruleId: AlertRuleId
- readonly ruleName: string
- readonly groupKey: string | null
- readonly signalType: HistoricalAlertSignalType
- readonly severity: AlertSeverity
- readonly comparator: AlertComparator
- readonly threshold: number
- readonly thresholdUpper: number | null
- readonly windowMinutes: number
- readonly value: number | null
- readonly sampleCount: number | null
- readonly linkUrl: string
- readonly sentAtMs: number
- readonly template?: AlertNotificationTemplate | null
-}
+type DeliveryPayloadContext = Omit<
+ DispatchContext,
+ "deliveryKey" | "destination" | "publicConfig" | "secretConfig"
+>
interface DeliveryAttemptFailure {
readonly message: string
@@ -308,7 +271,6 @@ const decodeUserIdSync = Schema.decodeUnknownSync(UserIdSchema)
const decodeAlertDestinationTypeSync = Schema.decodeUnknownSync(AlertDestinationTypeSchema)
const decodeAlertSeveritySync = Schema.decodeUnknownSync(AlertSeveritySchema)
const decodeAlertSignalTypeSync = Schema.decodeUnknownSync(AlertSignalTypeSchema)
-const decodeHistoricalAlertSignalTypeSync = Schema.decodeUnknownSync(HistoricalAlertSignalTypeSchema)
const decodeAlertComparatorSync = Schema.decodeUnknownSync(AlertComparatorSchema)
const decodeAlertCheckStatusSync = Schema.decodeUnknownSync(AlertCheckStatusSchema)
const decodeAlertIncidentTransitionSync = Schema.decodeUnknownSync(AlertIncidentTransitionSchema)
@@ -551,36 +513,6 @@ const encryptSecret = (
makeValidationError("Failed to encrypt destination secret"),
)
-const decryptSecret = (
- encrypted: {
- secretCiphertext: string
- secretIv: string
- secretTag: string
- },
- encryptionKey: Buffer,
-): Effect.Effect =>
- decryptAes256Gcm(
- {
- ciphertext: encrypted.secretCiphertext,
- iv: encrypted.secretIv,
- tag: encrypted.secretTag,
- },
- encryptionKey,
- () => makeValidationError("Failed to decrypt destination secret"),
- )
-
-const parsePublicConfig = (
- row: AlertDestinationRow,
-): Effect.Effect =>
- Schema.decodeUnknownEffect(DestinationPublicConfigSchema)(row.configJson).pipe(
- Effect.mapError((cause) => makeValidationError("Stored destination config is invalid", [], cause)),
- )
-
-const parseSecretConfig = (json: string): Effect.Effect =>
- Schema.decodeUnknownEffect(SecretConfigFromJson)(json).pipe(
- Effect.mapError((cause) => makeValidationError("Stored destination secret is invalid", [], cause)),
- )
-
type StoredDeliveryPayloadType = Schema.Schema.Type
const parseDeliveryPayload = (
@@ -795,12 +727,9 @@ const compileRulePlan = Effect.fn("AlertsService.compileRulePlan")(function* (ru
return yield* Effect.fail(makeValidationError("builder_query alerts require a queryBuilderDraft"))
}
const built = buildTimeseriesQuerySpec(rule.queryBuilderDraft)
- if (built.error != null || built.query == null || built.warnings.length > 0) {
+ if (built.error != null || built.query == null) {
return yield* Effect.fail(
- makeValidationError(
- built.error ?? built.warnings[0] ?? "Failed to build query builder spec",
- [...built.warnings],
- ),
+ makeValidationError(built.error ?? "Failed to build query builder spec", [...built.warnings]),
)
}
// Force the evaluation window's bucket size; the draft's stepInterval is
@@ -985,7 +914,7 @@ const rowToIncidentDocument = (row: AlertIncidentRow) =>
ruleId: decodeAlertRuleIdSync(row.ruleId),
ruleName: row.ruleName,
groupKey: row.groupKey,
- signalType: decodeHistoricalAlertSignalTypeSync(row.signalType),
+ signalType: decodeAlertSignalTypeSync(row.signalType),
severity: decodeAlertSeveritySync(row.severity),
status: decodeAlertIncidentStatusSync(row.status),
comparator: decodeAlertComparatorSync(row.comparator),
@@ -1290,9 +1219,13 @@ export class AlertsService extends Context.Service
+ makeValidationError("Stored destination config is invalid", [], cause),
+ onDecryptFailure: () => makeValidationError("Failed to decrypt destination secret"),
+ onSecretConfigInvalid: (cause) =>
+ makeValidationError("Stored destination secret is invalid", [], cause),
+ })
return {
row,
publicConfig,
@@ -1352,24 +1285,7 @@ export class AlertsService extends Context.Service {
const config: NotificationTemplateConfig = {
title: "top title",
body: "top body",
- overrides: { slack: { body: "slack body" } },
+ overrides: { "slack-bot": { body: "slack body" } },
}
it("returns nulls for a null config", () => {
- expect(resolveTemplate(null, "slack")).toEqual({ title: null, body: null })
+ expect(resolveTemplate(null, "slack-bot")).toEqual({ title: null, body: null })
})
it("applies per-destination override over the top-level field", () => {
- expect(resolveTemplate(config, "slack")).toEqual({ title: "top title", body: "slack body" })
+ expect(resolveTemplate(config, "slack-bot")).toEqual({ title: "top title", body: "slack body" })
})
it("falls back to the top-level field when no override for that destination", () => {
expect(resolveTemplate(config, "discord")).toEqual({ title: "top title", body: "top body" })
})
- it("uses a legacy override alias after the current destination key", () => {
- expect(resolveTemplate(config, "slack-bot", ["slack"])).toEqual({
- title: "top title",
- body: "slack body",
- })
- expect(
- resolveTemplate(
- {
- ...config,
- overrides: {
- slack: { body: "legacy body" },
- "slack-bot": { body: "current body" },
- },
- },
- "slack-bot",
- ["slack"],
- ),
- ).toEqual({ title: "top title", body: "current body" })
- })
-
it("treats blank strings as unset (→ null, i.e. built-in default)", () => {
- expect(resolveTemplate({ title: " ", body: "" }, "slack")).toEqual({
+ expect(resolveTemplate({ title: " ", body: "" }, "slack-bot")).toEqual({
title: null,
body: null,
})
diff --git a/apps/api/src/services/alert-templating/renderer.ts b/apps/api/src/services/alert-templating/renderer.ts
index 60fb7c1b4..54e6d5001 100644
--- a/apps/api/src/services/alert-templating/renderer.ts
+++ b/apps/api/src/services/alert-templating/renderer.ts
@@ -22,7 +22,7 @@ interface NotificationTemplateOverride {
/**
* Raw, user-supplied template config as stored on the rule / snapshotted into a
* delivery payload. `overrides` is keyed by destination type
- * (`slack`/`discord`/…); unset fields fall back override → top-level → default.
+ * (`slack-bot`/`discord`/…); unset fields fall back override → top-level → default.
*/
export interface NotificationTemplateConfig {
readonly title?: string | null
@@ -87,15 +87,12 @@ const firstNonEmpty = (...values: ReadonlyArray): str
export const resolveTemplate = (
config: NotificationTemplateConfig | null | undefined,
destinationType: string,
- legacyDestinationTypes: ReadonlyArray = [],
): ResolvedTemplate => {
if (config == null) return { title: null, body: null }
- const overrides = [destinationType, ...legacyDestinationTypes].map(
- (type) => config.overrides?.[type],
- )
+ const override = config.overrides?.[destinationType]
return {
- title: firstNonEmpty(...overrides.map((override) => override?.title), config.title),
- body: firstNonEmpty(...overrides.map((override) => override?.body), config.body),
+ title: firstNonEmpty(override?.title, config.title),
+ body: firstNonEmpty(override?.body, config.body),
}
}
diff --git a/apps/mobile/lib/alert-context.ts b/apps/mobile/lib/alert-context.ts
index ed2fe8e45..9d41a3c91 100644
--- a/apps/mobile/lib/alert-context.ts
+++ b/apps/mobile/lib/alert-context.ts
@@ -111,8 +111,6 @@ export function signalLabel(signalType: string): string {
return "Apdex"
case "throughput":
return "throughput"
- case "metric":
- return "metric"
default:
return signalType
}
@@ -171,14 +169,6 @@ export function alertPromptSuggestions(alert: AlertContext): string[] {
`Error rate vs latency correlation in ${group}`,
]
}
- if (sig === "metric") {
- return [
- `Raw metric values for ${group} last ${windowM}m`,
- `Compare this metric to the past week`,
- `Chart this metric for ${group} over 6h`,
- ]
- }
-
return [`Diagnose ${group}`, `Recent errors in ${group}`, `Slowest traces in ${group}`]
}
diff --git a/apps/web/src/components/alerts/alert-create-form-surface.tsx b/apps/web/src/components/alerts/alert-create-form-surface.tsx
index 19ecd07e6..742f7fbef 100644
--- a/apps/web/src/components/alerts/alert-create-form-surface.tsx
+++ b/apps/web/src/components/alerts/alert-create-form-surface.tsx
@@ -310,17 +310,20 @@ function makeSuggestedName(form: RuleFormState): string | null {
form.signalType === "builder_query" && form.queryBuilderDraft.addOns?.groupBy
? (form.queryBuilderDraft.groupBy ?? [])
: []
- const scope =
- form.serviceNames.length === 1
+ const queryOwnsScope = form.signalType === "builder_query" || form.signalType === "raw_query"
+ const scope = queryOwnsScope
+ ? queryGroupBy.length > 0
+ ? `per ${queryGroupBy.join(" · ")}`
+ : null
+ : form.serviceNames.length === 1
? form.serviceNames[0]!
: form.serviceNames.length > 1
? `${form.serviceNames.length} services`
- : queryGroupBy.length > 0
- ? `per ${queryGroupBy.join(" · ")}`
- : form.groupBy.length > 0
- ? `per ${form.groupBy.join(" · ")}`
- : null
- const env = form.environments.length > 0 ? form.environments.join(" · ") : null
+ : form.groupBy.length > 0
+ ? `per ${form.groupBy.join(" · ")}`
+ : null
+ const env =
+ !queryOwnsScope && form.environments.length > 0 ? form.environments.join(" · ") : null
const suffix = [scope, env].filter((part) => part !== null).join(" · ")
return suffix.length > 0 ? `${base} — ${suffix}` : base
}
diff --git a/apps/web/src/components/alerts/alert-create-page-content.test.ts b/apps/web/src/components/alerts/alert-create-page-content.test.ts
index 5059aecd2..285efaf37 100644
--- a/apps/web/src/components/alerts/alert-create-page-content.test.ts
+++ b/apps/web/src/components/alerts/alert-create-page-content.test.ts
@@ -5,8 +5,9 @@ import { deriveInitialRuleDraft } from "./alert-create-page-content"
/**
* The starter-template deep link from the overview empty state. With no ruleId /
- * chart params, `deriveInitialRuleDraft` reaches the template branch before the
- * rules result matters, so `Result.initial()` stands in for it.
+ * chart / dashboard params, `deriveInitialRuleDraft` reaches the template branch
+ * before the rules/dashboards results ever matter, so `Result.initial()` stands
+ * in for both.
*/
const loading = Result.initial()
@@ -44,18 +45,4 @@ describe("deriveInitialRuleDraft — template deep link", () => {
expect(draft.showTemplatesInitially).toBe(true)
expect(draft.key).toBe("new:blank")
})
-
- it("surfaces an invalid chart snapshot instead of using a second lookup path", () => {
- const draft = deriveInitialRuleDraft({
- search: { chart: "not-a-snapshot" },
- chartContext: undefined,
- rulesResult: loading,
- dashboardsResult: loading,
- })
-
- expect(draft.form.signalType).toBe("error_rate")
- expect(draft.prefillNotices[0]?.message).toContain("chart snapshot was invalid")
- expect(draft.showTemplatesInitially).toBe(false)
- expect(draft.key).toBe("invalid-chart-snapshot")
- })
})
diff --git a/apps/web/src/components/alerts/alert-create-page-content.tsx b/apps/web/src/components/alerts/alert-create-page-content.tsx
index 1cd65823b..a4652a45b 100644
--- a/apps/web/src/components/alerts/alert-create-page-content.tsx
+++ b/apps/web/src/components/alerts/alert-create-page-content.tsx
@@ -39,9 +39,10 @@ type InitialRuleDraft = {
editingRule: AlertRuleDocument | null
showTemplatesInitially: boolean
/**
- * The draft is a placeholder while the real rule or dashboard source is
- * loading. The form must not be shown yet — `form` is a blank default that
- * would read as "Create alert rule" until the fetch resolves.
+ * The draft is a placeholder while the real rule (or its dashboard source) is
+ * still loading. The form must not be shown yet — `form` is a blank default
+ * that would read as "Create alert rule" until the fetch resolves and the
+ * `key` change remounts it with the actual values.
*/
loading?: boolean
}
@@ -53,6 +54,10 @@ export function AlertCreatePageContent() {
() => (search.chart ? decodeAlertChartFromSearchParam(search.chart) : undefined),
[search.chart],
)
+
+ // The dashboards list is only needed for the legacy id-lookup fallback —
+ // when the navigation carried a decodable widget snapshot, prefill is
+ // synchronous and the fetch (plus its loading remount) is skipped entirely.
const needsDashboards =
!search.ruleId && chartContext == null && Boolean(search.dashboardId || search.widgetId)
@@ -108,8 +113,10 @@ export function AlertCreatePageContent() {
}
/**
- * Placeholder shown while an existing rule or dashboard widget source loads.
- * It mirrors the real surface so resolving the fetch swaps into the right shape.
+ * Placeholder shown while an existing rule (or a dashboard widget's prefill
+ * source) loads. Mirrors the real surface's chrome — same breadcrumb, same
+ * title, same two-column grid — so resolving the fetch swaps content into a
+ * page that is already the right shape.
*/
function AlertRuleFormSkeleton({ editing }: { editing: boolean }) {
return (
@@ -150,7 +157,12 @@ export function deriveInitialRuleDraft({
search: AlertCreateSearchValue
chartContext: AlertChartContext | undefined
rulesResult: Result.Result<{ rules: readonly AlertRuleDocument[] }, unknown>
- dashboardsResult: Result.Result<{ dashboards: readonly Dashboard[] }, unknown>
+ dashboardsResult: Result.Result<
+ {
+ dashboards: readonly Dashboard[]
+ },
+ unknown
+ >
}): InitialRuleDraft {
const base = defaultRuleForm(search.serviceName)
@@ -189,8 +201,9 @@ export function deriveInitialRuleDraft({
}
}
- // Snapshot carried through navigation — synchronous and immune to the
- // dashboard autosave race. Invalid snapshots fall through to id lookup.
+ // Snapshot carried through navigation — synchronous prefill, no dashboards
+ // fetch, immune to the autosave race. Garbage/oversized params decode to
+ // undefined and fall through to the id-lookup path below.
if (chartContext) {
const result = createWidgetAlertPrefill(chartContext.widget, base)
return {
@@ -257,21 +270,6 @@ export function deriveInitialRuleDraft({
}
}
- if (search.chart) {
- return {
- key: "invalid-chart-snapshot",
- form: base,
- prefillNotices: [
- {
- severity: "warning",
- message: "The source chart snapshot was invalid. Starting from a blank alert.",
- },
- ],
- editingRule: null,
- showTemplatesInitially: false,
- }
- }
-
// Starter-template deep link from the overview empty state — pre-apply the
// preset and skip the first-touch overlay. An unknown id falls through to the
// blank draft below (overlay still opens).
diff --git a/apps/web/src/components/alerts/overview/settings-tab.tsx b/apps/web/src/components/alerts/overview/settings-tab.tsx
index 75e6b5c8c..4cfaf9a37 100644
--- a/apps/web/src/components/alerts/overview/settings-tab.tsx
+++ b/apps/web/src/components/alerts/overview/settings-tab.tsx
@@ -2,7 +2,7 @@ import { Exit, Option } from "effect"
import { Fragment, useState, type Dispatch, type SetStateAction } from "react"
import { toast } from "sonner"
-import type { AlertDeliveryEventDocument, AlertDestinationDocument } from "@maple/domain/http"
+import type { AlertDestinationDocument } from "@maple/domain/http"
import { DestinationCard } from "@/components/alerts/destination-card"
import { DestinationDialog } from "@/components/alerts/destination-dialog"
@@ -197,7 +197,7 @@ export function AlertsSettingsTab({ manager, isAdmin }: { manager: DestinationMa
.onSuccess((response) => [...response.destinations] as AlertDestinationDocument[])
.orElse(() => [])
const deliveryEvents = Result.builder(deliveryEventsResult)
- .onSuccess((response) => response.data.map(v2DeliveryToDocument) as AlertDeliveryEventDocument[])
+ .onSuccess((response) => response.data.map(v2DeliveryToDocument))
.orElse(() => [])
const deliveryEventGroups = groupDeliveryEventsByDay(deliveryEvents)
diff --git a/apps/web/src/components/alerts/overview/shared.tsx b/apps/web/src/components/alerts/overview/shared.tsx
index 48d810ce4..aeefe2bad 100644
--- a/apps/web/src/components/alerts/overview/shared.tsx
+++ b/apps/web/src/components/alerts/overview/shared.tsx
@@ -20,8 +20,8 @@ const signalBadgeClass: Record = {
// Throughput rides the dedicated chart-throughput hue so the chip stays
// distinguishable from the amber p95/apdex chips (see DESIGN.md).
throughput: "border-[var(--chart-throughput)]/30 text-[var(--chart-throughput)]",
- metric: "border-muted-foreground/30 text-muted-foreground",
- query: "border-muted-foreground/30 text-muted-foreground",
+ builder_query: "border-muted-foreground/30 text-muted-foreground",
+ raw_query: "border-muted-foreground/30 text-muted-foreground",
}
export function SignalBadge({ signalType }: { signalType: string }) {
diff --git a/apps/web/src/components/alerts/scope-section.tsx b/apps/web/src/components/alerts/scope-section.tsx
index b3dd4b84b..665c82029 100644
--- a/apps/web/src/components/alerts/scope-section.tsx
+++ b/apps/web/src/components/alerts/scope-section.tsx
@@ -31,11 +31,7 @@ export function ScopeSection({
autocompleteValues,
}: ScopeSectionProps) {
const hasSpecificServices = form.serviceNames.length > 0
- const queryOwnsGrouping = form.signalType === "builder_query"
-
- const effectiveDataSource = form.signalType === "builder_query" ? form.queryDataSource : "traces"
-
- const groupByAttributeKeys = autocompleteValues[effectiveDataSource]?.attributeKeys
+ const groupByAttributeKeys = autocompleteValues.traces?.attributeKeys
return (
@@ -54,8 +50,7 @@ export function ScopeSection({
serviceNames: values,
// Clear group/exclude when narrowing to specific services so the
// disabled fields don't carry stale state into the submitted rule.
- groupBy:
- values.length > 0 && c.signalType !== "builder_query" ? [] : c.groupBy,
+ groupBy: values.length > 0 ? [] : c.groupBy,
excludeServiceNames: values.length > 0 ? [] : c.excludeServiceNames,
}))
}
@@ -76,26 +71,24 @@ export function ScopeSection({
- {!queryOwnsGrouping && (
-
-
Group by
-
onChange((c) => ({ ...c, groupBy: values }))}
- attributeKeys={groupByAttributeKeys}
- placeholder="service.name"
- className="w-full"
- disabled={hasSpecificServices}
- />
- {hasSpecificServices && (
-
- Disabled: each selected service is already its own group.
-
- )}
-
- )}
+
+
Group by
+
onChange((c) => ({ ...c, groupBy: values }))}
+ attributeKeys={groupByAttributeKeys}
+ placeholder="service.name"
+ className="w-full"
+ disabled={hasSpecificServices}
+ />
+ {hasSpecificServices && (
+
+ Disabled: each selected service is already its own group.
+
+ )}
+
Exclude services
diff --git a/apps/web/src/components/alerts/signal-and-threshold-section.tsx b/apps/web/src/components/alerts/signal-and-threshold-section.tsx
index 5203fe550..5c40931c7 100644
--- a/apps/web/src/components/alerts/signal-and-threshold-section.tsx
+++ b/apps/web/src/components/alerts/signal-and-threshold-section.tsx
@@ -23,6 +23,7 @@ import {
BracketsCurlyIcon,
ChartLineIcon,
ChevronDownIcon,
+ CircleWarningIcon,
CirclePercentageIcon,
FireIcon,
PulseIcon,
@@ -37,6 +38,7 @@ import {
} from "@/lib/alerts/form-utils"
import { Result, useAtomValue } from "@/lib/effect-atom"
import {
+ buildTimeseriesQuerySpec,
resetAggregationForMetricType,
resetQueryForDataSource,
type QueryBuilderDataSource,
@@ -531,24 +533,6 @@ type MetricRow = {
isMonotonic: boolean
}
-function applyQueryDraftToForm(
- current: RuleFormState,
- queryBuilderDraft: QueryBuilderQueryDraft,
-): RuleFormState {
- return {
- ...current,
- signalType: "builder_query",
- queryBuilderDraft,
- queryDataSource: queryBuilderDraft.dataSource,
- queryAggregation: queryBuilderDraft.aggregation,
- queryWhereClause: queryBuilderDraft.whereClause,
- metricName:
- queryBuilderDraft.dataSource === "metrics" ? queryBuilderDraft.metricName : current.metricName,
- metricType:
- queryBuilderDraft.dataSource === "metrics" ? queryBuilderDraft.metricType : current.metricType,
- }
-}
-
function useAlertMetricSelectionOptions(query: QueryBuilderQueryDraft) {
const [metricSearch, setMetricSearch] = useState("")
const deferredMetricSearch = useDeferredValue(metricSearch)
@@ -594,50 +578,66 @@ function useAlertMetricSelectionOptions(query: QueryBuilderQueryDraft) {
}
function AlertQueryPanel({ form, onChange, autocompleteValues }: SignalAndThresholdSectionProps) {
- const query = form.queryBuilderDraft as QueryBuilderQueryDraft
+ const query = form.queryBuilderDraft
+ const warnings = useMemo(() => buildTimeseriesQuerySpec(query).warnings, [query])
const { metricSelectionOptions, setMetricSearch } = useAlertMetricSelectionOptions(query)
const updateQuery = (updater: (query: QueryBuilderQueryDraft) => QueryBuilderQueryDraft) => {
- onChange((current) =>
- applyQueryDraftToForm(current, updater(current.queryBuilderDraft as QueryBuilderQueryDraft)),
- )
+ onChange((current) => ({
+ ...current,
+ queryBuilderDraft: updater(current.queryBuilderDraft),
+ }))
}
return (
-
updateQuery((current) => ({ ...current, aggregation }))}
- onMetricSelectionChange={(selection) =>
- updateQuery((current) =>
- current.dataSource === "metrics"
- ? {
- ...current,
- metricName: selection.metricName,
- metricType: selection.metricType,
- isMonotonic: selection.isMonotonic,
- aggregation: resetAggregationForMetricType(
- current.aggregation,
- selection.metricType,
- selection.isMonotonic,
- ),
- }
- : current,
- )
- }
- onClone={() => {}}
- onRemove={() => {}}
- onDataSourceChange={(dataSource: QueryBuilderDataSource) =>
- updateQuery((current) => resetQueryForDataSource(current, dataSource))
- }
- showHeaderActions={false}
- showVisibilityToggle={false}
- />
+
+
+ updateQuery((current) => ({ ...current, aggregation }))
+ }
+ onMetricSelectionChange={(selection) =>
+ updateQuery((current) =>
+ current.dataSource === "metrics"
+ ? {
+ ...current,
+ metricName: selection.metricName,
+ metricType: selection.metricType,
+ isMonotonic: selection.isMonotonic,
+ aggregation: resetAggregationForMetricType(
+ current.aggregation,
+ selection.metricType,
+ selection.isMonotonic,
+ ),
+ }
+ : current,
+ )
+ }
+ onClone={() => {}}
+ onRemove={() => {}}
+ onDataSourceChange={(dataSource: QueryBuilderDataSource) =>
+ updateQuery((current) => resetQueryForDataSource(current, dataSource))
+ }
+ showHeaderActions={false}
+ showVisibilityToggle={false}
+ />
+ {warnings.length > 0 && (
+
+
+
+ {warnings.map((warning) => (
+ {warning}
+ ))}
+
+
+ )}
+
)
}
diff --git a/apps/web/src/components/chat/alert-context.ts b/apps/web/src/components/chat/alert-context.ts
index dbcbf550b..66e0f8eb6 100644
--- a/apps/web/src/components/chat/alert-context.ts
+++ b/apps/web/src/components/chat/alert-context.ts
@@ -70,8 +70,6 @@ export const signalLabel = (signalType: string): string => {
return "Apdex"
case "throughput":
return "throughput"
- case "metric":
- return "metric"
default:
return signalType
}
diff --git a/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx b/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx
index bb7e22168..2b7019c27 100644
--- a/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx
+++ b/apps/web/src/components/dashboard-builder/widgets/widget-actions-context.tsx
@@ -74,8 +74,8 @@ export function WidgetActionsProvider({ widget, dataState, children }: WidgetAct
dashboardId && alertable
? () => {
// Carry the live widget (optimistic builder state) so the alert
- // page prefills without racing dashboard autosave. The ids remain
- // as the lookup fallback when the snapshot exceeds URL limits.
+ // page prefills without racing the dashboard autosave; the
+ // id pair stays as the lookup fallback for oversized payloads.
const chart = encodeAlertChartToSearchParam({
dashboardId,
widget: {
diff --git a/apps/web/src/lib/alerts/form-utils.test.ts b/apps/web/src/lib/alerts/form-utils.test.ts
index 8b88f4f19..17f24457a 100644
--- a/apps/web/src/lib/alerts/form-utils.test.ts
+++ b/apps/web/src/lib/alerts/form-utils.test.ts
@@ -1,4 +1,10 @@
import { describe, expect, it } from "vitest"
+import {
+ AlertDeliveryEventId,
+ AlertDestinationId,
+ AlertIncidentId,
+ AlertRuleId,
+} from "@maple/domain/http"
import {
buildDestinationCreateParamsV2,
buildDestinationUpdateParamsV2,
@@ -8,8 +14,10 @@ import {
deriveRuleQueryIssues,
domainThresholdToForm,
formThresholdToDomain,
+ normalizeRuleQueryDraft,
rawSqlHasValueColumn,
v2CheckToDocument,
+ v2DeliveryToDocument,
v2PreviewToResponse,
} from "./form-utils"
@@ -114,6 +122,54 @@ describe("buildRuleCreateParamsV2", () => {
const params = buildRuleCreateParamsV2({ ...defaultRuleForm(), name: "A" })
expect(params.environments).toEqual([])
})
+
+ it("normalizes and submits one query-builder draft", () => {
+ const queryBuilderDraft = normalizeRuleQueryDraft({
+ id: "A",
+ name: "A",
+ dataSource: "metrics",
+ aggregation: "rate",
+ metricName: "http.requests",
+ metricType: "sum",
+ })
+ const params = buildRuleCreateParamsV2({
+ ...defaultRuleForm(),
+ name: "Request rate",
+ signalType: "builder_query",
+ queryBuilderDraft,
+ })
+
+ expect(queryBuilderDraft).toMatchObject({
+ dataSource: "metrics",
+ whereClause: "",
+ groupBy: ["service.name"],
+ metricName: "http.requests",
+ metricType: "sum",
+ isMonotonic: true,
+ })
+ expect(params.query_builder_draft).toEqual(queryBuilderDraft)
+ })
+
+ it("keeps query-builder warnings non-fatal", () => {
+ const form = {
+ ...defaultRuleForm(),
+ name: "Requests by method",
+ signalType: "builder_query" as const,
+ queryBuilderDraft: normalizeRuleQueryDraft({
+ id: "A",
+ name: "A",
+ dataSource: "metrics",
+ aggregation: "avg",
+ metricName: "http.server.request.duration",
+ metricType: "histogram",
+ addOns: { groupBy: true, having: false, orderBy: false, limit: false, legend: false },
+ groupBy: ["attr.http.method", "attr.http.route"],
+ }),
+ }
+
+ expect(deriveRuleQueryIssues(form)).toEqual([])
+ expect(buildRuleCreateParamsV2(form).query_builder_draft).toEqual(form.queryBuilderDraft)
+ })
})
describe("slack-bot destination params", () => {
@@ -269,4 +325,35 @@ describe("v2 response mappers", () => {
evaluationDurationMs: 412,
})
})
+
+ it("maps a v2 delivery onto the camelCase domain document", () => {
+ const document = v2DeliveryToDocument({
+ id: AlertDeliveryEventId.make("00000000-0000-4000-8000-000000000101"),
+ object: "alert_delivery",
+ incident_id: AlertIncidentId.make("00000000-0000-4000-8000-000000000102"),
+ rule_id: AlertRuleId.make("00000000-0000-4000-8000-000000000103"),
+ destination_id: AlertDestinationId.make("00000000-0000-4000-8000-000000000104"),
+ destination_name: "On-call",
+ destination_type: "webhook",
+ delivery_key: "delivery-1",
+ event_type: "trigger",
+ attempt_number: 1,
+ status: "success",
+ scheduled_at: "2026-07-15T09:10:00.000Z",
+ attempted_at: "2026-07-15T09:10:01.000Z",
+ provider_message: "ok",
+ provider_reference: "ref-1",
+ response_code: 200,
+ error_message: null,
+ })
+
+ expect(document).toMatchObject({
+ destinationName: "On-call",
+ destinationType: "webhook",
+ deliveryKey: "delivery-1",
+ eventType: "trigger",
+ status: "success",
+ responseCode: 200,
+ })
+ })
})
diff --git a/apps/web/src/lib/alerts/form-utils.ts b/apps/web/src/lib/alerts/form-utils.ts
index 4559b1063..a8c1468e8 100644
--- a/apps/web/src/lib/alerts/form-utils.ts
+++ b/apps/web/src/lib/alerts/form-utils.ts
@@ -18,7 +18,6 @@ import {
type AlertDestinationType,
type AlertEventType,
type AlertSeverity,
- type HistoricalAlertSignalType,
type AlertSignalType,
type QueryBuilderQueryDraftPayload,
} from "@maple/domain/http"
@@ -32,10 +31,13 @@ import type {
V2AlertRuleTestParams,
} from "@maple/domain/http/v2"
import type { QueryEngineAlertReducer } from "@maple/query-engine"
-import type { QueryBuilderMetricType } from "@maple/query-engine/query-builder"
import { Cause, Exit, Option, Schema } from "effect"
import { v2ErrorInfo } from "@/lib/error-messages"
-import { buildTimeseriesQuerySpec, createQueryDraft } from "@/lib/query-builder/model"
+import {
+ buildTimeseriesQuerySpec,
+ createQueryDraft,
+ type QueryBuilderQueryDraft,
+} from "@/lib/query-builder/model"
import { formatErrorRate, formatLatency, formatNumber } from "@maple/ui/format"
const asHazelOrganizationId = Schema.decodeUnknownSync(HazelOrganizationId)
@@ -77,18 +79,12 @@ export type RuleFormState = {
consecutiveBreachesRequired: string
consecutiveHealthyRequired: string
renotifyIntervalMinutes: string
- metricName: string
- metricType: QueryBuilderMetricType
apdexThresholdMs: string
/**
- * Editing fields for the `builder_query` signal. They map 1:1 to a
- * `QueryBuilderQueryDraftPayload` — the same draft dashboard query-builder
- * charts use — which `buildRuleRequest` assembles at submit time.
+ * The normalized query-builder draft. It is the sole editable query state and
+ * is submitted verbatim after the wire payload is normalized on read.
*/
- queryDataSource: "traces" | "logs" | "metrics"
- queryAggregation: string
- queryWhereClause: string
- queryBuilderDraft: QueryBuilderQueryDraftPayload
+ queryBuilderDraft: QueryBuilderQueryDraft
/** Editing fields for the `raw_query` signal. */
rawQuerySql: string
rawQueryReducer: QueryEngineAlertReducer
@@ -168,7 +164,7 @@ export function getExitErrorMessage(exit: Exit.Exit, fallback:
return fallback
}
-export function formatSignalValue(signalType: HistoricalAlertSignalType, value: number | null): string {
+export function formatSignalValue(signalType: AlertSignalType, value: number | null): string {
if (value == null || Number.isNaN(value)) return "n/a"
switch (signalType) {
@@ -180,7 +176,6 @@ export function formatSignalValue(signalType: HistoricalAlertSignalType, value:
case "apdex":
return value.toFixed(3)
case "throughput":
- case "metric":
case "builder_query":
case "raw_query":
return formatNumber(value)
@@ -216,6 +211,43 @@ function parseNonNegativeNumber(value: string, fallback: number): number {
return parsed
}
+export function normalizeRuleQueryDraft(
+ draft: QueryBuilderQueryDraftPayload | null,
+): QueryBuilderQueryDraft {
+ const base = createQueryDraft(0)
+ if (draft == null) return base
+
+ const shared = {
+ ...base,
+ ...draft,
+ enabled: draft.enabled ?? base.enabled,
+ hidden: draft.hidden ?? base.hidden,
+ whereClause: draft.whereClause ?? base.whereClause,
+ stepInterval: draft.stepInterval ?? base.stepInterval,
+ orderByDirection: draft.orderByDirection ?? base.orderByDirection,
+ addOns: { ...base.addOns, ...draft.addOns },
+ groupBy: [...(draft.groupBy ?? base.groupBy)],
+ having: draft.having ?? base.having,
+ orderBy: draft.orderBy ?? base.orderBy,
+ limit: draft.limit ?? base.limit,
+ legend: draft.legend ?? base.legend,
+ }
+
+ if (draft.dataSource === "metrics") {
+ return {
+ ...shared,
+ dataSource: "metrics",
+ signalSource: draft.signalSource ?? "default",
+ metricName: draft.metricName ?? "",
+ metricType: draft.metricType ?? "gauge",
+ isMonotonic: draft.isMonotonic ?? draft.metricType === "sum",
+ }
+ }
+ return draft.dataSource === "logs"
+ ? { ...shared, dataSource: "logs" }
+ : { ...shared, dataSource: "traces" }
+}
+
export function defaultRuleForm(serviceName?: string): RuleFormState {
const queryBuilderDraft = createQueryDraft(0)
return {
@@ -237,12 +269,7 @@ export function defaultRuleForm(serviceName?: string): RuleFormState {
consecutiveBreachesRequired: "2",
consecutiveHealthyRequired: "2",
renotifyIntervalMinutes: "30",
- metricName: "",
- metricType: "gauge",
apdexThresholdMs: "500",
- queryDataSource: "traces",
- queryAggregation: "count",
- queryWhereClause: "",
queryBuilderDraft,
rawQuerySql: DEFAULT_RAW_QUERY_SQL,
rawQueryReducer: "identity",
@@ -253,7 +280,7 @@ export function defaultRuleForm(serviceName?: string): RuleFormState {
}
export function ruleToFormState(rule: AlertRuleDocument): RuleFormState {
- const queryBuilderDraft = rule.queryBuilderDraft ?? createQueryDraft(0)
+ const queryBuilderDraft = normalizeRuleQueryDraft(rule.queryBuilderDraft)
return {
name: rule.name,
notes: rule.notes ?? "",
@@ -274,13 +301,7 @@ export function ruleToFormState(rule: AlertRuleDocument): RuleFormState {
consecutiveBreachesRequired: String(rule.consecutiveBreachesRequired),
consecutiveHealthyRequired: String(rule.consecutiveHealthyRequired),
renotifyIntervalMinutes: String(rule.renotifyIntervalMinutes),
- metricName: queryBuilderDraft.dataSource === "metrics" ? (queryBuilderDraft.metricName ?? "") : "",
- metricType:
- queryBuilderDraft.dataSource === "metrics" ? (queryBuilderDraft.metricType ?? "gauge") : "gauge",
apdexThresholdMs: rule.apdexThresholdMs == null ? "500" : String(rule.apdexThresholdMs),
- queryDataSource: rule.queryBuilderDraft?.dataSource ?? "traces",
- queryAggregation: rule.queryBuilderDraft?.aggregation ?? "count",
- queryWhereClause: rule.queryBuilderDraft?.whereClause ?? "",
queryBuilderDraft,
rawQuerySql: rule.rawQuerySql ?? DEFAULT_RAW_QUERY_SQL,
rawQueryReducer: rule.rawQueryReducer ?? "identity",
@@ -290,48 +311,6 @@ export function ruleToFormState(rule: AlertRuleDocument): RuleFormState {
}
}
-/**
- * Assemble a `QueryBuilderQueryDraftPayload` from the simple builder_query form
- * fields. This is the same draft shape dashboard query-builder charts use, so
- * the alert evaluates through the identical compiler.
- */
-export function buildQueryDraftFromForm(form: RuleFormState): QueryBuilderQueryDraftPayload {
- if (form.signalType === "builder_query") return form.queryBuilderDraft
-
- // Fold a single selected service into the where clause — builder_query draws
- // all filtering from the draft, not the rule-level service scope.
- const userWhere = form.queryWhereClause.trim()
- const whereClause =
- form.serviceNames.length === 1
- ? [`service.name = "${form.serviceNames[0]}"`, userWhere]
- .filter((s) => s.length > 0)
- .join(" AND ")
- : userWhere
- const base = {
- id: "alert-query",
- name: "A",
- aggregation: form.queryAggregation,
- whereClause,
- groupBy: [...form.groupBy],
- addOns: {
- groupBy: form.groupBy.length > 0,
- having: false,
- orderBy: false,
- limit: false,
- legend: false,
- },
- }
- if (form.queryDataSource === "metrics") {
- return {
- ...base,
- dataSource: "metrics",
- metricName: form.metricName.trim(),
- metricType: form.metricType,
- }
- }
- return { ...base, dataSource: form.queryDataSource }
-}
-
export function rawSqlHasValueColumn(sql: string): boolean {
const trimmed = sql.trim()
if (trimmed.length === 0) return false
@@ -346,7 +325,7 @@ export function rawSqlHasValueColumn(sql: string): boolean {
export function deriveRuleQueryIssues(form: RuleFormState): string[] {
const issues: string[] = []
if (form.signalType === "builder_query") {
- const built = buildTimeseriesQuerySpec(buildQueryDraftFromForm(form))
+ const built = buildTimeseriesQuerySpec(form.queryBuilderDraft)
if (built.error != null || built.query == null) {
issues.push(`Query: ${built.error ?? "failed to build query"}`)
}
@@ -405,7 +384,8 @@ export function buildRuleCreateParamsV2(form: RuleFormState): V2AlertRuleCreateP
consecutive_healthy_required: parsePositiveNumber(form.consecutiveHealthyRequired, 2),
renotify_interval_minutes: parsePositiveNumber(form.renotifyIntervalMinutes, 30),
apdex_threshold_ms: signalType === "apdex" ? parsePositiveNumber(form.apdexThresholdMs, 500) : null,
- query_builder_draft: signalType === "builder_query" ? buildQueryDraftFromForm(form) : null,
+ query_builder_draft:
+ signalType === "builder_query" ? Object.fromEntries(Object.entries(form.queryBuilderDraft)) : null,
raw_query_sql: signalType === "raw_query" ? form.rawQuerySql.trim() || null : null,
raw_query_reducer: signalType === "raw_query" ? form.rawQueryReducer : null,
// Dedupe so the same destination is never persisted twice (e.g. when editing a
diff --git a/apps/web/src/lib/alerts/widget-chart-param.test.ts b/apps/web/src/lib/alerts/widget-chart-param.test.ts
index f3203fb39..cd7b929de 100644
--- a/apps/web/src/lib/alerts/widget-chart-param.test.ts
+++ b/apps/web/src/lib/alerts/widget-chart-param.test.ts
@@ -87,7 +87,9 @@ describe("encodeAlertChartToSearchParam / decodeAlertChartFromSearchParam", () =
expect(viaParam).toEqual(direct)
expect(viaParam.form.signalType).toBe("builder_query")
- expect(viaParam.form.queryWhereClause).toBe('service.name = "café/checkout+v2" AND attr.note = "a=b"')
+ expect(viaParam.form.queryBuilderDraft.whereClause).toBe(
+ 'service.name = "café/checkout+v2" AND attr.note = "a=b"',
+ )
})
it("round-trips a raw SQL widget keeping the SQL intact", () => {
diff --git a/apps/web/src/lib/alerts/widget-chart-param.ts b/apps/web/src/lib/alerts/widget-chart-param.ts
index 58145f99a..df8eae26c 100644
--- a/apps/web/src/lib/alerts/widget-chart-param.ts
+++ b/apps/web/src/lib/alerts/widget-chart-param.ts
@@ -34,7 +34,10 @@ export type AlertChartContext = Schema.Schema.Type {
)
expect(result.form.signalType).toBe("builder_query")
- expect(result.form.queryWhereClause).toBe('service.name = "checkout"')
+ expect(result.form.queryBuilderDraft.whereClause).toBe('service.name = "checkout"')
expect(result.form.name).toBe("Alert - Traffic")
expect(result.notices.map((notice) => notice.message).join("\n")).toContain("2 visible queries")
})
@@ -152,7 +152,7 @@ describe("createWidgetAlertPrefill", () => {
defaultRuleForm(),
)
- expect(result.form.queryDataSource).toBe("metrics")
+ expect(result.form.queryBuilderDraft.dataSource).toBe("metrics")
expect(result.notices.map((notice) => notice.message).join("\n")).toContain(
"Metric source requires a metric name",
)
@@ -160,44 +160,49 @@ describe("createWidgetAlertPrefill", () => {
})
describe("resolveWidgetAlertPrefill", () => {
- it("reloads the source widget when an oversized snapshot falls back to ids", () => {
+ it("returns a blank alert with a notice when the dashboard id is missing", () => {
const result = resolveWidgetAlertPrefill({
- dashboards: [
- {
- id: "dash",
- widgets: [
- {
- id: "w1",
- dataSource: {
- endpoint: "raw_sql_chart",
- params: { sql: "SELECT count() AS value FROM traces WHERE $__orgFilter" },
- },
- },
- ],
- },
- ],
- dashboardId: "dash",
+ dashboards: [{ id: "dash", widgets: [] }],
widgetId: "w1",
base: defaultRuleForm(),
})
- expect(result.form.signalType).toBe("raw_query")
- expect(result.form.rawQuerySql).toContain("$__orgFilter")
+ expect(result.form.signalType).toBe("error_rate")
+ expect(result.notices[0]?.message).toContain("dashboard id was missing")
+ })
+
+ it("returns a blank alert with a notice when the widget id is missing", () => {
+ const result = resolveWidgetAlertPrefill({
+ dashboards: [{ id: "dash", widgets: [] }],
+ dashboardId: "dash",
+ base: defaultRuleForm(),
+ })
+
+ expect(result.form.signalType).toBe("error_rate")
+ expect(result.notices[0]?.message).toContain("chart id was missing")
})
- it("returns a clear notice when either fallback id is missing", () => {
- const missingDashboard = resolveWidgetAlertPrefill({
+ it("returns a blank alert with a notice when the dashboard is missing", () => {
+ const result = resolveWidgetAlertPrefill({
dashboards: [],
+ dashboardId: "missing",
widgetId: "w1",
base: defaultRuleForm(),
})
- const missingWidget = resolveWidgetAlertPrefill({
- dashboards: [],
+
+ expect(result.form.signalType).toBe("error_rate")
+ expect(result.notices[0]?.message).toContain("dashboard could not be found")
+ })
+
+ it("returns a blank alert with a notice when the widget is missing", () => {
+ const result = resolveWidgetAlertPrefill({
+ dashboards: [{ id: "dash", widgets: [] }],
dashboardId: "dash",
+ widgetId: "missing",
base: defaultRuleForm(),
})
- expect(missingDashboard.notices[0]?.message).toContain("dashboard id was missing")
- expect(missingWidget.notices[0]?.message).toContain("chart id was missing")
+ expect(result.form.signalType).toBe("error_rate")
+ expect(result.notices[0]?.message).toContain("source chart could not be found")
})
})
diff --git a/apps/web/src/lib/alerts/widget-prefill.ts b/apps/web/src/lib/alerts/widget-prefill.ts
index 68b954484..d407aaaac 100644
--- a/apps/web/src/lib/alerts/widget-prefill.ts
+++ b/apps/web/src/lib/alerts/widget-prefill.ts
@@ -1,6 +1,10 @@
import type { QueryBuilderQueryDraftPayload } from "@maple/domain/http"
-import { buildQueryDraftFromForm, rawSqlHasValueColumn, type RuleFormState } from "@/lib/alerts/form-utils"
+import {
+ normalizeRuleQueryDraft,
+ rawSqlHasValueColumn,
+ type RuleFormState,
+} from "@/lib/alerts/form-utils"
import { buildTimeseriesQuerySpec } from "@/lib/query-builder/model"
export type WidgetAlertPrefillNotice = {
@@ -86,22 +90,17 @@ function queryToForm(
widget: AlertableDashboardWidget,
query: QueryBuilderQueryDraftPayload,
): RuleFormState {
+ const queryBuilderDraft = normalizeRuleQueryDraft(query)
return {
...base,
name: widgetAlertName(widget),
signalType: "builder_query",
- queryBuilderDraft: query,
- queryDataSource: query.dataSource,
- queryAggregation: query.aggregation,
- queryWhereClause: query.whereClause ?? "",
+ queryBuilderDraft,
// Builder thresholds compare against the query's raw output. error_rate
// is a 0–1 ratio, so the blank-form default of "5" (tuned for the
// percent-entry error_rate signal) would mean a 500% error rate.
threshold: query.aggregation === "error_rate" ? "0.05" : base.threshold,
groupBy: [],
- metricName: query.dataSource === "metrics" ? (query.metricName ?? "") : base.metricName,
- metricType:
- query.dataSource === "metrics" && query.metricType != null ? query.metricType : base.metricType,
}
}
@@ -200,7 +199,7 @@ export function createWidgetAlertPrefill(
}
const form = queryToForm(base, widget, selected)
- const built = buildTimeseriesQuerySpec(buildQueryDraftFromForm(form))
+ const built = buildTimeseriesQuerySpec(form.queryBuilderDraft)
if (built.error != null || built.query == null) {
notices.push({
severity: "warning",
diff --git a/apps/web/src/lib/collections/alerts.ts b/apps/web/src/lib/collections/alerts.ts
index 3ac968b89..3164fd764 100644
--- a/apps/web/src/lib/collections/alerts.ts
+++ b/apps/web/src/lib/collections/alerts.ts
@@ -5,7 +5,6 @@ import {
AlertDestinationType,
AlertEventType,
AlertGroupBy,
- HistoricalAlertSignalType,
AlertIncidentDocument,
AlertIncidentStatus,
AlertNotificationTemplate,
@@ -33,7 +32,6 @@ const asDestinationType = Schema.decodeUnknownSync(AlertDestinationType)
const asSeverity = Schema.decodeUnknownSync(AlertSeverity)
const asSignalType = Schema.decodeUnknownSync(AlertSignalType)
-const asHistoricalSignalType = Schema.decodeUnknownSync(HistoricalAlertSignalType)
const asComparator = Schema.decodeUnknownSync(AlertComparator)
const asIncidentStatus = Schema.decodeUnknownSync(AlertIncidentStatus)
const asEventType = Schema.decodeUnknownSync(AlertEventType)
@@ -241,7 +239,7 @@ export const rowToAlertIncidentDocument = (row: AlertIncidentRow): AlertIncident
ruleId: asAlertRuleId(row.rule_id),
ruleName: row.rule_name,
groupKey: row.group_key,
- signalType: asHistoricalSignalType(row.signal_type),
+ signalType: asSignalType(row.signal_type),
severity: asSeverity(row.severity),
status: asIncidentStatus(row.status),
comparator: asComparator(row.comparator),
diff --git a/apps/web/src/routes/alerts/create.tsx b/apps/web/src/routes/alerts/create.tsx
index 16c42a83a..c5f5a6c7f 100644
--- a/apps/web/src/routes/alerts/create.tsx
+++ b/apps/web/src/routes/alerts/create.tsx
@@ -8,13 +8,13 @@ const AlertCreateSearch = Schema.Struct({
ruleId: Schema.optional(Schema.String),
/** Starter-template id from the overview empty state — pre-applies that preset. */
template: Schema.optional(Schema.String),
- /** Dashboard/widget lookup fallback when the live chart snapshot is too large. */
+ /** Set by the "Create alert" action on a dashboard chart widget. */
dashboardId: Schema.optional(Schema.String),
widgetId: Schema.optional(Schema.String),
/**
* Base64url-encoded snapshot of the source widget (see widget-chart-param.ts).
- * Carries the live builder state so prefill doesn't race dashboard autosave;
- * dashboardId/widgetId remain as the oversized-payload fallback.
+ * Carries the live builder state so prefill doesn't race the dashboard
+ * autosave; dashboardId/widgetId remain as the lookup fallback.
*/
chart: Schema.optional(Schema.String),
})
diff --git a/apps/web/src/routes/alerts/index.tsx b/apps/web/src/routes/alerts/index.tsx
index bf3e65640..fdba19d98 100644
--- a/apps/web/src/routes/alerts/index.tsx
+++ b/apps/web/src/routes/alerts/index.tsx
@@ -16,8 +16,8 @@ type AlertsTab = "overview" | "settings"
const AlertsSearch = Schema.Struct({
/**
- * Accept any string so legacy deep links (`tab=monitor`, `tab=rules`) still
- * resolve; the component normalizes every value except settings to overview.
+ * Accepts any string so legacy deep links (`tab=monitor`, `tab=rules`) keep
+ * resolving — anything that isn't "settings" lands on the overview.
*/
tab: Schema.optional(Schema.String),
serviceName: Schema.optional(Schema.String),
diff --git a/lib/alchemy-maple/README.md b/lib/alchemy-maple/README.md
index 884db460b..d3d8b6c5f 100644
--- a/lib/alchemy-maple/README.md
+++ b/lib/alchemy-maple/README.md
@@ -78,7 +78,7 @@ Set `MAPLE_API_KEY` to a Maple API key (create one in the Maple dashboard, or wi
| Resource | Semantics |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Maple.Dashboard` | Full CRUD. Props are the v2 wire shape (`snake_case`, see `/v2/docs`); widget/variable documents pass through verbatim. Updates PATCH in place. |
-| `Maple.AlertDestination` | Full CRUD. Discriminated on `type` (slack, pagerduty, webhook, hazel, discord, email). Channel secrets are write-only — accept `Redacted` values. Changing `type` replaces the destination. |
+| `Maple.AlertDestination` | Full CRUD for declaratively provisionable types (PagerDuty, webhook, Discord, email). Channel secrets are write-only — accept `Redacted` values. Slack and Hazel use installed integrations managed in Maple. Changing `type` replaces the destination. |
| `Maple.AlertRule` | Full CRUD. `destination_ids` accepts outputs from `Maple.AlertDestination`. Rule names are org-unique, so lost state is re-adopted by name instead of duplicating. |
| `Maple.ApiKey` | Create / roll / revoke (the API has no key update). Changing props replaces the key; bumping the `rotate` prop rolls it in place (same name/scopes, new secret). `secret` is captured once and preserved in Alchemy state — it can never be re-read from the API. |
| `Maple.IngestKeys` | Read-only per-org singleton. Surfaces `publicKey` / `privateKey` as `Redacted` outputs; delete only stops tracking it. |
diff --git a/lib/alchemy-maple/test/alert-destination-props.test.ts b/lib/alchemy-maple/test/alert-destination-props.test.ts
index 80e847622..07c7091e1 100644
--- a/lib/alchemy-maple/test/alert-destination-props.test.ts
+++ b/lib/alchemy-maple/test/alert-destination-props.test.ts
@@ -4,19 +4,14 @@
* Alchemy types props as `InputProps`, a mapped type that collapses a
* union to the keys its members share — which erased every channel-specific
* field and made the resource uncallable. The declared call signature restores
- * it; these assertions are how we notice if it regresses. The compile-time half
- * is enforced by `tsc` (a stale `@ts-expect-error` fails as TS2578), the runtime
- * half by the create bodies below.
+ * it; these compile-time assertions are how we notice if it regresses (a stale
+ * `@ts-expect-error` fails as TS2578). Wire bodies are covered by contract.test.
*/
-import { describe, expect, it } from "vitest"
import { Effect } from "effect"
-import {
- AlertDestination,
- _alertDestinationCreateBody,
- type AlertDestinationProps,
-} from "../src/AlertDestination"
+import { expect, it } from "vitest"
+import { AlertDestination } from "../src/AlertDestination"
-// Every channel type is constructible with its own fields.
+// Every declaratively provisionable channel is constructible with its own fields.
export const accepts = Effect.gen(function* () {
yield* AlertDestination("pagerduty", { type: "pagerduty", name: "p", integration_key: "k" })
yield* AlertDestination("webhook", { type: "webhook", name: "w", url: "https://x", signing_secret: "s" })
@@ -34,15 +29,7 @@ export const rejects = Effect.gen(function* () {
yield* AlertDestination("c", { type: "email", name: "x" })
})
-const channels: ReadonlyArray<[string, AlertDestinationProps, string]> = [
- ["pagerduty", { type: "pagerduty", name: "p", integration_key: "k" }, "integration_key"],
- ["webhook", { type: "webhook", name: "w", url: "https://x" }, "url"],
- ["discord", { type: "discord", name: "d", webhook_url: "u" }, "webhook_url"],
- ["email", { type: "email", name: "e", member_user_ids: ["u_1"] }, "member_user_ids"],
-]
-
-describe("alert destination create bodies carry the channel's own fields", () => {
- it.each(channels)("%s", (_label, props, field) => {
- expect(_alertDestinationCreateBody(props)).toHaveProperty(field)
- })
+it("keeps the compile-time destination examples", () => {
+ expect(accepts).toBeDefined()
+ expect(rejects).toBeDefined()
})
diff --git a/lib/alchemy-maple/test/contract.test.ts b/lib/alchemy-maple/test/contract.test.ts
index 9ce33b9ba..eb037a6ac 100644
--- a/lib/alchemy-maple/test/contract.test.ts
+++ b/lib/alchemy-maple/test/contract.test.ts
@@ -40,7 +40,7 @@ describe("provider request bodies decode against the real v2 create-param schema
expect(() => decodes(V2DashboardCreateParams, body)).not.toThrow()
})
- it("alert destination create bodies (each channel type)", () => {
+ it("alert destination create bodies (declarative channel subset)", () => {
const bodies = [
_alertDestinationCreateBody({ type: "pagerduty", name: "PD", integration_key: "key" }),
_alertDestinationCreateBody({
diff --git a/packages/db/drizzle/0026_windy_bromley.sql b/packages/db/drizzle/0026_windy_bromley.sql
index 36274520c..fb98911c8 100644
--- a/packages/db/drizzle/0026_windy_bromley.sql
+++ b/packages/db/drizzle/0026_windy_bromley.sql
@@ -1,76 +1,16 @@
-UPDATE "alert_rules"
-SET
- "query_builder_draft_json" = jsonb_build_object(
- 'id', 'alert-query',
- 'name', 'A',
- 'enabled', true,
- 'hidden', false,
- 'dataSource', 'metrics',
- 'signalSource', 'default',
- 'metricName', "metric_name",
- 'metricType', "metric_type",
- 'isMonotonic', "metric_type" = 'sum',
- 'aggregation', "metric_aggregation",
- 'whereClause', concat_ws(
- ' AND ',
- CASE
- WHEN jsonb_typeof("service_names_json") = 'array'
- AND jsonb_array_length("service_names_json") > 0
- THEN 'service.name = ' || to_json(
- (
- SELECT string_agg(value, ',' ORDER BY ordinal)
- FROM jsonb_array_elements_text("service_names_json")
- WITH ORDINALITY AS services(value, ordinal)
- )
- )::text
- END,
- CASE
- WHEN jsonb_typeof("environments_json") = 'array'
- AND jsonb_array_length("environments_json") > 0
- THEN 'deployment.environment = ' || to_json(
- (
- SELECT string_agg(value, ',')
- FROM jsonb_array_elements_text("environments_json")
- )
- )::text
- END
- ),
- 'stepInterval', '',
- 'orderByDirection', 'desc',
- 'addOns', jsonb_build_object(
- 'groupBy',
- "group_by" IS NOT NULL
- OR (
- jsonb_typeof("service_names_json") = 'array'
- AND jsonb_array_length("service_names_json") > 1
- ),
- 'having', false,
- 'orderBy', false,
- 'limit', false,
- 'legend', false
- ),
- 'groupBy',
- CASE
- WHEN jsonb_typeof("service_names_json") = 'array'
- AND jsonb_array_length("service_names_json") > 1
- THEN jsonb_build_array('service.name')
- ELSE COALESCE("group_by"::jsonb, '[]'::jsonb)
- END,
- 'having', '',
- 'orderBy', '',
- 'limit', '',
- 'legend', ''
- ),
- "signal_type" = 'builder_query',
- -- The legacy compiled metric plan does not contain the draft's new
- -- multi-service/grouping semantics. Null explicitly marks it for the API's
- -- builder-query recompilation fallback on first read/evaluation.
- "query_spec_json" = NULL,
- "reducer" = 'identity',
- "sample_count_strategy" = 'metric_data_points',
- "service_names_json" = NULL,
- "environments_json" = NULL,
- "group_by" = NULL
+DELETE FROM "alert_delivery_events"
+WHERE "rule_id" IN (
+ SELECT "id" FROM "alert_rules" WHERE "signal_type" = 'metric'
+);--> statement-breakpoint
+DELETE FROM "alert_incidents"
+WHERE "rule_id" IN (
+ SELECT "id" FROM "alert_rules" WHERE "signal_type" = 'metric'
+);--> statement-breakpoint
+DELETE FROM "alert_rule_states"
+WHERE "rule_id" IN (
+ SELECT "id" FROM "alert_rules" WHERE "signal_type" = 'metric'
+);--> statement-breakpoint
+DELETE FROM "alert_rules"
WHERE "signal_type" = 'metric';--> statement-breakpoint
UPDATE "alert_rules" AS rules
SET "destination_ids_json" = COALESCE(
diff --git a/packages/db/src/migrations.test.ts b/packages/db/src/migrations.test.ts
index 555d02aef..2a4e3abca 100644
--- a/packages/db/src/migrations.test.ts
+++ b/packages/db/src/migrations.test.ts
@@ -107,7 +107,7 @@ describe("bundled migrations", () => {
}
}, 30_000)
- it("converts metric rules, invalidates stale plans, and removes retired destinations", async () => {
+ it("deletes metric rules and removes retired destinations", async () => {
const pg = new PGlite()
await pg.exec(readMigrationSqlBefore("0026_windy_bromley"))
@@ -129,8 +129,8 @@ describe("bundled migrations", () => {
created_at, updated_at, created_by, updated_by
) VALUES
(
- 'rule_multi', 'org_1', 'Multi-service metric', 'warning',
- '["api", "checkout"]', '["prod", "staging"]', 'metric',
+ 'rule_metric', 'org_1', 'Legacy metric', 'warning',
+ '["api"]', '["prod"]', 'metric',
'gt', 10, 5, 'http.requests', 'sum', 'rate',
'["dest_slack", "dest_webhook"]', '{"preserved": true}', 'max', 'skip',
$1, $1, 'user_1', 'user_1'
@@ -143,39 +143,41 @@ describe("bundled migrations", () => {
)`,
[now],
)
+ await pg.query(
+ `INSERT INTO alert_rule_states (org_id, rule_id, group_key, updated_at)
+ VALUES ('org_1', 'rule_metric', '__total__', $1)`,
+ [now],
+ )
+ await pg.query(
+ `INSERT INTO alert_incidents (
+ id, org_id, rule_id, incident_key, rule_name, signal_type, severity, status,
+ comparator, threshold, first_triggered_at, last_triggered_at, dedupe_key,
+ created_at, updated_at
+ ) VALUES (
+ 'inc_metric', 'org_1', 'rule_metric', 'metric-incident', 'Legacy metric',
+ 'metric', 'warning', 'open', 'gt', 10, $1, $1, 'metric-dedupe', $1, $1
+ )`,
+ [now],
+ )
+ await pg.query(
+ `INSERT INTO alert_delivery_events (
+ id, org_id, incident_id, rule_id, destination_id, delivery_key, event_type,
+ attempt_number, status, scheduled_at, payload_json, created_at, updated_at
+ ) VALUES (
+ 'delivery_metric', 'org_1', 'inc_metric', 'rule_metric', 'dest_webhook',
+ 'metric-delivery', 'trigger', 1, 'queued', $1, '{}', $1, $1
+ )`,
+ [now],
+ )
await pg.exec(readFileSync(resolve(migrationsDir(), "0026_windy_bromley.sql"), "utf8"))
- const migrated = await pg.query<{
- signal_type: string
- service_names_json: unknown
- environments_json: unknown
- group_by: string | null
- destination_ids_json: string[]
- query_spec_json: unknown
- reducer: string
- sample_count_strategy: string | null
- query_builder_draft_json: {
- whereClause: string
- groupBy: string[]
- addOns: { groupBy: boolean }
- }
- }>("SELECT * FROM alert_rules WHERE id = 'rule_multi'")
- expect(migrated.rows[0]).toMatchObject({
- signal_type: "builder_query",
- service_names_json: null,
- environments_json: null,
- group_by: null,
- destination_ids_json: ["dest_webhook"],
- query_spec_json: null,
- reducer: "identity",
- sample_count_strategy: "metric_data_points",
- query_builder_draft_json: {
- whereClause: 'service.name = "api,checkout" AND deployment.environment = "prod,staging"',
- groupBy: ["service.name"],
- addOns: { groupBy: true },
- },
- })
+ for (const table of ["alert_delivery_events", "alert_incidents", "alert_rule_states", "alert_rules"]) {
+ const deleted = await pg.query<{ count: number }>(
+ `SELECT count(*)::int AS count FROM ${table} WHERE ${table === "alert_rules" ? "id" : "rule_id"} = 'rule_metric'`,
+ )
+ expect(deleted.rows[0]?.count, `${table} metric rows`).toBe(0)
+ }
const orphaned = await pg.query<{ enabled: boolean; destination_ids_json: string[] }>(
"SELECT enabled, destination_ids_json FROM alert_rules WHERE id = 'rule_orphaned'",
diff --git a/packages/domain/src/http/alerts.test.ts b/packages/domain/src/http/alerts.test.ts
index cdafd5856..fded48e5b 100644
--- a/packages/domain/src/http/alerts.test.ts
+++ b/packages/domain/src/http/alerts.test.ts
@@ -5,16 +5,14 @@ import {
AlertNotificationTemplate,
AlertRuleUpsertRequest,
AlertSignalType,
- HistoricalAlertSignalType,
PagerDutyAlertDestinationConfig,
SlackBotAlertDestinationConfig,
WebhookAlertDestinationConfig,
} from "./alerts"
describe("alert signal compatibility", () => {
- it("keeps metric readable in history while rejecting it for new rules", () => {
+ it("rejects the retired metric signal", () => {
expect(Exit.isFailure(Schema.decodeUnknownExit(AlertSignalType)("metric"))).toBe(true)
- expect(Schema.decodeUnknownSync(HistoricalAlertSignalType)("metric")).toBe("metric")
})
})
diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts
index 2ab71d49e..28f6bd144 100644
--- a/packages/domain/src/http/alerts.ts
+++ b/packages/domain/src/http/alerts.ts
@@ -48,20 +48,6 @@ export const AlertSignalType = Schema.Literals([
})
export type AlertSignalType = Schema.Schema.Type
-/**
- * Signal values that may still exist in immutable alert history or queued
- * delivery payloads. New and updated rules intentionally use
- * {@link AlertSignalType}, which excludes the retired `metric` rule shape.
- */
-export const HistoricalAlertSignalType = Schema.Union([
- AlertSignalType,
- Schema.Literal("metric"),
-]).annotate({
- identifier: "@maple/HistoricalAlertSignalType",
- title: "Historical Alert Signal Type",
-})
-export type HistoricalAlertSignalType = Schema.Schema.Type
-
export const AlertGroupByDimension = Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed()).annotate({
identifier: "@maple/AlertGroupByDimension",
title: "Alert Group By Dimension",
@@ -600,7 +586,7 @@ export class AlertIncidentDocument extends Schema.Class("
ruleId: AlertRuleId,
ruleName: Schema.String,
groupKey: Schema.NullOr(Schema.String),
- signalType: HistoricalAlertSignalType,
+ signalType: AlertSignalType,
severity: AlertSeverity,
status: AlertIncidentStatus,
comparator: AlertComparator,
@@ -726,7 +712,7 @@ export class AlertCheckDocument extends Schema.Class("AlertC
timestamp: IsoDateTimeString,
groupKey: Schema.String,
status: AlertCheckStatus,
- signalType: HistoricalAlertSignalType,
+ signalType: AlertSignalType,
comparator: AlertComparator,
threshold: Schema.Number,
thresholdUpper: Schema.NullOr(Schema.Number),
diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts
index 2d682d10c..e16499148 100644
--- a/packages/domain/src/http/v2/alert-incidents.ts
+++ b/packages/domain/src/http/v2/alert-incidents.ts
@@ -5,7 +5,7 @@ import {
AlertEventType,
AlertIncidentStatus,
AlertSeverity,
- HistoricalAlertSignalType,
+ AlertSignalType,
} from "../alerts"
import { AuthorizationV2, V2SchemaErrors } from "./auth"
import { ListOf, ListQuery, Timestamp } from "./envelopes"
@@ -56,7 +56,7 @@ export const V2AlertIncident = Schema.Struct({
group_key: Schema.NullOr(Schema.String).annotate({
description: "The breaching group for grouped rules, or `null` for ungrouped rules.",
}),
- signal_type: HistoricalAlertSignalType.annotate({
+ signal_type: AlertSignalType.annotate({
description: "The signal the rule measures.",
examples: ["error_rate"],
}),
diff --git a/packages/domain/src/http/v2/alert-rules.ts b/packages/domain/src/http/v2/alert-rules.ts
index 185c7dad3..0be939aa6 100644
--- a/packages/domain/src/http/v2/alert-rules.ts
+++ b/packages/domain/src/http/v2/alert-rules.ts
@@ -7,7 +7,6 @@ import {
AlertComparator,
AlertEvaluationStatus,
AlertIncidentTransition,
- HistoricalAlertSignalType,
AlertNotificationTemplate,
AlertSeverity,
AlertSignalType,
@@ -539,7 +538,7 @@ export const V2AlertCheck = Schema.Struct({
description: "`breached`, `healthy`, `skipped`, or `error` (the evaluation query failed).",
examples: ["breached"],
}),
- signal_type: HistoricalAlertSignalType,
+ signal_type: AlertSignalType,
comparator: AlertComparator,
threshold: Schema.Number,
threshold_upper: Schema.NullOr(Schema.Number),
diff --git a/packages/domain/src/query-engine.ts b/packages/domain/src/query-engine.ts
index 6206d8435..83ff05e70 100644
--- a/packages/domain/src/query-engine.ts
+++ b/packages/domain/src/query-engine.ts
@@ -119,8 +119,6 @@ export const MetricsFilters = Schema.Struct({
metricNames: Schema.optional(Schema.Array(MetricName)),
metricType: MetricType,
serviceName: Schema.optional(ServiceName),
- /** Multi-value service scope. When present it supersedes `serviceName`. */
- serviceNames: Schema.optional(Schema.Array(ServiceName)),
// Metrics tables have no pre-extracted DeploymentEnv column, so this lowers to
// a predicate on `ResourceAttributes['deployment.environment']` (see
// `metricsTimeseriesQuery`). Same field name as TracesFilters/LogsFilters.
diff --git a/packages/query-engine/src/ch/queries/metrics.test.ts b/packages/query-engine/src/ch/queries/metrics.test.ts
index 9d8c08ce9..002e54f0c 100644
--- a/packages/query-engine/src/ch/queries/metrics.test.ts
+++ b/packages/query-engine/src/ch/queries/metrics.test.ts
@@ -45,17 +45,6 @@ describe("metricsTimeseriesQuery", () => {
expect(sql).toContain("ResourceAttributes['deployment.environment'] IN ('production')")
})
- it("uses a multi-service IN predicate instead of the scalar service filter", () => {
- const q = metricsTimeseriesQuery({
- metricType: "sum",
- serviceName: "ignored",
- serviceNames: ["api", "checkout"],
- })
- const { sql } = compileCH(q, baseParams)
- expect(sql).toContain("ServiceName IN ('api', 'checkout')")
- expect(sql).not.toContain("ServiceName = 'ignored'")
- })
-
it("omits the environment predicate when no environments are selected", () => {
const q = metricsTimeseriesQuery({ metricType: "sum", environments: [] })
const { sql } = compileCH(q, baseParams)
@@ -184,13 +173,6 @@ describe("metricsTimeseriesRateQuery", () => {
expect(sql).toContain("ServiceName = 'api'")
})
- it("applies a multi-service filter in the CTE", () => {
- const q = metricsTimeseriesRateQuery({ serviceName: "ignored", serviceNames: ["api", "checkout"] })
- const { sql } = compileCH(q, baseParams)
- expect(sql).toContain("ServiceName IN ('api', 'checkout')")
- expect(sql).not.toContain("ServiceName = 'ignored'")
- })
-
it("applies attributeKey filter", () => {
const q = metricsTimeseriesRateQuery({
attributeKey: "region",
@@ -275,7 +257,9 @@ describe("metricsTimeseriesRateQuery", () => {
it("applies an environment filter in the CTE", () => {
const q = metricsTimeseriesRateQuery({ environments: ["production", "staging"] })
const { sql } = compileCH(q, baseParams)
- expect(sql).toContain("ResourceAttributes['deployment.environment'] IN ('production', 'staging')")
+ expect(sql).toContain(
+ "ResourceAttributes['deployment.environment'] IN ('production', 'staging')",
+ )
})
it("falls back to raw metrics_sum when an environment filter is set", () => {
diff --git a/packages/query-engine/src/ch/queries/metrics.ts b/packages/query-engine/src/ch/queries/metrics.ts
index 39f5c7c0c..21fef51bd 100644
--- a/packages/query-engine/src/ch/queries/metrics.ts
+++ b/packages/query-engine/src/ch/queries/metrics.ts
@@ -44,8 +44,6 @@ const DEPLOYMENT_ENV_KEY = "deployment.environment"
interface MetricsQueryOpts {
metricType: MetricType
serviceName?: string
- /** Multi-service scope. When non-empty it supersedes `serviceName`. */
- serviceNames?: readonly string[]
/** Deployment environments to scope to. Empty/undefined means all. */
environments?: readonly string[]
groupByAttributeKey?: string
@@ -116,9 +114,7 @@ export function metricsTimeseriesQuery(opts: MetricsTimeseriesOpts) {
$.OrgId.eq(param.string("orgId")),
$.TimeUnix.gte(param.dateTime("startTime")),
$.TimeUnix.lte(param.dateTime("endTime")),
- opts.serviceNames?.length
- ? CH.inList($.ServiceName, opts.serviceNames)
- : CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
+ CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
CH.when(opts.attributeKey, (k: string) => $.Attributes.get(k).eq(opts.attributeValue ?? "")),
opts.environments?.length
? CH.inList($.ResourceAttributes.get(DEPLOYMENT_ENV_KEY), opts.environments)
@@ -147,8 +143,6 @@ export interface MetricsRateTimeseriesOpts {
metricNames?: ReadonlyArray
bucketSeconds?: number
serviceName?: string
- /** Multi-service scope. When non-empty it supersedes `serviceName`. */
- serviceNames?: ReadonlyArray
/** Deployment environments to scope to. Empty/undefined means all. */
environments?: readonly string[]
groupByAttributeKey?: string
@@ -236,9 +230,7 @@ function metricsTimeseriesRateFromSpanMetricsCallsHourly(
$.MetricName.eq(param.string("metricName")),
$.Hour.gte(previousBucket),
$.Hour.lte(endBucket),
- opts.serviceNames?.length
- ? CH.inList($.ServiceName, opts.serviceNames)
- : CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
+ CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
CH.when(opts.attributeKey === "span.kind" ? opts.attributeValue : undefined, (v: string) =>
$.SpanKind.eq(v),
),
@@ -390,9 +382,7 @@ export function metricsTimeseriesRateQuery(
CH.dynamicColumn("IsMonotonic").eq(1),
$.TimeUnix.gte(CH.intervalSub(param.dateTime("startTime"), param.int("bucketSeconds"))),
$.TimeUnix.lte(param.dateTime("endTime")),
- opts.serviceNames?.length
- ? CH.inList($.ServiceName, opts.serviceNames)
- : CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
+ CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
CH.when(opts.attributeKey, (k: string) => $.Attributes.get(k).eq(opts.attributeValue ?? "")),
opts.environments?.length
? CH.inList($.ResourceAttributes.get(DEPLOYMENT_ENV_KEY), opts.environments)
diff --git a/packages/query-engine/src/query-builder/model.test.ts b/packages/query-engine/src/query-builder/model.test.ts
index a0e878211..2517e0abe 100644
--- a/packages/query-engine/src/query-builder/model.test.ts
+++ b/packages/query-engine/src/query-builder/model.test.ts
@@ -192,16 +192,15 @@ describe("metrics resource.* support", () => {
expect(filters?.serviceName).toBe("api")
})
- it("lowers a comma-separated service scope to serviceNames", () => {
+ it("lowers deployment environments into the metrics filter", () => {
const { warnings, filters } = metricsFiltersOf({
- whereClause: 'service.name = "api,checkout"',
+ whereClause: 'deployment.environment = "production,staging"',
})
expect(warnings).toEqual([])
- expect(filters?.serviceNames).toEqual(["api", "checkout"])
- expect(filters).not.toHaveProperty("serviceName")
+ expect(filters?.environments).toEqual(["production", "staging"])
})
- it("warns (blocking) when the 5 resource-filter cap is exceeded", () => {
+ it("warns when the 5 resource-filter cap is exceeded", () => {
const clause = ["a", "b", "c", "d", "e", "f"].map((k) => `resource.${k} = "1"`).join(" AND ")
const { warnings, filters } = metricsFiltersOf({ whereClause: clause })
expect(filters?.resourceAttributeFilters).toHaveLength(5)
diff --git a/packages/query-engine/src/query-builder/model.ts b/packages/query-engine/src/query-builder/model.ts
index fd3594ec6..7d33611aa 100644
--- a/packages/query-engine/src/query-builder/model.ts
+++ b/packages/query-engine/src/query-builder/model.ts
@@ -457,7 +457,6 @@ interface MetricsFilterAccumulator {
metricName: string
metricType: QueryBuilderMetricType
serviceName?: string
- serviceNames?: string[]
environments?: string[]
groupByAttributeKey?: string
groupByResourceAttributeKey?: string
@@ -514,15 +513,7 @@ function applyMetricsClause(
}
return Match.value(key).pipe(
- Match.when("service.name", () => {
- const serviceNames = splitCsv(clause.value)
- if (serviceNames.length > 1) {
- const { serviceName: _serviceName, ...rest } = filters
- return { ...rest, serviceNames }
- }
- const { serviceNames: _serviceNames, ...rest } = filters
- return { ...rest, serviceName: serviceNames[0] ?? clause.value }
- }),
+ Match.when("service.name", () => ({ ...filters, serviceName: clause.value })),
Match.when("deployment.environment", () => ({
...filters,
environments: splitCsv(clause.value),
@@ -1163,10 +1154,7 @@ export function formatFiltersAsWhereClause(params: Record): str
const clauses: string[] = []
- if (Array.isArray(filters.serviceNames) && filters.serviceNames.length > 0) {
- const val = filters.serviceNames.filter((item): item is string => typeof item === "string").join(",")
- if (val) clauses.push(`service.name = "${val}"`)
- } else if (typeof filters.serviceName === "string" && filters.serviceName.trim()) {
+ if (typeof filters.serviceName === "string" && filters.serviceName.trim()) {
clauses.push(`service.name = "${filters.serviceName.trim()}"`)
}
diff --git a/packages/query-engine/src/runtime/metrics-alert-evaluation.test.ts b/packages/query-engine/src/runtime/metrics-alert-evaluation.test.ts
new file mode 100644
index 000000000..20800b095
--- /dev/null
+++ b/packages/query-engine/src/runtime/metrics-alert-evaluation.test.ts
@@ -0,0 +1,131 @@
+import { assert, describe, it } from "@effect/vitest"
+import { DeploymentEnvironment, MetricName, OrgId } from "@maple/domain"
+import { Effect } from "effect"
+import type { MetricsTimeseriesQuery } from "../query-engine"
+import {
+ makeQueryEngineEvaluateSeries,
+ type AlertEvaluateRequest,
+ type QueryEngineWarehouse,
+} from "./query-engine"
+
+const tenant = { orgId: OrgId.make("org_metrics_alert_test") }
+
+const makeRequest = (query: MetricsTimeseriesQuery): AlertEvaluateRequest => ({
+ startTime: "2026-04-01 00:00:00",
+ endTime: "2026-04-01 01:00:00",
+ source: { kind: "spec", query },
+ reducer: "identity",
+ sampleCountStrategy: "metric_data_points",
+})
+
+const makeWarehouse = (
+ rows: ReadonlyArray>,
+ onCompiled: (sql: string, context: string | undefined) => void = () => undefined,
+): QueryEngineWarehouse => ({
+ sqlQuery: () => Effect.die("unexpected sqlQuery"),
+ rawSqlQuery: () => Effect.die("unexpected rawSqlQuery"),
+ compiledQuery(_tenant, compiled, options) {
+ onCompiled(compiled.sql, options?.context)
+ return Effect.succeed(rows as ReadonlyArray)
+ },
+ compiledQueryWithCapabilities: () => Effect.die("unexpected capability-aware query"),
+})
+
+const baseFilters = {
+ metricName: MetricName.make("http.requests"),
+ metricType: "sum" as const,
+}
+
+describe("metric alert evaluation", () => {
+ it.effect("lowers environments and preserves regular aggregate sample counts", () =>
+ Effect.gen(function* () {
+ let sql = ""
+ let context: string | undefined
+ const warehouse = makeWarehouse(
+ [
+ {
+ bucket: "2026-04-01 00:00:00",
+ serviceName: "",
+ attributeValue: "",
+ avgValue: 3,
+ minValue: 1,
+ maxValue: 5,
+ sumValue: 12,
+ dataPointCount: 4,
+ },
+ ],
+ (compiledSql, compiledContext) => {
+ sql = compiledSql
+ context = compiledContext
+ },
+ )
+
+ const result = yield* makeQueryEngineEvaluateSeries(warehouse)(
+ tenant,
+ makeRequest({
+ kind: "timeseries",
+ source: "metrics",
+ metric: "sum",
+ filters: {
+ ...baseFilters,
+ environments: [DeploymentEnvironment.make("production")],
+ },
+ bucketSeconds: 3600,
+ }),
+ )
+
+ assert.include(sql, "OrgId = 'org_metrics_alert_test'")
+ assert.include(sql, "ResourceAttributes['deployment.environment'] IN ('production')")
+ assert.strictEqual(context, "metricsAlertEval")
+ assert.deepStrictEqual(result, [
+ {
+ bucket: "2026-04-01T00:00:00.000Z",
+ groupKey: "all",
+ value: 12,
+ sampleCount: 4,
+ },
+ ])
+ }),
+ )
+
+ for (const [metric, expectedValue] of [
+ ["rate", 2.5],
+ ["increase", 10],
+ ] as const) {
+ it.effect(`evaluates ${metric} rows with metric data-point sample counts`, () =>
+ Effect.gen(function* () {
+ let context: string | undefined
+ const warehouse = makeWarehouse(
+ [
+ {
+ bucket: "2026-04-01 00:00:00",
+ serviceName: "api",
+ attributeValue: "",
+ rateValue: 2.5,
+ increaseValue: 10,
+ dataPointCount: 4,
+ },
+ ],
+ (_sql, compiledContext) => {
+ context = compiledContext
+ },
+ )
+
+ const result = yield* makeQueryEngineEvaluateSeries(warehouse)(
+ tenant,
+ makeRequest({
+ kind: "timeseries",
+ source: "metrics",
+ metric,
+ filters: baseFilters,
+ bucketSeconds: 3600,
+ }),
+ )
+
+ assert.strictEqual(context, "metricsRateIncreaseAlertEval")
+ assert.strictEqual(result[0]?.value, expectedValue)
+ assert.strictEqual(result[0]?.sampleCount, 4)
+ }),
+ )
+ }
+})
diff --git a/packages/query-engine/src/runtime/query-engine.ts b/packages/query-engine/src/runtime/query-engine.ts
index 3dc286482..bd4bd59dd 100644
--- a/packages/query-engine/src/runtime/query-engine.ts
+++ b/packages/query-engine/src/runtime/query-engine.ts
@@ -835,6 +835,66 @@ const executeCHQuery = Effect.fnUntraced(function* <
return yield* annotateWarehouseError(warehouse.compiledQuery(tenant, compiled, options), context)
})
+type MetricsTimeseriesSpec = Extract
+
+const executeMetricsTimeseriesRows = Effect.fnUntraced(function* (
+ warehouse: QueryEngineWarehouse,
+ tenant: T,
+ query: MetricsTimeseriesSpec,
+ range: { readonly startTime: string; readonly endTime: string; readonly bucketSeconds: number },
+ contexts: { readonly value: string; readonly rate: string },
+) {
+ const groupByAttributeKey = query.groupBy?.includes("attribute")
+ ? query.filters.groupByAttributeKey
+ : undefined
+ const groupByResourceAttributeKey = query.groupBy?.includes("resource_attribute")
+ ? query.filters.groupByResourceAttributeKey
+ : undefined
+ const attributeFilter = query.filters.attributeFilters?.[0]
+ const options = {
+ serviceName: query.filters.serviceName,
+ environments: query.filters.environments,
+ groupByAttributeKey,
+ groupByResourceAttributeKey,
+ attributeKey: attributeFilter?.key,
+ attributeValue: attributeFilter?.value,
+ resourceAttributeFilters: query.filters.resourceAttributeFilters,
+ groupBy: query.groupBy,
+ seriesLimit: query.seriesLimit,
+ }
+ const params = {
+ orgId: tenant.orgId,
+ metricName: query.filters.metricName,
+ ...range,
+ }
+ const groupByKey = groupByAttributeKey ?? groupByResourceAttributeKey
+
+ if (query.metric === "rate" || query.metric === "increase") {
+ const rows = yield* executeCHQuery(
+ warehouse,
+ tenant,
+ CH.metricsTimeseriesRateQuery({
+ ...options,
+ metricName: query.filters.metricName,
+ metricNames: query.filters.metricNames,
+ bucketSeconds: range.bucketSeconds,
+ }),
+ params,
+ contexts.rate,
+ )
+ return { kind: "rate" as const, rows, groupByKey }
+ }
+
+ const rows = yield* executeCHQuery(
+ warehouse,
+ tenant,
+ CH.metricsTimeseriesQuery({ ...options, metricType: query.filters.metricType }),
+ params,
+ contexts.value,
+ )
+ return { kind: "value" as const, rows, groupByKey }
+})
+
/** Same as executeCHQuery but for union queries. */
const executeCHUnionQuery = Effect.fnUntraced(function* <
Output extends Record,
@@ -1253,58 +1313,25 @@ export const makeQueryEngineExecute = (warehouse: QueryEn
}
if (request.query.source === "metrics" && request.query.kind === "timeseries") {
- const groupByAttribute = request.query.groupBy?.includes("attribute")
- const groupByAttributeKey = groupByAttribute
- ? request.query.filters.groupByAttributeKey
- : undefined
- const groupByResourceAttributeKey = request.query.groupBy?.includes("resource_attribute")
- ? request.query.filters.groupByResourceAttributeKey
- : undefined
- const attributeFilter = request.query.filters.attributeFilters?.[0]
- const resourceAttributeFilters = request.query.filters.resourceAttributeFilters
-
- const isRateOrIncrease = request.query.metric === "rate" || request.query.metric === "increase"
-
- if (isRateOrIncrease) {
- const compiled = CH.compile(
- CH.metricsTimeseriesRateQuery({
- metricName: request.query.filters.metricName,
- metricNames: request.query.filters.metricNames,
- bucketSeconds: bucketSeconds!,
- serviceName: request.query.filters.serviceName,
- serviceNames: request.query.filters.serviceNames,
- environments: request.query.filters.environments,
- groupByAttributeKey,
- groupByResourceAttributeKey,
- attributeKey: attributeFilter?.key,
- attributeValue: attributeFilter?.value,
- resourceAttributeFilters,
- groupBy: request.query.groupBy,
- seriesLimit: request.query.seriesLimit,
- }),
- {
- orgId: tenant.orgId,
- metricName: request.query.filters.metricName,
- startTime: request.startTime,
- endTime: request.endTime,
- bucketSeconds: bucketSeconds!,
- },
- )
- const rateResult = yield* annotateWarehouseError(
- warehouse.compiledQuery(tenant, compiled, {
- profile: "aggregation",
- context: "metricsRateIncrease",
- }),
- "metricsRateIncrease",
- )
+ const execution = yield* executeMetricsTimeseriesRows(
+ warehouse,
+ tenant,
+ request.query,
+ {
+ startTime: request.startTime,
+ endTime: request.endTime,
+ bucketSeconds: bucketSeconds!,
+ },
+ { value: "metricsTimeseries", rate: "metricsRateIncrease" },
+ )
+ if (execution.kind === "rate") {
const rateValueField = request.query.metric === "rate" ? "rateValue" : "increaseValue"
-
const data = shapeMetricsGroupRows(
- rateResult,
+ execution.rows,
(row) => Number(row[rateValueField]),
request.query.groupBy,
- groupByAttributeKey ?? groupByResourceAttributeKey,
+ execution.groupByKey,
fillOptions,
)
@@ -1317,32 +1344,6 @@ export const makeQueryEngineExecute = (warehouse: QueryEn
})
}
- const result = yield* executeCHQuery(
- warehouse,
- tenant,
- CH.metricsTimeseriesQuery({
- metricType: request.query.filters.metricType,
- serviceName: request.query.filters.serviceName,
- serviceNames: request.query.filters.serviceNames,
- environments: request.query.filters.environments,
- groupByAttributeKey,
- groupByResourceAttributeKey,
- attributeKey: attributeFilter?.key,
- attributeValue: attributeFilter?.value,
- resourceAttributeFilters,
- groupBy: request.query.groupBy,
- seriesLimit: request.query.seriesLimit,
- }),
- {
- orgId: tenant.orgId,
- metricName: request.query.filters.metricName,
- startTime: request.startTime,
- endTime: request.endTime,
- bucketSeconds: bucketSeconds!,
- },
- "metricsTimeseries",
- )
-
const metricValueField = {
avg: "avgValue",
sum: "sumValue",
@@ -1356,17 +1357,17 @@ export const makeQueryEngineExecute = (warehouse: QueryEn
request.query.groupBy?.includes("none") || !request.query.groupBy?.length
? groupTimeSeriesRows(
collapseMetricTimeseriesRows(
- result as ReadonlyArray,
+ execution.rows as ReadonlyArray,
request.query.metric,
),
(row) => row.value,
fillOptions,
)
: shapeMetricsGroupRows(
- result,
+ execution.rows,
(row) => Number(row[valueField]),
request.query.groupBy,
- groupByAttributeKey ?? groupByResourceAttributeKey,
+ execution.groupByKey,
fillOptions,
)
@@ -1989,6 +1990,7 @@ export const computeAlertBuckets = Effect.fnUntraced(function*
}
+
if (query.source === "traces") {
const opts = extractTracesOpts(query.filters as Record)
const rows = yield* executeCHQuery(
@@ -2053,61 +2055,14 @@ export const computeAlertBuckets = Effect.fnUntraced(function* 0 ? metricsAggregateValueForMetric(query.metric, row) : null
const groupKey = composeMetricsGroupKey(
@@ -2228,7 +2183,9 @@ const computeRawSqlBuckets = Effect.fnUntraced(function*
// by `Date.parse`, which would read that space-separated form as local
// time rather than UTC.
bucket: normalizeBucket(
- typeof row.bucket === "string" || row.bucket instanceof Date ? row.bucket : range.startTime,
+ typeof row.bucket === "string" || row.bucket instanceof Date
+ ? row.bucket
+ : range.startTime,
),
groupKey,
value,
@@ -2249,7 +2206,10 @@ export const reduceAlertBuckets = (
obs: ReadonlyArray,
reducer: QueryEngineAlertReducer,
): ReadonlyArray => {
- const byGroup = new Map>()
+ const byGroup = new Map<
+ string,
+ Array<{ value: number | null; sampleCount: number; hasData: boolean }>
+ >()
for (const o of obs) {
const entry = { value: o.value, sampleCount: o.sampleCount, hasData: o.sampleCount > 0 }
const list = byGroup.get(o.groupKey)
@@ -2262,6 +2222,7 @@ export const reduceAlertBuckets = (
return reducePerGroupObservations(byGroup, reducer)
}
+
/**
* Annotate, validate and resolve the bucket size for one alert evaluation.
* Shared by `evaluate` and `evaluateSeries` so the two can never drift on which
From 0ea774bec18554f6a8295a0692e1656e4fee5e58 Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Fri, 31 Jul 2026 18:00:41 +0200
Subject: [PATCH 4/4] fix(db): make branches grant new tables to PUBLIC by
themselves
Migrations created tables with owner-only privileges, so every new or
rebuilt table had to be healed by a post-migrate grant pass keyed to
MAPLE_PG_RUNTIME_ROLE. Forgetting that env var on main is what caused the
2026-07-29 ingest outage, and the hard failure guarding it made the prod
migration unrunnable without hunting down a role name by hand.
Pin the migration session to `postgres` (ALTER ROLE ... SET role, so it
applies to the connections drizzle-kit opens itself) and install default
privileges granting PUBLIC before migrating. New tables are then born with
correct grants and the sweep is only a backfill for pre-existing objects.
PUBLIC rather than a named role is what actually matters: three of prod's
four pscale_api_* login roles are members of `postgres` with rolinherit and
read a postgres-owned table through inheritance, but the ingest gateway
connects via PSBouncer as pscale_api_rg068pnctlxw, which is not a member and
reads only through PUBLIC. That asymmetry is why the outage broke the
gateway alone while the API and alerting workers kept serving.
- replace grant-runtime-role.ts with ensure-privileges.ts (db:ensure-privileges)
- drop the MAPLE_PG_RUNTIME_ROLE guard and env var entirely
- stg/pr-preview workflows run the step BEFORE migrate; ordering is
load-bearing since default privileges only affect objects created after
Verified on pr-152: a table created after the change goes from relacl=null
to PUBLIC arwd with no grant pass and no configuration.
---
.github/workflows/deploy-pr-preview.yml | 17 +-
.github/workflows/deploy-prd.yml | 16 +-
.github/workflows/deploy-stg.yml | 15 +-
packages/db/package.json | 2 +-
packages/db/scripts/ensure-privileges.ts | 122 ++++++++++++++
packages/db/scripts/grant-runtime-role.ts | 158 ------------------
.../db/scripts/planetscale-apply-schema.ts | 58 ++-----
packages/db/scripts/planetscale-connection.ts | 56 +++++++
packages/db/scripts/reset-preview-branch.ts | 2 +-
9 files changed, 218 insertions(+), 228 deletions(-)
create mode 100644 packages/db/scripts/ensure-privileges.ts
delete mode 100644 packages/db/scripts/grant-runtime-role.ts
diff --git a/.github/workflows/deploy-pr-preview.yml b/.github/workflows/deploy-pr-preview.yml
index d6608ce8a..d3884d619 100644
--- a/.github/workflows/deploy-pr-preview.yml
+++ b/.github/workflows/deploy-pr-preview.yml
@@ -114,17 +114,18 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: bun scripts/planetscale-pr-branch.ts up "$PR_NUMBER"
- - name: Run database migrations
+ # BEFORE migrate: installs default privileges granting PUBLIC on every
+ # table created from here on, so a new or rebuilt table can never land
+ # owner-only and leave a consumer with "permission denied for table …".
+ # Default privileges only apply to objects created after they are set,
+ # hence the ordering. See packages/db/scripts/ensure-privileges.ts.
+ - name: Ensure database privileges
if: ${{ github.event.action != 'closed' && env.PLANETSCALE_SERVICE_TOKEN != '' }}
- run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:migrate
+ run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:ensure-privileges
- # Re-grant table/sequence privileges to the runtime role after every
- # migrate — table-rebuild migrations DROP+CREATE tables, which strips
- # grants and leaves the deployed worker with "permission denied for
- # table …" on every tick. See packages/db/scripts/grant-runtime-role.ts.
- - name: Grant runtime role privileges
+ - name: Run database migrations
if: ${{ github.event.action != 'closed' && env.PLANETSCALE_SERVICE_TOKEN != '' }}
- run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:grant
+ run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:migrate
# Reassign everything this run's ephemeral role created to `postgres`
# so the NEXT deploy's role (which inherits postgres) can drop it —
diff --git a/.github/workflows/deploy-prd.yml b/.github/workflows/deploy-prd.yml
index 669fcd963..8b6eae29b 100644
--- a/.github/workflows/deploy-prd.yml
+++ b/.github/workflows/deploy-prd.yml
@@ -56,13 +56,13 @@ jobs:
run: bun install --frozen-lockfile
# NOTE: prod schema migrations are applied OUT OF BAND (manually, via
- # `MAPLE_PG_RUNTIME_ROLE= bun run --cwd packages/db ps:apply-schema main`
- # against the direct 5432 port), NOT by this workflow — so a deploy never
- # touches the prod database and needs no MAPLE_PG_URL/admin credential. The
- # worker binds to the pre-configured `maple-prd` Hyperdrive (see
- # apps/api/alchemy.run.ts); MAPLE_PG_RUNTIME_ROLE must be the role embedded
- # in that Hyperdrive's connection so apply-schema re-grants it table access
- # after any rebuild (without it the alerting worker hits "permission denied
- # for table …"). Run `ps:apply-schema main` separately to apply grants alone.
+ # `bun run migrate:prod` → `ps:apply-schema main`, against the direct 5432
+ # port), NOT by this workflow — so a deploy never touches the prod database
+ # and needs no MAPLE_PG_URL/admin credential. The worker binds to the
+ # pre-configured `maple-prd` Hyperdrive (see apps/api/alchemy.run.ts).
+ # apply-schema installs default privileges granting PUBLIC before it
+ # migrates, so new and rebuilt tables are readable by every consumer —
+ # including the ingest gateway, which reaches Postgres through PSBouncer as
+ # a role that does NOT inherit `postgres`.
- name: Deploy PRD stack with Alchemy
run: bun run alchemy:deploy:prd
diff --git a/.github/workflows/deploy-stg.yml b/.github/workflows/deploy-stg.yml
index 120da440c..850d98f99 100644
--- a/.github/workflows/deploy-stg.yml
+++ b/.github/workflows/deploy-stg.yml
@@ -50,6 +50,14 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
+ # BEFORE migrate: installs default privileges granting PUBLIC on every
+ # table created from here on, so a new or rebuilt table can never land
+ # owner-only and leave a consumer with "permission denied for table …".
+ # Default privileges only apply to objects created after they are set,
+ # hence the ordering. See packages/db/scripts/ensure-privileges.ts.
+ - name: Ensure database privileges
+ run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:ensure-privileges
+
# Schema migrations run against the PlanetScale branch over the
# DIRECT port (5432, never the PSBouncer/Hyperdrive poolers — DDL
# through a transaction pooler misbehaves). MAPLE_PG_URL is the
@@ -57,12 +65,5 @@ jobs:
- name: Run database migrations
run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:migrate
- # Re-grant table/sequence privileges to the runtime role after every
- # migrate — table-rebuild migrations DROP+CREATE tables, which strips
- # grants and leaves the deployed worker with "permission denied for
- # table …" on every tick. See packages/db/scripts/grant-runtime-role.ts.
- - name: Grant runtime role privileges
- run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:grant
-
- name: Deploy STG stack with Alchemy
run: bun run alchemy:deploy:stg
diff --git a/packages/db/package.json b/packages/db/package.json
index 0efb3b521..54eca16ce 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -16,7 +16,7 @@
"db:migrate": "drizzle-kit migrate --config ./drizzle.config.ts",
"db:push": "drizzle-kit push --config ./drizzle.config.ts",
"db:studio": "drizzle-kit studio --config ./drizzle.config.ts",
- "db:grant": "bun scripts/grant-runtime-role.ts",
+ "db:ensure-privileges": "bun scripts/ensure-privileges.ts",
"db:reset-preview": "bun scripts/reset-preview-branch.ts",
"db:normalize-preview": "bun scripts/normalize-preview-ownership.ts",
"ps:apply-schema": "bun scripts/planetscale-apply-schema.ts"
diff --git a/packages/db/scripts/ensure-privileges.ts b/packages/db/scripts/ensure-privileges.ts
new file mode 100644
index 000000000..4098ca872
--- /dev/null
+++ b/packages/db/scripts/ensure-privileges.ts
@@ -0,0 +1,122 @@
+#!/usr/bin/env bun
+/**
+ * Make a PlanetScale Postgres branch grant correct privileges to new tables BY
+ * ITSELF, so a plain `drizzle-kit migrate` can never again ship a table the
+ * fleet cannot read.
+ *
+ * Run this BEFORE migrate (order matters — `ALTER DEFAULT PRIVILEGES` only
+ * applies to objects created after it):
+ *
+ * DATABASE_URL="$MAPLE_PG_URL" bun packages/db/scripts/ensure-privileges.ts
+ *
+ * `ps:apply-schema` calls `ensureRuntimePrivileges` directly, so the prod path
+ * needs no separate invocation.
+ *
+ * ── Why PUBLIC, and why no runtime-role name ──────────────────────────────
+ * Prod has four `pscale_api_*` login roles. Three are members of `postgres`
+ * with rolinherit and so can read a postgres-owned table through inheritance;
+ * `pscale_api_rg068pnctlxw` — the ingest gateway, which connects via PSBouncer
+ * on 6432 rather than Hyperdrive — is NOT a member and reads only through
+ * PUBLIC. That asymmetry is the whole shape of the 2026-07-29 outage: a new
+ * table with owner-only privileges broke the gateway alone while the API and
+ * alerting workers kept serving, so it hid for 21.7h.
+ *
+ * PUBLIC covers every consumer without naming any of them, which is what lets
+ * this run with no configuration. The old approach needed MAPLE_PG_RUNTIME_ROLE
+ * naming one specific role, and granting that role was never what kept the
+ * fleet up.
+ *
+ * ── Why default privileges rather than a post-migrate grant sweep ─────────
+ * A sweep has to be remembered on every path that ever runs DDL. Default
+ * privileges are a property of the branch: once set, every subsequent
+ * `CREATE TABLE` is born correct, including tables created by a rebuild
+ * migration that DROPped its grants. The sweep below is kept only to heal
+ * objects that predate this — it is idempotent and cheap.
+ *
+ * Default privileges are keyed to the CREATING role, so this pins the session to
+ * `postgres` first (see `pinSessionRoleToPostgres` in planetscale-connection.ts,
+ * which does the same for the brokered prod connection). Where that is not
+ * permitted we fall back to keying them to the session's own role, which is
+ * correct for stg / PR previews — there the migrate role and the runtime role
+ * are the same identity.
+ */
+import postgres from "postgres"
+import { fail } from "./planetscale-connection"
+
+/**
+ * Role names we are willing to interpolate as a quoted identifier — the value
+ * comes from `SELECT current_user`, not user input, but is validated anyway.
+ * PlanetScale roles are dotted (`pscale_api_.`); `.` is safe because we
+ * always double-quote, where Postgres treats it as a literal character.
+ */
+const ROLE_PATTERN = /^[A-Za-z_][A-Za-z0-9_$.-]*$/
+
+const quoteIdent = (role: string): string => {
+ if (!ROLE_PATTERN.test(role)) {
+ fail(`Refusing to use unsafe role name ${JSON.stringify(role)} (allowed: ${ROLE_PATTERN})`)
+ }
+ return `"${role}"`
+}
+
+/**
+ * Statements are ordered: schema usage, then the default privileges that make
+ * FUTURE objects correct, then the backfill sweep for existing ones.
+ *
+ * PUBLIC is a keyword, not an identifier — it must never be quoted.
+ */
+const statements = (owner: string): readonly string[] => {
+ const ident = quoteIdent(owner)
+ return [
+ "GRANT USAGE ON SCHEMA public TO PUBLIC",
+ `ALTER DEFAULT PRIVILEGES FOR ROLE ${ident} IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO PUBLIC`,
+ `ALTER DEFAULT PRIVILEGES FOR ROLE ${ident} IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO PUBLIC`,
+ "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO PUBLIC",
+ "GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO PUBLIC",
+ ]
+}
+
+/** Ask the server — a PlanetScale URL username carries a routing suffix that is
+ * stripped before the server sees it, so it is not a usable role name. */
+const currentUser = async (sql: postgres.Sql): Promise => {
+ const [row] = await sql`SELECT current_user`
+ const role: unknown = row?.current_user
+ if (typeof role !== "string" || role.length === 0) {
+ return fail("`SELECT current_user` returned no role")
+ }
+ return role
+}
+
+/**
+ * Apply the privilege scaffolding on `connectionUrl`. Idempotent — safe to run
+ * before every migrate.
+ */
+export const ensureRuntimePrivileges = async (connectionUrl: string): Promise => {
+ const sql = postgres(connectionUrl, { max: 1, fetch_types: false })
+ try {
+ // Own the objects as `postgres` where membership allows it, so the default
+ // privileges are keyed to the role that will actually create prod's tables.
+ try {
+ await sql.unsafe("SET ROLE postgres")
+ } catch {
+ console.log("→ SET ROLE postgres not permitted — keying defaults to the session role")
+ }
+ const owner = await currentUser(sql)
+ console.log(`→ Ensuring PUBLIC privileges (objects created by "${owner}")\n`)
+ for (const statement of statements(owner)) {
+ console.log(` → ${statement}`)
+ await sql.unsafe(statement)
+ }
+ console.log(`\n✓ Privileges ensured — future tables owned by "${owner}" are granted to PUBLIC`)
+ } finally {
+ await sql.end()
+ }
+}
+
+// CLI entry (skipped when imported by ps:apply-schema).
+if (import.meta.main) {
+ const url = process.env.DATABASE_URL?.trim()
+ if (!url) {
+ fail("DATABASE_URL is not set — usage: DATABASE_URL=… bun scripts/ensure-privileges.ts")
+ }
+ await ensureRuntimePrivileges(url as string)
+}
diff --git a/packages/db/scripts/grant-runtime-role.ts b/packages/db/scripts/grant-runtime-role.ts
deleted file mode 100644
index adfc8cdd1..000000000
--- a/packages/db/scripts/grant-runtime-role.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Re-grant table/sequence privileges on a PlanetScale Postgres branch to the
- * RUNTIME app role (the role the deployed worker authenticates as through its
- * Hyperdrive binding) — distinct from the ephemeral `migrate--`
- * role that runs DDL.
- *
- * Why this exists: schema migrations create (and table-rebuild data-migrations
- * DROP+CREATE) tables owned by the migration role / `postgres`. In Postgres,
- * `DROP TABLE` discards all grants and a fresh `CREATE TABLE` only carries owner
- * privileges — so after a rebuild the runtime role hits `permission denied for
- * table …` on every read/write until it is re-granted. Nothing in plain
- * `drizzle-kit migrate` does that, so we run this idempotent grant pass right
- * after every migrate.
- *
- * Two connection modes:
- * 1. DATABASE_URL set → connect directly (stg / PR-preview CI, where the
- * migrate step already has MAPLE_PG_URL). Runtime role defaults to the
- * session's `current_user` (the managed Hyperdrive is built from the same
- * URL, so this is a harmless self-grant) unless MAPLE_PG_RUNTIME_ROLE
- * overrides. It must come from the session and NOT from the URL's
- * username: PlanetScale connection strings carry a routing suffix
- * (`.`) that is stripped before the server sees it, so
- * granting to the URL username fails with `role … does not exist`.
- * 2. branch arg only → broker an ephemeral postgres-inheriting credential
- * via `withBranchConnection` (prod `main`, applied out of band). The
- * brokered URL's user is the migration role, NOT the runtime role, so
- * MAPLE_PG_RUNTIME_ROLE is REQUIRED here.
- *
- * Usage:
- * # prod (out of band, alongside ps:apply-schema main)
- * MAPLE_PG_RUNTIME_ROLE= PLANETSCALE_ORG= \
- * bun packages/db/scripts/grant-runtime-role.ts main
- *
- * # against a direct connection string (stg / PR preview)
- * DATABASE_URL="$MAPLE_PG_URL" bun packages/db/scripts/grant-runtime-role.ts
- */
-import postgres from "postgres"
-import { fail, resolveDatabase, withBranchConnection } from "./planetscale-connection"
-
-/**
- * Postgres role names we accept. The role is interpolated into DDL as a quoted
- * identifier (it cannot be a bind parameter), so we whitelist a conservative
- * identifier charset and reject everything else rather than trust the input.
- * PlanetScale runtime roles are dotted (e.g. `pscale_api_.`); the `.` is
- * safe because we always double-quote, where Postgres treats it as a literal
- * character, not a schema separator.
- */
-const RUNTIME_ROLE_PATTERN = /^[A-Za-z_][A-Za-z0-9_$.-]*$/
-
-const quoteIdent = (role: string): string => {
- if (!RUNTIME_ROLE_PATTERN.test(role)) {
- fail(
- `Refusing to grant to unsafe role name ${JSON.stringify(role)} (allowed: ${RUNTIME_ROLE_PATTERN})`,
- )
- }
- return `"${role}"`
-}
-
-/**
- * The grant pass: re-grant the runtime role on every existing table/sequence in
- * `public`. This is the reliable fix — it covers every object regardless of
- * owner and is re-applied after every migrate, so a table-rebuild migration that
- * strips grants is healed on the next deploy.
- *
- * We deliberately do NOT use `ALTER DEFAULT PRIVILEGES`: keyed to the ephemeral
- * migration role it vanishes when that role is dropped each run, and keyed to a
- * hardcoded owner (`postgres`) it breaks wherever the owner role is named
- * differently (local docker, self-grant stg). The explicit re-grant after every
- * migrate makes default privileges unnecessary.
- */
-const grantStatements = (role: string): readonly string[] => {
- const ident = quoteIdent(role)
- return [
- `GRANT USAGE ON SCHEMA public TO ${ident}`,
- `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${ident}`,
- `GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO ${ident}`,
- // …and to PUBLIC, which is what actually keeps the fleet up.
- //
- // Granting the single named role is NOT sufficient. Prod has six
- // `pscale_api_*` login roles; only the Hyperdrive one holds explicit
- // grants, and every other consumer reads through PUBLIC — including the
- // ingest gateway, which connects via PSBouncer (6432, no Hyperdrive) as a
- // different role entirely. Every pre-existing table carries these PUBLIC
- // grants, so a new table without them is the odd one out and breaks the
- // gateway alone while the API and alerting keep working — which is exactly
- // how the 2026-07-29 outage hid for 21.7h.
- //
- // PUBLIC is a keyword, not an identifier: it must never be quoted.
- "GRANT USAGE ON SCHEMA public TO PUBLIC",
- "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO PUBLIC",
- "GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO PUBLIC",
- ]
-}
-
-/**
- * Open a short-lived connection on `connectionUrl` and apply the grant pass for
- * `role`. Idempotent — safe to re-run on every deploy.
- */
-const sessionRole = async (sql: postgres.Sql): Promise => {
- const [row] = await sql`SELECT current_user`
- const role: unknown = row?.current_user
- if (typeof role !== "string" || role.length === 0) {
- return fail("`SELECT current_user` returned no role — cannot determine who to grant to")
- }
- return role
-}
-
-export const applyRuntimeGrants = async (connectionUrl: string, explicitRole?: string): Promise => {
- const sql = postgres(connectionUrl, { max: 1, fetch_types: false })
- try {
- // No explicit role → self-grant to whoever this connection authenticated
- // as. Asking the server (rather than parsing the URL) is what makes this
- // work on PlanetScale, whose usernames carry a routing suffix.
- const role = explicitRole ?? (await sessionRole(sql))
- console.log(`→ Granting runtime privileges to "${role}"\n`)
- for (const statement of grantStatements(role)) {
- console.log(` → ${statement}`)
- await sql.unsafe(statement)
- }
- console.log(`✓ Runtime grants applied to "${role}"`)
- } finally {
- await sql.end()
- }
-}
-
-const requireRole = (): string => {
- const explicit = process.env.MAPLE_PG_RUNTIME_ROLE?.trim()
- if (explicit) return explicit
- return fail(
- "MAPLE_PG_RUNTIME_ROLE is not set — cannot determine the runtime app role to grant. " +
- "Set it to the role embedded in the prod Hyperdrive (maple-db) connection.",
- )
-}
-
-// CLI entry (skipped when imported by ps:apply-schema).
-if (import.meta.main) {
- const directUrl = process.env.DATABASE_URL?.trim()
- if (directUrl) {
- // Mode 1: direct connection. Without an override the grant targets the
- // session's own role — the managed Hyperdrive (stg / PR preview) is built
- // from this same URL, so that self-grant is a no-op which future-proofs
- // against the runtime/migrate roles ever diverging.
- console.log("→ Granting runtime privileges via DATABASE_URL")
- await applyRuntimeGrants(directUrl, process.env.MAPLE_PG_RUNTIME_ROLE?.trim() || undefined)
- } else {
- // Mode 2: broker an ephemeral credential for the branch (prod path).
- const branch = process.argv[2]?.trim()
- if (!branch) {
- fail("Usage: DATABASE_URL=… grant-runtime-role.ts OR grant-runtime-role.ts ")
- }
- const role = requireRole()
- await withBranchConnection(branch as string, async (connectionUrl) => {
- console.log(`→ Granting runtime privileges to "${role}" on ${resolveDatabase()}/${branch}\n`)
- await applyRuntimeGrants(connectionUrl, role)
- })
- }
-}
diff --git a/packages/db/scripts/planetscale-apply-schema.ts b/packages/db/scripts/planetscale-apply-schema.ts
index 02e29b93a..87162a16f 100644
--- a/packages/db/scripts/planetscale-apply-schema.ts
+++ b/packages/db/scripts/planetscale-apply-schema.ts
@@ -18,7 +18,7 @@
*/
import { spawnSync } from "node:child_process"
import { resolve } from "node:path"
-import { applyRuntimeGrants } from "./grant-runtime-role"
+import { ensureRuntimePrivileges } from "./ensure-privileges"
import { fail, resolveDatabase, withBranchConnection } from "./planetscale-connection"
const branch = process.argv[2]?.trim()
@@ -28,38 +28,19 @@ if (!branch) {
const packageDir = resolve(import.meta.dir, "..")
-// The runtime app role (the role the deployed worker authenticates as through
-// its Hyperdrive binding) differs from the ephemeral migration role used here.
-// A fresh `CREATE TABLE` carries OWNER privileges only, and a table-rebuild
-// migration DROPs the grants outright — so without the re-grant pass the app
-// role hits "permission denied for table …" on the new table.
-//
-// This is a hard failure on prod, not a warning. On 2026-07-29 a `main` apply
-// without MAPLE_PG_RUNTIME_ROLE created `org_spend_limits` with postgres-only
-// grants; the ingest gateway's startup probe joins that table, could not read
-// it, exited, burned Railway's 5 restart retries and stayed down — a 21.7h
-// total ingest outage for every org. The skip printed a warning nobody saw.
-const runtimeRole = process.env.MAPLE_PG_RUNTIME_ROLE?.trim()
-
-// Branches serving deployed traffic, where a missing grant pass takes production
-// down rather than merely inconveniencing a preview.
-const PROTECTED_BRANCHES = new Set(["main", "stg"])
-
-if (PROTECTED_BRANCHES.has(branch as string) && !runtimeRole) {
- fail(
- `Refusing to apply schema to "${branch}" without MAPLE_PG_RUNTIME_ROLE.\n\n` +
- " New tables are created with owner-only privileges, so the deployed app role\n" +
- " would get \"permission denied\" on anything this migration creates.\n\n" +
- " Set it to the role embedded in the prod Hyperdrive connection, e.g.\n" +
- ` MAPLE_PG_RUNTIME_ROLE= PLANETSCALE_ORG= bun scripts/planetscale-apply-schema.ts ${branch}\n\n` +
- " Discover the role with:\n" +
- " SELECT DISTINCT grantee FROM information_schema.table_privileges\n" +
- " WHERE table_name = 'org_ingest_keys';",
- )
-}
-
await withBranchConnection(branch as string, async (connectionUrl) => {
- console.log(`→ Applying schema to ${resolveDatabase()}/${branch} via drizzle-kit migrate\n`)
+ // BEFORE migrate, not after: this installs default privileges, which only
+ // apply to objects created after they are set. A fresh `CREATE TABLE` carries
+ // OWNER privileges only and a table-rebuild migration DROPs grants outright,
+ // so without this the ingest gateway — the one consumer that reads through
+ // PUBLIC rather than by inheriting `postgres` — hits "permission denied for
+ // table …" on anything the migration creates. That is the 2026-07-29 outage:
+ // `org_spend_limits` landed owner-only, the gateway's startup probe joins it,
+ // the probe failed, the process exited, Railway burned its 5 restart retries,
+ // and ingest was down 21.7h for every org.
+ await ensureRuntimePrivileges(connectionUrl)
+
+ console.log(`\n→ Applying schema to ${resolveDatabase()}/${branch} via drizzle-kit migrate\n`)
const proc = spawnSync("bun", ["run", "db:migrate"], {
cwd: packageDir,
env: { ...process.env, DATABASE_URL: connectionUrl },
@@ -69,17 +50,4 @@ await withBranchConnection(branch as string, async (connectionUrl) => {
fail("drizzle-kit migrate failed")
}
console.log(`\n✓ Schema applied to ${resolveDatabase()}/${branch}`)
-
- if (runtimeRole) {
- console.log(`\n→ Re-granting runtime privileges to "${runtimeRole}"\n`)
- await applyRuntimeGrants(connectionUrl, runtimeRole)
- } else {
- // Only reachable for preview branches — PROTECTED_BRANCHES bail out above.
- console.warn(
- `\n⚠ MAPLE_PG_RUNTIME_ROLE not set — SKIPPING runtime grants on "${branch}". Anything this ` +
- 'migration created is readable by its owner only, so a deployed app role would hit ' +
- '"permission denied for table …". Tolerated here because "' +
- `${branch}" is a preview branch; it is a hard error on ${[...PROTECTED_BRANCHES].join(" / ")}.`,
- )
- }
})
diff --git a/packages/db/scripts/planetscale-connection.ts b/packages/db/scripts/planetscale-connection.ts
index 08e7b956d..cb5185522 100644
--- a/packages/db/scripts/planetscale-connection.ts
+++ b/packages/db/scripts/planetscale-connection.ts
@@ -14,6 +14,7 @@
* (otherwise an interactive `pscale auth login` session is used)
*/
import { spawnSync } from "node:child_process"
+import postgres from "postgres"
const FAILURE = 1
@@ -138,6 +139,60 @@ const deleteCredential = (database: string, branch: string, id: string): void =>
}
}
+/**
+ * Role names we are willing to interpolate into DDL as a quoted identifier.
+ * The value comes from the server (`SELECT current_user`), not from user input,
+ * but it is still validated rather than trusted.
+ */
+const ROLE_PATTERN = /^[A-Za-z_][A-Za-z0-9_$.-]*$/
+
+/**
+ * Make the ephemeral migration role *run as* `postgres` for every session it
+ * opens, so DDL creates objects owned by `postgres`.
+ *
+ * This is what makes plain `drizzle-kit migrate` produce correctly-privileged
+ * tables with no follow-up grant pass. The branch carries default privileges
+ * keyed to `postgres` (`ALTER DEFAULT PRIVILEGES FOR ROLE postgres` — visible in
+ * `pg_default_acl` as `{=arwd/postgres}` on tables, `{=rwU/postgres}` on
+ * sequences), and those only fire for objects whose CREATING role is `postgres`.
+ * Without this, DDL runs as `migrate--`, the defaults never apply,
+ * and the new table lands with owner-only privileges.
+ *
+ * That gap is what caused the 2026-07-29 ingest outage. Ownership alone was
+ * never the issue — the role is revoked with `--successor postgres`, so tables
+ * end up postgres-owned anyway, and reassignment does not retroactively add
+ * ACLs. Three of prod's four `pscale_api_*` login roles are members of
+ * `postgres` with rolinherit, so they read a fresh table through inheritance;
+ * `pscale_api_rg068pnctlxw` — the ingest gateway, which connects via PSBouncer —
+ * is NOT a member and reads only through the PUBLIC grants the default
+ * privileges provide. Hence a new table broke the gateway alone while the API
+ * and alerting workers kept serving.
+ *
+ * `ALTER ROLE … SET role` (rather than a `SET ROLE` on one session) is what
+ * makes this hold for `drizzle-kit`, which opens its own connections: the
+ * setting is applied at login for every subsequent session of that role. It is
+ * permitted for a non-superuser altering itself because `role` is USERSET and
+ * the ephemeral role is created `--inherited-roles postgres`.
+ */
+const pinSessionRoleToPostgres = async (connectionUrl: string): Promise => {
+ const sql = postgres(connectionUrl, { max: 1, fetch_types: false })
+ try {
+ // Ask the server: PlanetScale connection strings carry a routing suffix
+ // (`.`) that is stripped before the server sees it, so the
+ // URL username is not a usable role name.
+ const [row] = await sql`SELECT current_user`
+ const role: unknown = row?.current_user
+ if (typeof role !== "string" || !ROLE_PATTERN.test(role)) {
+ fail(`Could not determine the migration role to pin (got ${JSON.stringify(role)})`)
+ return
+ }
+ await sql.unsafe(`ALTER ROLE "${role}" SET role = 'postgres'`)
+ console.log(`✓ Migration role "${role}" pinned to run as postgres\n`)
+ } finally {
+ await sql.end()
+ }
+}
+
/**
* Run `fn` with a direct (port 5432, the cluster `postgres` database)
* connection URL to the given branch, then revoke the ephemeral credential.
@@ -160,6 +215,7 @@ export const withBranchConnection = async (
console.log(`::add-mask::${credential.password}`)
console.log(`✓ Minted ephemeral credential for ${database}/${branch} (host ${host})\n`)
try {
+ await pinSessionRoleToPostgres(credential.url)
await fn(credential.url)
} finally {
console.log()
diff --git a/packages/db/scripts/reset-preview-branch.ts b/packages/db/scripts/reset-preview-branch.ts
index 1a818e9a6..369142d53 100644
--- a/packages/db/scripts/reset-preview-branch.ts
+++ b/packages/db/scripts/reset-preview-branch.ts
@@ -54,7 +54,7 @@ const fail = (message: string): never => {
/**
* Role/publication names are interpolated as quoted identifiers (they cannot be
* bind parameters), so whitelist a conservative charset — same rationale as
- * grant-runtime-role.ts. PlanetScale roles are dotted; `.` is literal inside
+ * ensure-privileges.ts. PlanetScale roles are dotted; `.` is literal inside
* double quotes.
*/
const IDENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_$.-]*$/