Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
74ff30e
wip(slos): burn-rate alert rule per SLO
Makisuo Nov 6, 2026
1244703
Merge branch 'feat/slo-compute' into feat/slo-burn-alerts
Makisuo Nov 6, 2026
2c287c5
feat(slos): multiwindow burn-rate alerts through the alert engine
Makisuo Nov 6, 2026
d63a299
fix(slos): hold burn alerts through outages, cap SLOs, review fixes
Makisuo Nov 6, 2026
453ab1f
Merge branch 'feat/slo-compute' into feat/slo-burn-alerts
Makisuo Nov 6, 2026
8783e35
refactor(slos): tier state, window labels and rule name in @maple/dom…
Makisuo Nov 6, 2026
491af50
style(slos): effect String helpers in sloNameFromRuleName
Makisuo Nov 6, 2026
c1ce4d0
Merge branch 'feat/slo-compute' into feat/slo-burn-alerts
Makisuo Nov 6, 2026
f19c26d
Merge branch 'feat/slo-compute' into feat/slo-burn-alerts
Makisuo Nov 6, 2026
b260147
Merge branch 'feat/slo-compute' into feat/slo-burn-alerts
Makisuo Nov 6, 2026
bff51a8
fix(slos): a recovery message names every burn tier
Makisuo Nov 6, 2026
0ff5d8f
Merge branch 'feat/slo-compute' into feat/slo-burn-alerts
Makisuo Nov 6, 2026
b4fc336
chore(slos): regenerate the iOS OpenAPI spec
Makisuo Nov 6, 2026
1743297
Merge branch 'feat/slo-compute' into feat/slo-burn-alerts
Makisuo Nov 6, 2026
9ce727c
Merge branch 'feat/slo-compute' into feat/slo-burn-alerts
Makisuo Nov 6, 2026
85dfde3
Merge branch 'feat/slo-compute' into feat/slo-burn-alerts
Makisuo Nov 6, 2026
692cb65
fix(ios): label the slo_burn_rate signal type
Makisuo Nov 6, 2026
c865aad
fix(slos): burn rates read as 14.4x in notifications and on iOS; test…
Makisuo Nov 6, 2026
a4cf3d1
test(slos): a held critical tier keeps its page open while the warnin…
Makisuo Nov 6, 2026
fef1f48
test(slos): each burn tier notifies its own destinations
Makisuo Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/src/routes/v2/alchemy-provider.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { DashboardPersistenceService } from "@maple/backend/services/dashboards/
import { SharedDashboardService } from "@maple/backend/services/dashboards/SharedDashboardService"
import { AlertRuntime, AlertsService } from "@maple/backend/services/alerts/AlertsService"
import { UptimeAlertSource } from "@maple/backend/services/uptime/UptimeAlertSource"
import { SloAlertSource } from "@maple/backend/services/slos/SloAlertSource"
import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDestinationsService"
import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService"
import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService"
Expand Down Expand Up @@ -186,6 +187,9 @@ const makeHarness = () => {
alertReadModelsLive,
alertRulesLive,
UptimeAlertSource.layer.pipe(Layer.provide(testDb.layer)),
Layer.effect(SloAlertSource, SloAlertSource.make).pipe(
Layer.provide(Layer.mergeAll(testDb.layer, warehouseLive)),
),
),
),
)
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/routes/v2/alerts.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { DashboardPersistenceService } from "@maple/backend/services/dashboards/
import { SharedDashboardService } from "@maple/backend/services/dashboards/SharedDashboardService"
import { AlertRuntime, AlertsService } from "@maple/backend/services/alerts/AlertsService"
import { UptimeAlertSource } from "@maple/backend/services/uptime/UptimeAlertSource"
import { SloAlertSource } from "@maple/backend/services/slos/SloAlertSource"
import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDestinationsService"
import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService"
import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService"
Expand Down Expand Up @@ -190,6 +191,9 @@ const makeHarness = (
alertReadModelsLive,
alertRulesLive,
UptimeAlertSource.layer.pipe(Layer.provide(testDb.layer)),
Layer.effect(SloAlertSource, SloAlertSource.make).pipe(
Layer.provide(Layer.mergeAll(testDb.layer, warehouseLive)),
),
),
),
)
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/routes/v2/slos.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,18 @@ describe("v2 slos", () => {
window_days: 30,
})
const id: string = created.body.id
// Every SLO gets its burn-rate rule, alerting with no destinations until some are added.
expect(created.body.alert_rule_id).toMatch(/^alrt_/)
Comment thread
Makisuo marked this conversation as resolved.
Resolved
expect(created.body.alerting).toEqual({
enabled: true,
critical_destination_ids: [],
warning_destination_ids: [],
})
const paused = await harness.request("PATCH", `/v2/slos/${id}`, token, {
alerting: { enabled: false },
})
expect(paused.body.alerting.enabled).toBe(false)
expect(paused.body.alert_rule_id).toBe(created.body.alert_rule_id)

