Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions apps/loopover-ui/content/docs/ams-observability.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
---
title: Observing your miner
description: Point Grafana at redacted AMS reporting exports to see attempt and prediction history without exposing the miner's live local ledgers.
---

How to point Grafana at redacted miner reporting exports to see attempt and prediction history
without exposing the miner's live local ledgers. This covers the **miner-specific** observability
wiring only; for general self-host operations, see the [AMS operations
runbook](/docs/ams-operations-runbook).

## What's observable

The miner writes append-only SQLite ledgers under `LOOPOVER_MINER_CONFIG_DIR` (default
`~/.config/loopover-miner` on a laptop, or `/data/miner` in the fleet Docker image — see the [AMS
deployment guide](/docs/ams-deployment)):

<FeatureRow
items={[
{
title: "attempt-log.sqlite3",
description:
"The driver-level attempt event trace (event type, action class, mode, reason, timestamps), table attempt_log_events. One attempt_outcome_summary row per completed attempt also carries the real configured provider and the real accumulated cost_usd -- tokens_used is always NULL today, an honest gap rather than a fabricated 0: no coding-agent driver reports real token usage yet.",
},
{
title: "prediction-ledger.sqlite3",
description: "Recorded predicted-gate verdicts for later scoring.",
},
]}
/>

<Callout variant="warn">
Those live files can contain free-form payloads, repo/target identifiers, readiness scores, and
blocker/warning codes. Keep `LOOPOVER_MINER_CONFIG_DIR` private to the miner. Grafana should read
only sanitized reporting exports that operators create from those ledgers.
</Callout>

## Point Grafana at reporting exports

The repo ships datasource provisioning at
`grafana/provisioning/datasources/ams-ledgers.yml` — two **read-only** `frser-sqlite-datasource`
entries: `AMS Attempt Log` (uid `ams-attempt-log`) and `AMS Prediction Ledger` (uid
`ams-prediction-ledger`). Their default paths live under Grafana's reporting mount, not under the
miner config directory.

**1. Install the SQLite plugin** in Grafana — the same one the maintainer LoopoverDB datasource
uses:

<CodeBlock lang="bash" code={`GF_INSTALL_PLUGINS=frser-sqlite-datasource`} />

**2. Run the AMS reporting exporter**, a dedicated compose profile (only useful when a miner also
runs on this same host — an engine-only deployment has nothing for it to read):

<CodeBlock lang="bash" code={`docker compose --profile ams-observability up -d`} />

Set `LOOPOVER_MINER_CONFIG_DIR` in your `.env` to the same directory your miner uses. The
`ams-reporting-exporter` container mounts it **read-only**, runs
`scripts/export-ams-reporting-db.sh` on an interval
(`LOOPOVER_AMS_REPORTING_EXPORT_INTERVAL_SECONDS`, default 30s), and writes the redacted snapshots
into the same `reporting` volume Grafana already reads — Grafana itself never mounts the live
ledgers. The exported schema drops `attempt_log_events.reason`/`.payload_json` (the free-form
fields) entirely; every other column, including the `predictions` table's
`blocker_codes_json`/`warning_codes_json` (fixed, engine-defined codes — never free text), passes
through unchanged.

**3. Restart Grafana.** The two datasources appear under **Connections → Data sources**, already
provisioned (non-editable) so they survive restarts.

## Load a dashboard

Dashboards live in `grafana/dashboards/` and are auto-provisioned from that directory. To
visualize AMS activity, add a dashboard JSON there — or import one at runtime via the Grafana UI
(**Dashboards → Import**) — and point its panels at the `AMS Attempt Log` / `AMS Prediction
Ledger` datasources above.

<Callout variant="note">
Panels should query only the redacted reporting schema (e.g. `SELECT * FROM
attempt_log_events`), never a `payload_json`/`reason` column — the exporter drops both, so a
panel referencing them returns no such column.
</Callout>

## Prometheus metrics (opt-in)

