From 9b489dc14cac71067ab407166c1f18ea00649a06 Mon Sep 17 00:00:00 2001 From: planadecu Date: Wed, 30 Sep 2026 01:27:37 +0200 Subject: [PATCH 1/3] Upgrade glassnode-api to 1.0.0; recover rate-limited startup metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - glassnode-api 0.29.3 -> 1.0.0. The only source change it needs: bulk values (BulkEntry['v'], since 0.30) can be null — sortAssetsByMcap skips them (treated like a missing value) instead of the whole market-cap fetch failing and silently falling back to alphabetical order. MetricMetadata.parameters is now optional; every read already used `?.` with a fallback. Node >= 22 matches the release PR. - Startup metadata burst: of 1,817 getMetricMetadata calls, ~16% came back 429 (rate limited) even with 1.0's default 2 retries, and were silently dropped — losing display names, and leaking 160 point-in-time variants into the metric list (the is_pit filter needs the metadata). A dedicated startup client (API-key mode only; x402 stays at 0 retries so a paid call is never repeated) retries up to 5 times, and batches drop from 20 to 10. Measured live: 1,807/1,817 metadata kept (was 1,485), PIT leak 160 -> 5, ~89 s first startup (cached for a day). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- package.json | 2 +- pnpm-lock.yaml | 12 ++++++------ src/lib/api-client.ts | 15 +++++++++++++++ src/lib/startup-data.ts | 15 +++++++++------ test/lib/api-client.spec.ts | 32 ++++++++++++++++++++++++++++++++ test/lib/startup-data.spec.ts | 20 ++++++++++++++++++++ 6 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 test/lib/api-client.spec.ts create mode 100644 test/lib/startup-data.spec.ts diff --git a/package.json b/package.json index eb7fd85..4ab6e0e 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@x402/fetch": "2.26.0", "canvas": "^3.2.3", "chafa-wasm": "^0.3.3", - "glassnode-api": "^0.29.3", + "glassnode-api": "^1.0.0", "ink": "^7.1.1", "ink-spinner": "^5.0.0", "ink-uplot": "^0.2.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29af624..462dbf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: ^0.3.3 version: 0.3.3 glassnode-api: - specifier: ^0.29.3 - version: 0.29.3(@x402/evm@2.26.0(typescript@7.0.2))(@x402/fetch@2.26.0)(viem@2.56.8(typescript@7.0.2)(zod@4.6.5)) + specifier: ^1.0.0 + version: 1.0.0(@x402/evm@2.26.0(typescript@7.0.2))(@x402/fetch@2.26.0)(viem@2.56.8(typescript@7.0.2)(zod@4.6.5)) ink: specifier: ^7.1.1 version: 7.1.1(@types/react@19.3.0)(react@19.3.0) @@ -722,9 +722,9 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - glassnode-api@0.29.3: - resolution: {integrity: sha512-oCLTVHddtKxc8Ow4muf2ELOk72ibF4QNLypxtpCmlWfOkAvyekd8jRI23N8dkWECofxhn+aj6ocCaMWHBkbfkQ==} - engines: {node: '>=18.0.0'} + glassnode-api@1.0.0: + resolution: {integrity: sha512-oZMeZaamV4T1n7hpL6KCyykwU6DZR0kJFtkaXEIbtFTLOK1PBZw6b7lYzgd2Rs0QTBgWYEm1L3CX4LPzW8MsUw==} + engines: {node: '>=22.0.0'} peerDependencies: '@x402/evm': ^2.18.0 '@x402/fetch': ^2.18.0 @@ -1697,7 +1697,7 @@ snapshots: github-from-package@0.0.0: {} - glassnode-api@0.29.3(@x402/evm@2.26.0(typescript@7.0.2))(@x402/fetch@2.26.0)(viem@2.56.8(typescript@7.0.2)(zod@4.6.5)): + glassnode-api@1.0.0(@x402/evm@2.26.0(typescript@7.0.2))(@x402/fetch@2.26.0)(viem@2.56.8(typescript@7.0.2)(zod@4.6.5)): dependencies: zod: 4.6.5 optionalDependencies: diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index cc86b24..2c4fdac 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -8,6 +8,9 @@ const { version } = require('../../package.json') as { version: string }; export const USER_AGENT = `glassnode-terminal-${version}`; let clientInstance: GlassnodeAPI | null = null; +// API-key mode only: see createStartupClient(). +let startupClientInstance: GlassnodeAPI | null = null; +const STARTUP_MAX_RETRIES = 5; let x402Enabled = false; let x402FetchStore: typeof globalThis.fetch | null = null; let x402BaseUrl = 'https://x402.glassnode.com'; @@ -131,6 +134,7 @@ export async function initClient(): Promise { } clientInstance = new GlassnodeAPI({ apiKey, fetch: userAgentFetch, logger }); + startupClientInstance = new GlassnodeAPI({ apiKey, fetch: userAgentFetch, logger, maxRetries: STARTUP_MAX_RETRIES }); log('info', 'API-key mode enabled'); return clientInstance; } @@ -145,3 +149,14 @@ export function createClient(): GlassnodeAPI { } return clientInstance; } + +/** + * Client for the startup metadata burst (~1,800 calls). The API rate-limits part of it + * (429); with glassnode-api's default 2 retries ~16% of the metadata was lost, with 5 + * (backoff honours Retry-After) nearly none. Interactive calls keep the default so a rate + * limit doesn't stall the UI. x402 mode has no separate client: retrying a paid call could + * pay twice, so it stays at 0 retries. + */ +export function createStartupClient(): GlassnodeAPI { + return startupClientInstance ?? createClient(); +} diff --git a/src/lib/startup-data.ts b/src/lib/startup-data.ts index e818d17..f123dae 100644 --- a/src/lib/startup-data.ts +++ b/src/lib/startup-data.ts @@ -1,5 +1,5 @@ import type { AssetMetadata, MetricMetadata } from 'glassnode-api'; -import { createClient, isX402Enabled, fetchX402AdvancedPaths } from './api-client.js'; +import { createClient, createStartupClient, isX402Enabled, fetchX402AdvancedPaths } from './api-client.js'; import { readCache, writeCache } from './cache.js'; import type { MetricListItem } from './types.js'; import { X402_CATALOG, X402_ASSET_SETS, X402_ASSETS, type X402CatalogEntry } from './x402-catalog.js'; @@ -11,14 +11,16 @@ export interface StartupData { metricMetadataMap: Record; } -const BATCH_SIZE = 20; +const BATCH_SIZE = 10; const BATCH_DELAY_MS = 100; async function fetchAllMetricMetadata( metricPaths: string[], onProgress?: (done: number, total: number) => void, ): Promise> { - const client = createClient(); + // Batches of 10 plus the startup client's extra retries keep rate-limited (429) calls + // near zero; measured: 2 retries/batch 20 lost ~296 of 1,817, 5 retries/batch 10 ~10. + const client = createStartupClient(); const result: Record = {}; for (let i = 0; i < metricPaths.length; i += BATCH_SIZE) { @@ -46,15 +48,16 @@ async function fetchAllMetricMetadata( } /** Sort assets by descending market cap, then symbol. */ -function sortAssetsByMcap( +export function sortAssetsByMcap( assetData: AssetMetadata[], - mcapData: Array<{ bulk: Array<{ a: string; v: number }> }>, + // v is null for an asset with no value at that time (glassnode-api >= 0.30). + mcapData: Array<{ bulk: Array<{ a: string; v: number | null }> }>, ): AssetMetadata[] { const mcapBySymbol = new Map(); if (mcapData.length > 0) { const latest = mcapData[mcapData.length - 1]!; for (const entry of latest.bulk) { - mcapBySymbol.set(entry.a.toUpperCase(), entry.v); + if (entry.v !== null) mcapBySymbol.set(entry.a.toUpperCase(), entry.v); } } return [...assetData].sort((a, b) => { diff --git a/test/lib/api-client.spec.ts b/test/lib/api-client.spec.ts new file mode 100644 index 0000000..2d90e73 --- /dev/null +++ b/test/lib/api-client.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const constructed = vi.hoisted(() => [] as Array>); +vi.mock('glassnode-api', () => ({ + GlassnodeAPI: class { + constructor(public config: Record) { + constructed.push(config); + } + }, +})); + +describe('api-client startup client', () => { + beforeEach(() => { + constructed.length = 0; + vi.resetModules(); + delete process.env['X402_PRIVATE_KEY']; + process.env['GLASSNODE_API_KEY'] = 'test-key'; + process.env['GLASSNODE_MODE'] = 'api'; + }); + + it('uses a separate client with more retries for the startup metadata burst (API-key mode)', async () => { + const { initClient, createClient, createStartupClient } = await import('../../src/lib/api-client.js'); + await initClient(); + const interactive = createClient() as unknown as { config: Record }; + const startup = createStartupClient() as unknown as { config: Record }; + expect(startup).not.toBe(interactive); + // Interactive calls keep glassnode-api's default; the startup burst retries rate limits more. + expect(interactive.config['maxRetries']).toBeUndefined(); + expect(startup.config['maxRetries']).toBe(5); + expect(startup.config['apiKey']).toBe('test-key'); + }); +}); diff --git a/test/lib/startup-data.spec.ts b/test/lib/startup-data.spec.ts new file mode 100644 index 0000000..2971ea8 --- /dev/null +++ b/test/lib/startup-data.spec.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('../../src/lib/api-client.js', () => ({ createClient: vi.fn(), createStartupClient: vi.fn(), isX402Enabled: () => false })); +const { sortAssetsByMcap } = await import('../../src/lib/startup-data.js'); + +const asset = (symbol: string) => ({ id: symbol.toLowerCase(), symbol, name: symbol }) as never; + +describe('sortAssetsByMcap', () => { + it('orders by the latest market cap, treating a null value (no data) like a missing one', () => { + const sorted = sortAssetsByMcap( + [asset('ADA'), asset('BTC'), asset('XYZ'), asset('ETH')], + [ + { bulk: [{ a: 'btc', v: 1 }, { a: 'eth', v: 2 }] }, + // glassnode-api >= 0.30: an asset without a value comes back as v: null. + { bulk: [{ a: 'btc', v: 900 }, { a: 'eth', v: 400 }, { a: 'xyz', v: null }] }, + ], + ); + expect(sorted.map((a) => a.symbol)).toEqual(['BTC', 'ETH', 'ADA', 'XYZ']); + }); +}); From c8c2a1795e47369a4c68d02486a8a21fe14797d5 Mon Sep 17 00:00:00 2001 From: planadecu Date: Wed, 30 Sep 2026 01:34:04 +0200 Subject: [PATCH 2/3] Bump ink-uplot to 0.2.16 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 4ab6e0e..3dbd10f 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "glassnode-api": "^1.0.0", "ink": "^7.1.1", "ink-spinner": "^5.0.0", - "ink-uplot": "^0.2.15", + "ink-uplot": "^0.2.16", "path2d": "^0.3.4", "react": "^19.3.0", "uplot": "^1.6.32", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 462dbf4..68890fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ importers: specifier: ^5.0.0 version: 5.0.0(ink@7.1.1(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) ink-uplot: - specifier: ^0.2.15 - version: 0.2.15(ink@7.1.1(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(uplot@1.6.32) + specifier: ^0.2.16 + version: 0.2.16(ink@7.1.1(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(uplot@1.6.32) path2d: specifier: ^0.3.4 version: 0.3.4 @@ -766,8 +766,8 @@ packages: '@types/react': optional: true - ink-uplot@0.2.15: - resolution: {integrity: sha512-k9rOhV/ryoAdyDZOeXHOCp62ONhdgD1gBMRan9ck/fctHQ9AYr6N8cHecVHdPxEyTQsiFUQj3l2HVxSWUFYE2g==} + ink-uplot@0.2.16: + resolution: {integrity: sha512-n5ct2/cQZBEOiru/lMpUvYYg7HtPEJenWjrC7Kn/z7IRAG+LF4oLt/LHBdnfHSuSDFbt+wjkz1nYcYggTxwmlg==} engines: {node: '>=18.0.0'} peerDependencies: ink: '>=4.0.0' @@ -1723,7 +1723,7 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - ink-uplot@0.2.15(ink@7.1.1(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(uplot@1.6.32): + ink-uplot@0.2.16(ink@7.1.1(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(uplot@1.6.32): dependencies: canvas: 3.2.3 chafa-wasm: 0.3.3 From 556c3161e06f42c853e9e64d5ad03958a4345a0c Mon Sep 17 00:00:00 2001 From: planadecu Date: Wed, 30 Sep 2026 01:41:15 +0200 Subject: [PATCH 3/3] Wait out the API's rate-limit window at startup; hide _pit metrics by suffix From review of this PR: - The API's 429s carry x-rate-limit-reset (seconds until its 1,200/minute window resets) but no Retry-After, so retries used short jittered backoff and could give up before the reset. rateLimitAwareFetch (startup client only) exposes the reset as Retry-After, which glassnode-api honours, capped at a 60 s maxRetryDelay. Live: 1,817/1,817 metadata (was 1,807), first startup 75 s. - listedMetrics hides point-in-time variants by `_pit` suffix as well as is_pit, so a variant whose metadata failed to load can't leak into the list. - Startup cache key -> startup-data-v2 so a day-old cache that lost metadata to rate limits isn't reused after upgrading. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- src/lib/api-client.ts | 37 +++++++++++++++++++++++++++++------ src/lib/startup-data.ts | 20 ++++++++++++++----- test/lib/api-client.spec.ts | 23 ++++++++++++++++++++++ test/lib/startup-data.spec.ts | 10 +++++++++- 4 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 2c4fdac..8d4d58e 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -11,6 +11,7 @@ let clientInstance: GlassnodeAPI | null = null; // API-key mode only: see createStartupClient(). let startupClientInstance: GlassnodeAPI | null = null; const STARTUP_MAX_RETRIES = 5; +const STARTUP_MAX_RETRY_DELAY_MS = 60_000; // the API's rate-limit window let x402Enabled = false; let x402FetchStore: typeof globalThis.fetch | null = null; let x402BaseUrl = 'https://x402.glassnode.com'; @@ -70,6 +71,23 @@ const userAgentFetch: typeof globalThis.fetch = (input, init) => { return globalThis.fetch(req); }; +/** + * The API's 429s carry `x-rate-limit-reset` (seconds until the per-minute window resets) + * but no `Retry-After`, so glassnode-api falls back to short jittered backoff and can give + * up before the window resets. Expose the reset as `Retry-After`, which glassnode-api honours + * (capped at `maxRetryDelay`), so a retry waits for the new window. + */ +export function rateLimitAwareFetch(inner: typeof globalThis.fetch): typeof globalThis.fetch { + return async (input, init) => { + const res = await inner(input, init); + const reset = res.headers.get('x-rate-limit-reset'); + if (res.status !== 429 || res.headers.has('retry-after') || !reset || !/^\d+$/.test(reset)) return res; + const headers = new Headers(res.headers); + headers.set('retry-after', reset); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); + }; +} + /** * Initialize the shared GlassnodeAPI client. Must be awaited once at startup, * before any createClient() call. Credentials are chosen from the environment: @@ -134,7 +152,13 @@ export async function initClient(): Promise { } clientInstance = new GlassnodeAPI({ apiKey, fetch: userAgentFetch, logger }); - startupClientInstance = new GlassnodeAPI({ apiKey, fetch: userAgentFetch, logger, maxRetries: STARTUP_MAX_RETRIES }); + startupClientInstance = new GlassnodeAPI({ + apiKey, + fetch: rateLimitAwareFetch(userAgentFetch), + logger, + maxRetries: STARTUP_MAX_RETRIES, + maxRetryDelay: STARTUP_MAX_RETRY_DELAY_MS, + }); log('info', 'API-key mode enabled'); return clientInstance; } @@ -151,11 +175,12 @@ export function createClient(): GlassnodeAPI { } /** - * Client for the startup metadata burst (~1,800 calls). The API rate-limits part of it - * (429); with glassnode-api's default 2 retries ~16% of the metadata was lost, with 5 - * (backoff honours Retry-After) nearly none. Interactive calls keep the default so a rate - * limit doesn't stall the UI. x402 mode has no separate client: retrying a paid call could - * pay twice, so it stays at 0 retries. + * Client for the startup metadata burst (~1,800 calls against a 1,200/minute limit). With + * glassnode-api's default 2 short retries ~16% of the metadata was rate-limited (429) and + * lost. This client retries up to 5 times and, via rateLimitAwareFetch, waits for the + * rate-limit window to reset. Interactive calls keep the default so a rate limit doesn't + * stall the UI. x402 mode has no separate client: retrying a paid call could pay twice, + * so it stays at 0 retries. */ export function createStartupClient(): GlassnodeAPI { return startupClientInstance ?? createClient(); diff --git a/src/lib/startup-data.ts b/src/lib/startup-data.ts index f123dae..a7a6be7 100644 --- a/src/lib/startup-data.ts +++ b/src/lib/startup-data.ts @@ -18,8 +18,8 @@ async function fetchAllMetricMetadata( metricPaths: string[], onProgress?: (done: number, total: number) => void, ): Promise> { - // Batches of 10 plus the startup client's extra retries keep rate-limited (429) calls - // near zero; measured: 2 retries/batch 20 lost ~296 of 1,817, 5 retries/batch 10 ~10. + // Batches of 10 plus the startup client's rate-limit-aware retries keep lost (429) calls + // near zero; measured: 2 retries/batch 20 lost ~296 of 1,817. const client = createStartupClient(); const result: Record = {}; @@ -68,6 +68,15 @@ export function sortAssetsByMcap( }); } +/** + * Metrics to list: everything except point-in-time (`_pit`) variants. Checks the path suffix + * as well as `is_pit`, so a variant whose metadata failed to load (e.g. rate-limited) is still + * hidden; every `is_pit` metric's path ends in `_pit` and no other does. + */ +export function listedMetrics(paths: string[], metadata: Record): string[] { + return paths.filter((p) => !(metadata[p]?.is_pit || p.endsWith('_pit'))); +} + /** Turn catalog entries into the metrics list + a metadata map buildMetricList understands. */ function catalogToStartupParts(entries: readonly X402CatalogEntry[]): { metrics: string[]; @@ -96,7 +105,9 @@ export async function loadStartupData( onProgress?: (message: string) => void, ): Promise { // Cache is per-mode: x402 shows only the ~326 advanced metrics, API mode shows all. - const cacheKey = isX402Enabled() ? 'startup-data-x402' : 'startup-data'; + // v2: glassnode-api 1.0 + rate-limit-aware metadata fetch — don't reuse a day-old cache + // that lost metadata to rate limits (missing names, leaked _pit metrics). + const cacheKey = isX402Enabled() ? 'startup-data-x402' : 'startup-data-v2'; const cached = readCache(cacheKey); if (cached) { @@ -134,8 +145,7 @@ async function loadStartupDataApi( onProgress?.(`Fetching metric metadata... ${done}/${total}`); }); - const filteredMetrics = metricPaths.filter((p) => !metricMetadataMap[p]?.is_pit); - return { assets: sortedAssets, metrics: filteredMetrics, metricMetadataMap }; + return { assets: sortedAssets, metrics: listedMetrics(metricPaths, metricMetadataMap), metricMetadataMap }; } /** diff --git a/test/lib/api-client.spec.ts b/test/lib/api-client.spec.ts index 2d90e73..2fa9e67 100644 --- a/test/lib/api-client.spec.ts +++ b/test/lib/api-client.spec.ts @@ -27,6 +27,29 @@ describe('api-client startup client', () => { // Interactive calls keep glassnode-api's default; the startup burst retries rate limits more. expect(interactive.config['maxRetries']).toBeUndefined(); expect(startup.config['maxRetries']).toBe(5); + // Long enough to wait out the API's 60 s rate-limit window (via Retry-After). + expect(startup.config['maxRetryDelay']).toBe(60_000); + expect(startup.config['fetch']).not.toBe(interactive.config['fetch']); expect(startup.config['apiKey']).toBe('test-key'); }); }); + +describe('rateLimitAwareFetch', () => { + const respond = (status: number, headers: Record) => + vi.fn(async () => new Response('{}', { status, headers })); + + it("exposes the API's x-rate-limit-reset as Retry-After on a 429", async () => { + const { rateLimitAwareFetch } = await import('../../src/lib/api-client.js'); + const res = await rateLimitAwareFetch(respond(429, { 'x-rate-limit-reset': '37' }))('https://x'); + expect(res.status).toBe(429); + expect(res.headers.get('retry-after')).toBe('37'); + }); + + it('leaves other responses and an existing Retry-After alone', async () => { + const { rateLimitAwareFetch } = await import('../../src/lib/api-client.js'); + const ok = await rateLimitAwareFetch(respond(200, { 'x-rate-limit-reset': '37' }))('https://x'); + expect(ok.headers.get('retry-after')).toBeNull(); + const own = await rateLimitAwareFetch(respond(429, { 'x-rate-limit-reset': '37', 'retry-after': '5' }))('https://x'); + expect(own.headers.get('retry-after')).toBe('5'); + }); +}); diff --git a/test/lib/startup-data.spec.ts b/test/lib/startup-data.spec.ts index 2971ea8..ed5a049 100644 --- a/test/lib/startup-data.spec.ts +++ b/test/lib/startup-data.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; vi.mock('../../src/lib/api-client.js', () => ({ createClient: vi.fn(), createStartupClient: vi.fn(), isX402Enabled: () => false })); -const { sortAssetsByMcap } = await import('../../src/lib/startup-data.js'); +const { sortAssetsByMcap, listedMetrics } = await import('../../src/lib/startup-data.js'); const asset = (symbol: string) => ({ id: symbol.toLowerCase(), symbol, name: symbol }) as never; @@ -18,3 +18,11 @@ describe('sortAssetsByMcap', () => { expect(sorted.map((a) => a.symbol)).toEqual(['BTC', 'ETH', 'ADA', 'XYZ']); }); }); + +describe('listedMetrics', () => { + it('hides point-in-time variants, even when their metadata failed to load', () => { + const paths = ['/a/count', '/a/count_pit', '/b/sum_pit', '/c/ratio']; + const metadata = { '/a/count_pit': { is_pit: true }, '/a/count': { is_pit: false } } as never; + expect(listedMetrics(paths, metadata)).toEqual(['/a/count', '/c/ratio']); + }); +});