From 11906cefd32f3bacb76729711ab44b3f6d9ea7a0 Mon Sep 17 00:00:00 2001 From: faruke24 Date: Wed, 29 Jul 2026 00:49:42 +0000 Subject: [PATCH] feat(observability): add extension cost and count Prometheus counters Add sorokeep_extension_cost_xlm_total and sorokeep_extensions_total counters labeled by contract_id and entry_type. - src/observability/metrics/cost.ts: collectCostMetrics(db) queries extension_history joined with contract_entries, summing cost_xlm with COALESCE(..., 0) to handle NULLs. A zero-value baseline is emitted for every registered (contract_id, entry_type) pair so counters are never absent, satisfying the Prometheus counter contract. - src/observability/registry.ts: MetricsCollector registry with registerMetricsCollector / collectAllMetrics / _resetRegistryForTesting. collectCostMetrics is registered as the first built-in collector. - tests/observability/metrics/cost.test.ts: 16 tests written before implementation (TDD). Covers correct summation from 3 seeded rows, NULL cost handling, per-label isolation across entry types and contracts, and zero-value emission for contracts with no extensions. --- src/observability/metrics/cost.ts | 148 ++++++++ src/observability/registry.ts | 95 +++++ tests/observability/metrics/cost.test.ts | 419 +++++++++++++++++++++++ 3 files changed, 662 insertions(+) create mode 100644 src/observability/metrics/cost.ts create mode 100644 src/observability/registry.ts create mode 100644 tests/observability/metrics/cost.test.ts diff --git a/src/observability/metrics/cost.ts b/src/observability/metrics/cost.ts new file mode 100644 index 00000000..69faea9d --- /dev/null +++ b/src/observability/metrics/cost.ts @@ -0,0 +1,148 @@ +/** + * src/observability/metrics/cost.ts + * + * Prometheus counters for extension cost and count, labeled by + * `contract_id` and `entry_type`. + * + * Exported counters (Prometheus naming convention — cumulative totals): + * + * sorokeep_extension_cost_xlm_total + * The cumulative XLM cost of all TTL-extension transactions, partitioned + * by contract and ledger-entry type. NULL cost values in extension_history + * are treated as 0 (COALESCE), consistent with aggregateDailyCostSnapshots. + * + * sorokeep_extensions_total + * The cumulative count of TTL-extension transactions, partitioned by the + * same label set. + * + * Usage: + * + * import { collectCostMetrics } from "./cost.js"; + * const samples = collectCostMetrics(db); + * // samples is CostMetricSample[] — feed to your /metrics endpoint renderer + * + * Note: This module deliberately avoids importing prom-client so that the + * project stays dependency-free. The /metrics HTTP handler is responsible for + * rendering the samples into Prometheus text-exposition format. + */ + +import type Database from "better-sqlite3"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** A single labelled counter value ready for Prometheus text exposition. */ +export interface CostMetricSample { + /** Prometheus metric name, e.g. "sorokeep_extension_cost_xlm_total". */ + readonly metricName: string; + /** Label name → label value pairs, e.g. { contract_id: "C...", entry_type: "instance" }. */ + readonly labels: Readonly>; + /** Current counter value (always >= 0). */ + readonly value: number; +} + +// --------------------------------------------------------------------------- +// Internal query row shape returned by the aggregation SQL +// --------------------------------------------------------------------------- +interface CostRow { + contract_id: string; + entry_type: string; + total_cost_xlm: number; + total_extensions: number; +} + +// --------------------------------------------------------------------------- +// Internal query row shape for the "all registered entries" zero-baseline +// --------------------------------------------------------------------------- +interface EntryRow { + contract_id: string; + entry_type: string; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +/** + * Query `extension_history` (joined with `contract_entries` for the label) + * and return labelled counter samples for both metrics. + * + * Key invariant: every `(contract_id, entry_type)` pair that exists in + * `contract_entries` will appear in the result with a value of at least 0, + * even when no extensions have been recorded yet. This matches the Prometheus + * counter contract — counters must exist from the moment they are observable, + * not only after the first increment. + * + * @param db A better-sqlite3 Database instance (may be in-memory for tests). + * @returns Flat array of CostMetricSample — two entries per label set + * (one for each metric name), ordered by contract_id then entry_type. + */ +export function collectCostMetrics(db: Database.Database): CostMetricSample[] { + // ── Step 1: collect the set of (contract_id, entry_type) pairs that are + // registered in contract_entries. This provides the zero-value + // baseline so counters are never absent. + const allEntryRows = db + .prepare<[], EntryRow>( + `SELECT DISTINCT contract_id, entry_type + FROM contract_entries + ORDER BY contract_id, entry_type`, + ) + .all(); + + if (allEntryRows.length === 0) { + // No contracts registered → nothing to expose. + return []; + } + + // ── Step 2: aggregate actual extension data from extension_history, joined + // with contract_entries to recover the entry_type label. + // Logic mirrors aggregateDailyCostSnapshots in repositories.ts. + const costRows = db + .prepare<[], CostRow>( + `SELECT eh.contract_id AS contract_id, + ce.entry_type AS entry_type, + SUM(COALESCE(eh.cost_xlm, 0.0)) AS total_cost_xlm, + COUNT(*) AS total_extensions + FROM extension_history eh + JOIN contract_entries ce ON ce.id = eh.contract_entry_id + GROUP BY eh.contract_id, ce.entry_type + ORDER BY eh.contract_id, ce.entry_type`, + ) + .all(); + + // ── Step 3: build a lookup map from the aggregated rows so we can merge + // with the full entry-type baseline in O(n). + const costByKey = new Map(); + for (const row of costRows) { + costByKey.set(`${row.contract_id}::${row.entry_type}`, row); + } + + // ── Step 4: emit two CostMetricSample values per (contract_id, entry_type) + // pair, using 0 as the fallback when no extensions exist yet. + const samples: CostMetricSample[] = []; + + for (const entry of allEntryRows) { + const key = `${entry.contract_id}::${entry.entry_type}`; + const agg = costByKey.get(key); + + const labels: Record = { + contract_id: entry.contract_id, + entry_type: entry.entry_type, + }; + + samples.push({ + metricName: "sorokeep_extension_cost_xlm_total", + labels, + value: agg ? agg.total_cost_xlm : 0, + }); + + samples.push({ + metricName: "sorokeep_extensions_total", + labels, + value: agg ? agg.total_extensions : 0, + }); + } + + return samples; +} diff --git a/src/observability/registry.ts b/src/observability/registry.ts new file mode 100644 index 00000000..801be806 --- /dev/null +++ b/src/observability/registry.ts @@ -0,0 +1,95 @@ +/** + * src/observability/registry.ts + * + * Central registry for Sorokeep's Prometheus-compatible metrics collectors. + * + * Each registered collector is a function that accepts a better-sqlite3 + * Database instance and returns an array of CostMetricSample (or any future + * MetricSample type). The /metrics HTTP endpoint iterates over all registered + * collectors, gathers their samples, and renders them into Prometheus text- + * exposition format. + * + * Adding a new metric family is a one-liner: + * + * registerMetricsCollector(collectFooMetrics); + * + * ───────────────────────────────────────────────────────────────────────────── + * Registered collectors (append below — do not remove existing entries): + * ───────────────────────────────────────────────────────────────────────────── + * 1. collectCostMetrics → sorokeep_extension_cost_xlm_total + * → sorokeep_extensions_total + */ + +import type Database from "better-sqlite3"; +import type { CostMetricSample } from "./metrics/cost.js"; +import { collectCostMetrics } from "./metrics/cost.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Any metric sample type that can be produced by a collector. */ +export type MetricSample = CostMetricSample; + +/** + * A metrics collector: a pure function that queries the database and returns + * labelled counter/gauge samples. + */ +export type MetricsCollector = (db: Database.Database) => MetricSample[]; + +// --------------------------------------------------------------------------- +// Registry state +// --------------------------------------------------------------------------- + +const collectors: MetricsCollector[] = []; + +// --------------------------------------------------------------------------- +// Registration API +// --------------------------------------------------------------------------- + +/** + * Register a new metrics collector. Collectors are called in registration + * order when `collectAllMetrics` is invoked. + */ +export function registerMetricsCollector(collector: MetricsCollector): void { + collectors.push(collector); +} + +/** + * Invoke every registered collector and return the merged sample array. + * Errors from individual collectors are caught and logged so that one broken + * collector cannot silence the others. + */ +export function collectAllMetrics(db: Database.Database): MetricSample[] { + const all: MetricSample[] = []; + for (const collector of collectors) { + try { + const samples = collector(db); + all.push(...samples); + } catch (err) { + // Preserve observability even when a single collector throws. + // The /metrics handler may choose to log this error separately. + console.error("[observability] collector threw:", err); + } + } + return all; +} + +/** + * Return the number of currently registered collectors. + * Exposed mainly for testing. + */ +export function collectorCount(): number { + return collectors.length; +} + +/** Test-only: reset the registry to an empty state. */ +export function _resetRegistryForTesting(): void { + collectors.length = 0; +} + +// --------------------------------------------------------------------------- +// Built-in registrations +// (Add exactly one line per new metric family — keep this list in order.) +// --------------------------------------------------------------------------- +registerMetricsCollector(collectCostMetrics); diff --git a/tests/observability/metrics/cost.test.ts b/tests/observability/metrics/cost.test.ts new file mode 100644 index 00000000..6d983b25 --- /dev/null +++ b/tests/observability/metrics/cost.test.ts @@ -0,0 +1,419 @@ +/** + * Tests for src/observability/metrics/cost.ts + * + * Covers: + * - sorokeep_extension_cost_xlm_total (XLM sum, labeled by contract_id + entry_type) + * - sorokeep_extensions_total (count, same label set) + * + * TDD: these tests are written before the implementation exists and should + * fail until cost.ts and registry.ts are in place. + */ + +import type Database from "better-sqlite3"; +import { beforeEach, afterEach, describe, it, expect } from "vitest"; +import { getDatabaseForTesting } from "../../../src/db/database.js"; +import { + insertContract, + upsertEntry, + getEntriesForContract, + recordExtension, +} from "../../../src/db/repositories.js"; +import { + collectCostMetrics, + type CostMetricSample, +} from "../../../src/observability/metrics/cost.js"; + +// A realistic-looking contract ID (56-character Stellar address) +const CONTRACT_A = "CBEOJUP5FU6KKOEZ7RMTSKZ7YLBS5D6LVATIGCESOGXSZEQ2UWQFKZW6"; +const CONTRACT_B = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC6"; + +let db: Database.Database; + +beforeEach(() => { + db = getDatabaseForTesting(); +}); + +afterEach(() => { + db.close(); +}); + +// --------------------------------------------------------------------------- +// Helper: seed a contract + one entry of the given type, return the entry id. +// --------------------------------------------------------------------------- +function seedContractAndEntry( + contractId: string, + entryType: "instance" | "wasm" | "persistent" | "temporary", + entryKeySuffix = "1", +): number { + insertContract(db, { id: contractId, name: contractId, network: "testnet" }); + upsertEntry(db, { + contract_id: contractId, + entry_key_xdr: `${entryType}_key_${entryKeySuffix}`, + entry_type: entryType, + label: `${entryType}-label`, + live_until_ledger: 50_000, + }); + return getEntriesForContract(db, contractId).find( + (e) => e.entry_type === entryType, + )!.id; +} + +// --------------------------------------------------------------------------- +// Helper: record an extension for a given entry. +// --------------------------------------------------------------------------- +function addExtension( + contractId: string, + entryId: number, + costXlm: number | null, +): void { + recordExtension(db, { + contract_id: contractId, + contract_entry_id: entryId, + old_ttl_ledgers: 10_000, + new_ttl_ledgers: 100_000, + tx_hash: `txhash_${Math.random().toString(36).slice(2)}`, + cost_xlm: costXlm, + executed_at_ledger: 5_000_000, + }); +} + +// =========================================================================== +// Suite 1 — shape of the return value +// =========================================================================== +describe("collectCostMetrics — return shape", () => { + it("returns an array of CostMetricSample objects", () => { + const instanceEntryId = seedContractAndEntry(CONTRACT_A, "instance"); + addExtension(CONTRACT_A, instanceEntryId, 0.01); + + const samples = collectCostMetrics(db); + + expect(Array.isArray(samples)).toBe(true); + expect(samples.length).toBeGreaterThan(0); + + const first = samples[0]!; + expect(first).toHaveProperty("metricName"); + expect(first).toHaveProperty("labels"); + expect(first).toHaveProperty("value"); + expect(typeof first.metricName).toBe("string"); + expect(typeof first.labels).toBe("object"); + expect(typeof first.value).toBe("number"); + }); + + it("includes both sorokeep_extension_cost_xlm_total and sorokeep_extensions_total samples", () => { + const entryId = seedContractAndEntry(CONTRACT_A, "instance"); + addExtension(CONTRACT_A, entryId, 0.05); + + const samples = collectCostMetrics(db); + const names = new Set(samples.map((s) => s.metricName)); + + expect(names.has("sorokeep_extension_cost_xlm_total")).toBe(true); + expect(names.has("sorokeep_extensions_total")).toBe(true); + }); + + it("labels every sample with contract_id and entry_type", () => { + const entryId = seedContractAndEntry(CONTRACT_A, "wasm"); + addExtension(CONTRACT_A, entryId, 0.02); + + const samples = collectCostMetrics(db); + for (const s of samples) { + expect(s.labels).toHaveProperty("contract_id"); + expect(s.labels).toHaveProperty("entry_type"); + expect(typeof s.labels["contract_id"]).toBe("string"); + expect(typeof s.labels["entry_type"]).toBe("string"); + } + }); +}); + +// =========================================================================== +// Suite 2 — cost counter correctness +// =========================================================================== +describe("sorokeep_extension_cost_xlm_total", () => { + it("sums cost_xlm correctly for three extensions on the same contract/entry_type", () => { + const entryId = seedContractAndEntry(CONTRACT_A, "instance"); + addExtension(CONTRACT_A, entryId, 0.10); + addExtension(CONTRACT_A, entryId, 0.20); + addExtension(CONTRACT_A, entryId, 0.15); + + const samples = collectCostMetrics(db); + const sample = samples.find( + (s) => + s.metricName === "sorokeep_extension_cost_xlm_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "instance", + ); + + expect(sample).toBeDefined(); + // 0.10 + 0.20 + 0.15 = 0.45 (allow for floating-point rounding) + expect(sample!.value).toBeCloseTo(0.45, 6); + }); + + it("treats NULL cost_xlm as 0 when summing", () => { + const entryId = seedContractAndEntry(CONTRACT_A, "wasm"); + addExtension(CONTRACT_A, entryId, null); + addExtension(CONTRACT_A, entryId, null); + + const samples = collectCostMetrics(db); + const sample = samples.find( + (s) => + s.metricName === "sorokeep_extension_cost_xlm_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "wasm", + ); + + expect(sample).toBeDefined(); + expect(sample!.value).toBe(0); + }); + + it("mixes NULL and non-NULL cost_xlm correctly", () => { + const entryId = seedContractAndEntry(CONTRACT_A, "persistent"); + addExtension(CONTRACT_A, entryId, null); + addExtension(CONTRACT_A, entryId, 0.3); + addExtension(CONTRACT_A, entryId, null); + + const samples = collectCostMetrics(db); + const sample = samples.find( + (s) => + s.metricName === "sorokeep_extension_cost_xlm_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "persistent", + ); + + expect(sample).toBeDefined(); + expect(sample!.value).toBeCloseTo(0.3, 6); + }); + + it("keeps costs separate across different entry types", () => { + const instanceId = seedContractAndEntry(CONTRACT_A, "instance", "inst"); + const wasmId = seedContractAndEntry(CONTRACT_A, "wasm", "wasm"); + + addExtension(CONTRACT_A, instanceId, 1.0); + addExtension(CONTRACT_A, wasmId, 2.0); + + const samples = collectCostMetrics(db); + + const instanceSample = samples.find( + (s) => + s.metricName === "sorokeep_extension_cost_xlm_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "instance", + ); + const wasmSample = samples.find( + (s) => + s.metricName === "sorokeep_extension_cost_xlm_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "wasm", + ); + + expect(instanceSample!.value).toBeCloseTo(1.0, 6); + expect(wasmSample!.value).toBeCloseTo(2.0, 6); + }); + + it("keeps costs separate across different contracts", () => { + const entryA = seedContractAndEntry(CONTRACT_A, "instance", "a"); + insertContract(db, { id: CONTRACT_B, name: CONTRACT_B, network: "testnet" }); + upsertEntry(db, { + contract_id: CONTRACT_B, + entry_key_xdr: "instance_key_b", + entry_type: "instance", + label: "instance-b", + live_until_ledger: 50_000, + }); + const entryB = getEntriesForContract(db, CONTRACT_B)[0]!.id; + + addExtension(CONTRACT_A, entryA, 5.0); + addExtension(CONTRACT_B, entryB, 3.0); + + const samples = collectCostMetrics(db); + + const aSample = samples.find( + (s) => + s.metricName === "sorokeep_extension_cost_xlm_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "instance", + ); + const bSample = samples.find( + (s) => + s.metricName === "sorokeep_extension_cost_xlm_total" && + s.labels["contract_id"] === CONTRACT_B && + s.labels["entry_type"] === "instance", + ); + + expect(aSample!.value).toBeCloseTo(5.0, 6); + expect(bSample!.value).toBeCloseTo(3.0, 6); + }); +}); + +// =========================================================================== +// Suite 3 — count counter correctness +// =========================================================================== +describe("sorokeep_extensions_total", () => { + it("counts three extensions correctly", () => { + const entryId = seedContractAndEntry(CONTRACT_A, "instance"); + addExtension(CONTRACT_A, entryId, 0.1); + addExtension(CONTRACT_A, entryId, 0.1); + addExtension(CONTRACT_A, entryId, 0.1); + + const samples = collectCostMetrics(db); + const sample = samples.find( + (s) => + s.metricName === "sorokeep_extensions_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "instance", + ); + + expect(sample).toBeDefined(); + expect(sample!.value).toBe(3); + }); + + it("counts extensions with NULL cost_xlm", () => { + const entryId = seedContractAndEntry(CONTRACT_A, "wasm"); + addExtension(CONTRACT_A, entryId, null); + addExtension(CONTRACT_A, entryId, null); + + const samples = collectCostMetrics(db); + const sample = samples.find( + (s) => + s.metricName === "sorokeep_extensions_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "wasm", + ); + + expect(sample).toBeDefined(); + expect(sample!.value).toBe(2); + }); + + it("counts separately per entry_type", () => { + const instanceId = seedContractAndEntry(CONTRACT_A, "instance", "inst"); + const persistentId = seedContractAndEntry(CONTRACT_A, "persistent", "pers"); + + addExtension(CONTRACT_A, instanceId, 0.1); + addExtension(CONTRACT_A, instanceId, 0.1); + addExtension(CONTRACT_A, persistentId, 0.1); + + const samples = collectCostMetrics(db); + + const instanceCount = samples.find( + (s) => + s.metricName === "sorokeep_extensions_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "instance", + ); + const persistentCount = samples.find( + (s) => + s.metricName === "sorokeep_extensions_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "persistent", + ); + + expect(instanceCount!.value).toBe(2); + expect(persistentCount!.value).toBe(1); + }); +}); + +// =========================================================================== +// Suite 4 — zero-extension contracts (Prometheus counter must exist at zero) +// =========================================================================== +describe("zero-extension contracts — counters must exist, not be absent", () => { + it("emits zero-value cost and count samples for a contract with no extensions", () => { + // Register the contract and an entry but never call addExtension. + seedContractAndEntry(CONTRACT_A, "instance"); + + const samples = collectCostMetrics(db); + + const costSample = samples.find( + (s) => + s.metricName === "sorokeep_extension_cost_xlm_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "instance", + ); + const countSample = samples.find( + (s) => + s.metricName === "sorokeep_extensions_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === "instance", + ); + + expect(costSample).toBeDefined(); + expect(costSample!.value).toBe(0); + + expect(countSample).toBeDefined(); + expect(countSample!.value).toBe(0); + }); + + it("emits zero for every entry_type present in contract_entries even with no extensions", () => { + insertContract(db, { id: CONTRACT_A, name: "test", network: "testnet" }); + // Register three different entry types + for (const type of ["instance", "wasm", "persistent"] as const) { + upsertEntry(db, { + contract_id: CONTRACT_A, + entry_key_xdr: `key_${type}`, + entry_type: type, + label: type, + live_until_ledger: 50_000, + }); + } + + const samples = collectCostMetrics(db); + + for (const type of ["instance", "wasm", "persistent"]) { + const costSample = samples.find( + (s) => + s.metricName === "sorokeep_extension_cost_xlm_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === type, + ); + const countSample = samples.find( + (s) => + s.metricName === "sorokeep_extensions_total" && + s.labels["contract_id"] === CONTRACT_A && + s.labels["entry_type"] === type, + ); + + expect(costSample, `cost sample missing for entry_type=${type}`).toBeDefined(); + expect(costSample!.value, `cost value not zero for entry_type=${type}`).toBe(0); + + expect(countSample, `count sample missing for entry_type=${type}`).toBeDefined(); + expect(countSample!.value, `count value not zero for entry_type=${type}`).toBe(0); + } + }); + + it("returns an empty array (not an error) when no contracts are registered", () => { + // Completely empty database + const samples = collectCostMetrics(db); + expect(Array.isArray(samples)).toBe(true); + expect(samples).toHaveLength(0); + }); +}); + +// =========================================================================== +// Suite 5 — Prometheus text exposition format helpers (on registry) +// =========================================================================== +describe("metric metadata", () => { + it("metricName is exactly 'sorokeep_extension_cost_xlm_total' for cost samples", () => { + const entryId = seedContractAndEntry(CONTRACT_A, "instance"); + addExtension(CONTRACT_A, entryId, 0.1); + + const samples = collectCostMetrics(db); + const costSamples = samples.filter( + (s) => s.metricName === "sorokeep_extension_cost_xlm_total", + ); + expect(costSamples.length).toBeGreaterThan(0); + for (const s of costSamples) { + expect(s.metricName).toBe("sorokeep_extension_cost_xlm_total"); + } + }); + + it("metricName is exactly 'sorokeep_extensions_total' for count samples", () => { + const entryId = seedContractAndEntry(CONTRACT_A, "instance"); + addExtension(CONTRACT_A, entryId, 0.1); + + const samples = collectCostMetrics(db); + const countSamples = samples.filter( + (s) => s.metricName === "sorokeep_extensions_total", + ); + expect(countSamples.length).toBeGreaterThan(0); + for (const s of countSamples) { + expect(s.metricName).toBe("sorokeep_extensions_total"); + } + }); +});