The two sections above cover Grafana-via-SQLite (historical attempt/prediction rows). Separately,
the miner CLI exposes four Prometheus text-exposition documents for live counters/gauges — a
completely independent, **off-by-default** mechanism: nothing in the miner package runs these on a
schedule or opens a port itself.

- `loopover-miner metrics` — prediction-calibration counters (`loopover_miner_prediction*_total`)
- `loopover-miner queue metrics` — portfolio-queue backlog + lease-age gauges
(`loopover_miner_portfolio_queue*`)
- `loopover-miner ledger metrics` — event-ledger counters (`loopover_miner_events_total`)
- `loopover-miner governor metrics` — write-rate-limit + cap-usage pressure gauges
(`loopover_miner_governor*`)

None of these is a long-running HTTP server — Prometheus can't scrape a one-shot CLI command
directly. To opt in, wire `scripts/export-miner-prometheus-textfile.sh` into your own cron/systemd
timer alongside node_exporter's textfile collector: the script runs all four commands and
atomically writes their concatenated output to `$LOOPOVER_MINER_PROMETHEUS_TEXTFILE` (default
`/var/lib/node_exporter/textfile_collector/loopover_miner.prom`), the standard directory
node_exporter's textfile collector watches. Point `LOOPOVER_MINER_BIN` at the miner binary if it
isn't on `PATH`.

<Callout variant="note">
A broken/corrupt local store for one family (e.g. the portfolio queue) never blocks the other
three — that family's metrics are simply omitted from the file for that run (logged to stderr),
not the whole export.
</Callout>

<CodeBlock
code={`# crontab -e
*/5 * * * * LOOPOVER_MINER_CONFIG_DIR=/data/miner sh /path/to/loopover/scripts/export-miner-prometheus-textfile.sh`}
/>

Then point your own `prometheus.yml` at node_exporter as usual — no changes to this repo's
`prometheus/` config are needed. See `prometheus/rules/alerts.yml`'s `loopover-miner-prediction` /
`loopover-miner-portfolio-queue` / `loopover-miner-governor` rule groups for alert rules that
already target these exact metric names.

## Anonymized central telemetry (opt-in, off by default)

Everything above stays entirely on your own machine. Separately, the miner can send a small,
anonymized batch of its own PR-outcome history to LoopOver's hosted AMS collector — the same
fleet-growth/usage telemetry Orb's self-host collector already sends for maintainers, mirrored for
contributors:

<CodeBlock lang="bash" code={`loopover-miner orb export --enable --send`} />

<FeatureRow
items={[
{
title: "--enable alone",
description:
"Only builds and prints the anonymized batch locally -- no network call, so you can inspect exactly what would be sent before ever transmitting anything.",
},
{
title: "--enable --send",
description:
"Additionally POSTs that batch to the collector and advances a local cursor, so the next run only sends events since the last successful send.",
},
]}
/>

**What's sent:** for each of your own resolved PRs — an HMAC-anonymized repo hash and PR hash (a
per-instance secret generated once and kept only on your machine; the collector never holds it and
can't reverse the hash), the `merged`/`closed` decision, a fixed low-cardinality rejection-reason
bucket, and the close timestamp. No repo names, PR numbers, diffs, code, or free text ever leave
your machine.

<Callout variant="safety">
Nothing is sent unless you explicitly opt in. There is no default-on behavior here (unlike Orb's
own maintainer-side collector) — every invocation requires `--enable --send` explicitly.
</Callout>

- `LOOPOVER_MINER_AMS_COLLECTOR_URL` — override the collector endpoint (default: LoopOver's hosted
collector).
- `LOOPOVER_MINER_AMS_COLLECTOR_TOKEN` — optional bearer credential, only needed if your collector
requires one.
- `LOOPOVER_MINER_ORB_EXPORT_DB` — override the local secret+cursor store path (default:
`orb-export.sqlite3` under `LOOPOVER_MINER_CONFIG_DIR`).
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import { Link } from "@tanstack/react-router";

import { Callout } from "@/components/site/primitives";

