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
80 changes: 56 additions & 24 deletions apps/uptime/src/uptime-transition-alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { uptimeSchedules } from "@databuddy/db/schema";
import { config } from "@databuddy/env/app";
import {
NotificationClient,
buildAlarmNotificationConfig,
buildAlarmNotificationTargets,
} from "@databuddy/notifications";
import { Cache, Context, Data, Duration, Effect, Layer, Option } from "effect";
import type { ScheduleData } from "./actions";
Expand All @@ -27,6 +27,7 @@ class AlarmLookupError extends Data.TaggedError("AlarmLookupError")<{

class NotificationSendError extends Data.TaggedError("NotificationSendError")<{
alarmId: string;
channel: string;
cause: unknown;
}> {}

Expand Down Expand Up @@ -184,19 +185,60 @@ const sendToAlarm = (
alarm: LinkedAlarm,
payload: Parameters<NotificationClient["send"]>[0]
) => {
const { clientConfig, channels } = buildAlarmNotificationConfig(
alarm.destinations
);
if (channels.length === 0) {
return Effect.succeed(false);
const targets = buildAlarmNotificationTargets(alarm.destinations);
if (targets.length === 0) {
return Effect.succeed(0);
}

return Effect.tryPromise({
try: () =>
new NotificationClient(clientConfig)
.send(payload, { channels })
.then(() => true),
catch: (cause) => new NotificationSendError({ alarmId: alarm.id, cause }),
return Effect.gen(function* () {
const results = yield* Effect.all(
targets.map((target) =>
Effect.tryPromise({
try: () =>
new NotificationClient(target.clientConfig).send(payload, {
channels: [target.channel],
}),
catch: (cause) =>
new NotificationSendError({
alarmId: alarm.id,
channel: target.channel,
cause,
}),
}).pipe(
Effect.map((deliveryResults) => {
let successes = 0;
for (const result of deliveryResults) {
if (result.success) {
successes += 1;
} else {
captureError(
new Error(
result.error ??
`Notification delivery failed for ${result.channel}`
),
{
error_step: "alarm_notification_result",
alarm_id: alarm.id,
channel: result.channel,
}
);
}
}
return successes;
}),
Effect.catchTag("NotificationSendError", (e) => {
captureError(e.cause, {
error_step: "alarm_notification",
alarm_id: e.alarmId,
channel: e.channel,
});
return Effect.succeed(0);
})
)
),
{ concurrency: "unbounded" }
);
return results.reduce((total, count) => total + count, 0);
});
};

Expand Down Expand Up @@ -311,21 +353,11 @@ const handleTransition = (options: {
.filter((alarm) => alarm.destinations.length > 0);

const results = yield* Effect.all(
sendable.map((alarm) =>
sendToAlarm(alarm, payload).pipe(
Effect.catchTag("NotificationSendError", (e) => {
captureError(e.cause, {
error_step: "alarm_notification",
alarm_id: e.alarmId,
});
return Effect.succeed(false);
})
)
),
sendable.map((alarm) => sendToAlarm(alarm, payload)),
{ concurrency: "unbounded" }
);

const fired = results.filter(Boolean).length;
const fired = results.reduce((total, count) => total + count, 0);
return { alarms_fired: fired, transition_kind: kind };
});

Expand Down
63 changes: 63 additions & 0 deletions packages/notifications/src/__tests__/alarm-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import {
buildAlarmNotificationConfig,
buildAlarmNotificationTargets,
} from "../alarm-config";

describe("buildAlarmNotificationTargets", () => {
test("keeps same-channel destinations as separate delivery targets", () => {
const firstSlack = "https://hooks.slack.com/services/T000/B000/first";
const secondSlack = "https://hooks.slack.com/services/T000/B000/second";

const targets = buildAlarmNotificationTargets([
{ type: "slack", identifier: firstSlack, config: {} },
{ type: "slack", identifier: secondSlack, config: {} },
{
type: "webhook",
identifier: "https://example.com/alarm",
config: {
headers: {
authorization: "drop-me",
"X-Array": ["drop-me"],
"X-Alarm": "keep-me",
"X-Bad\r\nName": "drop-me",
"X-Bad-Value": "drop\r\nme",
},
},
},
]);

expect(targets.map((target) => target.channel)).toEqual([
"slack",
"slack",
"webhook",
]);
expect(targets[0]?.clientConfig.slack?.webhookUrl).toBe(firstSlack);
expect(targets[1]?.clientConfig.slack?.webhookUrl).toBe(secondSlack);
expect(targets[2]?.clientConfig.webhook).toEqual({
url: "https://example.com/alarm",
headers: { "X-Alarm": "keep-me" },
});
});
});

describe("buildAlarmNotificationConfig", () => {
test("keeps legacy channels unique when duplicate destination types are provided", () => {
const firstSlack = "https://hooks.slack.com/services/T000/B000/first";
const secondSlack = "https://hooks.slack.com/services/T000/B000/second";

const config = buildAlarmNotificationConfig([
{ type: "slack", identifier: firstSlack, config: {} },
{ type: "slack", identifier: secondSlack, config: {} },
{
type: "webhook",
identifier: "https://example.com/alarm",
config: {},
},
]);

expect(config.channels).toEqual(["slack", "webhook"]);
expect(config.clientConfig.slack?.webhookUrl).toBe(firstSlack);
expect(config.clientConfig.webhook?.url).toBe("https://example.com/alarm");
});
});
107 changes: 73 additions & 34 deletions packages/notifications/src/alarm-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { NotificationClientConfig } from "./client";
import type { NotificationChannel } from "./types";

interface AlarmDestination {
Expand All @@ -6,6 +7,11 @@ interface AlarmDestination {
type: string;
}

export interface AlarmNotificationTarget {
channel: NotificationChannel;
clientConfig: NotificationClientConfig;
}

const FORBIDDEN_WEBHOOK_HEADERS = new Set([
"authorization",
"content-length",
Expand Down Expand Up @@ -56,9 +62,29 @@ function sanitizeWebhookHeaders(
return Object.keys(out).length > 0 ? out : undefined;
}

/**
* Legacy adapter for callers that can only represent one provider per channel.
* Use buildAlarmNotificationTargets for per-destination fanout.
*/
export function buildAlarmNotificationConfig(destinations: AlarmDestination[]) {
const clientConfig: Record<string, Record<string, unknown>> = {};
const channels: NotificationChannel[] = [];
const clientConfig: NotificationClientConfig = {};
const channels = new Set<NotificationChannel>();

for (const target of buildAlarmNotificationTargets(destinations)) {
if (channels.has(target.channel)) {
continue;
}
Object.assign(clientConfig, target.clientConfig);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
channels.add(target.channel);
}

return { clientConfig, channels: Array.from(channels) };
}
Comment on lines 69 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 buildAlarmNotificationConfig now silently fires the last same-channel destination multiple times

The wrapper rebuilds clientConfig with Object.assign, so for two Slack destinations the second URL overwrites the first. channels still gets ["slack", "slack"]. When a caller creates new NotificationClient(clientConfig).send(payload, { channels }), the single Slack provider (URL #2) is invoked twice — URL #1 never fires. This is strictly worse than the pre-PR behavior (last one wins, called once) and is a regression introduced by this wrapper. All callers in this PR have been updated to use buildAlarmNotificationTargets directly, but the exported buildAlarmNotificationConfig is still reachable and silently broken for the case the PR is fixing.


export function buildAlarmNotificationTargets(
destinations: AlarmDestination[]
): AlarmNotificationTarget[] {
const targets: AlarmNotificationTarget[] = [];

for (const dest of destinations) {
const cfg = (dest.config ?? {}) as Record<string, unknown>;
Expand All @@ -67,42 +93,55 @@ export function buildAlarmNotificationConfig(destinations: AlarmDestination[]) {
if (!isAllowedSlackWebhook(dest.identifier)) {
continue;
}
clientConfig.slack = { webhookUrl: dest.identifier };
channels.push("slack");
targets.push({
channel: "slack",
clientConfig: { slack: { webhookUrl: dest.identifier } },
});
} else if (dest.type === "webhook") {
clientConfig.webhook = {
url: dest.identifier,
headers: sanitizeWebhookHeaders(cfg.headers),
};
channels.push("webhook");
targets.push({
channel: "webhook",
clientConfig: {
webhook: {
url: dest.identifier,
headers: sanitizeWebhookHeaders(cfg.headers),
},
},
});
} else if (dest.type === "email") {
clientConfig.email = {
defaultTo: dest.identifier,
from: (cfg.from as string) || "Databuddy <alerts@databuddy.cc>",
sendEmailAction: async (payload: {
to: string | string[];
subject: string;
html?: string;
text?: string;
from?: string;
}) => {
const { Resend } = await import("resend");
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
return;
}
const resend = new Resend(apiKey);
await resend.emails.send({
from: payload.from || "Databuddy <alerts@databuddy.cc>",
to: Array.isArray(payload.to) ? payload.to : [payload.to],
subject: payload.subject,
html: payload.html || payload.text || "",
});
targets.push({
channel: "email",
clientConfig: {
email: {
defaultTo: dest.identifier,
from:
typeof cfg.from === "string"
? cfg.from
: "Databuddy <alerts@databuddy.cc>",
sendEmailAction: async (payload: {
to: string | string[];
subject: string;
html?: string;
text?: string;
from?: string;
}) => {
const { Resend } = await import("resend");
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
return;
}
const resend = new Resend(apiKey);
await resend.emails.send({
from: payload.from || "Databuddy <alerts@databuddy.cc>",
to: Array.isArray(payload.to) ? payload.to : [payload.to],
subject: payload.subject,
html: payload.html || payload.text || "",
});
},
},
},
};
channels.push("email");
});
}
}

return { clientConfig, channels };
return targets;
}
5 changes: 4 additions & 1 deletion packages/notifications/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ export * from "./providers";
export * from "./templates/anomaly";
export * from "./templates/uptime";
export * from "./types";
export { buildAlarmNotificationConfig } from "./alarm-config";
export {
buildAlarmNotificationConfig,
buildAlarmNotificationTargets,
} from "./alarm-config";
38 changes: 37 additions & 1 deletion packages/rpc/src/lib/alarm-notifications.ts
Original file line number Diff line number Diff line change
@@ -1 +1,37 @@
export { buildAlarmNotificationConfig as toNotificationConfig } from "@databuddy/notifications";
import {
NotificationClient,
buildAlarmNotificationConfig,
buildAlarmNotificationTargets,
type NotificationPayload,
type NotificationResult,
} from "@databuddy/notifications";

export const toNotificationConfig = buildAlarmNotificationConfig;
export const toNotificationTargets = buildAlarmNotificationTargets;

type NotificationTarget = ReturnType<
typeof buildAlarmNotificationTargets
>[number];

function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

export async function sendNotificationTarget(
target: NotificationTarget,
payload: NotificationPayload
): Promise<NotificationResult[]> {
try {
return await new NotificationClient(target.clientConfig).send(payload, {
channels: [target.channel],
});
} catch (error) {
return [
{
success: false,
channel: target.channel,
error: getErrorMessage(error),
},
];
}
}
Loading
Loading