170 lines
4.8 KiB
TypeScript
170 lines
4.8 KiB
TypeScript
import { wrapZodFetch } from "@trigger.dev/core/v3/zodfetch";
|
|
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
|
|
import { createLRUMemoryStore } from "@internal/cache";
|
|
import { z } from "zod";
|
|
import { env } from "~/env.server";
|
|
|
|
const StatusPageSchema = z.object({
|
|
data: z.object({
|
|
id: z.string(),
|
|
type: z.string(),
|
|
attributes: z.object({
|
|
aggregate_state: z.enum(["operational", "degraded", "downtime"]),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
const StatusReportsSchema = z.object({
|
|
data: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
type: z.literal("status_report"),
|
|
attributes: z.object({
|
|
title: z.string().nullable(),
|
|
starts_at: z.string().nullable(),
|
|
ends_at: z.string().nullable(),
|
|
aggregate_state: z.string().nullable(),
|
|
}),
|
|
})
|
|
),
|
|
pagination: z.object({
|
|
first: z.string().nullable(),
|
|
last: z.string().nullable(),
|
|
prev: z.string().nullable(),
|
|
next: z.string().nullable(),
|
|
}),
|
|
});
|
|
|
|
export type AggregateState = "operational" | "degraded" | "downtime";
|
|
|
|
export type IncidentStatus = {
|
|
status: AggregateState;
|
|
title: string | null;
|
|
};
|
|
|
|
type CachedResult = { success: true; data: IncidentStatus } | { success: false; error: unknown };
|
|
|
|
const ctx = new DefaultStatefulContext();
|
|
const memory = createLRUMemoryStore(100);
|
|
|
|
const cache = createCache({
|
|
query: new Namespace<CachedResult>(ctx, {
|
|
stores: [memory],
|
|
fresh: 15_000,
|
|
stale: 30_000,
|
|
}),
|
|
});
|
|
|
|
export class BetterStackClient {
|
|
private readonly baseUrl = "https://uptime.betterstack.com/api/v2";
|
|
|
|
async getIncidentStatus(): Promise<CachedResult> {
|
|
const apiKey = env.BETTERSTACK_API_KEY;
|
|
const statusPageId = env.BETTERSTACK_STATUS_PAGE_ID;
|
|
|
|
if (!apiKey || !statusPageId) {
|
|
return { success: false, error: "Missing BetterStack configuration" };
|
|
}
|
|
|
|
const cachedResult = await cache.query.swr("betterstack-incident-status", () =>
|
|
this.fetchIncidentStatus(apiKey, statusPageId)
|
|
);
|
|
|
|
if (cachedResult.err || !cachedResult.val) {
|
|
return { success: false, error: cachedResult.err ?? "No result from cache" };
|
|
}
|
|
|
|
return cachedResult.val;
|
|
}
|
|
|
|
private async fetchIncidentStatus(apiKey: string, statusPageId: string): Promise<CachedResult> {
|
|
const headers = {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
"Content-Type": "application/json",
|
|
};
|
|
const retryConfig = {
|
|
retry: { maxAttempts: 3, minTimeoutInMs: 1000, maxTimeoutInMs: 5000 },
|
|
};
|
|
|
|
try {
|
|
// Fetch the status page to get aggregate state
|
|
const statusPageResult = await wrapZodFetch(
|
|
StatusPageSchema,
|
|
`${this.baseUrl}/status-pages/${statusPageId}`,
|
|
{ headers },
|
|
retryConfig
|
|
);
|
|
|
|
if (!statusPageResult.success) {
|
|
return { success: false, error: statusPageResult.error };
|
|
}
|
|
|
|
const status = statusPageResult.data.data.attributes.aggregate_state;
|
|
|
|
// If operational, no need to fetch reports
|
|
if (status === "operational") {
|
|
return { success: true, data: { status, title: null } };
|
|
}
|
|
|
|
// Fetch status reports to get the incident title
|
|
const title = await this.fetchActiveReportTitle(apiKey, statusPageId, headers, retryConfig);
|
|
|
|
return { success: true, data: { status, title } };
|
|
} catch (error) {
|
|
console.error("Failed to fetch incident status from BetterStack:", error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
};
|
|
}
|
|
}
|
|
|
|
private async fetchActiveReportTitle(
|
|
apiKey: string,
|
|
statusPageId: string,
|
|
headers: Record<string, string>,
|
|
retryConfig: { retry: { maxAttempts: number; minTimeoutInMs: number; maxTimeoutInMs: number } }
|
|
): Promise<string | null> {
|
|
const reportsUrl = `${this.baseUrl}/status-pages/${statusPageId}/status-reports`;
|
|
|
|
let reportsResult = await wrapZodFetch(
|
|
StatusReportsSchema,
|
|
reportsUrl,
|
|
{ headers },
|
|
retryConfig
|
|
);
|
|
|
|
if (!reportsResult.success) {
|
|
return null;
|
|
}
|
|
|
|
// Fetch last page if there are multiple pages (most recent reports are at the end)
|
|
const { first, last } = reportsResult.data.pagination;
|
|
if (last && last !== first) {
|
|
const lastPageResult = await wrapZodFetch(
|
|
StatusReportsSchema,
|
|
last,
|
|
{ headers },
|
|
retryConfig
|
|
);
|
|
if (lastPageResult.success) {
|
|
reportsResult = lastPageResult;
|
|
}
|
|
}
|
|
|
|
// Find active reports (not resolved, not ended)
|
|
const activeReports = reportsResult.data.data.filter(
|
|
(report) =>
|
|
report.attributes.aggregate_state !== "resolved" && report.attributes.ends_at === null
|
|
);
|
|
|
|
if (activeReports.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Return the title from the most recent active report
|
|
const mostRecent = activeReports[activeReports.length - 1];
|
|
return mostRecent.attributes.title;
|
|
}
|
|
}
|