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
2 changes: 2 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 @@ -140,6 +140,8 @@ const incident = (overrides: Record<string, unknown>) => ({
acknowledgedByName: null,
acknowledgedVia: null,
errorIssueId: null,
silenceId: null,
silencedAt: null,
...overrides,
})

Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/routes/v2/alert-incidents.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HttpApiBuilder } from "effect/http-api"
import type { AlertIncidentDocument, AlertIncidentEventDocument } from "@maple/domain/http"
import { CurrentTenant, isAlertNotificationEventType } from "@maple/domain/http"
import type { V2AlertIncident, V2AlertIncidentEvent } from "@maple/domain/http/v2"
import { MapleApiV2, paginateOffsetQuery } from "@maple/domain/http/v2"
import { MapleApiV2, paginateOffsetQuery, timestampOrNull } from "@maple/domain/http/v2"
import { Duration, Effect, Option, Array as Arr } from "effect"
import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService"
import {
Expand Down Expand Up @@ -46,6 +46,8 @@ const toV2Incident = (doc: AlertIncidentDocument): V2AlertIncident => ({
: { user_id: doc.acknowledgedByUserId, name: doc.acknowledgedByName },
acknowledged_via: doc.acknowledgedVia,
error_issue_id: doc.errorIssueId,
silence_id: doc.silenceId,
silenced_at: timestampOrNull(doc.silencedAt),
})

const toV2Event = (doc: AlertIncidentEventDocument): V2AlertIncidentEvent => ({
Expand Down
193 changes: 193 additions & 0 deletions apps/api/src/routes/v2/alert-silences.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { HttpApiBuilder } from "effect/http-api"
import {
AlertSilenceCreateRequest,
AlertSilenceUpdateRequest,
CurrentTenant,
IsoDateTimeString,
type AlertSilenceDocument,
type AlertSilenceMatchers,
} from "@maple/domain/http"
import type {
V2AlertSilence,
V2AlertSilenceMatchers,
V2AlertSilenceUpdateParams,
} from "@maple/domain/http/v2"
import { MapleApiV2, paginateOffsetQuery, timestamp, V2ParameterInvalid } from "@maple/domain/http/v2"
import { Array as Arr, Clock, DateTime, Effect, Schema } from "effect"
import { auditDiff } from "@/routes/v2/audit-changes"
import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService"
import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService"
import { timestampMs } from "@maple/backend/platform/time"

const decodeIso = (value: string, param: string) =>
Schema.decodeEffect(IsoDateTimeString)(value).pipe(
Effect.mapError(() => V2ParameterInvalid.make("Expected an ISO-8601 UTC timestamp.", { param })),
)

const silenceAuditDiff = auditDiff<keyof V2AlertSilenceUpdateParams & keyof V2AlertSilence>({
fields: ["reason", "matchers", "starts_at", "ends_at"],
})

const toMatchers = (matchers: V2AlertSilenceMatchers): AlertSilenceMatchers => ({
...(matchers.rule_ids === undefined ? undefined : { ruleIds: matchers.rule_ids }),
...(matchers.tags === undefined ? undefined : { tags: matchers.tags }),
...(matchers.services === undefined ? undefined : { services: matchers.services }),
...(matchers.environments === undefined ? undefined : { environments: matchers.environments }),
})

const fromMatchers = (matchers: AlertSilenceMatchers): V2AlertSilenceMatchers => ({
...(matchers.ruleIds === undefined ? undefined : { rule_ids: matchers.ruleIds }),
...(matchers.tags === undefined ? undefined : { tags: matchers.tags }),
...(matchers.services === undefined ? undefined : { services: matchers.services }),
...(matchers.environments === undefined ? undefined : { environments: matchers.environments }),
})

const hasMatcher = (matchers: V2AlertSilenceMatchers) =>
(matchers.rule_ids?.length ?? 0) +
(matchers.tags?.length ?? 0) +
(matchers.services?.length ?? 0) +
(matchers.environments?.length ?? 0) >
0

const requireMatcher = (matchers: V2AlertSilenceMatchers) =>
hasMatcher(matchers)
? Effect.void
: Effect.fail(
V2ParameterInvalid.make("A silence needs at least one rule, tag, service or environment.", {
param: "matchers",
}),
)

export const toV2Silence = (doc: AlertSilenceDocument): V2AlertSilence => ({
id: doc.id,
object: "alert_silence",
reason: doc.reason,
matchers: fromMatchers(doc.matchers),
status: doc.status,
starts_at: timestamp(doc.startsAt),
ends_at: timestamp(doc.endsAt),
created_by: doc.createdBy,
created_at: timestamp(doc.createdAt),
updated_at: timestamp(doc.updatedAt),
})

export const HttpV2AlertSilencesLive = HttpApiBuilder.group(MapleApiV2, "alertSilences", (handlers) =>
Effect.gen(function* () {
const silences = yield* AlertSilencesService

return handlers
.handle("list", ({ query }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const page = yield* paginateOffsetQuery(query, ({ limit, offset }) =>
silences
.list(tenant.orgId, {
...(query.status === undefined ? undefined : { status: query.status }),
limit,
offset,
})
.pipe(Effect.map((response) => Arr.map(response.silences, toV2Silence))),
)
return { object: "list" as const, ...page }
}),
)
.handle("retrieve", ({ params }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const silence = yield* silences.get(tenant.orgId, params.id)
return toV2Silence(silence)
}),
)
.handle("create", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireMatcher(payload.matchers)
if ((payload.ends_at === undefined) === (payload.duration_minutes === undefined)) {
return yield* V2ParameterInvalid.make(
"Give exactly one of ends_at and duration_minutes.",
{
param: payload.ends_at === undefined ? "ends_at" : "duration_minutes",
},
)
}
// The start is always sent, so a duration counts from exactly the start the silence gets.
const nowMs = yield* Clock.currentTimeMillis
const startsAtMs =
payload.starts_at === undefined ? nowMs : timestampMs(payload.starts_at)
const endsAt = yield* decodeIso(
payload.ends_at ??
DateTime.formatIso(
DateTime.makeUnsafe(startsAtMs + (payload.duration_minutes ?? 0) * 60_000),
),
payload.ends_at === undefined ? "duration_minutes" : "ends_at",
)
const startsAt = yield* decodeIso(
payload.starts_at ?? DateTime.formatIso(DateTime.makeUnsafe(startsAtMs)),
"starts_at",
)
const created = yield* silences.create(
tenant.orgId,
tenant.userId,
tenant.roles,
new AlertSilenceCreateRequest({
reason: payload.reason,
matchers: toMatchers(payload.matchers),
startsAt,
endsAt,
}),
)
yield* recordHttpAudit("alert_silence.created", {
resourceId: created.id,
metadata: { reason: created.reason, endsAt: created.endsAt },
})
return toV2Silence(created)
}),
)
.handle("update", ({ params, payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
if (payload.matchers !== undefined) yield* requireMatcher(payload.matchers)
const current = yield* silences.get(tenant.orgId, params.id)
const startsAt =
payload.starts_at === undefined
? undefined
: yield* decodeIso(payload.starts_at, "starts_at")
const endsAt =
payload.ends_at === undefined
? undefined
: yield* decodeIso(payload.ends_at, "ends_at")
const updated = yield* silences.update(
tenant.orgId,
tenant.userId,
tenant.roles,
params.id,
new AlertSilenceUpdateRequest({
...(payload.reason === undefined ? undefined : { reason: payload.reason }),
...(payload.matchers === undefined
? undefined
: { matchers: toMatchers(payload.matchers) }),
...(startsAt === undefined ? undefined : { startsAt }),
...(endsAt === undefined ? undefined : { endsAt }),
}),
)
yield* recordHttpAudit("alert_silence.updated", {
resourceId: updated.id,
changes: silenceAuditDiff(payload, toV2Silence(current), toV2Silence(updated)),
metadata: { reason: updated.reason },
})
return toV2Silence(updated)
}),
)
.handle("end", ({ params }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const ended = yield* silences.end(tenant.orgId, tenant.userId, tenant.roles, params.id)
yield* recordHttpAudit("alert_silence.ended", {
resourceId: ended.id,
metadata: { reason: ended.reason },
})

Check notice on line 188 in apps/api/src/routes/v2/alert-silences.http.ts

View check run for this annotation

Maple Review Bot / Maple / review

correctness: `end` audits a silence it never changed

`AlertSilencesService.end` returns the row untouched when the silence has already ended (`AlertSilencesService.ts:309`), but the route records `alert_silence.ended` regardless. A repeated or stale `POST /:id/end` therefore adds an audit entry naming a user who ended nothing.
return toV2Silence(ended)
}),
)
}),
)
87 changes: 86 additions & 1 deletion apps/api/src/routes/v2/alerts.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ import { FetchHttpClient, HttpRouter } from "effect/http"
import { HttpApiBuilder } from "effect/http-api"
import {
AlertMemberDirectoryUnavailableError,
AlertSilenceId,
IntegrationsRevokedError,
OrgId,
UserId,
WarehouseQuotaExceededError,
} from "@maple/domain/http"
import { MapleApiV2 } from "@maple/domain/http/v2"
import { AlertSilencePublicId, MapleApiV2 } from "@maple/domain/http/v2"
import { BucketCacheService } from "@maple/query-engine/caching"
import { EdgeCacheService } from "@maple/cache"
import { CacheBackendLive } from "@maple/backend/platform/CacheBackendLive"
Expand Down Expand Up @@ -1306,4 +1307,88 @@ describe("v2 alerts over HTTP", () => {

await harness.dispose()
})