const listed = await harness.request("GET", "/v2/slos", token)
expect(listed.body.data.map((slo: { readonly id: string }) => slo.id)).toEqual([id])
Expand All @@ -195,6 +207,7 @@ describe("v2 slos", () => {
target_percent: 99,
window_days: 7,
environments: [],
alerting: { enabled: false },
})

const retrieved = await harness.request("GET", `/v2/slos/${id}`, token)
Expand Down
36 changes: 34 additions & 2 deletions apps/api/src/routes/v2/slos.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
sloSliToWire,
V2InsufficientPermissions,
} from "@maple/domain/http/v2"
import type { V2Slo, V2SloStatus } from "@maple/domain/http/v2"
import type { V2Slo, V2SloAlertingParams, V2SloStatus } from "@maple/domain/http/v2"
import { Effect, Array as Arr } from "effect"
import { auditDiff } from "@/routes/v2/audit-changes"
import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService"
Expand All @@ -28,6 +28,11 @@ export const toV2Slo = (slo: SloRecord): V2Slo => ({
target_percent: slo.targetPercent,
window_days: slo.windowDays,
alert_rule_id: slo.alertRuleId,
alerting: {
enabled: slo.alerting.enabled,
critical_destination_ids: slo.alerting.criticalDestinationIds,
warning_destination_ids: slo.alerting.warningDestinationIds,
},
created_at: isoTimestamp(slo.createdAt),
updated_at: isoTimestamp(slo.updatedAt),
})
Expand All @@ -49,9 +54,34 @@ const toV2Status = (sloId: SloRecord["id"], status: SloStatus): V2SloStatus => (
})

const sloAuditDiff = auditDiff({
fields: ["name", "description", "service_name", "environments", "sli", "target_percent", "window_days"],
fields: [
"name",
"description",
"service_name",
"environments",
"sli",
"target_percent",
"window_days",
"alerting",
],
})

/** Wire alerting params to the service's, keeping only the fields the caller sent. */
const alertingFromWire = (alerting: V2SloAlertingParams | undefined) =>
alerting === undefined
? undefined
: {
alerting: {
...(alerting.enabled === undefined ? undefined : { enabled: alerting.enabled }),
...(alerting.critical_destination_ids === undefined
? undefined
: { criticalDestinationIds: alerting.critical_destination_ids }),
...(alerting.warning_destination_ids === undefined
? undefined
: { warningDestinationIds: alerting.warning_destination_ids }),
},
}