// AMS (loopover-miner) observability cross-reference (#5191). A dual-role self-hoster running both ORB (the
// review service) and AMS (the miner) on one box otherwise has no in-app pointer from the operations / quickstart /
// workflow docs to the miner's observability setup. Keeping the callout — and its link target — in one place keeps
// the wording byte-identical across all three routes instead of relying on three hand-copied copies staying in sync.
//
// The target is the in-repo "Observing your miner" guide (landed in #5190): the single AMS observability entry point,
// which itself covers pointing Grafana at the redacted AMS ledger datasources AND loading an AMS dashboard from
// grafana/dashboards/. It is a markdown guide, not an in-app /docs/* route, so a GitHub-blob link is the correct
// target here — the same convention the docs already use for in-repo file references (see docs.self-hosting-configuration.tsx).
export const AMS_OBSERVABILITY_DOC_URL =
"https://github.com/JSONbored/gittensory/blob/main/packages/loopover-miner/docs/observability.md";
// The target is the "Observing your miner" guide: the single AMS observability entry point, which covers pointing
// Grafana at the redacted AMS ledger datasources AND loading an AMS dashboard from grafana/dashboards/. It now has
// its own in-app /docs/ams-observability route (#6024, ported from the source packages/loopover-miner/docs/observability.md
// this constant used to point at directly) -- an in-app Link keeps the reader on the docs site instead of bouncing
// to GitHub.
export const AMS_OBSERVABILITY_DOC_URL = "/docs/ams-observability";

