Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/ai/src/mcp/tools/__tests__/alert-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ describe("alert tools", () => {
template: "high_error_rate",
destination_ids: DEST_ID,
environments: ["production"],
alert_on_no_data: true,
},
seen,
)
Expand All @@ -413,6 +414,7 @@ describe("alert tools", () => {
destinationIds: [DEST_ID],
environments: ["production"],
groupBy: ["service.name"],
alertOnNoData: true,
})
expect(text(result)).toContain("## Alert Rule Created")

Expand Down Expand Up @@ -544,6 +546,8 @@ describe("alert tools", () => {
name: "Checkout errors",
serviceNames: ["checkout"],
groupBy: ["service.name", "attr.http.route"],
// Carried over from the saved rule, which skips empty windows.
alertOnNoData: false,
})
})

Expand Down
4 changes: 4 additions & 0 deletions apps/ai/src/mcp/tools/create-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ export const CreateAlertRuleParameters = Schema.Struct({
minimum_sample_count: P.optionalNumber(
"Skip evaluation below this many samples in the window (default: 0). For raw_query it sums the `samples` column; without one each returned row counts as 1, so it gates on buckets, not events.",
),
alert_on_no_data: P.optionalFlag(
"Count a window with no data as a breach, so a rule whose query stops matching opens an incident instead of going quiet (default false: such windows are skipped). Not supported with group_by.",
),
consecutive_breaches: P.optionalNumber("Consecutive breaches before alerting (default: 2)"),
consecutive_healthy: P.optionalNumber("Consecutive healthy evaluations before resolving (default: 2)"),
renotify_interval_minutes: P.optionalNumber("Re-notification interval in minutes (default: 30)"),
Expand Down Expand Up @@ -232,6 +235,7 @@ export const buildAlertRuleRequest = Effect.fnUntraced(function* (
...(params.minimum_sample_count === undefined
? undefined
: { minimumSampleCount: params.minimum_sample_count }),
...(params.alert_on_no_data === undefined ? undefined : { alertOnNoData: params.alert_on_no_data }),
...(params.consecutive_breaches === undefined
? undefined
: { consecutiveBreachesRequired: params.consecutive_breaches }),
Expand Down
16 changes: 16 additions & 0 deletions apps/ai/src/mcp/tools/get-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ import { formatCondition, ruleConfigWarnings, ruleNotFound, toAlertRuleRow } fro
import * as P from "../lib/params"
import { doc, type DocBlock } from "../lib/tool-doc"

/** How the rule treats an empty window, in words. */
const noDataLabel = (behavior: string): string => {
switch (behavior) {
case "skip":
return "skip the check (never breaches)"
case "zero":
return "read as 0"
case "alert":
return "breach (alerts when the rule goes blind)"
default:
return behavior
}
}

export function registerGetAlertRuleTool(server: McpToolRegistrar) {
server.define({
name: "get_alert_rule",
Expand Down Expand Up @@ -37,6 +51,7 @@ export function registerGetAlertRuleTool(server: McpToolRegistrar) {
excludeServiceNames: [...rule.excludeServiceNames],
groupBy: rule.groupBy ? [...rule.groupBy] : null,
minimumSampleCount: rule.minimumSampleCount,
noDataBehavior: rule.noDataBehavior,
consecutiveBreachesRequired: rule.consecutiveBreachesRequired,
consecutiveHealthyRequired: rule.consecutiveHealthyRequired,
renotifyIntervalMinutes: rule.renotifyIntervalMinutes,
Expand Down Expand Up @@ -86,6 +101,7 @@ export function registerGetAlertRuleTool(server: McpToolRegistrar) {
doc.heading("Evaluation"),
doc.fields([
["Minimum Sample Count", rule.minimumSampleCount],
["When No Data", noDataLabel(rule.noDataBehavior)],
["Consecutive Breaches Required", rule.consecutiveBreachesRequired],
["Consecutive Healthy Required", rule.consecutiveHealthyRequired],
["Renotify Interval", `${rule.renotifyIntervalMinutes}m`],
Expand Down
2 changes: 1 addition & 1 deletion apps/ai/src/mcp/tools/preview-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const previewWarnings = (
const warnings: Array<string> = []
if (windows > 0 && noData === windows) {
warnings.push(
`Every window had no data: the query matched nothing in this range. Saved as is, every check would be skipped, which reads as quiet, not healthy. Check the filters${clamped ? "; the range was already clamped to the preview cap, so try a longer window_minutes rather than an earlier start_time" : ", or widen start_time"}.`,
`Every window had no data: the query matched nothing in this range. Saved as is, every check would be skipped, which reads as quiet, not healthy (alert_on_no_data makes it fire instead). Check the filters${clamped ? "; the range was already clamped to the preview cap, so try a longer window_minutes rather than an earlier start_time" : ", or widen start_time"}.`,
)
} else if (windows > 0 && belowMin === windows) {
warnings.push(
Expand Down
4 changes: 4 additions & 0 deletions apps/ai/src/mcp/tools/update-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export const UpdateAlertRuleParameters = Schema.Struct({
minimum_sample_count: P.optionalNumber(
"Skip evaluation below this many samples in the window. For raw_query it sums the `samples` column; without one each returned row counts as 1.",
),
alert_on_no_data: P.optionalFlag(
"Count a window with no data as a breach instead of skipping it. Not supported with group_by.",
),
consecutive_breaches: P.optionalNumber("Consecutive breaches before alerting"),
consecutive_healthy: P.optionalNumber("Consecutive healthy evaluations before resolving"),
renotify_interval_minutes: P.optionalNumber("Re-notification interval in minutes"),
Expand Down Expand Up @@ -128,6 +131,7 @@ export function buildUpdatedRequest(
queryBuilderDraft: params.query_builder_draft ?? current.queryBuilderDraft,
rawQuerySql: params.raw_query_sql ?? current.rawQuerySql,
rawQueryReducer: params.raw_query_reducer ?? current.rawQueryReducer,
alertOnNoData: params.alert_on_no_data ?? current.noDataBehavior === "alert",
destinationIds: [...(params.destination_ids ?? current.destinationIds)],
}
}
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/routes/v2/alert-rules.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const toV2Rule = (doc: AlertRuleDocument): V2AlertRule => ({
raw_query_reducer: doc.rawQueryReducer,
destination_ids: doc.destinationIds,
no_data_behavior: doc.noDataBehavior,
alert_on_no_data: doc.noDataBehavior === "alert",
last_evaluation_error: doc.lastEvaluationError,
last_evaluated_at: doc.lastEvaluatedAt,
last_scheduled_at: doc.lastScheduledAt,
Expand Down Expand Up @@ -122,6 +123,7 @@ const ruleAuditDiff = auditDiff<keyof V2AlertRuleUpdateParams & keyof V2AlertRul
"query_builder_draft",
"raw_query_sql",
"raw_query_reducer",
"alert_on_no_data",
"destination_ids",
],
// Query drafts and raw SQL are config blobs — audit that they changed, not their bodies.
Expand Down Expand Up @@ -212,6 +214,9 @@ const toUpsertRequest = (
minimumSampleCount: params.minimum_sample_count,
}
: undefined),
...(params.alert_on_no_data !== undefined
? { alertOnNoData: params.alert_on_no_data }
: undefined),
...(params.consecutive_breaches_required !== undefined
? {
consecutiveBreachesRequired: params.consecutive_breaches_required,
Expand Down Expand Up @@ -304,6 +309,7 @@ const mergeUpsertRequest = (
queryBuilderDraft,
rawQuerySql,
rawQueryReducer,
alertOnNoData: patch.alert_on_no_data ?? doc.noDataBehavior === "alert",
destinationIds: patch.destination_ids ?? doc.destinationIds,
})
})
Expand Down
1 change: 1 addition & 0 deletions apps/ios/Maple/Fixtures/FixtureAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ struct FixtureAPI: MapleAPI {
threshold: Double, window: Int, breaches: Int, healthy: Int
) -> AlertRule {
AlertRule(
alertOnNoData: false,
comparator: .gt,
consecutiveBreachesRequired: breaches,
consecutiveHealthyRequired: healthy,
Expand Down
14 changes: 12 additions & 2 deletions apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@
"description": "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": [
{
"alert_on_no_data": false,
"apdex_threshold_ms": null,
"comparator": "gt",
"consecutive_breaches_required": 2,
Expand Down Expand Up @@ -527,6 +528,13 @@
}
],
"properties": {
"alert_on_no_data": {
"description": "Whether a window with no data counts as a breach (`no_data_behavior` is `alert`).",
"examples": [
false
],
"type": "boolean"
},
"apdex_threshold_ms": {
"description": "For `apdex` rules: the satisfied-latency threshold in milliseconds.",
"exclusiveMinimum": 0,
Expand Down Expand Up @@ -743,6 +751,7 @@
"renotify_interval_minutes",
"destination_ids",
"no_data_behavior",
"alert_on_no_data",
"created_at",
"updated_at",
"created_by",
Expand Down Expand Up @@ -3641,10 +3650,11 @@
"type": "string"
},
"_maple_QueryEngineNoDataBehavior": {
"description": "What the evaluator does when the window has no data: `skip` the check or treat the value as `zero`.",
"description": "What the evaluator does when the window has no data: `skip` the check, treat the value as `zero`, or `alert` (count it as a breach; set with `alert_on_no_data`).",
"enum": [
"skip",
"zero"
"zero",
"alert"
],
"examples": [
"skip"
Expand Down
6 changes: 3 additions & 3 deletions apps/landing/src/content/docs/alerting/alert-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ A built-in signal only sees entry-point spans. A service that records a failure

**Query** (`builder_query`) uses the same query builder as dashboard charts. It can read traces, logs, metrics or product events, with its own filters and group-by. The quickest way to build one is from a chart: open a dashboard, open the chart's menu and choose **Create alert**.

**Raw SQL** (`raw_query`) runs your own SQL. The query must include `$__orgFilter` and a `$__timeFilter(...)` on the time column, and return a time bucket and a value. **Reduce buckets by** turns the buckets in the window into one value: **Last bucket**, **Sum**, **Average**, **Minimum** or **Maximum**. To evaluate several groups, return a group column. See the [SQL reference](/docs/reference/sql).
**Raw SQL** (`raw_query`) runs your own SQL. The query must include `$__orgFilter` and a `$__timeFilter(...)` on the time column, and return a time bucket and a value. **Reduce buckets by** turns the buckets in the window into one value: **Last bucket**, **Sum**, **Average**, **Minimum** or **Maximum**. To evaluate several groups, return a group column. Return a `samples` column with the number of events behind each row: **Min samples** sums it, and without it every row counts as one sample, so the minimum counts buckets rather than events. See the [SQL reference](/docs/reference/sql).

For **Query** and **Raw SQL** rules the query carries its own filters, so the **Scope** section is hidden.

Expand Down Expand Up @@ -97,7 +97,7 @@ Maple evaluates every enabled rule once a minute. Each check aggregates the last

A skipped check counts neither as a breach nor as healthy. It leaves the breach and healthy counters where they were.

A window with no data at all is skipped, with one exception: a **Throughput** rule with `<` or `<=` treats an empty window as zero.
A window with no data at all is skipped, with two exceptions: a rule with **Alert when there is no data** on (`alert_on_no_data`) counts it as a breach, and otherwise a **Throughput** rule with `<` or `<=` treats it as zero. Turn the switch on for **Raw SQL** rules, where a query that stops matching otherwise goes quiet instead of firing. It is not available on grouped rules: a group that stops reporting keeps its open incident until its telemetry returns. On a **Raw SQL** rule that returns a group column, it fires only when the query returns no rows at all.

The **Min samples** check runs before the threshold comparison, and that zero still counts as zero samples. For **Throughput** the sample count is the signal, so a drop rule with the blank form's default of 50 skips every window below 50 requests, including a full outage, and cannot fire for those windows. Set **Min samples** to 0 for throughput drop rules, as the **Throughput drop** template does. Then traffic stopping entirely fires the rule.

Expand Down Expand Up @@ -171,7 +171,7 @@ The response lists the value and status of each window per group in `series`, an

## Troubleshooting

- **The rule never fires.** Open the rule and look at its checks. If every check is skipped, the window has fewer samples than **Min samples**, or the scope matches no data. Check the service names and environments against what is arriving.
- **The rule never fires.** Open the rule and look at its checks. If every check is skipped, each one says why: **no data** means the scope or query matches nothing, **below min samples** means the window has fewer samples than **Min samples**. Check the service names and environments against what is arriving.
- **The rule fires and resolves over and over.** Raise **Breaches to fire** and **Healthy to resolve**, or widen the window.
- **Save is disabled.** The action bar lists what is missing, such as a rule name or a destination.

Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/alerts/signal-and-threshold-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Card } from "@maple/ui/components/ui/card"
import { Input } from "@maple/ui/components/ui/input"
import { Label } from "@maple/ui/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select"
import { Switch } from "@maple/ui/components/ui/switch"
import { cn } from "@maple/ui/lib/utils"

import { AlertSegmentedSelect } from "@/components/alerts/alert-segmented-select"
Expand All @@ -34,6 +35,7 @@ import {
comparatorLabels,
isRangeComparator,
RAW_QUERY_REDUCER_LABELS,
ruleFormIsGrouped,
type RuleFormState,
} from "@/lib/alerts/form-utils"
import { Result, useAtomValue } from "@/lib/effect-atom"
Expand Down Expand Up @@ -187,6 +189,7 @@ export function SignalAndThresholdSection({
const [advancedOpen, setAdvancedOpen] = useState(false)

const kind = signalTypeToKind(form.signalType)
const grouped = ruleFormIsGrouped(form)

/* Switching tier-1 has to seed a valid signalType for the new kind.
Built-in defaults to error_rate; the other three map 1:1 since
Expand Down Expand Up @@ -381,6 +384,24 @@ export function SignalAndThresholdSection({
}))
}
/>
<div className="flex items-start gap-2.5 sm:col-span-2 lg:col-span-3">
<Switch
id="rule-alert-on-no-data"
checked={form.alertOnNoData && !grouped}
disabled={grouped}
onCheckedChange={(checked) =>
onChange((c) => ({ ...c, alertOnNoData: checked }))
}
/>
<div className="space-y-0.5">
<Label htmlFor="rule-alert-on-no-data">Alert when there is no data</Label>
<p className="text-muted-foreground text-xs">
{grouped
? "Not available on grouped rules: a group that stops reporting keeps its incident open until telemetry returns."
: "Count a window with no data as a breach. Off, those windows are skipped and the rule goes quiet when its query stops matching."}
</p>
</div>
</div>
</div>
)}
</div>
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/lib/alerts/diagnosis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ export function buildDiagnosis(input: DiagnosisInput): DiagnosisStage[] {
const noDataExplainer =
rule.noDataBehavior === "zero"
? "No-data windows are treated as 0 (they still evaluate)."
: "No-data windows are skipped (they never breach)."
: rule.noDataBehavior === "alert"
? "No-data windows count as breaches."
: "No-data windows are skipped (they never breach). Turn on alerting when there is no data to hear about it."
// Checks recorded before skip reasons existed carry none and count as neither.
const belowMinChecks = groupChecks.filter((c) => c.skipReason === "below_min_samples").length
const noDataChecks = groupChecks.filter((c) => c.skipReason === "no_data").length
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/lib/alerts/form-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
buildRuleCreateParamsV2,
defaultDestinationForm,
defaultRuleForm,
ruleFormIsGrouped,
deriveRuleQueryIssues,
domainThresholdToForm,
formThresholdToDomain,
Expand Down Expand Up @@ -389,3 +390,14 @@ describe("v2 response mappers", () => {
})
})
})

describe("alert on no data", () => {
it("is sent only for rules that are not grouped", () => {
const form = { ...defaultRuleForm(), name: "A", alertOnNoData: true }
expect(buildRuleCreateParamsV2(form).alert_on_no_data).toBe(true)
const grouped = { ...form, serviceNames: [], groupBy: ["service.name"] }
expect(ruleFormIsGrouped(grouped)).toBe(true)
expect(buildRuleCreateParamsV2(grouped).alert_on_no_data).toBe(false)
expect(ruleFormIsGrouped({ ...grouped, signalType: "raw_query" })).toBe(false)
})
})
14 changes: 14 additions & 0 deletions apps/web/src/lib/alerts/form-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export type RuleFormState = {
thresholdUpper: string
windowMinutes: string
minimumSampleCount: string
/** Count an empty window as a breach instead of skipping it. */
alertOnNoData: boolean
consecutiveBreachesRequired: string
consecutiveHealthyRequired: string
renotifyIntervalMinutes: string
Expand Down Expand Up @@ -250,6 +252,7 @@ export function defaultRuleForm(serviceName?: string): RuleFormState {
thresholdUpper: "",
windowMinutes: "5",
minimumSampleCount: "50",
alertOnNoData: false,
consecutiveBreachesRequired: "2",
consecutiveHealthyRequired: "2",
renotifyIntervalMinutes: "30",
Expand All @@ -263,6 +266,15 @@ export function defaultRuleForm(serviceName?: string): RuleFormState {
}
}

/** Grouped rules cannot alert on no data: a group that stops reporting is held by its incident. */
export function ruleFormIsGrouped(form: RuleFormState): boolean {
if (form.signalType === "raw_query") return false
if (form.signalType === "builder_query") {
return form.queryBuilderDraft.groupBy.some((token) => token !== "none" && token.length > 0)
}
return form.groupBy.length > 0
}

export function ruleToFormState(rule: AlertRuleDocument): RuleFormState {
const queryBuilderDraft = normalizeRuleQueryDraft(rule.queryBuilderDraft)
return {
Expand All @@ -282,6 +294,7 @@ export function ruleToFormState(rule: AlertRuleDocument): RuleFormState {
rule.thresholdUpper == null ? "" : domainThresholdToForm(rule.signalType, rule.thresholdUpper),
windowMinutes: String(rule.windowMinutes),
minimumSampleCount: String(rule.minimumSampleCount),
alertOnNoData: rule.noDataBehavior === "alert",
consecutiveBreachesRequired: String(rule.consecutiveBreachesRequired),
consecutiveHealthyRequired: String(rule.consecutiveHealthyRequired),
renotifyIntervalMinutes: String(rule.renotifyIntervalMinutes),
Expand Down Expand Up @@ -364,6 +377,7 @@ export function buildRuleCreateParamsV2(form: RuleFormState): V2AlertRuleCreateP
: null,
window_minutes: parsePositiveNumber(form.windowMinutes, 5),
minimum_sample_count: parseNonNegativeNumber(form.minimumSampleCount, 0),
alert_on_no_data: form.alertOnNoData && !ruleFormIsGrouped(form),
consecutive_breaches_required: parsePositiveNumber(form.consecutiveBreachesRequired, 2),
consecutive_healthy_required: parsePositiveNumber(form.consecutiveHealthyRequired, 2),
renotify_interval_minutes: parsePositiveNumber(form.renotifyIntervalMinutes, 30),
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/lib/collections/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ const asIncidentStatus = Schema.decodeUnknownSync(AlertIncidentStatus)
const asHoldReason = Schema.decodeUnknownSync(AlertIncidentHoldReason)
const asEventType = Schema.decodeUnknownSync(AlertEventType)
const asReducer = Schema.decodeUnknownSync(QueryEngineAlertReducer)
const asNoDataBehavior = Schema.decodeUnknownSync(QueryEngineNoDataBehavior)
// Lenient: a behavior added by a newer server reads as skip rather than breaking the alerts list.
const decodeNoDataBehavior = Schema.decodeUnknownOption(QueryEngineNoDataBehavior)

const decodeNotificationTemplate = Schema.decodeUnknownOption(AlertNotificationTemplate)
const decodeQueryBuilderDraft = Schema.decodeUnknownOption(QueryBuilderQueryDraftSchema)
Expand Down Expand Up @@ -216,7 +217,7 @@ export const rowToAlertRuleDocument = (
rawQuerySql: row.raw_query_sql ?? null,
rawQueryReducer: row.signal_type === "raw_query" ? asReducer(row.reducer) : null,
destinationIds: safeParseStringArray(row.destination_ids_json).map((id) => decodeDestinationId(id)),
noDataBehavior: asNoDataBehavior(row.no_data_behavior),
noDataBehavior: Option.getOrElse(decodeNoDataBehavior(row.no_data_behavior), () => "skip" as const),
lastEvaluationError: state?.last_error ?? null,
lastEvaluatedAt: state?.last_evaluated_at != null ? decodeIso(state.last_evaluated_at) : null,
lastScheduledAt: row.last_scheduled_at != null ? decodeIso(row.last_scheduled_at) : null,
Expand Down
Loading
Loading