const requireManage = Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* Policy.check(tenant.roles, "alerts:manage", (denial) =>
Expand Down Expand Up @@ -97,6 +127,7 @@ export const HttpV2SlosLive = HttpApiBuilder.group(MapleApiV2, "slos", (handlers
...(payload.window_days === undefined
? undefined
: { windowDays: payload.window_days }),
...alertingFromWire(payload.alerting),
createdBy: tenant.userId,
})
yield* recordHttpAudit("slo.created", {
Expand Down Expand Up @@ -128,6 +159,7 @@ export const HttpV2SlosLive = HttpApiBuilder.group(MapleApiV2, "slos", (handlers
...(payload.window_days === undefined
? undefined
: { windowDays: payload.window_days }),
...alertingFromWire(payload.alerting),
updatedBy: tenant.userId,
})
yield* recordHttpAudit("slo.updated", {
Expand Down
5 changes: 5 additions & 0 deletions apps/ios/Maple/Components/AlertFormatting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ enum SignalUnit: Codable {
case apdex
case count
case plain
/// An SLO burn rate: a multiple of the sustainable rate, shown as `14.4x` like the web.
case burnRate

func format(_ value: Double?) -> String {
guard let value, value.isFinite else { return Format.placeholder }
Expand All @@ -24,6 +26,7 @@ enum SignalUnit: Codable {
case .perMinute: return Format.count(value) + "/min"
case .apdex: return String(format: "%.2f", value)
case .count, .plain: return Format.count(value)
case .burnRate: return String(format: "%.1fx", value)
}
}
}
Expand All @@ -46,6 +49,8 @@ struct SignalDisplay: Codable {
case .rawQuery: self.init(label: "Raw query", unit: .plain)
// A monitor's hidden rule: down is 1, up is 0, so the value reads as a plain number.
case .uptime: self.init(label: "Uptime check", unit: .plain)
// An SLO's hidden rule: the value is how many times faster than sustainable the budget burns.
case .sloBurnRate: self.init(label: "SLO burn rate", unit: .burnRate)
}
}

Expand Down
3 changes: 2 additions & 1 deletion apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6971,7 +6971,8 @@
"throughput",
"builder_query",
"raw_query",
"uptime"
"uptime",
"slo_burn_rate"
],
"examples": [
"error_rate"
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/services/alerts/AlertDeliveryDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ type SummaryLineContext = Pick<
*/
export const buildSummaryLine = (context: SummaryLineContext, em: (value: string) => string): string => {
if (context.signalType === "uptime") return em(uptimeSummaryLine(context))
// An SLO's check names both windows and the tier, which one value and window cannot.
if (context.signalType === "slo_burn_rate" && context.detail != null && context.eventType !== "test")
return em(context.detail)
const signal = formatSignalLabel(context)
const observed = formatSignalMetric(context.value, signalDisplayOf(context))
const window = formatWindow(context.windowMinutes)
Expand Down
33 changes: 20 additions & 13 deletions packages/backend/src/services/alerts/AlertRuleModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
type AlertThresholds,
type QueryBuilderQueryDraftPayload,
type OrgId,
isManagedSignalType,
type ManagedSignalType,
} from "@maple/domain/http"
import * as PG from "@maple-dev/effect-orm/postgres"
import { AlertRules, type AlertRuleRow } from "@maple/db/tables"
Expand Down Expand Up @@ -229,12 +231,17 @@ const planGroupingTokens = (
return groupBy
}

/** Why the generic rule APIs refuse an `uptime` rule. */
export const UPTIME_RULE_MANAGED_MESSAGE =
"uptime rules belong to an uptime monitor; create, edit or delete the monitor instead"
/** Why the generic rule APIs refuse a rule another resource owns. */
export const managedRuleMessage = (signalType: ManagedSignalType): string =>
signalType === "uptime"
? "uptime rules belong to an uptime monitor; create, edit or delete the monitor instead"
: "slo_burn_rate rules belong to an SLO; create, edit or delete the SLO instead"

/** The stored plan of every `uptime` rule: no query, the evaluator reads the monitor's state. */
export const UPTIME_RULE_PLAN = new CompiledAlertQueryPlan({
/**
* The stored plan of every managed rule (`uptime`, `slo_burn_rate`): no query, the evaluator
* reads the owning resource instead.
*/
export const MANAGED_RULE_PLAN = new CompiledAlertQueryPlan({
kind: "spec",
query: null,
rawSql: null,
Expand Down Expand Up @@ -292,9 +299,9 @@ export const uptimeRuleFields = (monitor: {
rawQuerySql: null,
destinationIdsJson: [...monitor.destinationIds],
querySpecJson: null,
reducer: UPTIME_RULE_PLAN.reducer,
reducer: MANAGED_RULE_PLAN.reducer,
sampleCountStrategy: null,
noDataBehavior: UPTIME_RULE_PLAN.noDataBehavior,
noDataBehavior: MANAGED_RULE_PLAN.noDataBehavior,
} as const
}

Expand Down Expand Up @@ -539,8 +546,8 @@ export const compileRulePlan = Effect.fn("AlertsService.compileRulePlan")(functi
sampleCountStrategy: null,
noDataBehavior,
})
} else if (rule.signalType === "uptime") {
return yield* makeAlertValidationError(UPTIME_RULE_MANAGED_MESSAGE)
} else if (isManagedSignalType(rule.signalType)) {
return yield* makeAlertValidationError(managedRuleMessage(rule.signalType))
} else {
return yield* makeAlertValidationError(`Unsupported signal type: ${rule.signalType}`)
}
Expand Down Expand Up @@ -574,8 +581,8 @@ const parseCompiledPlan = (
component: "compiled_plan",
cause,
})
// An uptime rule reads its monitor's state, not the warehouse: no query to decode.
if (row.signalType === "uptime") return Effect.succeed(UPTIME_RULE_PLAN)
// A managed rule reads its owner (a monitor's state, an SLO's burn), never a stored query.
if (isManagedSignalType(row.signalType)) return Effect.succeed(MANAGED_RULE_PLAN)
if (row.signalType === "raw_query") {
if (row.rawQuerySql == null) {
return Effect.fail(invalid("Stored raw alert is missing its SQL query", "rawQuerySql is null"))
Expand Down Expand Up @@ -868,8 +875,8 @@ export const makeAlertRuleNormalizer = (runtime: AlertRuntimeApi) => {
request: AlertRuleUpsertRequest,
options?: { readonly forPreview?: boolean },
): Effect.fn.Return<NormalizedRule, AlertValidationError> {
if (request.signalType === "uptime") {
return yield* invalidRuleError([UPTIME_RULE_MANAGED_MESSAGE])
if (isManagedSignalType(request.signalType)) {
return yield* invalidRuleError([managedRuleMessage(request.signalType)])
}
const name = Str.trim(request.name)
// A builder query's draft carries its own scope and never reads the
Expand Down
16 changes: 9 additions & 7 deletions packages/backend/src/services/alerts/AlertRulesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
type AlertRuleUpsertRequest,
type OrgId,
type UserId,
isManagedSignalType,
MANAGED_SIGNAL_TYPES,
} from "@maple/domain/http"
import * as Orm from "@maple-dev/effect-orm/database"
import * as PG from "@maple-dev/effect-orm/postgres"
Expand All @@ -39,7 +41,7 @@ import {
rowToRuleDocument,
selectStoredAlertRules,
storedRuleCondition,
UPTIME_RULE_MANAGED_MESSAGE,
managedRuleMessage,
type RuleEvaluationState,
} from "./AlertRuleModel"
import { tierConditionChanged } from "./alert-thresholds"
Expand Down Expand Up @@ -252,11 +254,11 @@ export const makeAlertRulePersistence = (options: {
const activeRows = yield* db.run(
PG.from(AlertRules)
.select("id")
// Monitors' backing rules are capped by the monitor allowance instead.
// Managed rules (monitors, SLOs) are not counted against the rule cap.
.where(($) => [
$.orgId.eq(orgId),
$.enabled.eq(true),
$.signalType.neq("uptime"),
$.signalType.notIn(...MANAGED_SIGNAL_TYPES),
]),
)
const alreadyActive =
Expand Down Expand Up @@ -364,7 +366,7 @@ export const makeAlertRulePersistence = (options: {
db.run(
// Monitors' backing rules are managed through the monitor, so the rule list hides them.
selectStoredAlertRules()
.where(($) => [$.orgId.eq(orgId), $.signalType.neq("uptime")])
.where(($) => [$.orgId.eq(orgId), $.signalType.notIn(...MANAGED_SIGNAL_TYPES)])
.orderBy(["createdAt", "desc"], ["id", "desc"]),
),
)
Expand Down Expand Up @@ -414,9 +416,9 @@ export const makeAlertRulePersistence = (options: {
) {
yield* requireAlertsPermission(roles)
const row = yield* requireRuleRow(orgId, ruleId)
// Hidden from the generic rule APIs: a monitor's rule is deleted with its monitor.
if (row.signalType === "uptime") {
return yield* new AlertRuleNotFoundError({ message: UPTIME_RULE_MANAGED_MESSAGE, ruleId })
// Hidden from the generic rule APIs: a managed rule is deleted with its owner.
if (isManagedSignalType(row.signalType)) {
return yield* new AlertRuleNotFoundError({ message: managedRuleMessage(row.signalType), ruleId })
}
const deleted = yield* dbExecute((db) => db.transaction(deleteRuleRows(db, orgId, ruleId)))
const txid = readTxid(deleted)
Expand Down
Loading
Loading