/** A `note` callout pointing a dual-role ORB+AMS operator at the "Observing your miner" observability guide. */
export function AmsObservabilityCallout() {
return (
<Callout variant="note" title="Running the miner on this box too?">
If you also run <strong>AMS</strong> (the <code>loopover-miner</code>) on this host, see{" "}
<a href={AMS_OBSERVABILITY_DOC_URL} target="_blank" rel="noopener noreferrer">
Observing your miner
</a>{" "}
to point Grafana at the redacted AMS ledger datasources and load its Grafana dashboard —
separate from the ORB review-service observability above.
<Link to={AMS_OBSERVABILITY_DOC_URL}>Observing your miner</Link> to point Grafana at the
redacted AMS ledger datasources and load its Grafana dashboard — separate from the ORB
review-service observability above.
</Callout>
);
}
1 change: 1 addition & 0 deletions apps/loopover-ui/src/components/site/docs-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const docsNav: DocsGroup[] = [
items: [
{ to: "/docs/ams-deployment", label: "Deployment guide" },
{ to: "/docs/ams-operations-runbook", label: "Operations runbook" },
{ to: "/docs/ams-observability", label: "Observing your miner" },
],
},
],
Expand Down
21 changes: 21 additions & 0 deletions apps/loopover-ui/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { Route as DocsFumadocsSpikeApiReferenceRouteImport } from './routes/docs
import { Route as DocsBranchAnalysisRouteImport } from './routes/docs.branch-analysis'
import { Route as DocsBetaOnboardingRouteImport } from './routes/docs.beta-onboarding'
import { Route as DocsAmsOperationsRunbookRouteImport } from './routes/docs.ams-operations-runbook'
import { Route as DocsAmsObservabilityRouteImport } from './routes/docs.ams-observability'
import { Route as DocsAmsDeploymentRouteImport } from './routes/docs.ams-deployment'
import { Route as DocsAiSummariesRouteImport } from './routes/docs.ai-summaries'
import { Route as AppWorkbenchRouteImport } from './routes/app.workbench'
Expand Down Expand Up @@ -336,6 +337,11 @@ const DocsAmsOperationsRunbookRoute =
path: '/ams-operations-runbook',
getParentRoute: () => DocsRoute,
} as any)
const DocsAmsObservabilityRoute = DocsAmsObservabilityRouteImport.update({
id: '/ams-observability',
path: '/ams-observability',
getParentRoute: () => DocsRoute,
} as any)
const DocsAmsDeploymentRoute = DocsAmsDeploymentRouteImport.update({
id: '/ams-deployment',
path: '/ams-deployment',
Expand Down Expand Up @@ -449,6 +455,7 @@ export interface FileRoutesByFullPath {
'/app/workbench': typeof AppWorkbenchRoute
'/docs/ai-summaries': typeof DocsAiSummariesRoute
'/docs/ams-deployment': typeof DocsAmsDeploymentRoute
'/docs/ams-observability': typeof DocsAmsObservabilityRoute
'/docs/ams-operations-runbook': typeof DocsAmsOperationsRunbookRoute
'/docs/beta-onboarding': typeof DocsBetaOnboardingRoute
'/docs/branch-analysis': typeof DocsBranchAnalysisRoute
Expand Down Expand Up @@ -514,6 +521,7 @@ export interface FileRoutesByTo {
'/app/workbench': typeof AppWorkbenchRoute
'/docs/ai-summaries': typeof DocsAiSummariesRoute
'/docs/ams-deployment': typeof DocsAmsDeploymentRoute
'/docs/ams-observability': typeof DocsAmsObservabilityRoute
'/docs/ams-operations-runbook': typeof DocsAmsOperationsRunbookRoute
'/docs/beta-onboarding': typeof DocsBetaOnboardingRoute
'/docs/branch-analysis': typeof DocsBranchAnalysisRoute
Expand Down Expand Up @@ -583,6 +591,7 @@ export interface FileRoutesById {
'/app/workbench': typeof AppWorkbenchRoute
'/docs/ai-summaries': typeof DocsAiSummariesRoute
'/docs/ams-deployment': typeof DocsAmsDeploymentRoute
'/docs/ams-observability': typeof DocsAmsObservabilityRoute
'/docs/ams-operations-runbook': typeof DocsAmsOperationsRunbookRoute
'/docs/beta-onboarding': typeof DocsBetaOnboardingRoute
'/docs/branch-analysis': typeof DocsBranchAnalysisRoute
Expand Down Expand Up @@ -653,6 +662,7 @@ export interface FileRouteTypes {
| '/app/workbench'
| '/docs/ai-summaries'
| '/docs/ams-deployment'
| '/docs/ams-observability'
| '/docs/ams-operations-runbook'
| '/docs/beta-onboarding'
| '/docs/branch-analysis'
Expand Down Expand Up @@ -718,6 +728,7 @@ export interface FileRouteTypes {
| '/app/workbench'
| '/docs/ai-summaries'
| '/docs/ams-deployment'
| '/docs/ams-observability'
| '/docs/ams-operations-runbook'
| '/docs/beta-onboarding'
| '/docs/branch-analysis'
Expand Down Expand Up @@ -786,6 +797,7 @@ export interface FileRouteTypes {
| '/app/workbench'
| '/docs/ai-summaries'
| '/docs/ams-deployment'
| '/docs/ams-observability'
| '/docs/ams-operations-runbook'
| '/docs/beta-onboarding'
| '/docs/branch-analysis'
Expand Down Expand Up @@ -1187,6 +1199,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsAmsOperationsRunbookRouteImport
parentRoute: typeof DocsRoute
}
'/docs/ams-observability': {
id: '/docs/ams-observability'
path: '/ams-observability'
fullPath: '/docs/ams-observability'
preLoaderRoute: typeof DocsAmsObservabilityRouteImport
parentRoute: typeof DocsRoute
}
'/docs/ams-deployment': {
id: '/docs/ams-deployment'
path: '/ams-deployment'
Expand Down Expand Up @@ -1360,6 +1379,7 @@ const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)
interface DocsRouteChildren {
DocsAiSummariesRoute: typeof DocsAiSummariesRoute
DocsAmsDeploymentRoute: typeof DocsAmsDeploymentRoute
DocsAmsObservabilityRoute: typeof DocsAmsObservabilityRoute
DocsAmsOperationsRunbookRoute: typeof DocsAmsOperationsRunbookRoute
DocsBetaOnboardingRoute: typeof DocsBetaOnboardingRoute
DocsBranchAnalysisRoute: typeof DocsBranchAnalysisRoute
Expand Down Expand Up @@ -1402,6 +1422,7 @@ interface DocsRouteChildren {
const DocsRouteChildren: DocsRouteChildren = {
DocsAiSummariesRoute: DocsAiSummariesRoute,
DocsAmsDeploymentRoute: DocsAmsDeploymentRoute,
DocsAmsObservabilityRoute: DocsAmsObservabilityRoute,
DocsAmsOperationsRunbookRoute: DocsAmsOperationsRunbookRoute,
DocsBetaOnboardingRoute: DocsBetaOnboardingRoute,
DocsBranchAnalysisRoute: DocsBranchAnalysisRoute,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { readFileSync } from "node:fs";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";

vi.mock("@tanstack/react-router", () => ({
Link: ({ to, children }: { to: string; children: ReactNode }) => <a href={to}>{children}</a>,
}));

import {
AMS_OBSERVABILITY_DOC_URL,
Expand All @@ -24,11 +29,9 @@ describe("AMS observability cross-reference callout", () => {
expect(link.getAttribute("href")).toBe(AMS_OBSERVABILITY_DOC_URL);
});

it("targets a well-formed, non-empty absolute https URL (guards against a blank/copy-paste link)", () => {
it("targets a well-formed, non-empty in-app docs path (guards against a blank/copy-paste link)", () => {
expect(AMS_OBSERVABILITY_DOC_URL).toBeTruthy();
const url = new URL(AMS_OBSERVABILITY_DOC_URL);
expect(url.protocol).toBe("https:");
expect(url.hostname).toBe("github.com");
expect(AMS_OBSERVABILITY_DOC_URL.startsWith("/docs/")).toBe(true);
});

it.each(ROUTES_WITH_CALLOUT)("wires the callout into %s", (_path, docPath) => {
Expand Down
49 changes: 49 additions & 0 deletions apps/loopover-ui/src/routes/docs.ams-observability.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { Suspense } from "react";

import { DocsPage } from "@/components/site/docs-page";
import { docsClientLoader } from "@/lib/docs-client-loader";

// Rendered from content/docs/ams-observability.mdx via fumadocs-mdx's browser entry
// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/ams-observability")({
loader: async () => {
const { docsSource } = await import("@/lib/docs-source");
const page = docsSource.getPage(["ams-observability"]);
if (!page) throw notFound();
return { path: page.path, title: page.data.title, description: page.data.description };
},
head: () => ({
meta: [
{ title: "Observing your miner — LoopOver docs" },
{
name: "description",
content:
"Point Grafana at redacted AMS reporting exports to see attempt and prediction history without exposing the miner's live local ledgers.",
},
{ property: "og:title", content: "Observing your miner — LoopOver docs" },
{
property: "og:description",
content:
"Point Grafana at redacted AMS reporting exports to see attempt and prediction history without exposing the miner's live local ledgers.",
},
{ property: "og:url", content: "/docs/ams-observability" },
],
links: [{ rel: "canonical", href: "/docs/ams-observability" }],
}),
component: AmsObservability,
});

function AmsObservability() {
const { path, title, description } = Route.useLoaderData();
const Content = docsClientLoader.getComponent(path);
return (
<DocsPage eyebrow="Maintainers" title={title} description={description}>
<Suspense fallback={<p className="text-token-sm text-muted-foreground">Loading…</p>}>
<Content />
</Suspense>
</DocsPage>
);
}
1 change: 1 addition & 0 deletions apps/loopover-ui/src/routes/docs.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const AUDIENCES: Audience[] = [
{ to: "/docs/self-hosting-unified-ams-orb", label: "Unified ORB + AMS" },
{ to: "/docs/ams-deployment", label: "AMS deployment guide" },
{ to: "/docs/ams-operations-runbook", label: "AMS operations runbook" },
{ to: "/docs/ams-observability", label: "Observing your miner" },
{ to: "/docs/self-hosting-docs-audit", label: "Self-host docs audit" },
{ to: "/docs/maintainer-install-trust", label: "Install & trust guide" },
{ to: "/docs/github-app", label: "GitHub App configuration" },
Expand Down
Loading
Loading