diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index ad19249fb6..eb2bc1fdb1 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -34,6 +34,7 @@ export function printHelp(input) { " gittensory-miner claim release [--json]", " gittensory-miner claim list [--repo ] [--status active|released|expired] [--json]", " gittensory-miner ledger list [--repo ] [--since ] [--type ] [--json]", + " gittensory-miner ledger metrics Print event-ledger counters in Prometheus text format", " gittensory-miner plan list [--status pending|running|completed|failed] [--json]", " gittensory-miner plan show [--json]", " gittensory-miner governor list [--repo ] [--type allowed|denied|throttled|kill_switch] [--json]", diff --git a/packages/gittensory-miner/lib/event-ledger-cli.d.ts b/packages/gittensory-miner/lib/event-ledger-cli.d.ts index 1482434989..6741347b6c 100644 --- a/packages/gittensory-miner/lib/event-ledger-cli.d.ts +++ b/packages/gittensory-miner/lib/event-ledger-cli.d.ts @@ -63,11 +63,18 @@ export function collectEventLedgerAuditFeed( export function renderLedgerTable(events: LedgerEntry[]): string; +export function renderEventLedgerMetrics(events: readonly LedgerEntry[]): string; + export function runLedgerList( args: string[], options?: { initEventLedger?: () => EventLedger }, ): number; +export function runLedgerMetrics( + args: string[], + options?: { initEventLedger?: () => EventLedger }, +): number; + export function runLedgerCli( subcommand: string | undefined, args: string[], diff --git a/packages/gittensory-miner/lib/event-ledger-cli.js b/packages/gittensory-miner/lib/event-ledger-cli.js index 1f913ce668..bb83e62730 100644 --- a/packages/gittensory-miner/lib/event-ledger-cli.js +++ b/packages/gittensory-miner/lib/event-ledger-cli.js @@ -164,6 +164,46 @@ export function renderLedgerTable(events) { return [header, ...lines].join("\n"); } +const EVENT_LEDGER_METRICS_USAGE = "Usage: gittensory-miner ledger metrics"; + +// Prometheus metric name for the per-type event-ledger counter. Mirrors the `gittensory_miner_*_total` naming and +// the HELP/TYPE/label conventions of the engine's renderMinerPredictionMetrics +// (packages/gittensory-engine/src/miner-prediction-metrics.ts) rather than importing across the package boundary. +const MINER_EVENTS_TOTAL = "gittensory_miner_events_total"; + +/** HELP-text escaping — backslash + newline (mirrors miner-prediction-metrics.ts's escapeHelpText). */ +function escapeHelpText(help) { + return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); +} + +/** Prometheus label-value escaping — backslash, double-quote, newline — so an arbitrary event `type` string can + * never break the metric line (mirrors miner-prediction-metrics.ts's escapeLabelValue). */ +function escapeLabelValue(value) { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); +} + +/** + * Render event-ledger activity as Prometheus text-exposition counters: one `gittensory_miner_events_total{type}` + * series per event type, so a self-hoster's own Grafana/alerting can scrape ledger activity instead of polling + * `ledger list --json` (#4841). Pure + side-effect-free — the caller supplies the rows and prints the result; + * deterministic (series emitted in sorted type order); always emits HELP/TYPE so an empty ledger is still a + * well-formed exposition document. + */ +export function renderEventLedgerMetrics(events) { + const totalByType = new Map(); + for (const entry of events) { + totalByType.set(entry.type, (totalByType.get(entry.type) ?? 0) + 1); + } + const lines = [ + `# HELP ${MINER_EVENTS_TOTAL} ${escapeHelpText("Event-ledger entries the miner has recorded, by event type.")}`, + `# TYPE ${MINER_EVENTS_TOTAL} counter`, + ]; + for (const [type, count] of [...totalByType.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(`${MINER_EVENTS_TOTAL}{type="${escapeLabelValue(type)}"} ${count}`); + } + return `${lines.join("\n")}\n`; +} + function withEventLedger(options, run) { const ownsLedger = options.initEventLedger === undefined; const eventLedger = (options.initEventLedger ?? initEventLedger)(); @@ -203,8 +243,28 @@ export function runLedgerList(args, options = {}) { } } +export function runLedgerMetrics(args, options = {}) { + if (args.length > 0) { + console.error(EVENT_LEDGER_METRICS_USAGE); + return 2; + } + + try { + return withEventLedger(options, (eventLedger) => { + // renderEventLedgerMetrics returns a newline-terminated document; console.log re-adds the terminator, so + // trim it to emit exactly one trailing newline (mirrors metrics-cli.js's runMetrics). + console.log(renderEventLedgerMetrics(eventLedger.readEvents()).trimEnd()); + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + export function runLedgerCli(subcommand, args, options = {}) { if (subcommand === "list") return runLedgerList(args, options); + if (subcommand === "metrics") return runLedgerMetrics(args, options); console.error(`Unknown ledger subcommand: ${subcommand ?? ""}. ${LEDGER_LIST_USAGE}`); return 2; } diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts index e36d35781b..af9b245c75 100644 --- a/test/unit/miner-cli.test.ts +++ b/test/unit/miner-cli.test.ts @@ -65,6 +65,7 @@ describe("gittensory-miner CLI helpers", () => { expect(text).toContain("gittensory-miner --help"); expect(text).toContain("gittensory-miner version"); expect(text).toContain("gittensory-miner metrics"); + expect(text).toContain("gittensory-miner ledger metrics"); expect(text).toContain("--no-update-check"); }); diff --git a/test/unit/miner-event-ledger-cli.test.ts b/test/unit/miner-event-ledger-cli.test.ts index 72008b6a8a..5f97b70857 100644 --- a/test/unit/miner-event-ledger-cli.test.ts +++ b/test/unit/miner-event-ledger-cli.test.ts @@ -9,9 +9,11 @@ import { import { filterLedgerEvents, parseLedgerListArgs, + renderEventLedgerMetrics, renderLedgerTable, runLedgerCli, runLedgerList, + runLedgerMetrics, } from "../../packages/gittensory-miner/lib/event-ledger-cli.js"; import type { LedgerEntry } from "../../packages/gittensory-miner/lib/event-ledger.d.ts"; @@ -26,6 +28,16 @@ function tempLedger() { return ledger; } +function tempEventDbPath() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-event-ledger-metrics-")); + roots.push(root); + return join(root, "event-ledger.sqlite3"); +} + +function metricEntry(seq: number, type: string): LedgerEntry { + return { id: seq, seq, type, repoFullName: null, payload: {}, createdAt: "2026-07-04T12:00:00.000Z" }; +} + afterEach(() => { for (const ledger of ledgers.splice(0)) ledger.close(); closeDefaultEventLedger(); @@ -129,3 +141,108 @@ describe("gittensory-miner event ledger CLI (#2290)", () => { expect(error).toHaveBeenCalledWith("since must be a non-negative integer seq cursor."); }); }); + +describe("gittensory-miner ledger metrics CLI (#4841)", () => { + it("renderEventLedgerMetrics emits one sorted gittensory_miner_events_total series per type", () => { + const text = renderEventLedgerMetrics([ + metricEntry(1, "manage_pr_update"), + metricEntry(2, "discovered_issue"), + metricEntry(3, "manage_pr_update"), + ]); + expect(text).toContain( + "# HELP gittensory_miner_events_total Event-ledger entries the miner has recorded, by event type.", + ); + expect(text).toContain("# TYPE gittensory_miner_events_total counter"); + // Series are emitted in sorted type order, so "discovered_issue" precedes "manage_pr_update". + expect(text).toContain('gittensory_miner_events_total{type="discovered_issue"} 1'); + expect(text).toContain('gittensory_miner_events_total{type="manage_pr_update"} 2'); + expect(text.indexOf("discovered_issue")).toBeLessThan(text.indexOf("manage_pr_update")); + expect(text.endsWith("\n")).toBe(true); + }); + + it("renderEventLedgerMetrics still emits a well-formed document for an empty ledger", () => { + expect(renderEventLedgerMetrics([])).toBe( + "# HELP gittensory_miner_events_total Event-ledger entries the miner has recorded, by event type.\n" + + "# TYPE gittensory_miner_events_total counter\n", + ); + }); + + it("renderEventLedgerMetrics escapes label-breaking characters in the event type", () => { + expect(renderEventLedgerMetrics([metricEntry(1, 'weird"type')])).toContain( + 'gittensory_miner_events_total{type="weird\\"type"} 1', + ); + }); + + it("runLedgerMetrics renders event counters as Prometheus text and returns 0", () => { + const eventLedger = tempLedger(); + eventLedger.appendEvent({ type: "discovered_issue", repoFullName: "acme/widgets", payload: { issueNumber: 1 } }); + eventLedger.appendEvent({ type: "manage_pr_update", repoFullName: "acme/widgets", payload: { prNumber: 2 } }); + eventLedger.appendEvent({ type: "manage_pr_update", repoFullName: "acme/other", payload: { prNumber: 3 } }); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runLedgerMetrics([], { initEventLedger: () => eventLedger })).toBe(0); + + const text = String(log.mock.calls[0]?.[0]); + expect(text).toContain("# TYPE gittensory_miner_events_total counter"); + expect(text).toContain('gittensory_miner_events_total{type="discovered_issue"} 1'); + expect(text).toContain('gittensory_miner_events_total{type="manage_pr_update"} 2'); + // The output is a single, once-terminated document (no doubled trailing blank line). + expect(text.endsWith("\n")).toBe(false); + }); + + it("runLedgerMetrics opens and closes its own default ledger when none is injected", () => { + const dbPath = tempEventDbPath(); + const seed = initEventLedger(dbPath); + seed.appendEvent({ type: "plan_built", payload: { steps: 1 } }); + seed.close(); + + const prev = process.env.GITTENSORY_MINER_EVENT_LEDGER_DB; + process.env.GITTENSORY_MINER_EVENT_LEDGER_DB = dbPath; + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + expect(runLedgerMetrics([])).toBe(0); + } finally { + if (prev === undefined) delete process.env.GITTENSORY_MINER_EVENT_LEDGER_DB; + else process.env.GITTENSORY_MINER_EVENT_LEDGER_DB = prev; + } + expect(String(log.mock.calls[0]?.[0])).toContain('gittensory_miner_events_total{type="plan_built"} 1'); + }); + + it("runLedgerMetrics rejects unexpected arguments with a usage error", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(runLedgerMetrics(["--json"], { initEventLedger: () => tempLedger() })).toBe(2); + expect(error).toHaveBeenCalledWith("Usage: gittensory-miner ledger metrics"); + }); + + it("runLedgerMetrics surfaces a thrown Error message and exits non-zero", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect( + runLedgerMetrics([], { + initEventLedger: () => { + throw new Error("event ledger is locked"); + }, + }), + ).toBe(2); + expect(error).toHaveBeenCalledWith("event ledger is locked"); + }); + + it("runLedgerMetrics stringifies a non-Error throw", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect( + runLedgerMetrics([], { + initEventLedger: () => { + throw "event-ledger-unavailable"; + }, + }), + ).toBe(2); + expect(error).toHaveBeenCalledWith("event-ledger-unavailable"); + }); + + it("runLedgerCli dispatches the metrics subcommand", () => { + const eventLedger = tempLedger(); + eventLedger.appendEvent({ type: "plan_built", payload: { steps: 1 } }); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runLedgerCli("metrics", [], { initEventLedger: () => eventLedger })).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain('gittensory_miner_events_total{type="plan_built"} 1'); + }); +});