it("creates, lists, extends and ends an alert silence", async () => {
const harness = makeHarness()
const key = await harness.bootstrapKey(["alerts:write"])
const { rule } = await createWebhookAndRule(harness, key.secret)

const created = await harness.request("POST", "/v2/alerts/silences", key.secret, {
reason: "Deploying checkout v2.3",
matchers: { rule_ids: [rule.body.id], services: ["checkout"] },
duration_minutes: 60,
})
expect(created.status).toBe(200)
expect(created.body).toMatchObject({
object: "alert_silence",
status: "active",
matchers: { rule_ids: [rule.body.id], services: ["checkout"] },
})
expect(created.body.id).toMatch(/^sil_/)
expect(Date.parse(created.body.ends_at) - Date.parse(created.body.starts_at)).toBe(60 * 60_000)
const id: string = created.body.id

const active = await harness.request("GET", "/v2/alerts/silences?status=active", key.secret)
expect(active.body.data.map((silence: { id: string }) => silence.id)).toEqual([id])

const endsAt = new Date(Date.parse(created.body.ends_at) + 60 * 60_000).toISOString()
const extended = await harness.request("PATCH", `/v2/alerts/silences/${id}`, key.secret, {
ends_at: endsAt,
})
expect(extended.status).toBe(200)
expect(extended.body.ends_at).toBe(endsAt)

const ended = await harness.request("POST", `/v2/alerts/silences/${id}/end`, key.secret)
expect(ended.status).toBe(200)
expect(ended.body.status).toBe("ended")
const stillActive = await harness.request("GET", "/v2/alerts/silences?status=active", key.secret)
expect(stillActive.body.data).toEqual([])

const unknownId = Schema.encodeSync(AlertSilencePublicId)(
Schema.decodeSync(AlertSilenceId)("0b8f7c1e-2f61-4f1e-9d2a-3c4b5d6e7f80"),
)
const missing = await harness.request("GET", `/v2/alerts/silences/${unknownId}`, key.secret)
expect(missing.status).toBe(404)

await harness.dispose()
})

