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 ? ( - - ) : null} ) } @@ -887,49 +868,6 @@ export function DestinationDialog({ /> - {form.type === "slack" && ( - <> -
- - - 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" - /> -
-
- - - onFormChange((current) => ({ - ...current, - channelLabel: event.target.value, - })) - } - placeholder="#ops-alerts" - className="font-mono text-xs" - /> -
- - )} - {form.type === "slack-bot" && ( )} - - {form.type === "hazel" && ( - <> -
- - - 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. -

-
-
- - - 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 && ( -
- - 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. -

- )} -
- )} +
+ + 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. +

+ )} +
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_$.-]*$/