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
29 changes: 29 additions & 0 deletions apps/loopover-ui/content/docs/self-hosting-operations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,35 @@ captured event.
]}
/>

## AI observability (`$ai_generation`)

When PostHog is configured (the same `POSTHOG_API_KEY`/`POSTHOG_HOST` as error tracking above — no
separate flag), every self-host AI provider attempt is also captured as a PostHog LLM-analytics
event, so spend/latency/failure are visible per provider+model+effort without opening raw logs.
This covers the full self-host AI surface: the configured review chain (`AI_PROVIDER` —
ollama/openai/openai-compatible/anthropic, and the `claude-code`/`codex` subscription CLIs) and the
three standalone bindings (`AI_EMBED`, `AI_VISION`, `AI_ADVISORY`).

<FeatureRow
items={[
{
title: "Event names",
description:
"$ai_generation for a chat/review call, $ai_embedding for an embedding call (RAG) — PostHog's own recognized LLM-analytics event family.",
},
{
title: "Properties",
description:
"$ai_model, $ai_provider, $ai_latency (seconds), $ai_http_status, $ai_input_tokens/$ai_output_tokens, $ai_is_error, $ai_total_cost_usd (when derivable), $ai_model_parameters (e.g. effort), plus repo/pr from the same operational allowlist above.",
},
{
title: "Content policy",
description:
"Metadata only — never the prompt or completion text. $ai_generation's own optional content fields ($ai_input/$ai_output_choices) are never populated, the same redaction posture as every other capture path on this page.",
},
]}
/>

## PostHog alert classes and runbook