it("rejects a silence without a matcher, an end or a sensible window", async () => {
const harness = makeHarness()
const key = await harness.bootstrapKey(["alerts:write"])
const post = (body: unknown) => harness.request("POST", "/v2/alerts/silences", key.secret, body)

const noMatcher = await post({ reason: "x", matchers: {}, duration_minutes: 10 })
const noEnd = await post({ reason: "x", matchers: { tags: ["db"] } })
const both = await post({
reason: "x",
matchers: { tags: ["db"] },
duration_minutes: 10,
ends_at: new Date(Date.now() + 60_000).toISOString(),
})
const tooLong = await post({
reason: "x",
matchers: { tags: ["db"] },
duration_minutes: 31 * 24 * 60,
})
const ended = await post({
reason: "x",
matchers: { tags: ["db"] },
ends_at: new Date(Date.now() - 60_000).toISOString(),
})
for (const response of [noMatcher, noEnd, both, tooLong, ended]) {
expect(response.status).toBe(400)
expect(response.body.error.type).toBe("invalid_request_error")
}

const readOnly = await harness.bootstrapKey(["alerts:read"])
const denied = await harness.request("POST", "/v2/alerts/silences", readOnly.secret, {
reason: "x",
matchers: { tags: ["db"] },
duration_minutes: 10,
})
expect(denied.status).toBe(403)

await harness.dispose()
})
})
4 changes: 4 additions & 0 deletions apps/api/src/routes/v2/v2-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDe
import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService"
import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService"
import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService"
import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService"
import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService"
import { ErrorsService } from "@maple/backend/services/errors/ErrorsService"
import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService"
Expand Down Expand Up @@ -46,6 +47,7 @@ import { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngin
import { HttpV2AlertDeliveriesLive } from "./alert-deliveries.http"
import { HttpV2AlertDestinationsLive } from "./alert-destinations.http"
import { HttpV2AlertIncidentsLive } from "./alert-incidents.http"
import { HttpV2AlertSilencesLive } from "./alert-silences.http"
import { HttpV2AlertRulesLive } from "./alert-rules.http"
import { HttpV2ApiKeysLive } from "./api-keys.http"
import { HttpV2AttributeMappingsLive } from "./attribute-mappings.http"
Expand Down Expand Up @@ -189,6 +191,8 @@ const v2GroupLayersExceptOnboardingChecklist = (
HttpV2AlertIncidentsLive.pipe(
Layer.provide(Layer.merge(AlertIncidentAckService.layer, OrgMembersServiceStubLayer)),
),
// Real service: it needs only the Database every harness already provides.
HttpV2AlertSilencesLive.pipe(Layer.provide(AlertSilencesService.layer)),
HttpV2IngestKeysLive,
HttpV2SourcesLive,
HttpV2ErrorIssuesLive,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { ClerkWebhookRouter } from "@/routes/webhooks/clerk.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 { HttpV2AlertSilencesLive } from "@/routes/v2/alert-silences.http"
import { HttpV2AlertRulesLive } from "@/routes/v2/alert-rules.http"
import { HttpV2AnomaliesLive } from "@/routes/v2/anomalies.http"
import { HttpV2ApiKeysLive } from "@/routes/v2/api-keys.http"
Expand Down Expand Up @@ -183,6 +184,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe(
HttpV2AlertRulesLive,
HttpV2AlertDestinationsLive,
HttpV2AlertIncidentsLive,
HttpV2AlertSilencesLive,
HttpV2IngestKeysLive,
HttpV2SourcesLive,
HttpV2ChatIntegrationsLive,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/runtime/service-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDe
import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService"
import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService"
import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService"
import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService"
import { AnomalyDetectionService } from "@maple/backend/services/alerts/AnomalyDetectionService"

import { PlanetScaleOAuthService } from "@maple/backend/services/auth/PlanetScaleOAuthService"
Expand Down Expand Up @@ -180,6 +181,7 @@ export const HttpServicesLive = Layer.mergeAll(
AlertReadModelsService.layer,
AlertIncidentAckService.layer,
AlertRulesService.layer,
AlertSilencesService.layer,
AlertsService.layer,
AnomalyDetectionService.layer,
AiTriageService.layer,
Expand Down
Loading
Loading