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