Tune PostHog insight alerts for **persistent failure classes**, not one-off
Expand Down
12 changes: 12 additions & 0 deletions apps/loopover-ui/src/lib/ams-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ export const AMS_ENV_REFERENCE_ROWS: MinerEnvReferenceRow[] = [
firstReference: "lib/portfolio-queue.ts",
defaultValue: null,
},
{
name: "LOOPOVER_MINER_POSTHOG_API_KEY",
firstReference: "lib/egress-allowlist.ts",
defaultValue: null,
},
{
name: "LOOPOVER_MINER_POSTHOG_HOST",
firstReference: "lib/egress-allowlist.ts",
defaultValue: null,
},
{
name: "LOOPOVER_MINER_PREDICTION_LEDGER_DB",
firstReference: "lib/prediction-ledger.ts",
Expand Down Expand Up @@ -271,6 +281,8 @@ export const AMS_ENV_REFERENCE_MARKDOWN = [
"| `LOOPOVER_MINER_POLICY_DOC_CACHE_DB` | `lib/policy-doc-cache.ts` | (none) |",
"| `LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB` | `lib/policy-verdict-cache.ts` | (none) |",
"| `LOOPOVER_MINER_PORTFOLIO_QUEUE_DB` | `lib/portfolio-queue.ts` | (none) |",
"| `LOOPOVER_MINER_POSTHOG_API_KEY` | `lib/egress-allowlist.ts` | (none) |",
"| `LOOPOVER_MINER_POSTHOG_HOST` | `lib/egress-allowlist.ts` | (none) |",
"| `LOOPOVER_MINER_PREDICTION_LEDGER_DB` | `lib/prediction-ledger.ts` | (none) |",
"| `LOOPOVER_MINER_RANKED_CANDIDATES_DB` | `lib/ranked-candidates.ts` | (none) |",
"| `LOOPOVER_MINER_REPLAY_SNAPSHOT_DB` | `lib/replay-snapshot.ts` | (none) |",
Expand Down
1 change: 1 addition & 0 deletions packages/loopover-miner/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ and `LOOPOVER_MINER_CONFIG_DIR` are covered above under the fleet/state notes; t
| `LOOPOVER_MINER_REPO_CLONE_DIR` | `dist/lib/repo-clone.js` | Base directory for cloned target repos. |
| `LOOPOVER_MINER_WORKTREE_DIR` | `dist/lib/worktree-allocator.js` | Base directory for per-attempt git worktrees. |
| `LOOPOVER_MINER_SENTRY_DSN`, `LOOPOVER_MINER_SENTRY_ENVIRONMENT` | `dist/lib/sentry.js` | Optional Sentry error reporting (DSN + environment tag). |
| `LOOPOVER_MINER_POSTHOG_API_KEY`, `LOOPOVER_MINER_POSTHOG_HOST` | `dist/lib/posthog.js` | Optional PostHog error + AI-generation reporting ([#8292](https://github.com/JSONbored/loopover/issues/8292), epic [#8286](https://github.com/JSONbored/loopover/issues/8286)) — runs alongside Sentry during the parallel-run window; API key + optional ingestion host override (defaults to PostHog's US-cloud host). |
| `LOOPOVER_MINER_VERSION` | `dist/lib/version.js` | Overrides the reported release id (e.g. for a custom build). |

## Optional hosted discovery plane (opt-in)
Expand Down
18 changes: 13 additions & 5 deletions packages/loopover-miner/bin/loopover-miner-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { initPredictionLedger, type PredictionLedgerEntry } from "../lib/predict
import { loadMinerFileSecrets } from "../lib/env-file-indirection.js";
import { installCliSignalHandlers } from "../lib/process-lifecycle.js";
import { captureMinerErrorAndFlush, initMinerSentry } from "../lib/sentry.js";
import { captureMinerPostHogErrorAndFlush, initMinerPostHog } from "../lib/posthog.js";

// MCP stdio server for @loopover/miner (scaffold #5153). Mirrors the packages/loopover-mcp
// harness (MCP SDK server + stdio transport). Tools:
Expand Down Expand Up @@ -443,16 +444,23 @@ if (invokedPath && invokedPath === realpathSync(fileURLToPath(import.meta.url)))
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
await initMinerSentry(process.env);
installCliSignalHandlers({ captureError: captureMinerErrorAndFlush });
await Promise.all([initMinerSentry(process.env), initMinerPostHog(process.env)]);
installCliSignalHandlers({
captureError: (error, context) =>
Promise.all([captureMinerErrorAndFlush(error, context), captureMinerPostHogErrorAndFlush(error, context)]).then(() => undefined),
});

createMinerMcpServer()
.connect(new StdioServerTransport())
.catch(async (error) => {
console.error(error);
// Awaited so the captured event has a chance to actually reach Sentry before exit() tears the process
// down -- a bare synchronous capture only queues it (#6011 follow-up).
await captureMinerErrorAndFlush(error, { kind: "mcp_startup_connect_failed" });
// Awaited so the captured event has a chance to actually reach each configured sink before exit() tears
// the process down -- a bare synchronous capture only queues it (#6011 follow-up, extended to PostHog
// per #8292's parallel-run posture).
await Promise.all([
captureMinerErrorAndFlush(error, { kind: "mcp_startup_connect_failed" }),
captureMinerPostHogErrorAndFlush(error, { kind: "mcp_startup_connect_failed" }),
]);
process.exit(1);
});
}
Expand Down
26 changes: 17 additions & 9 deletions packages/loopover-miner/bin/loopover-miner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { runOrbExportCli } from "../lib/orb-export.js";
import { runTenantCli } from "../lib/tenant-cli.js";
import { installCliSignalHandlers } from "../lib/process-lifecycle.js";
import { captureMinerErrorAndFlush, initMinerSentry } from "../lib/sentry.js";
import { captureMinerPostHogErrorAndFlush, initMinerPostHog } from "../lib/posthog.js";
import { runStateCli } from "../lib/run-state-cli.js";
import { runInit } from "../lib/laptop-init.js";
import { createWizardIo, runInteractiveInit } from "../lib/init-wizard.js";
Expand Down Expand Up @@ -48,20 +49,27 @@ try {
process.exit(2);
}

// Opt-in Sentry (#6011): a complete no-op unless the operator sets LOOPOVER_MINER_SENTRY_DSN themselves. Must
// run AFTER loadMinerFileSecrets (so a `_FILE`-mounted DSN resolves first) and BEFORE installCliSignalHandlers
// (so a startup crash is still captured).
// Opt-in Sentry (#6011) + opt-in PostHog (#8292, epic #8286 -- parallel-run alongside Sentry, both capture
// when both configured): each a complete no-op unless its own env var is set. Must run AFTER
// loadMinerFileSecrets (so a `_FILE`-mounted DSN/key resolves first) and BEFORE installCliSignalHandlers
// (so a startup crash is still captured by whichever sink(s) are configured).
/* v8 ignore start -- process entry point (this bin's top level runs unconditionally, every invocation);
* exercised by the real --help/--version subprocess spawn in miner-package-skeleton.test.ts (a different
* Node process, invisible to this test run's own coverage instrumentation), not unit-coverable here. The
* functions themselves (initMinerSentry, installCliSignalHandlers) are fully unit-tested in isolation
* (miner-sentry.test.ts, miner-process-lifecycle.test.ts) -- this is only the top-level wiring that calls
* them, mirroring src/server.ts's identical, already-established exemption in codecov.yml. */
await initMinerSentry(process.env);
* functions themselves (initMinerSentry, initMinerPostHog, installCliSignalHandlers) are fully unit-tested
* in isolation (miner-sentry.test.ts, miner-posthog.test.ts, miner-process-lifecycle.test.ts) -- this is
* only the top-level wiring that calls them, mirroring src/server.ts's identical, already-established
* exemption in codecov.yml. */
await Promise.all([initMinerSentry(process.env), initMinerPostHog(process.env)]);

// Register signal + crash handlers once, before any command runs, so an interrupted run closes its open ledgers
// cleanly instead of dying mid-write (#4826). Covers every subcommand below, including the local ones.
installCliSignalHandlers({ captureError: captureMinerErrorAndFlush });
// cleanly instead of dying mid-write (#4826). Covers every subcommand below, including the local ones. Both
// opt-in sinks capture the crash independently -- neither call awaits the other, so a slow/hung flush on one
// sink can never delay the other's delivery.
installCliSignalHandlers({
captureError: (error, context) =>
Promise.all([captureMinerErrorAndFlush(error, context), captureMinerPostHogErrorAndFlush(error, context)]).then(() => undefined),
});
/* v8 ignore stop */

// Peel the global logging flags (--quiet/--verbose/--log-level) off the front of argv and configure the
Expand Down
2 changes: 2 additions & 0 deletions packages/loopover-miner/docs/env-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Generated by `npm run miner:env-reference`. Do not edit manually.
| `LOOPOVER_MINER_POLICY_DOC_CACHE_DB` | `lib/policy-doc-cache.ts` | (none) |
| `LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB` | `lib/policy-verdict-cache.ts` | (none) |
| `LOOPOVER_MINER_PORTFOLIO_QUEUE_DB` | `lib/portfolio-queue.ts` | (none) |
| `LOOPOVER_MINER_POSTHOG_API_KEY` | `lib/egress-allowlist.ts` | (none) |
| `LOOPOVER_MINER_POSTHOG_HOST` | `lib/egress-allowlist.ts` | (none) |
| `LOOPOVER_MINER_PREDICTION_LEDGER_DB` | `lib/prediction-ledger.ts` | (none) |
| `LOOPOVER_MINER_RANKED_CANDIDATES_DB` | `lib/ranked-candidates.ts` | (none) |
| `LOOPOVER_MINER_REPLAY_SNAPSHOT_DB` | `lib/replay-snapshot.ts` | (none) |
Expand Down
51 changes: 50 additions & 1 deletion packages/loopover-miner/lib/coding-agent-construction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import { spawn as nodeSpawn } from "node:child_process";
import {
CODING_AGENT_DRIVER_CONFIG_ENV,
createCodingAgentDriver,
resolveFirstConfiguredCodingAgentDriverName,
type AgentSdkHooks,
Expand All @@ -21,6 +22,7 @@ import {
type HouseRulesConfig,
type HouseRulesOptions,
} from "./coding-agent-house-rules.js";
import { captureMinerPostHogAiGeneration } from "./posthog.js";

/**
* Real `child_process.spawn`-backed implementation of the engine's `CliSubprocessSpawnFn` contract. Captures
Expand Down Expand Up @@ -71,6 +73,46 @@ export function createRealCliSubprocessSpawn(): CliSubprocessSpawnFn {
});
}

/** Wrap a real `CodingAgentDriver` with PostHog `$ai_generation` capture (#8296 AMS follow-up, epic #8286).
* Lives here, not in `@loopover/engine` (the pure driver package cannot depend on `posthog-node` or any
* vendor client -- the same "no cross-package import" boundary this file's own header already documents
* for its spawn implementation): this is the miner CLI's own host-bound construction site, exactly where
* `src/selfhost/`'s equivalent ORB-side wrapper (`withAiGenerationCapture`, ai.ts) lives relative to its
* own chain. `CodingAgentDriverResult` carries a single blended `costUsd`/`tokensUsed` (no input/output
* split, unlike ORB's `AiUsage`) -- captureMinerPostHogAiGeneration is deliberately built for that exact
* shape, never fabricating a split its source data doesn't have. A driver failure is reported via
* `result.ok === false` (the real, observed contract every shipped driver follows -- none of them throw
* for an ordinary task failure), with a genuine thrown exception handled defensively on top. */
export function withCodingAgentAiGenerationCapture(providerName: string, model: string, driver: CodingAgentDriver): CodingAgentDriver {
return {
async run(task) {
const startedAtMs = Date.now();
try {
const result = await driver.run(task);
captureMinerPostHogAiGeneration({
provider: providerName,
model,
latencyMs: Date.now() - startedAtMs,
isError: !result.ok,
totalTokens: result.tokensUsed,
totalCostUsd: result.costUsd,
error: result.ok ? undefined : result.error,
});
return result;
} catch (error) {
captureMinerPostHogAiGeneration({
provider: providerName,
model,
latencyMs: Date.now() - startedAtMs,
isError: true,
error,
});
throw error;
}
},
};
}

export type ConstructProductionCodingAgentDriverOptions = {
spawn?: CliSubprocessSpawnFn;
query?: AgentSdkQueryFn;
Expand Down Expand Up @@ -108,12 +150,19 @@ export function constructProductionCodingAgentDriver(
(providerName.trim().toLowerCase() === "agent-sdk"
? buildHouseRulesAgentSdkHooks(options.houseRulesConfig, options.houseRulesOptions)
: undefined);
return createCodingAgentDriver({
const driver = createCodingAgentDriver({
providerName,
env,
spawn: options.spawn ?? createRealCliSubprocessSpawn(),
...(options.query !== undefined ? { query: options.query } : {}),
...(hooks !== undefined ? { hooks } : {}),
...(options.listChangedFiles !== undefined ? { listChangedFiles: options.listChangedFiles } : {}),
});
// #8296 AMS follow-up: the configured model env var (CODING_AGENT_DRIVER_CONFIG_ENV), when this
// provider declares one -- agent-sdk declares none (its session uses the account/CLI default), so it
// falls back to the provider name itself, mirroring ai.ts's own "unconfigured -> falls back to a
// sensible default" convention.
const modelEnvKey = CODING_AGENT_DRIVER_CONFIG_ENV[providerName as keyof typeof CODING_AGENT_DRIVER_CONFIG_ENV]?.model;
const model = (modelEnvKey ? env[modelEnvKey] : undefined) || providerName;
return withCodingAgentAiGenerationCapture(providerName, model, driver);
}
9 changes: 9 additions & 0 deletions packages/loopover-miner/lib/egress-allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
// (`ORB_ENROLLMENT_SECRET` or #8202/#8246's `LOOPOVER_TENANT_SECRET_TOKEN` is set)
// - the discovery-index plane (`LOOPOVER_MINER_DISCOVERY_INDEX_URL`) -- only if set (opt-in, no default)
// - Sentry (`LOOPOVER_MINER_SENTRY_DSN`) -- only if set (opt-in, no default)
// - PostHog (`LOOPOVER_MINER_POSTHOG_API_KEY`, #8292) -- only if set (opt-in); host defaults to
// us.i.posthog.com when `LOOPOVER_MINER_POSTHOG_HOST` isn't also set, mirroring posthog.ts's own default
// - Neon's API (console.neon.tech, #7858's per-attempt DB fork) -- only if all three
// `LOOPOVER_MINER_NEON_*` vars are set, mirroring `resolveAttemptDbForkConfig`'s own all-or-nothing gate
// This is deliberately NOT exhaustive against every possible operator configuration (a fully custom, self-run
Expand Down Expand Up @@ -76,6 +78,10 @@ const ECOSYSTEM_REGISTRY_HOSTS: Record<AmsNetworkAllowlistEcosystem, string[]> =
/** Neon's REST API host (`attempt-db-fork.ts`'s own `DEFAULT_API_BASE_URL`) -- fixed, not per-project. */
const NEON_API_HOST = "console.neon.tech";

/** PostHog's US-cloud ingestion host -- matches posthog.ts's own `DEFAULT_POSTHOG_HOST` default, used when
* an operator sets LOOPOVER_MINER_POSTHOG_API_KEY without also overriding LOOPOVER_MINER_POSTHOG_HOST. */
const DEFAULT_POSTHOG_HOST = "us.i.posthog.com";

function hostnameOf(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
Expand Down Expand Up @@ -115,6 +121,9 @@ export function resolveEgressAllowlist(networkAllowlist: AmsNetworkAllowlist, en
if (discoveryIndexHost) add(discoveryIndexHost, "loopover-platform");
const sentryHost = hostnameOf(env.LOOPOVER_MINER_SENTRY_DSN);
if (sentryHost) add(sentryHost, "loopover-platform");
if (env.LOOPOVER_MINER_POSTHOG_API_KEY) {
add(hostnameOf(env.LOOPOVER_MINER_POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST, "loopover-platform");
}
if (env.LOOPOVER_MINER_NEON_API_KEY && env.LOOPOVER_MINER_NEON_PROJECT_ID && env.LOOPOVER_MINER_NEON_PARENT_BRANCH_ID) {
add(NEON_API_HOST, "loopover-platform");
}
Expand Down
Loading
Loading