diff --git a/apps/address-enrichment/CHANGELOG.md b/apps/address-enrichment/CHANGELOG.md
index 28d36a36d4..cf492c5d4a 100644
--- a/apps/address-enrichment/CHANGELOG.md
+++ b/apps/address-enrichment/CHANGELOG.md
@@ -1,5 +1,12 @@
# @anticapture/address-enrichment
+## 1.1.3
+
+### Patch Changes
+
+- Updated dependencies [[`5c3da3b`](https://github.com/blockful/anticapture/commit/5c3da3b399569ac8046403c46c5264d0802b73c4)]:
+ - @anticapture/observability@1.1.0
+
## 1.1.2
### Patch Changes
diff --git a/apps/address-enrichment/package.json b/apps/address-enrichment/package.json
index ff5dd0c904..c9e3c1879c 100644
--- a/apps/address-enrichment/package.json
+++ b/apps/address-enrichment/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/address-enrichment",
- "version": "1.1.2",
+ "version": "1.1.3",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
diff --git a/apps/api/CHANGELOG.md b/apps/api/CHANGELOG.md
index a9360d3d75..07a19d32d4 100644
--- a/apps/api/CHANGELOG.md
+++ b/apps/api/CHANGELOG.md
@@ -1,5 +1,12 @@
# @anticapture/api
+## 1.8.4
+
+### Patch Changes
+
+- Updated dependencies [[`5c3da3b`](https://github.com/blockful/anticapture/commit/5c3da3b399569ac8046403c46c5264d0802b73c4)]:
+ - @anticapture/observability@1.1.0
+
## 1.8.3
### Patch Changes
diff --git a/apps/api/package.json b/apps/api/package.json
index 09c9a8a703..b4d395cc5d 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/api",
- "version": "1.8.3",
+ "version": "1.8.4",
"private": true,
"main": "dist/index.js",
"type": "module",
diff --git a/apps/authful/CHANGELOG.md b/apps/authful/CHANGELOG.md
index e41cb2f907..59f1acbbda 100644
--- a/apps/authful/CHANGELOG.md
+++ b/apps/authful/CHANGELOG.md
@@ -1,5 +1,12 @@
# @anticapture/authful
+## 0.6.1
+
+### Patch Changes
+
+- Updated dependencies [[`5c3da3b`](https://github.com/blockful/anticapture/commit/5c3da3b399569ac8046403c46c5264d0802b73c4)]:
+ - @anticapture/observability@1.1.0
+
## 0.6.0
### Minor Changes
diff --git a/apps/authful/package.json b/apps/authful/package.json
index 5cd1b74e8b..f8f5b06470 100644
--- a/apps/authful/package.json
+++ b/apps/authful/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/authful",
- "version": "0.6.0",
+ "version": "0.6.1",
"private": true,
"type": "module",
"main": "dist/index.js",
diff --git a/apps/dashboard/CHANGELOG.md b/apps/dashboard/CHANGELOG.md
index f1566e1912..0c765d8b34 100644
--- a/apps/dashboard/CHANGELOG.md
+++ b/apps/dashboard/CHANGELOG.md
@@ -1,5 +1,17 @@
# @anticapture/dashboard
+## 2.17.0
+
+### Minor Changes
+
+- [#2168](https://github.com/blockful/anticapture/pull/2168) [`ac8e8a0`](https://github.com/blockful/anticapture/commit/ac8e8a00d5ba97c4afd1e8ab1ebd22ffef43fef8) Thanks [@alextnetto](https://github.com/alextnetto)! - Add the Security Council card to the Shutter DAO overview: the 5-of-8 multisig that owns the Snapshot X space and vetoes proposals via `cancel` before execution. Support councils with no fixed term ("No expiration") in the card.
+
+### Patch Changes
+
+- [#2169](https://github.com/blockful/anticapture/pull/2169) [`68414b8`](https://github.com/blockful/anticapture/commit/68414b873120e6288afc27036b020c0f36100885) Thanks [@brunod-e](https://github.com/brunod-e)! - Fix the whitelabel delegates table clipping the Delegate button and scrolling horizontally, and stop the hidden row actions from squeezing the address name
+
+- [#2167](https://github.com/blockful/anticapture/pull/2167) [`f705ff8`](https://github.com/blockful/anticapture/commit/f705ff8d5066370b793c332b3c244cebf361f9a9) Thanks [@alextnetto](https://github.com/alextnetto)! - Service providers page: respect each program's report deadline (`reportDueDays` in `ensdao/spp`), so SPP3 reports are due 30 days after each quarter ends, per its Program Terms
+
## 2.16.0
### Minor Changes
diff --git a/apps/dashboard/features/dao-overview/components/CountdownDaoInfo.tsx b/apps/dashboard/features/dao-overview/components/CountdownDaoInfo.tsx
index f2bc177d5e..0d29e26d27 100644
--- a/apps/dashboard/features/dao-overview/components/CountdownDaoInfo.tsx
+++ b/apps/dashboard/features/dao-overview/components/CountdownDaoInfo.tsx
@@ -15,6 +15,11 @@ export const CountdownDaoInfo = ({
const targetTimestamp = securityCouncil?.expiration.timestamp;
const countdown = useCountdown(targetTimestamp);
+ // timestamp === 0 marks a council with no fixed term: show a static label
+ // instead of a countdown.
+ const noExpiration =
+ !!securityCouncil && !securityCouncil.expiration.timestamp;
+
const formattedCountdown = useMemo(() => {
if (!countdown || countdown.isLoading) return null;
return {
@@ -25,6 +30,14 @@ export const CountdownDaoInfo = ({
};
}, [countdown]);
+ if (noExpiration) {
+ return (
+
+ No expiration
+
+ );
+ }
+
if (!formattedCountdown) return null;
return (
diff --git a/apps/dashboard/features/dao-overview/components/SecurityCouncilCard.tsx b/apps/dashboard/features/dao-overview/components/SecurityCouncilCard.tsx
index 75cf7cc864..43dcc1f424 100644
--- a/apps/dashboard/features/dao-overview/components/SecurityCouncilCard.tsx
+++ b/apps/dashboard/features/dao-overview/components/SecurityCouncilCard.tsx
@@ -17,7 +17,8 @@ export const SecurityCouncilCard = ({
const { securityCouncil } = daoOverview;
const progress = useMemo(() => {
- if (!securityCouncil) return 0;
+ // timestamp === 0 marks a council with no fixed term: no progress to show.
+ if (!securityCouncil || !securityCouncil.expiration.timestamp) return 0;
const start = new Date(securityCouncil.expiration.startDate).getTime();
const end = new Date(securityCouncil.expiration.date).getTime();
const now = Date.now();
@@ -29,7 +30,8 @@ export const SecurityCouncilCard = ({
}, [securityCouncil]);
const warning = useMemo(() => {
- if (!securityCouncil) return 0;
+ // No fixed term (timestamp === 0) means no danger zone to render.
+ if (!securityCouncil || !securityCouncil.expiration.timestamp) return 0;
const start = new Date(securityCouncil.expiration.startDate).getTime();
const end = new Date(securityCouncil.expiration.date).getTime();
diff --git a/apps/dashboard/features/holders-and-delegates/delegate/Delegates.tsx b/apps/dashboard/features/holders-and-delegates/delegate/Delegates.tsx
index a4ea97c1d9..98b0de14b1 100644
--- a/apps/dashboard/features/holders-and-delegates/delegate/Delegates.tsx
+++ b/apps/dashboard/features/holders-and-delegates/delegate/Delegates.tsx
@@ -306,7 +306,9 @@ export const Delegates = ({
/>
{!isMobile && (
-
+ // Only in the flow while hovered, so the invisible actions do
+ // not reserve ~130px next to the name on every row.
+
),
meta: {
- columnClassName: isWhitelabel ? "w-[13%]" : "w-[16%]",
+ columnClassName: isWhitelabel ? "w-[12%]" : "w-[16%]",
},
},
{
@@ -528,7 +530,7 @@ export const Delegates = ({
),
meta: {
- columnClassName: isWhitelabel ? "w-[10%]" : "w-[11%]",
+ columnClassName: isWhitelabel ? "w-[9%]" : "w-[11%]",
},
},
{
@@ -579,7 +581,7 @@ export const Delegates = ({
),
meta: {
- columnClassName: isWhitelabel ? "w-[13%]" : "w-[16%]",
+ columnClassName: isWhitelabel ? "w-[12%]" : "w-[16%]",
},
},
{
@@ -622,7 +624,7 @@ export const Delegates = ({
),
meta: {
- columnClassName: isWhitelabel ? "w-[11%]" : "w-[12%]",
+ columnClassName: isWhitelabel ? "w-[10%]" : "w-[12%]",
},
},
...(isWhitelabel
@@ -652,7 +654,7 @@ export const Delegates = ({
},
header: () => null,
meta: {
- columnClassName: "w-24 px-2",
+ columnClassName: "w-40 px-2",
},
} satisfies ColumnDef
,
]
diff --git a/apps/dashboard/features/service-providers/components/ServiceProvidersTable.tsx b/apps/dashboard/features/service-providers/components/ServiceProvidersTable.tsx
index f0b1a5839c..ffb7b1e7ff 100644
--- a/apps/dashboard/features/service-providers/components/ServiceProvidersTable.tsx
+++ b/apps/dashboard/features/service-providers/components/ServiceProvidersTable.tsx
@@ -103,7 +103,11 @@ export const ServiceProvidersTable = ({
const isYear2 = year2ColIndex !== undefined;
const isCurrentQuarter =
col.year === currentYear && col.quarter === currentQuarter;
- const dueDateLabel = getDueDateLabel(col.quarter);
+ const dueDateLabel = getDueDateLabel(
+ col.year,
+ col.quarter,
+ program.reportDueDays,
+ );
return {
id: key,
diff --git a/apps/dashboard/features/service-providers/hooks/useServiceProvidersData.ts b/apps/dashboard/features/service-providers/hooks/useServiceProvidersData.ts
index 9ee8a52a63..4cbb79bc68 100644
--- a/apps/dashboard/features/service-providers/hooks/useServiceProvidersData.ts
+++ b/apps/dashboard/features/service-providers/hooks/useServiceProvidersData.ts
@@ -31,7 +31,7 @@ const buildProviders = (
githubSlug: entry.slug,
streamDuration: programEntry.streamDuration,
years: Object.fromEntries(
- Object.entries(result.data)
+ Object.entries(result.data[programKey] ?? {})
.filter(([, slugData]) => slugData[entry.slug])
.map(([year, slugData]) => [year, slugData[entry.slug]]),
),
diff --git a/apps/dashboard/features/service-providers/types.ts b/apps/dashboard/features/service-providers/types.ts
index 15a1a208c8..819ff60f1c 100644
--- a/apps/dashboard/features/service-providers/types.ts
+++ b/apps/dashboard/features/service-providers/types.ts
@@ -43,6 +43,8 @@ export type ProgramConfig = {
year2Quarters?: string[];
budget: number;
startDate: string;
+ /** Days after each quarter ends that its report is due, per the program's terms. */
+ reportDueDays?: number;
discussionUrl: string;
budgetProposal: ProgramProposal;
selectionProposal: ProgramProposal;
@@ -78,6 +80,7 @@ export type ProgramDefinition = {
name: string;
year1Quarters: ParsedQuarter[];
year2Quarters: ParsedQuarter[];
+ reportDueDays: number;
discussionUrl: string;
budgetProposal: ProgramProposal;
selectionProposal: ProgramProposal;
diff --git a/apps/dashboard/features/service-providers/utils/computeQuarterStatus.test.ts b/apps/dashboard/features/service-providers/utils/computeQuarterStatus.test.ts
new file mode 100644
index 0000000000..aa9ef07e79
--- /dev/null
+++ b/apps/dashboard/features/service-providers/utils/computeQuarterStatus.test.ts
@@ -0,0 +1,60 @@
+import {
+ computeQuarterStatus,
+ getDueDate,
+ getDueDateLabel,
+} from "@/features/service-providers/utils/computeQuarterStatus";
+
+describe("getDueDate", () => {
+ test("defaults to the last second of the quarter", () => {
+ expect(getDueDate(2026, "Q3").toISOString()).toBe(
+ "2026-09-30T23:59:59.000Z",
+ );
+ });
+
+ test("adds the program's report window after the quarter ends", () => {
+ expect(getDueDate(2026, "Q3", 30).toISOString()).toBe(
+ "2026-10-30T23:59:59.000Z",
+ );
+ });
+
+ test("rolls a Q4 report window into the next year", () => {
+ expect(getDueDate(2026, "Q4", 30).toISOString()).toBe(
+ "2027-01-30T23:59:59.000Z",
+ );
+ });
+});
+
+describe("getDueDateLabel", () => {
+ test("labels the quarter end when there is no report window", () => {
+ expect(getDueDateLabel(2026, "Q3")).toBe("Due by Sep 30");
+ });
+
+ test("labels the actual deadline when there is a report window", () => {
+ expect(getDueDateLabel(2026, "Q3", 30)).toBe("Due by Oct 30");
+ expect(getDueDateLabel(2026, "Q4", 30)).toBe("Due by Jan 30");
+ });
+});
+
+describe("computeQuarterStatus", () => {
+ const oct5 = new Date("2026-10-05T12:00:00Z");
+
+ test("is overdue the day after the quarter ends without a report window", () => {
+ expect(computeQuarterStatus(2026, "Q3", oct5)).toBe("overdue");
+ });
+
+ test("is still due soon inside the program's report window", () => {
+ expect(computeQuarterStatus(2026, "Q3", oct5, 30)).toBe("due_soon");
+ });
+
+ test("is overdue once the report window has passed", () => {
+ expect(
+ computeQuarterStatus(2026, "Q3", new Date("2026-10-31T00:00:00Z"), 30),
+ ).toBe("overdue");
+ });
+
+ test("is upcoming more than 30 days before the deadline", () => {
+ expect(
+ computeQuarterStatus(2026, "Q3", new Date("2026-09-28T00:00:00Z"), 30),
+ ).toBe("upcoming");
+ });
+});
diff --git a/apps/dashboard/features/service-providers/utils/computeQuarterStatus.ts b/apps/dashboard/features/service-providers/utils/computeQuarterStatus.ts
index 198a5dc993..b2e6b39ba2 100644
--- a/apps/dashboard/features/service-providers/utils/computeQuarterStatus.ts
+++ b/apps/dashboard/features/service-providers/utils/computeQuarterStatus.ts
@@ -10,25 +10,37 @@ const QUARTER_END_DATES: Record = {
Q4: { month: 12, day: 31 },
};
-export const getDueDate = (year: number, quarter: QuarterKey): Date => {
+// reportDueDays: days after the quarter ends that the program gives providers
+// to publish the report (e.g. SPP3 Program Terms §6.3: 30 days).
+export const getDueDate = (
+ year: number,
+ quarter: QuarterKey,
+ reportDueDays = 0,
+): Date => {
const { month, day } = QUARTER_END_DATES[quarter];
- return new Date(Date.UTC(year, month - 1, day, 23, 59, 59));
+ return new Date(Date.UTC(year, month - 1, day + reportDueDays, 23, 59, 59));
};
-export const getDueDateLabel = (quarter: QuarterKey): string => {
- const { month, day } = QUARTER_END_DATES[quarter];
- const monthName = new Date(2000, month - 1).toLocaleString("en-US", {
+export const getDueDateLabel = (
+ year: number,
+ quarter: QuarterKey,
+ reportDueDays = 0,
+): string => {
+ const dueDate = getDueDate(year, quarter, reportDueDays);
+ const monthName = dueDate.toLocaleString("en-US", {
month: "short",
+ timeZone: "UTC",
});
- return `Due by ${monthName} ${day}`;
+ return `Due by ${monthName} ${dueDate.getUTCDate()}`;
};
export const computeQuarterStatus = (
year: number,
quarter: QuarterKey,
now: Date,
+ reportDueDays = 0,
): ReportStatus => {
- const deadline = getDueDate(year, quarter);
+ const deadline = getDueDate(year, quarter, reportDueDays);
if (now > deadline) return "overdue";
diff --git a/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.test.ts b/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.test.ts
new file mode 100644
index 0000000000..35a5688930
--- /dev/null
+++ b/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.test.ts
@@ -0,0 +1,72 @@
+import type { ProviderEntry } from "@/features/service-providers/types";
+import {
+ buildProgramData,
+ parseProgramConfig,
+} from "@/features/service-providers/utils/fetchServiceProvidersData";
+
+const proposal = {
+ id: "EP 1.1",
+ title: "Proposal",
+ date: "2026-01-01",
+ forumUrl: "https://discuss.ens.domains/t/1",
+ snapshotUrl: "https://snapshot.box/#/s:ens.eth/proposal/0x1",
+};
+
+const programConfig = {
+ name: "Program",
+ year1Quarters: ["2026/Q3", "2026/Q4"],
+ budget: 1_000_000,
+ startDate: "2026-08-01",
+ discussionUrl: "https://discuss.ens.domains/t/1",
+ budgetProposal: proposal,
+ selectionProposal: proposal,
+};
+
+const provider: ProviderEntry = {
+ name: "Provider",
+ slug: "provider",
+ programs: { SPP3: { budget: 100_000, streamDuration: 1 } },
+ reports: { "2026/Q4": "https://discuss.ens.domains/t/2" },
+};
+
+const oct5 = new Date("2026-10-05T12:00:00Z");
+
+describe("parseProgramConfig", () => {
+ test("defaults the report window to 0 days", () => {
+ expect(parseProgramConfig(programConfig).reportDueDays).toBe(0);
+ });
+
+ test("keeps the program's report window", () => {
+ expect(
+ parseProgramConfig({ ...programConfig, reportDueDays: 30 }).reportDueDays,
+ ).toBe(30);
+ });
+});
+
+describe("buildProgramData", () => {
+ test("uses each program's own report window for the same quarter", () => {
+ const quarterEnd = parseProgramConfig(programConfig);
+ const thirtyDays = parseProgramConfig({
+ ...programConfig,
+ reportDueDays: 30,
+ });
+
+ expect(
+ buildProgramData(quarterEnd, [provider], oct5)[2026].provider.Q3,
+ ).toEqual({ status: "overdue" });
+ expect(
+ buildProgramData(thirtyDays, [provider], oct5)[2026].provider.Q3,
+ ).toEqual({ status: "due_soon" });
+ });
+
+ test("marks quarters with a report link as published", () => {
+ const program = parseProgramConfig({ ...programConfig, reportDueDays: 30 });
+
+ expect(
+ buildProgramData(program, [provider], oct5)[2026].provider.Q4,
+ ).toEqual({
+ status: "published",
+ reportUrl: "https://discuss.ens.domains/t/2",
+ });
+ });
+});
diff --git a/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.ts b/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.ts
index 174fdd3159..92dc543ab5 100644
--- a/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.ts
+++ b/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.ts
@@ -7,6 +7,7 @@ import type {
ProgramConfig,
ProgramDefinition,
ProgramsConfig,
+ ProviderEntry,
ProvidersConfig,
QuarterKey,
QuarterReport,
@@ -22,7 +23,8 @@ export type ServiceProvidersData = Record>;
export type ServiceProvidersResult = {
config: ProvidersConfig;
programs: Record;
- data: ServiceProvidersData;
+ // Keyed by program: the same quarter can have different deadlines per program.
+ data: Record;
avatarUrls: Record;
};
@@ -36,6 +38,7 @@ export const parseProgramConfig = (
config: ProgramConfig,
): ProgramDefinition => ({
name: config.name,
+ reportDueDays: config.reportDueDays ?? 0,
discussionUrl: config.discussionUrl,
budgetProposal: config.budgetProposal,
selectionProposal: config.selectionProposal,
@@ -47,13 +50,9 @@ export const parseProgramConfig = (
.filter((q): q is ParsedQuarter => q !== null),
});
-const collectYears = (
- programs: Record,
-): number[] => [
+const collectYears = (program: ProgramDefinition): number[] => [
...new Set(
- Object.values(programs).flatMap((p) =>
- [...p.year1Quarters, ...p.year2Quarters].map((q) => q.year),
- ),
+ [...program.year1Quarters, ...program.year2Quarters].map((q) => q.year),
),
];
@@ -61,6 +60,7 @@ const buildQuarterReport = (
year: number,
quarter: QuarterKey,
reports: Record,
+ reportDueDays: number,
now: Date,
): QuarterReport => {
const reportUrl = reports[`${year}/${quarter}`];
@@ -69,9 +69,37 @@ const buildQuarterReport = (
return { status: "published", reportUrl };
}
- return { status: computeQuarterStatus(year, quarter, now) };
+ return { status: computeQuarterStatus(year, quarter, now, reportDueDays) };
};
+export const buildProgramData = (
+ program: ProgramDefinition,
+ providers: ProviderEntry[],
+ now: Date,
+): ServiceProvidersData =>
+ Object.fromEntries(
+ collectYears(program).map((year) => [
+ year,
+ Object.fromEntries(
+ providers.map((provider) => [
+ provider.slug,
+ Object.fromEntries(
+ QUARTERS.map((quarter) => [
+ quarter,
+ buildQuarterReport(
+ year,
+ quarter,
+ provider.reports,
+ program.reportDueDays,
+ now,
+ ),
+ ]),
+ ) as YearData, // safe: maps over all 4 quarters
+ ]),
+ ),
+ ]),
+ );
+
const fetchJson = async (path: string): Promise => {
const response = await fetch(`${GITHUB_RAW_BASE}/${path}`);
if (!response.ok) {
@@ -122,23 +150,12 @@ export const fetchServiceProvidersData =
]),
);
- const years = collectYears(programs);
const now = new Date();
const data = Object.fromEntries(
- years.map((year) => [
- year,
- Object.fromEntries(
- config.providers.map((provider) => [
- provider.slug,
- Object.fromEntries(
- QUARTERS.map((quarter) => [
- quarter,
- buildQuarterReport(year, quarter, provider.reports, now),
- ]),
- ) as YearData, // safe: maps over all 4 quarters
- ]),
- ),
+ Object.entries(programs).map(([key, program]) => [
+ key,
+ buildProgramData(program, config.providers, now),
]),
);
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index e939538a3d..6677d67907 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/dashboard",
- "version": "2.16.0",
+ "version": "2.17.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
diff --git a/apps/dashboard/shared/dao-config/shu.ts b/apps/dashboard/shared/dao-config/shu.ts
index 2ee3d55d9b..5dec4506e0 100644
--- a/apps/dashboard/shared/dao-config/shu.ts
+++ b/apps/dashboard/shared/dao-config/shu.ts
@@ -51,6 +51,32 @@ export const SHU: DaoConfiguration = {
name: "Anticapture",
url: toAbsoluteUrl("/shu/proposals/"),
},
+ securityCouncil: {
+ isActive: true,
+ // Shutter's veto is exercised through Snapshot X: the Security Council
+ // (5-of-8 Safe 0x3ea7...c0C0) owns the Snapshot X Space
+ // (0x594EB60b35C4E91A06a5df988e0504f7463cB769) and vetoes a proposal by
+ // calling Space.cancel(proposalId) any time before it is executed.
+ vetoCouncilAddress: "0x3ea731dAF66D6A7980549f90152CD9A761B9c0C0",
+ multisig: {
+ threshold: 5,
+ signers: 8,
+ externalLink:
+ "https://app.safe.global/home?safe=eth:0x3ea731dAF66D6A7980549f90152CD9A761B9c0C0",
+ description:
+ "The Security Council is a multisig with eight signers, needing five signatures to veto a proposal by cancelling it on the Snapshot X space before execution.",
+ },
+ expiration: {
+ // The veto is an owner privilege with no fixed term: timestamp 0 makes
+ // the card show "No expiration" instead of a countdown. Only startDate
+ // is rendered (as "Start") — the council took ownership of the Space on
+ // 2026-09-07; the other fields are unused.
+ startDate: "September 7, 2026",
+ date: "",
+ timestamp: 0,
+ alertExpiration: 0,
+ },
+ },
},
attackProfitability: {
riskLevel: RiskLevel.HIGH,
diff --git a/apps/dashboard/shared/dao-config/types.ts b/apps/dashboard/shared/dao-config/types.ts
index 38e24cdd75..3624b589d2 100644
--- a/apps/dashboard/shared/dao-config/types.ts
+++ b/apps/dashboard/shared/dao-config/types.ts
@@ -114,6 +114,13 @@ export interface DaoOverviewConfig {
};
expiration: {
date: string;
+ /**
+ * Unix seconds when the veto term ends. Use `0` for a council with no
+ * fixed term (e.g. a Safe that owns a governance module and can cancel
+ * indefinitely): the card then shows "No expiration" instead of a
+ * countdown and hides the progress/danger bar, and the other expiration
+ * fields are ignored (only `startDate` is still shown as "Start").
+ */
timestamp: number;
startDate: string;
alertExpiration: number;
diff --git a/apps/gateful/CHANGELOG.md b/apps/gateful/CHANGELOG.md
index 120d49ce83..a6bbd566d0 100644
--- a/apps/gateful/CHANGELOG.md
+++ b/apps/gateful/CHANGELOG.md
@@ -1,5 +1,12 @@
# @anticapture/gateful
+## 1.8.3
+
+### Patch Changes
+
+- Updated dependencies [[`5c3da3b`](https://github.com/blockful/anticapture/commit/5c3da3b399569ac8046403c46c5264d0802b73c4)]:
+ - @anticapture/observability@1.1.0
+
## 1.8.2
### Patch Changes
diff --git a/apps/gateful/package.json b/apps/gateful/package.json
index 189e89f1ae..05b331d598 100644
--- a/apps/gateful/package.json
+++ b/apps/gateful/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/gateful",
- "version": "1.8.2",
+ "version": "1.8.3",
"private": true,
"description": "Gateful REST API aggregator for Anticapture DAO APIs",
"main": "dist/src/index.js",
diff --git a/apps/indexer/CHANGELOG.md b/apps/indexer/CHANGELOG.md
index e8a3767aaf..5e0277c9f7 100644
--- a/apps/indexer/CHANGELOG.md
+++ b/apps/indexer/CHANGELOG.md
@@ -1,5 +1,12 @@
# @anticapture/indexer
+## 1.2.3
+
+### Patch Changes
+
+- Updated dependencies [[`5c3da3b`](https://github.com/blockful/anticapture/commit/5c3da3b399569ac8046403c46c5264d0802b73c4)]:
+ - @anticapture/observability@1.1.0
+
## 1.2.2
### Patch Changes
diff --git a/apps/indexer/package.json b/apps/indexer/package.json
index 541c311f5c..862e0f0ba6 100644
--- a/apps/indexer/package.json
+++ b/apps/indexer/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/indexer",
- "version": "1.2.2",
+ "version": "1.2.3",
"private": true,
"scripts": {
"dev": "ponder dev",
diff --git a/apps/offchain-indexer/CHANGELOG.md b/apps/offchain-indexer/CHANGELOG.md
index b7c2b84cd6..2f38ca89ea 100644
--- a/apps/offchain-indexer/CHANGELOG.md
+++ b/apps/offchain-indexer/CHANGELOG.md
@@ -1,5 +1,12 @@
# @anticapture/offchain-indexer
+## 1.1.4
+
+### Patch Changes
+
+- Updated dependencies [[`5c3da3b`](https://github.com/blockful/anticapture/commit/5c3da3b399569ac8046403c46c5264d0802b73c4)]:
+ - @anticapture/observability@1.1.0
+
## 1.1.3
### Patch Changes
diff --git a/apps/offchain-indexer/package.json b/apps/offchain-indexer/package.json
index 2240a7cd27..da150c003b 100644
--- a/apps/offchain-indexer/package.json
+++ b/apps/offchain-indexer/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/offchain-indexer",
- "version": "1.1.3",
+ "version": "1.1.4",
"private": true,
"type": "module",
"scripts": {
diff --git a/apps/relayer/CHANGELOG.md b/apps/relayer/CHANGELOG.md
index 6b7b3c4278..5e341654c4 100644
--- a/apps/relayer/CHANGELOG.md
+++ b/apps/relayer/CHANGELOG.md
@@ -1,5 +1,12 @@
# @anticapture/relayer
+## 1.3.1
+
+### Patch Changes
+
+- Updated dependencies [[`5c3da3b`](https://github.com/blockful/anticapture/commit/5c3da3b399569ac8046403c46c5264d0802b73c4)]:
+ - @anticapture/observability@1.1.0
+
## 1.3.0
### Minor Changes
diff --git a/apps/relayer/package.json b/apps/relayer/package.json
index 3c5d01d30d..8ab98a66db 100644
--- a/apps/relayer/package.json
+++ b/apps/relayer/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/relayer",
- "version": "1.3.0",
+ "version": "1.3.1",
"private": true,
"type": "module",
"scripts": {
diff --git a/apps/user-api/CHANGELOG.md b/apps/user-api/CHANGELOG.md
index ac58b2403d..a5f3010714 100644
--- a/apps/user-api/CHANGELOG.md
+++ b/apps/user-api/CHANGELOG.md
@@ -1,5 +1,12 @@
# @anticapture/user-api
+## 0.3.2
+
+### Patch Changes
+
+- Updated dependencies [[`5c3da3b`](https://github.com/blockful/anticapture/commit/5c3da3b399569ac8046403c46c5264d0802b73c4)]:
+ - @anticapture/observability@1.1.0
+
## 0.3.1
### Patch Changes
diff --git a/apps/user-api/package.json b/apps/user-api/package.json
index 360daa7a74..71a3def66e 100644
--- a/apps/user-api/package.json
+++ b/apps/user-api/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/user-api",
- "version": "0.3.1",
+ "version": "0.3.2",
"private": true,
"type": "module",
"main": "dist/index.js",
diff --git a/infra/monitoring/README.md b/infra/monitoring/README.md
index 78d93c498a..f0a7b0c4ca 100644
--- a/infra/monitoring/README.md
+++ b/infra/monitoring/README.md
@@ -60,6 +60,33 @@ environments continue using their configured authentication provider.
The standalone `infra/erpc/Dockerfile.monitoring` image is legacy. Use this
unified monitoring stack for the normal Anticapture Railway deployment.
+## Notification system metrics
+
+Prometheus also scrapes the four services of the
+[notification system](https://github.com/blockful/notification-system)
+(`notification-subscription-server`, `notification-dispatcher`,
+`notification-consumers`, `notification-logic-system`). This is what backs the
+uptime figure blockful reports to the ENS DAO for the notification system, so
+it is measured the same way as the Anticapture services. Set these variables on
+the Prometheus service:
+
+```text
+NOTIF_SUBSCRIPTION_SERVER_ENDPOINT=${{.RAILWAY_PRIVATE_DOMAIN}}:
+NOTIF_DISPATCHER_ENDPOINT=${{.RAILWAY_PRIVATE_DOMAIN}}:
+NOTIF_CONSUMERS_ENDPOINT=${{.RAILWAY_PRIVATE_DOMAIN}}:
+NOTIF_LOGIC_SYSTEM_ENDPOINT=${{.RAILWAY_PRIVATE_DOMAIN}}:
+```
+
+Each service serves `/metrics` on the same Fastify server as its `/health`
+check, so the port is the one that service listens on (`PORT`, or
+`WEBHOOK_API_PORT` for the consumer, whose Slack bot occupies `PORT`). The
+endpoints are unauthenticated and only reachable through Railway private
+networking. The services emit `http_server_request_duration_seconds` with the
+same labels as Gateful, so the generic `HighLatency` and `HighErrorRate` alerts
+cover them, and `ServiceDown` covers the scrape itself. The dashboard's
+"Notification System" row shows scrape status, request rate, 5xx and p99
+latency per service; there is no per-channel delivery counter yet.
+
## Alert lifecycle
Every Prometheus rule keeps a fired alert active for 15 minutes after its
diff --git a/infra/monitoring/alerts.yml b/infra/monitoring/alerts.yml
index e75c5941f6..608a8913ff 100644
--- a/infra/monitoring/alerts.yml
+++ b/infra/monitoring/alerts.yml
@@ -2,7 +2,9 @@ groups:
- name: anticapture
interval: 30s
rules:
- # Non-DAO service is not responding (no scrape data for 5 minutes)
+ # Non-DAO service is not responding (no scrape data for 5 minutes). Matches
+ # every job outside the anticapture--* pattern, including the
+ # notification-* jobs of the notification system.
- alert: ServiceDown
expr: up{job!~"anticapture-.*-(api|indexer(?:-ponder)?)"} == 0
for: 5m
diff --git a/infra/monitoring/grafana/dashboards/anticapture.json b/infra/monitoring/grafana/dashboards/anticapture.json
index c6dca4b30c..2e3ba7bd92 100644
--- a/infra/monitoring/grafana/dashboards/anticapture.json
+++ b/infra/monitoring/grafana/dashboards/anticapture.json
@@ -553,7 +553,7 @@
{
"id": 13,
"title": "General Scrape Status",
- "description": "Gateway/auth/relayer/RPC/monitoring scrape status.",
+ "description": "Gateway/auth/relayer/RPC/monitoring and notification-system scrape status.",
"type": "stat",
"gridPos": {
"x": 0,
@@ -2474,6 +2474,224 @@
}
]
},
+ {
+ "id": 90,
+ "title": "Notification System",
+ "type": "row",
+ "collapsed": false,
+ "gridPos": {
+ "x": 0,
+ "y": 137,
+ "w": 24,
+ "h": 1
+ },
+ "panels": []
+ },
+ {
+ "id": 91,
+ "title": "Notification Scrape Status",
+ "description": "blockful/notification-system: subscription-server, dispatcher, consumers and logic-system scrape status. Backs the notification system uptime reported to the ENS DAO.",
+ "type": "stat",
+ "gridPos": {
+ "x": 0,
+ "y": 138,
+ "w": 24,
+ "h": 4
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "mappings": [
+ {
+ "type": "value",
+ "options": {
+ "1": {
+ "text": "UP",
+ "color": "green"
+ }
+ }
+ },
+ {
+ "type": "value",
+ "options": {
+ "0": {
+ "text": "DOWN",
+ "color": "red"
+ }
+ }
+ }
+ ],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "red",
+ "value": null
+ },
+ {
+ "color": "green",
+ "value": 1
+ }
+ ]
+ },
+ "color": {
+ "mode": "thresholds"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "colorMode": "background",
+ "graphMode": "none",
+ "justifyMode": "center",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "name"
+ },
+ "targets": [
+ {
+ "expr": "label_replace(up{job=~\"notification-.*\"}, \"service\", \"$1\", \"job\", \"notification-(.*)\")",
+ "refId": "A",
+ "legendFormat": "{{service}}"
+ }
+ ]
+ },
+ {
+ "id": 92,
+ "title": "Notification Request Rate",
+ "description": "HTTP requests per second per notification service (/health and /metrics excluded).",
+ "type": "timeseries",
+ "gridPos": {
+ "x": 0,
+ "y": 142,
+ "w": 8,
+ "h": 7
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "reqps",
+ "color": {
+ "mode": "palette-classic"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ },
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ }
+ },
+ "targets": [
+ {
+ "expr": "label_replace(sum by (job) (rate(http_server_request_duration_seconds_count{job=~\"notification-.*\"}[$__rate_interval])), \"service\", \"$1\", \"job\", \"notification-(.*)\")",
+ "refId": "A",
+ "legendFormat": "{{service}}"
+ }
+ ]
+ },
+ {
+ "id": 93,
+ "title": "Notification 5xx by Service",
+ "description": "",
+ "type": "timeseries",
+ "gridPos": {
+ "x": 8,
+ "y": 142,
+ "w": 8,
+ "h": 7
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "reqps",
+ "color": {
+ "mode": "palette-classic"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ },
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ }
+ },
+ "targets": [
+ {
+ "expr": "label_replace(sum by (job, http_route) (rate(http_server_request_duration_seconds_count{job=~\"notification-.*\",http_response_status_code=~\"5..\"}[$__rate_interval])), \"service\", \"$1\", \"job\", \"notification-(.*)\") > 0",
+ "refId": "A",
+ "legendFormat": "{{service}} {{http_route}}"
+ }
+ ]
+ },
+ {
+ "id": 94,
+ "title": "Notification p99 Latency",
+ "description": "",
+ "type": "timeseries",
+ "gridPos": {
+ "x": 16,
+ "y": 142,
+ "w": 8,
+ "h": 7
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s",
+ "color": {
+ "mode": "palette-classic"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ },
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ }
+ },
+ "targets": [
+ {
+ "expr": "label_replace(histogram_quantile(0.99, sum by (le, job) (rate(http_server_request_duration_seconds_bucket{job=~\"notification-.*\"}[$__rate_interval]))), \"service\", \"$1\", \"job\", \"notification-(.*)\")",
+ "refId": "A",
+ "legendFormat": "{{service}}"
+ }
+ ]
+ },
{
"id": 80,
"title": "Traces",
@@ -2481,7 +2699,7 @@
"collapsed": false,
"gridPos": {
"x": 0,
- "y": 137,
+ "y": 149,
"w": 24,
"h": 1
},
@@ -2493,7 +2711,7 @@
"type": "traces",
"gridPos": {
"x": 0,
- "y": 138,
+ "y": 150,
"w": 24,
"h": 10
},
diff --git a/infra/monitoring/prometheus.yml b/infra/monitoring/prometheus.yml
index 65a2f4ad34..7dd43b0675 100644
--- a/infra/monitoring/prometheus.yml
+++ b/infra/monitoring/prometheus.yml
@@ -53,6 +53,35 @@ scrape_configs:
static_configs:
- targets: ["${RELAYER_ENDPOINT}"]
+ # Notification system (repo blockful/notification-system). Each service serves
+ # /metrics on the same Fastify server as its Railway /health check, so every
+ # endpoint is that service's private domain plus the port it listens on. The
+ # notification-* prefix keeps them out of the anticapture--* patterns, so
+ # ServiceDown and the General Scrape Status panel pick them up unchanged.
+ - job_name: notification-subscription-server
+ metrics_path: "/metrics"
+ scrape_interval: 15s
+ static_configs:
+ - targets: ["${NOTIF_SUBSCRIPTION_SERVER_ENDPOINT}"]
+
+ - job_name: notification-dispatcher
+ metrics_path: "/metrics"
+ scrape_interval: 15s
+ static_configs:
+ - targets: ["${NOTIF_DISPATCHER_ENDPOINT}"]
+
+ - job_name: notification-consumers
+ metrics_path: "/metrics"
+ scrape_interval: 15s
+ static_configs:
+ - targets: ["${NOTIF_CONSUMERS_ENDPOINT}"]
+
+ - job_name: notification-logic-system
+ metrics_path: "/metrics"
+ scrape_interval: 15s
+ static_configs:
+ - targets: ["${NOTIF_LOGIC_SYSTEM_ENDPOINT}"]
+
- job_name: anticapture-erpc
metrics_path: "/metrics"
scrape_interval: 15s
diff --git a/packages/observability/CHANGELOG.md b/packages/observability/CHANGELOG.md
new file mode 100644
index 0000000000..e20ec2e83d
--- /dev/null
+++ b/packages/observability/CHANGELOG.md
@@ -0,0 +1,15 @@
+# @anticapture/observability
+
+## 1.1.0
+
+### Minor Changes
+
+- [#2165](https://github.com/blockful/anticapture/pull/2165) [`5c3da3b`](https://github.com/blockful/anticapture/commit/5c3da3b399569ac8046403c46c5264d0802b73c4) Thanks [@LeonardoVieira1630](https://github.com/LeonardoVieira1630)! - Add the shared HTTP server metrics helpers: `createHttpServerMetrics` /
+ `createHttpServerRequestDuration` (framework-agnostic, the standard
+ `http_server_request_duration_seconds` histogram with the labels the alerts and
+ dashboard expect) and `registerFastifyMetrics` under
+ `@anticapture/observability/fastify` (serves `/metrics` and records requests).
+ Fix `createObservabilityProvider` registering the OpenTelemetry instrumentations
+ before the global meter provider was set, which made the HTTP
+ auto-instrumentation record into a no-op meter; `http_server_duration` and
+ `http_client_duration` are now exported.
diff --git a/packages/observability/package.json b/packages/observability/package.json
index 9ccfb38371..4f3946bdaf 100644
--- a/packages/observability/package.json
+++ b/packages/observability/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/observability",
- "version": "1.0.1",
+ "version": "1.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -9,6 +9,11 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
+ },
+ "./fastify": {
+ "types": "./dist/fastify.d.ts",
+ "import": "./dist/fastify.js",
+ "require": "./dist/fastify.cjs"
}
},
"files": [
@@ -28,7 +33,8 @@
"typecheck": "tsc --noEmit",
"clean": "rm -rf node_modules dist *.tsbuildinfo",
"lint": "eslint src",
- "lint:fix": "eslint src --fix"
+ "lint:fix": "eslint src --fix",
+ "test": "vitest run"
},
"dependencies": {
"@opentelemetry/core": "^1.30.1",
@@ -48,17 +54,23 @@
"devDependencies": {
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.16.5",
+ "fastify": "^5.3.2",
"pino-pretty": "^13.1.3",
"tsup": "^8.5.1",
- "typescript": "^5.8.3"
+ "typescript": "^5.8.3",
+ "vitest": "^4.0.18"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0",
- "pino-pretty": "^13.0.0"
+ "pino-pretty": "^13.0.0",
+ "fastify": "^5.0.0"
},
"peerDependenciesMeta": {
"pino-pretty": {
"optional": true
+ },
+ "fastify": {
+ "optional": true
}
}
}
diff --git a/packages/observability/src/fastify.test.ts b/packages/observability/src/fastify.test.ts
new file mode 100644
index 0000000000..cc0a4561af
--- /dev/null
+++ b/packages/observability/src/fastify.test.ts
@@ -0,0 +1,68 @@
+import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
+import { MeterProvider } from "@opentelemetry/sdk-metrics";
+import fastify, { type FastifyInstance } from "fastify";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { registerFastifyMetrics } from "./fastify.js";
+
+describe("registerFastifyMetrics", () => {
+ const exporter = new PrometheusExporter({ preventServerStart: true });
+ const meterProvider = new MeterProvider({ readers: [exporter] });
+ let server: FastifyInstance;
+
+ beforeAll(async () => {
+ server = fastify();
+ registerFastifyMetrics(server, {
+ exporter,
+ meter: meterProvider.getMeter("test"),
+ });
+ server.get("/health", () => ({ status: "ok" }));
+ server.get("/items/:id", () => ({ ok: true }));
+ server.get("/boom", () => {
+ throw new Error("boom");
+ });
+ await server.ready();
+ });
+
+ afterAll(async () => {
+ await server.close();
+ await meterProvider.shutdown();
+ });
+
+ it("serves Prometheus text at /metrics", async () => {
+ const res = await server.inject({ method: "GET", url: "/metrics" });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers["content-type"]).toContain("text/plain");
+ });
+
+ it("records requests by route pattern and status, skipping /health and /metrics", async () => {
+ await server.inject({ method: "GET", url: "/items/42" });
+ await server.inject({ method: "GET", url: "/boom" });
+ await server.inject({ method: "GET", url: "/health" });
+ await server.inject({ method: "GET", url: "/nope" });
+
+ const { body } = await server.inject({ method: "GET", url: "/metrics" });
+ const samples = body
+ .split("\n")
+ .filter((l) =>
+ l.startsWith("http_server_request_duration_seconds_count"),
+ );
+
+ const has = (...parts: string[]) =>
+ samples.some((l) => parts.every((p) => l.includes(p)));
+
+ expect(
+ has('http_route="/items/:id"', 'http_response_status_code="200"'),
+ ).toBe(true);
+ expect(has('http_route="/boom"', 'http_response_status_code="500"')).toBe(
+ true,
+ );
+ expect(
+ has('http_route="unmatched"', 'http_response_status_code="404"'),
+ ).toBe(true);
+ expect(has('http_route="/health"')).toBe(false);
+ expect(has('http_route="/metrics"')).toBe(false);
+ expect(has('http_route="/items/42"')).toBe(false);
+ });
+});
diff --git a/packages/observability/src/fastify.ts b/packages/observability/src/fastify.ts
new file mode 100644
index 0000000000..c0a3075788
--- /dev/null
+++ b/packages/observability/src/fastify.ts
@@ -0,0 +1,57 @@
+import type { Meter } from "@opentelemetry/api";
+import type { FastifyInstance } from "fastify";
+
+import { createHttpServerMetrics } from "./http-metrics.js";
+import {
+ collectPrometheusMetrics,
+ type PrometheusExporter,
+} from "./prometheus.js";
+
+export interface FastifyMetricsOptions {
+ /** The exporter returned by createObservabilityProvider. */
+ exporter: PrometheusExporter;
+ /** Where to serve the Prometheus text format. Default `/metrics`. */
+ path?: string;
+ /**
+ * Routes left out of the request histogram. Default: the metrics path and
+ * `/health`, so the scraper and the platform health check do not dominate
+ * the request rate of a low-traffic service.
+ */
+ skipRoutes?: string[];
+ meter?: Meter;
+}
+
+/**
+ * Serves Prometheus metrics and records every other request in the standard
+ * `http_server_request_duration_seconds` histogram. Only types are imported
+ * from fastify, so this entry adds no runtime dependency.
+ */
+export function registerFastifyMetrics(
+ server: FastifyInstance,
+ options: FastifyMetricsOptions,
+): void {
+ const path = options.path ?? "/metrics";
+ const skipRoutes = new Set(options.skipRoutes ?? [path, "/health"]);
+ const httpMetrics = createHttpServerMetrics(options.meter);
+
+ server.get(path, async (_request, reply) => {
+ const { body, contentType } = await collectPrometheusMetrics(
+ options.exporter,
+ );
+ return reply.type(contentType).send(body);
+ });
+
+ server.addHook("onResponse", async (request, reply) => {
+ // routeOptions.url is the route pattern; unmatched requests (404) have
+ // none and share a single label value.
+ const route = request.routeOptions.url ?? "unmatched";
+ if (skipRoutes.has(route)) return;
+
+ httpMetrics.record({
+ method: request.method,
+ route,
+ statusCode: reply.statusCode,
+ durationSeconds: reply.elapsedTime / 1000,
+ });
+ });
+}
diff --git a/packages/observability/src/http-metrics.test.ts b/packages/observability/src/http-metrics.test.ts
new file mode 100644
index 0000000000..fededb2b6c
--- /dev/null
+++ b/packages/observability/src/http-metrics.test.ts
@@ -0,0 +1,36 @@
+import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
+import { MeterProvider } from "@opentelemetry/sdk-metrics";
+import { describe, expect, it } from "vitest";
+
+import { createHttpServerMetrics } from "./http-metrics.js";
+import { collectPrometheusMetrics } from "./prometheus.js";
+
+describe("createHttpServerMetrics", () => {
+ it("records the standard histogram with the standard labels", async () => {
+ const exporter = new PrometheusExporter({ preventServerStart: true });
+ const meterProvider = new MeterProvider({ readers: [exporter] });
+ const httpMetrics = createHttpServerMetrics(meterProvider.getMeter("test"));
+
+ httpMetrics.record({
+ method: "GET",
+ route: "/items/:id",
+ statusCode: 200,
+ durationSeconds: 0.02,
+ attributes: { client_source: "dashboard" },
+ });
+
+ const { body, contentType } = await collectPrometheusMetrics(exporter);
+
+ expect(contentType).toContain("text/plain");
+ const count = body
+ .split("\n")
+ .find((l) => l.startsWith("http_server_request_duration_seconds_count"));
+ expect(count).toContain('http_request_method="GET"');
+ expect(count).toContain('http_route="/items/:id"');
+ expect(count).toContain('http_response_status_code="200"');
+ expect(count).toContain('client_source="dashboard"');
+ expect(count?.endsWith(" 1")).toBe(true);
+
+ await meterProvider.shutdown();
+ });
+});
diff --git a/packages/observability/src/http-metrics.ts b/packages/observability/src/http-metrics.ts
new file mode 100644
index 0000000000..a45c000a6c
--- /dev/null
+++ b/packages/observability/src/http-metrics.ts
@@ -0,0 +1,64 @@
+import {
+ metrics,
+ type Attributes,
+ type Histogram,
+ type Meter,
+} from "@opentelemetry/api";
+
+/**
+ * The one HTTP server histogram every Anticapture service exposes. The
+ * Prometheus alerts (HighLatency, HighErrorRate) and the Grafana dashboard
+ * query this exact name and these labels, so a service that records it is
+ * covered by them with no rule change.
+ */
+export const HTTP_SERVER_REQUEST_DURATION_NAME =
+ "http_server_request_duration_seconds";
+
+export const HTTP_SERVER_REQUEST_DURATION_BUCKETS = [
+ 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
+];
+
+export interface HttpServerRequestSample {
+ method: string;
+ /** Route pattern (e.g. `/users/:id`), never the raw URL, to bound cardinality. */
+ route: string;
+ statusCode: number;
+ durationSeconds: number;
+ /** Extra labels a service wants on top of the standard ones (e.g. client_source). */
+ attributes?: Attributes;
+}
+
+export interface HttpServerMetrics {
+ histogram: Histogram;
+ record: (sample: HttpServerRequestSample) => void;
+}
+
+export function createHttpServerRequestDuration(meter: Meter): Histogram {
+ return meter.createHistogram(HTTP_SERVER_REQUEST_DURATION_NAME, {
+ description: "Duration of HTTP requests in seconds",
+ advice: { explicitBucketBoundaries: HTTP_SERVER_REQUEST_DURATION_BUCKETS },
+ });
+}
+
+/**
+ * Framework-agnostic recorder for the standard HTTP server histogram. Call it
+ * after the global meter provider is set (createObservabilityProvider does
+ * that), otherwise the histogram binds to the no-op meter.
+ */
+export function createHttpServerMetrics(
+ meter: Meter = metrics.getMeter("http-server"),
+): HttpServerMetrics {
+ const histogram = createHttpServerRequestDuration(meter);
+
+ return {
+ histogram,
+ record: ({ method, route, statusCode, durationSeconds, attributes }) => {
+ histogram.record(durationSeconds, {
+ ...attributes,
+ http_request_method: method,
+ http_route: route,
+ http_response_status_code: statusCode,
+ });
+ },
+ };
+}
diff --git a/packages/observability/src/index.ts b/packages/observability/src/index.ts
index 1f4ad75d20..8357df717b 100644
--- a/packages/observability/src/index.ts
+++ b/packages/observability/src/index.ts
@@ -1,4 +1,12 @@
export { createLogger, type Logger } from "./logger.js";
+export {
+ createHttpServerMetrics,
+ createHttpServerRequestDuration,
+ HTTP_SERVER_REQUEST_DURATION_BUCKETS,
+ HTTP_SERVER_REQUEST_DURATION_NAME,
+ type HttpServerMetrics,
+ type HttpServerRequestSample,
+} from "./http-metrics.js";
export {
collectPrometheusMetrics,
createObservabilityProvider,
diff --git a/packages/observability/src/observability.test.ts b/packages/observability/src/observability.test.ts
new file mode 100644
index 0000000000..5932c840e8
--- /dev/null
+++ b/packages/observability/src/observability.test.ts
@@ -0,0 +1,42 @@
+import { createRequire } from "node:module";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ collectPrometheusMetrics,
+ createObservabilityProvider,
+} from "./observability.js";
+
+// The HTTP instrumentation patches `http` through require-in-the-middle, so
+// the module has to be required (not statically imported) after the provider
+// exists, exactly as a CJS service does with `import "./instrumentation"` first.
+const require = createRequire(import.meta.url);
+
+describe("createObservabilityProvider", () => {
+ it("exports the HTTP auto-instrumentation metrics", async () => {
+ const provider = createObservabilityProvider("observability-test");
+ const http = require("http") as typeof import("node:http");
+
+ const server = http.createServer((_req, res) => res.end("ok"));
+ await new Promise((resolve) =>
+ server.listen(0, "127.0.0.1", resolve),
+ );
+ const { port } = server.address() as { port: number };
+
+ await new Promise((resolve) =>
+ http.get(`http://127.0.0.1:${port}/`, (res) => {
+ res.resume();
+ res.on("end", resolve);
+ }),
+ );
+
+ const { body } = await collectPrometheusMetrics(provider.exporter);
+ expect(body).toContain("# TYPE http_server_duration histogram");
+ expect(body).toMatch(
+ /http_server_duration_count\{[^}]*http_status_code="200"/,
+ );
+
+ await new Promise((resolve) => server.close(() => resolve()));
+ await provider.shutdown();
+ });
+});
diff --git a/packages/observability/src/observability.ts b/packages/observability/src/observability.ts
index 2b36564b80..cb30fce007 100644
--- a/packages/observability/src/observability.ts
+++ b/packages/observability/src/observability.ts
@@ -1,8 +1,4 @@
import { metrics } from "@opentelemetry/api";
-import {
- PrometheusExporter,
- PrometheusSerializer,
-} from "@opentelemetry/exporter-prometheus";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { HostMetrics } from "@opentelemetry/host-metrics";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
@@ -16,11 +12,14 @@ import {
} from "@opentelemetry/sdk-trace-node";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
-export { PrometheusExporter, PrometheusSerializer };
-
-export const PROMETHEUS_MIME_TYPE = "text/plain; version=0.0.4; charset=utf-8";
+import { PrometheusExporter } from "./prometheus.js";
-const prometheusSerializer = new PrometheusSerializer();
+export {
+ collectPrometheusMetrics,
+ PROMETHEUS_MIME_TYPE,
+ PrometheusExporter,
+ PrometheusSerializer,
+} from "./prometheus.js";
export interface ObservabilityProvider {
meterProvider: MeterProvider;
@@ -29,17 +28,6 @@ export interface ObservabilityProvider {
shutdown: () => Promise;
}
-export async function collectPrometheusMetrics(
- exporter: PrometheusExporter,
-): Promise<{ body: string; contentType: string }> {
- const result = await exporter.collect();
-
- return {
- body: prometheusSerializer.serialize(result.resourceMetrics),
- contentType: PROMETHEUS_MIME_TYPE,
- };
-}
-
export function createObservabilityProvider(
serviceName: string,
options?: { resourceAttributes?: Record },
@@ -74,13 +62,18 @@ export function createObservabilityProvider(
});
tracerProvider.register();
+ metrics.setGlobalMeterProvider(meterProvider);
+
+ // The meter provider must be set (and passed) before the instrumentations
+ // are registered: each instrumentation creates its instruments from the
+ // provider available at registration time, and the API's default is a
+ // no-op meter that silently drops http.server.duration and friends.
registerInstrumentations({
tracerProvider,
+ meterProvider,
instrumentations: [new HttpInstrumentation(), new PgInstrumentation()],
});
- metrics.setGlobalMeterProvider(meterProvider);
-
new HostMetrics({ meterProvider }).start();
const shutdown = async () => {
diff --git a/packages/observability/src/prometheus.ts b/packages/observability/src/prometheus.ts
new file mode 100644
index 0000000000..9a6b2d1ea1
--- /dev/null
+++ b/packages/observability/src/prometheus.ts
@@ -0,0 +1,21 @@
+import {
+ PrometheusExporter,
+ PrometheusSerializer,
+} from "@opentelemetry/exporter-prometheus";
+
+export { PrometheusExporter, PrometheusSerializer };
+
+export const PROMETHEUS_MIME_TYPE = "text/plain; version=0.0.4; charset=utf-8";
+
+const prometheusSerializer = new PrometheusSerializer();
+
+export async function collectPrometheusMetrics(
+ exporter: PrometheusExporter,
+): Promise<{ body: string; contentType: string }> {
+ const result = await exporter.collect();
+
+ return {
+ body: prometheusSerializer.serialize(result.resourceMetrics),
+ contentType: PROMETHEUS_MIME_TYPE,
+ };
+}
diff --git a/packages/observability/tsup.config.ts b/packages/observability/tsup.config.ts
index c04a779cd0..5e60432a7f 100644
--- a/packages/observability/tsup.config.ts
+++ b/packages/observability/tsup.config.ts
@@ -1,7 +1,7 @@
import { defineConfig } from "tsup";
const shared = {
- entry: ["src/index.ts"],
+ entry: ["src/index.ts", "src/fastify.ts"],
dts: true,
outDir: "dist",
// Bundle all OTel packages to avoid ESM/CJS resolution issues in consumers
@@ -24,5 +24,8 @@ export default defineConfig([
...shared,
format: ["cjs"],
dts: false, // only emit .d.ts once from the ESM build
+ // Share the bundled OTel code between the two entries instead of
+ // duplicating it in fastify.cjs.
+ splitting: true,
},
]);
diff --git a/packages/observability/vitest.config.mts b/packages/observability/vitest.config.mts
new file mode 100644
index 0000000000..148bcec8a7
--- /dev/null
+++ b/packages/observability/vitest.config.mts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ globals: true,
+ restoreMocks: true,
+ exclude: ["dist/**", "node_modules/**"],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 42b4a30ff1..f70e9c5b0c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1057,6 +1057,9 @@ importers:
"@types/node":
specifier: ^20.16.5
version: 20.19.37
+ fastify:
+ specifier: ^5.3.2
+ version: 5.8.5
pino-pretty:
specifier: ^13.1.3
version: 13.1.3
@@ -1066,6 +1069,9 @@ importers:
typescript:
specifier: ^5.8.3
version: 5.9.3
+ vitest:
+ specifier: ^4.0.18
+ version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(vite@7.0.5(@types/node@20.19.37)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
packages:
"@adobe/css-tools@4.4.3":
@@ -2418,6 +2424,7 @@ packages:
{
integrity: sha512-bKEa8BupnZxNjLk9ZDntvgQGm5jogeE2wHdMbYifhet3GTyxgDi6pXoOK8+aqHYQGg1C3OALi9hVVWnrv7JJWQ==,
}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
hasBin: true
"@better-auth/core@1.4.21":
@@ -5212,12 +5219,48 @@ packages:
}
engines: { node: ">=18.0.0", npm: ">=9.0.0" }
+ "@fastify/ajv-compiler@4.0.5":
+ resolution:
+ {
+ integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==,
+ }
+
"@fastify/busboy@3.2.0":
resolution:
{
integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==,
}
+ "@fastify/error@4.2.0":
+ resolution:
+ {
+ integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==,
+ }
+
+ "@fastify/fast-json-stringify-compiler@5.0.3":
+ resolution:
+ {
+ integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==,
+ }
+
+ "@fastify/forwarded@3.0.1":
+ resolution:
+ {
+ integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==,
+ }
+
+ "@fastify/merge-json-schemas@0.2.1":
+ resolution:
+ {
+ integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==,
+ }
+
+ "@fastify/proxy-addr@5.1.0":
+ resolution:
+ {
+ integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==,
+ }
+
"@figma/code-connect@1.4.1":
resolution:
{
@@ -11720,6 +11763,12 @@ packages:
}
engines: { node: ">=6.5" }
+ abstract-logging@2.0.1:
+ resolution:
+ {
+ integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==,
+ }
+
accepts@1.3.8:
resolution:
{
@@ -12297,6 +12346,12 @@ packages:
}
engines: { node: ">= 0.4" }
+ avvio@9.2.0:
+ resolution:
+ {
+ integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==,
+ }
+
axe-core@4.10.3:
resolution:
{
@@ -14124,6 +14179,7 @@ packages:
{
integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==,
}
+ deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
crypto-random-string@4.0.0:
resolution:
@@ -16359,6 +16415,12 @@ packages:
integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==,
}
+ fast-decode-uri-component@1.0.1:
+ resolution:
+ {
+ integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==,
+ }
+
fast-deep-equal@3.1.3:
resolution:
{
@@ -16416,6 +16478,12 @@ packages:
integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==,
}
+ fast-json-stringify@6.3.0:
+ resolution:
+ {
+ integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==,
+ }
+
fast-levenshtein@2.0.6:
resolution:
{
@@ -16429,6 +16497,12 @@ packages:
}
engines: { node: ">=10.0" }
+ fast-querystring@1.1.2:
+ resolution:
+ {
+ integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==,
+ }
+
fast-redact@3.5.0:
resolution:
{
@@ -16491,6 +16565,12 @@ packages:
integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==,
}
+ fastify@5.8.5:
+ resolution:
+ {
+ integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==,
+ }
+
fastq@1.19.1:
resolution:
{
@@ -16651,6 +16731,13 @@ packages:
}
engines: { node: ">=14.16" }
+ find-my-way@9.5.0:
+ resolution:
+ {
+ integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==,
+ }
+ engines: { node: ">=20" }
+
find-up@4.1.0:
resolution:
{
@@ -19124,6 +19211,12 @@ packages:
}
engines: { node: ">=12.0.0" }
+ json-schema-ref-resolver@3.0.0:
+ resolution:
+ {
+ integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==,
+ }
+
json-schema-to-ts@2.7.2:
resolution:
{
@@ -19371,6 +19464,12 @@ packages:
engines: { node: ">=16" }
hasBin: true
+ light-my-request@6.6.0:
+ resolution:
+ {
+ integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==,
+ }
+
lightningcss-android-arm64@1.31.1:
resolution:
{
@@ -23034,6 +23133,12 @@ packages:
integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==,
}
+ process-warning@4.0.1:
+ resolution:
+ {
+ integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==,
+ }
+
process-warning@5.0.0:
resolution:
{
@@ -24196,6 +24301,13 @@ packages:
}
engines: { node: ">=18" }
+ ret@0.5.0:
+ resolution:
+ {
+ integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==,
+ }
+ engines: { node: ">=10" }
+
retry@0.13.1:
resolution:
{
@@ -24349,6 +24461,13 @@ packages:
}
engines: { node: ">= 0.4" }
+ safe-regex2@5.1.1:
+ resolution:
+ {
+ integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==,
+ }
+ hasBin: true
+
safe-stable-stringify@2.5.0:
resolution:
{
@@ -25906,6 +26025,13 @@ packages:
}
engines: { node: ">=8.0" }
+ toad-cache@3.7.0:
+ resolution:
+ {
+ integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==,
+ }
+ engines: { node: ">=12" }
+
toidentifier@1.0.1:
resolution:
{
@@ -32521,8 +32647,31 @@ snapshots:
"@faker-js/faker@9.9.0": {}
+ "@fastify/ajv-compiler@4.0.5":
+ dependencies:
+ ajv: 8.18.0
+ ajv-formats: 3.0.1(ajv@8.18.0)
+ fast-uri: 3.0.6
+
"@fastify/busboy@3.2.0": {}
+ "@fastify/error@4.2.0": {}
+
+ "@fastify/fast-json-stringify-compiler@5.0.3":
+ dependencies:
+ fast-json-stringify: 6.3.0
+
+ "@fastify/forwarded@3.0.1": {}
+
+ "@fastify/merge-json-schemas@0.2.1":
+ dependencies:
+ dequal: 2.0.3
+
+ "@fastify/proxy-addr@5.1.0":
+ dependencies:
+ "@fastify/forwarded": 3.0.1
+ ipaddr.js: 2.4.0
+
"@figma/code-connect@1.4.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)":
dependencies:
boxen: 5.1.1
@@ -37488,6 +37637,15 @@ snapshots:
msw: 2.12.10(@types/node@20.19.37)(typescript@5.9.3)
vite: 7.0.5(@types/node@20.19.37)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ "@vitest/mocker@4.1.0(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(vite@7.0.5(@types/node@20.19.37)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))":
+ dependencies:
+ "@vitest/spy": 4.1.0
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ msw: 2.12.10(@types/node@20.19.37)(typescript@5.9.3)
+ vite: 7.0.5(@types/node@20.19.37)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+
"@vitest/mocker@4.1.0(msw@2.12.10(@types/node@25.4.0)(typescript@5.9.3))(vite@7.0.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))":
dependencies:
"@vitest/spy": 4.1.0
@@ -38312,6 +38470,8 @@ snapshots:
dependencies:
event-target-shim: 5.0.1
+ abstract-logging@2.0.1: {}
+
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -38680,6 +38840,11 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
+ avvio@9.2.0:
+ dependencies:
+ "@fastify/error": 4.2.0
+ fastq: 1.19.1
+
axe-core@4.10.3: {}
axios-retry@4.5.0(axios@1.19.0):
@@ -41612,6 +41777,8 @@ snapshots:
fast-copy@4.0.2: {}
+ fast-decode-uri-component@1.0.1: {}
+
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
@@ -41644,10 +41811,23 @@ snapshots:
fast-json-stable-stringify@2.1.0: {}
+ fast-json-stringify@6.3.0:
+ dependencies:
+ "@fastify/merge-json-schemas": 0.2.1
+ ajv: 8.18.0
+ ajv-formats: 3.0.1(ajv@8.18.0)
+ fast-uri: 3.0.6
+ json-schema-ref-resolver: 3.0.0
+ rfdc: 1.4.1
+
fast-levenshtein@2.0.6: {}
fast-printf@1.6.10: {}
+ fast-querystring@1.1.2:
+ dependencies:
+ fast-decode-uri-component: 1.0.1
+
fast-redact@3.5.0: {}
fast-safe-stringify@2.1.1: {}
@@ -41675,6 +41855,24 @@ snapshots:
fastestsmallesttextencoderdecoder@1.0.22: {}
+ fastify@5.8.5:
+ dependencies:
+ "@fastify/ajv-compiler": 4.0.5
+ "@fastify/error": 4.2.0
+ "@fastify/fast-json-stringify-compiler": 5.0.3
+ "@fastify/proxy-addr": 5.1.0
+ abstract-logging: 2.0.1
+ avvio: 9.2.0
+ fast-json-stringify: 6.3.0
+ find-my-way: 9.5.0
+ light-my-request: 6.6.0
+ pino: 9.14.0
+ process-warning: 5.0.0
+ rfdc: 1.4.1
+ secure-json-parse: 4.1.0
+ semver: 7.8.5
+ toad-cache: 3.7.0
+
fastq@1.19.1:
dependencies:
reusify: 1.1.0
@@ -41774,6 +41972,12 @@ snapshots:
common-path-prefix: 3.0.0
pkg-dir: 7.0.0
+ find-my-way@9.5.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-querystring: 1.1.2
+ safe-regex2: 5.1.1
+
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
@@ -43540,6 +43744,10 @@ snapshots:
json-schema-compare: 0.2.2
lodash: 4.17.23
+ json-schema-ref-resolver@3.0.0:
+ dependencies:
+ dequal: 2.0.3
+
json-schema-to-ts@2.7.2:
dependencies:
"@babel/runtime": 7.29.7
@@ -43657,6 +43865,12 @@ snapshots:
dependencies:
isomorphic.js: 0.2.5
+ light-my-request@6.6.0:
+ dependencies:
+ cookie: 1.1.1
+ process-warning: 4.0.1
+ set-cookie-parser: 2.7.1
+
lightningcss-android-arm64@1.31.1:
optional: true
@@ -46348,6 +46562,8 @@ snapshots:
process-warning@3.0.0: {}
+ process-warning@4.0.1: {}
+
process-warning@5.0.0: {}
process@0.11.10: {}
@@ -47217,6 +47433,8 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
+ ret@0.5.0: {}
+
retry@0.13.1: {}
rettime@0.10.1: {}
@@ -47340,6 +47558,10 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
+ safe-regex2@5.1.1:
+ dependencies:
+ ret: 0.5.0
+
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
@@ -48359,6 +48581,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ toad-cache@3.7.0: {}
+
toidentifier@1.0.1: {}
totalist@3.0.1: {}
@@ -49214,6 +49438,35 @@ snapshots:
- tsx
- yaml
+ vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(vite@7.0.5(@types/node@20.19.37)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)):
+ dependencies:
+ "@vitest/expect": 4.1.0
+ "@vitest/mocker": 4.1.0(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(vite@7.0.5(@types/node@20.19.37)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
+ "@vitest/pretty-format": 4.1.0
+ "@vitest/runner": 4.1.0
+ "@vitest/snapshot": 4.1.0
+ "@vitest/spy": 4.1.0
+ "@vitest/utils": 4.1.0
+ es-module-lexer: 2.0.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 4.0.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.2
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.0.3
+ vite: 7.0.5(@types/node@20.19.37)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ "@opentelemetry/api": 1.9.0
+ "@types/node": 20.19.37
+ jsdom: 24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)
+ transitivePeerDependencies:
+ - msw
+
vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.10(@types/node@25.4.0)(typescript@5.9.3))(vite@7.0.5(@types/node@25.4.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
"@vitest/expect": 4.1.0