diff --git a/apps/loopover-ui/content/docs/self-hosting-operations.mdx b/apps/loopover-ui/content/docs/self-hosting-operations.mdx index 9dc8812807..8df82316fd 100644 --- a/apps/loopover-ui/content/docs/self-hosting-operations.mdx +++ b/apps/loopover-ui/content/docs/self-hosting-operations.mdx @@ -549,6 +549,50 @@ changing Sentry env, restart the `loopover` service — there is no hot reload. home to a maintainer-owned project unless you configure one. +## Enabling PostHog error tracking (parallel run alongside Sentry) + +PostHog error tracking is **opt-in and off by default**, and runs **in parallel with Sentry +above** — both sinks are active simultaneously when both are configured; enabling one does not +disable the other. Leave `POSTHOG_API_KEY` unset for a complete no-op with negligible overhead. +The same key also activates MCP tool-call telemetry (`src/mcp/telemetry.ts`) — one PostHog +project covers both surfaces. + + + +Every captured event is scrubbed by the same redaction primitives Sentry's `beforeSend` uses +(`src/selfhost/redaction-scrub.ts`, shared by both sinks so there is one security-critical +implementation, not two independently-drifting copies): secret-shaped keys/values, JWTs, local +filesystem paths, and installation ids (hashed, never raw) are all redacted before an event +leaves the box. + +PostHog's own [exception autocapture](https://posthog.com/docs/error-tracking/installation/node) +is enabled, so genuinely uncaught exceptions and unhandled rejections are captured automatically +— mirroring Sentry's own default global-handler posture — in addition to the explicit capture +call sites mirrored throughout the codebase. + +There is no PostHog equivalent to Sentry Cron Monitors' missed-check-in alerting, so the five +recurring jobs (`scheduled-loop`, `orb-export`, `orb-relay-drain`, `orb-relay-register`, +`queue-dead-letter-revive`) instead emit a plain heartbeat event, `orb_monitor_heartbeat` +(properties: `monitor`, `status: "ok" | "error"`, `duration_ms`), on every run. Configure a +PostHog insight alert on a no-data condition over that event (filtered to a given monitor name) +to reproduce the same "silent death is an alert" property Sentry Crons provides. + +After changing PostHog env, restart the `loopover` service — there is no hot reload. + ## Browser Sentry (operator UI) The operator UI (`apps/loopover-ui`) has its own, separate client-side Sentry diff --git a/apps/loopover-ui/src/lib/selfhost-env-reference.ts b/apps/loopover-ui/src/lib/selfhost-env-reference.ts index bf580440a2..f4701460cf 100644 --- a/apps/loopover-ui/src/lib/selfhost-env-reference.ts +++ b/apps/loopover-ui/src/lib/selfhost-env-reference.ts @@ -429,6 +429,14 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "PORT", firstReference: "src/server.ts", }, + { + name: "POSTHOG_ENVIRONMENT", + firstReference: "src/server.ts", + }, + { + name: "POSTHOG_RELEASE", + firstReference: "src/selfhost/posthog.ts", + }, { name: "PUBLIC_API_ORIGIN", firstReference: "src/selfhost/preflight.ts", @@ -656,6 +664,8 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `PGPOOL_MAX` | `src/selfhost/queue-common.ts` |", "| `PGVECTOR_ENABLED` | `src/server.ts` |", "| `PORT` | `src/server.ts` |", + "| `POSTHOG_ENVIRONMENT` | `src/server.ts` |", + "| `POSTHOG_RELEASE` | `src/selfhost/posthog.ts` |", "| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts` |", "| `PUBLIC_ORIGIN_ACKNOWLEDGED` | `src/server.ts` |", "| `PUBLIC_SITE_ORIGIN` | `src/server.ts` |", diff --git a/src/env.d.ts b/src/env.d.ts index 7ab1919feb..9d401cc4a6 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -629,15 +629,21 @@ declare global { * token is sufficient — this probe never writes). A secret — never commit a real value. See * CLOUDFLARE_D1_MONITOR_ACCOUNT_ID. */ CLOUDFLARE_D1_MONITOR_API_TOKEN?: string; - /** Opt-in MCP telemetry (#6228/#6235): the PostHog project API key the typed `src/mcp/telemetry.ts` - * wrapper sends anonymized tool-call counters to. Unset (default — every self-hoster who doesn't opt in) - * ⇒ recordMcpToolCall is a safe no-op that records nothing, byte-identical to before this module existed. - * Only the #6228 allowlist is ever sent — tool name, caller type, ok, coarse duration — never arguments, - * source, or any wallet/hotkey/trust-score data. A secret — inject via `wrangler secret`, never commit. */ + /** Opt-in PostHog project API key, shared by two independent surfaces (#8287's env-var decision): (1) MCP + * telemetry (#6228/#6235) — the typed `src/mcp/telemetry.ts` wrapper sends anonymized tool-call counters + * (tool name, caller type, ok, coarse duration — never arguments, source, or wallet/hotkey/trust-score + * data); (2) self-host ORB error tracking (`src/selfhost/posthog.ts`) — the parallel-run PostHog sink + * alongside SENTRY_DSN, both active simultaneously when both are configured. Unset (default — every + * self-hoster who doesn't opt in) ⇒ both surfaces are safe no-ops, byte-identical to before either + * module existed. A secret — inject via `wrangler secret` (Worker) or a mounted secret file (self-host), + * never commit. */ POSTHOG_API_KEY?: string; - /** Opt-in MCP telemetry host override (#6235): the PostHog ingestion host recordMcpToolCall points at - * (e.g. https://eu.i.posthog.com for EU-cloud). Unset ⇒ the US-cloud default (https://us.i.posthog.com). - * Only meaningful alongside POSTHOG_API_KEY; ignored when telemetry is unconfigured. */ + /** PostHog ingestion host override (e.g. https://eu.i.posthog.com for EU-cloud), shared by both surfaces + * POSTHOG_API_KEY activates. Unset ⇒ the US-cloud default (https://us.i.posthog.com). Only meaningful + * alongside POSTHOG_API_KEY; ignored when unconfigured. Self-host error tracking also reads its own + * process.env-only vars (POSTHOG_MIN_SEVERITY, POSTHOG_REPO_MIN_SEVERITY, POSTHOG_ENVIRONMENT, + * POSTHOG_SERVER_NAME, POSTHOG_RELEASE) — not declared here, mirroring SENTRY_MIN_SEVERITY/ + * SENTRY_REPO_MIN_SEVERITY's identical self-host-only precedent (src/selfhost/sentry.ts). */ POSTHOG_HOST?: string; } } diff --git a/src/queue/ai-review-orchestration.ts b/src/queue/ai-review-orchestration.ts index b0da79fc8d..d9cf92248a 100644 --- a/src/queue/ai-review-orchestration.ts +++ b/src/queue/ai-review-orchestration.ts @@ -59,6 +59,7 @@ import { resolveEnrichmentLinkedIssueNumbers, } from "../review/enrichment-wire"; import { captureReviewFailure } from "../selfhost/sentry"; +import { capturePostHogReviewFailure } from "../selfhost/posthog"; import { isReputationEnabled, shouldSkipAiForReputation } from "../review/reputation-wire"; import { isConvergenceRepoAllowed } from "../review/cutover-gate"; import { resolveConvergedFeature } from "../review/feature-activation"; @@ -822,6 +823,20 @@ export async function runAiReviewForAdvisory( /* v8 ignore next -- current review runner always supplies diagnostics for completed AI attempts. */ review_diagnostics: formatReviewDiagnosticsForCapture(result.reviewDiagnostics ?? []), }, "ai_review_inconclusive"); + capturePostHogReviewFailure(new Error("AI review inconclusive — no usable verdict for the PR head"), { + kind: "review", + reason: "ai_review_inconclusive", + installationId: args.installationId, + owner: args.repoFullName.split("/")[0], + repo: args.repoFullName, + pr: args.pr.number, + head_sha: args.advisory.headSha, + ai_review_mode: args.settings.aiReviewMode, + reviewer_count: result.reviewerCount, + public_notes: hasPublicReviewAssessment(result.advisoryNotes), + /* v8 ignore next -- current review runner always supplies diagnostics for completed AI attempts. */ + review_diagnostics: formatReviewDiagnosticsForCapture(result.reviewDiagnostics ?? []), + }, "ai_review_inconclusive"); } args.advisory.findings.push(...findings); const metadataFor = ( @@ -900,6 +915,27 @@ export async function runAiReviewForAdvisory( }, "ai_review_public_summary_missing", ); + capturePostHogReviewFailure( + new Error("AI review did not produce public notes for the PR head"), + { + kind: "review", + reason: "ai_review_public_summary_missing", + installationId: args.installationId, + owner: args.repoFullName.split("/")[0], + repo: args.repoFullName, + pr: args.pr.number, + head_sha: args.advisory.headSha, + ai_review_mode: args.settings.aiReviewMode, + reviewer_count: result.reviewerCount, + /* v8 ignore next -- current review runner always supplies diagnostics for completed AI attempts. */ + review_diagnostics: formatReviewDiagnosticsForCapture(result.reviewDiagnostics ?? []), + configured_reviewers: + env.AI_REVIEW_PLAN?.reviewers?.map((reviewer) => reviewer.model) ?? + null, + combine: env.AI_REVIEW_PLAN?.combine ?? null, + }, + "ai_review_public_summary_missing", + ); return { notes: "AI review is unavailable for this PR head. LoopOver is holding this PR for manual review until the configured AI provider returns a usable public review summary.", @@ -929,6 +965,13 @@ export async function runAiReviewForAdvisory( pr: args.pr.number, head_sha: args.advisory.headSha, }, "ai_review_failed"); + capturePostHogReviewFailure(error, { + kind: "review", + installationId: args.installationId, + repo: args.repoFullName, + pr: args.pr.number, + head_sha: args.advisory.headSha, + }, "ai_review_failed"); return undefined; } finally { // #regate-dup-prep: only release a lock THIS call actually claimed. A caller-supplied diff --git a/src/queue/processors.ts b/src/queue/processors.ts index a666b9304b..e4e3a3ec73 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -585,6 +585,7 @@ import { shouldApplyRepoCultureProfile } from "../review/repo-culture-profile-wi import { applyReviewMemorySuppression, getCachedReviewSuppressions, invalidateReviewSuppressionCache, shouldApplyReviewMemory } from "../review/review-memory-wire"; import { isEnrichmentEnabled } from "../review/enrichment-wire"; import { captureReviewFailure } from "../selfhost/sentry"; +import { capturePostHogReviewFailure } from "../selfhost/posthog"; import { setReviewPipelineSpanOutcome, withReviewPipelineSpan, @@ -9168,6 +9169,15 @@ async function maybePublishPrPublicSurface( head_sha: advisory.headSha, failedOutputs: failedOutputs.map((failure) => failure.output), }, "pr_public_surface_publish_failed"); + capturePostHogReviewFailure(new Error("PR public-surface publish failed — review produced output but nothing was posted to the PR"), { + kind: "publish", + installationId, + owner: repoFullName.split("/")[0], + repo: repoFullName, + pr: pr.number, + head_sha: advisory.headSha, + failedOutputs: failedOutputs.map((failure) => failure.output), + }, "pr_public_surface_publish_failed"); // At least one output failed for a reason that can plausibly clear on its own (rate limit / 5xx / momentary // token issue) — retry the whole job instead of leaving the review permanently unposted. A mix of transient // and permanent failures still retries: the permanent one re-fails identically next pass and re-audits, but @@ -10367,6 +10377,16 @@ async function maybePublishPrPublicSurface( reviewer_count: aiReview?.reviewerCount ?? 0, public_notes: hasPublicReviewAssessment(aiReview?.notes), }, "ai_review_public_summary_missing"); + capturePostHogReviewFailure(new Error(message), { + kind: "review", + reason: "ai_review_public_summary_missing", + installationId, + repo: repoFullName, + pr: pr.number, + head_sha: advisory.headSha, + reviewer_count: aiReview?.reviewerCount ?? 0, + public_notes: hasPublicReviewAssessment(aiReview?.notes), + }, "ai_review_public_summary_missing"); } // Secrets-scan (#audit-3.4): always scans the REAL resolved diff and, on a CONCRETE credential hit, appends a diff --git a/src/selfhost/monitored-work.ts b/src/selfhost/monitored-work.ts index 149771ecbf..856f9c756f 100644 --- a/src/selfhost/monitored-work.ts +++ b/src/selfhost/monitored-work.ts @@ -2,6 +2,20 @@ import type { EnqueueWebhookResult } from "../github/webhook"; import { ORB_RELAY_REGISTER_UNHEALTHY_FAILURE_STREAK, type OrbRelayRegistrationState } from "../orb/broker-client"; import { incr } from "./metrics"; import { withSentryMonitor } from "./sentry"; +import { withPostHogMonitor } from "./posthog"; + +/** Run both monitor wrappers around one callback: Sentry's structured check-in + PostHog's heartbeat event, + * in parallel (#8287) -- neither observes the other's outcome, each independently no-ops when its own sink + * is unconfigured. The callback itself only runs ONCE; withPostHogMonitor wraps a thunk that replays whatever + * withSentryMonitor already produced/threw, so a failure is captured by both sinks without executing the + * underlying work twice. */ +async function withBothMonitors( + name: Parameters[0] & Parameters[0], + context: Record | undefined, + callback: () => Promise, +): Promise { + return withPostHogMonitor(name, context, () => withSentryMonitor(name, context, callback)); +} export type OrbRelayEvent = { deliveryId: string; @@ -59,7 +73,7 @@ export async function runScheduledLoopWithMonitor( cron: string, scheduled: () => T | Promise, ): Promise { - return withSentryMonitor( + return withBothMonitors( "scheduled-loop", { jobType: "scheduled-loop", cron }, () => Promise.resolve(scheduled()), @@ -70,7 +84,7 @@ export async function runOrbExportWithMonitor( exportBatch: () => Promise, log: (line: string) => void = console.log, ): Promise { - await withSentryMonitor("orb-export", { jobType: "orb-export" }, async () => { + await withBothMonitors("orb-export", { jobType: "orb-export" }, async () => { const exported = await exportBatch(); if (exported > 0) log(JSON.stringify({ event: "selfhost_orb_export", exported })); @@ -91,7 +105,7 @@ export async function drainOrbRelayWithMonitor(args: { log?: (line: string) => void; nowMs?: number; }): Promise { - await withSentryMonitor( + await withBothMonitors( "orb-relay-drain", { jobType: "orb-relay-drain", pendingAckCount: args.state.pendingAck.length }, async () => { @@ -247,7 +261,7 @@ export async function registerOrbRelayWithMonitor(args: { log?: (line: string) => void; nowMs?: number; }): Promise { - await withSentryMonitor("orb-relay-register", { jobType: "orb-relay-register" }, async () => { + await withBothMonitors("orb-relay-register", { jobType: "orb-relay-register" }, async () => { const result = await args.register(args.env, args.state); if (result.status === "skipped" || result.status === "already_registered" || result.status === "backoff") return; const mode = args.env.ORB_RELAY_MODE === "pull" ? "pull" : "push"; diff --git a/src/selfhost/pg-queue.ts b/src/selfhost/pg-queue.ts index e74512c067..f925ef2a6f 100644 --- a/src/selfhost/pg-queue.ts +++ b/src/selfhost/pg-queue.ts @@ -9,6 +9,7 @@ import { incr } from "./metrics"; import { withReviewSpan } from "./tracing"; import { withOtelSpan } from "./otel"; import { captureError, withSentryMonitor } from "./sentry"; +import { capturePostHogError, withPostHogMonitor } from "./posthog"; import { consumingRetryDelayMs, deterministicJitterMs, @@ -632,10 +633,15 @@ export function createPgQueue( * fires; the outer try/catch (this function's actual job) still guards the setInterval callback. */ async function reviveDeadLetterJobsSafely(): Promise { try { - await withSentryMonitor( + await withPostHogMonitor( "queue-dead-letter-revive", { jobType: "queue-dead-letter-revive" }, - reviveDeadLetterJobs, + () => + withSentryMonitor( + "queue-dead-letter-revive", + { jobType: "queue-dead-letter-revive" }, + reviveDeadLetterJobs, + ), ); } catch (error) { console.error( @@ -646,6 +652,7 @@ export function createPgQueue( }), ); captureError(error, { kind: "queue_dead_letter_revive_crashed" }, "queue_dead_letter_revive_crashed"); + capturePostHogError(error, { kind: "queue_dead_letter_revive_crashed" }, "queue_dead_letter_revive_crashed"); } } @@ -772,6 +779,7 @@ export function createPgQueue( }), ); captureError(error, { kind: "queue_foreground_liveness_release_crashed" }, "queue_foreground_liveness_release_crashed"); + capturePostHogError(error, { kind: "queue_foreground_liveness_release_crashed" }, "queue_foreground_liveness_release_crashed"); } } @@ -1102,6 +1110,12 @@ export function createPgQueue( recovered, timeoutMs: processingTimeoutMs, }, "processing_timeout"); + capturePostHogError(new Error("self-host queue processing lease expired"), { + kind: "job_recovered", + reason: "processing_timeout", + recovered, + timeoutMs: processingTimeoutMs, + }, "processing_timeout"); } const job = await claimNext(); if (!job) return false; @@ -1130,6 +1144,11 @@ export function createPgQueue( reason: "unparseable_payload", jobId: job.id, }, "unparseable_payload"); + capturePostHogError(new Error("unparseable queue payload"), { + kind: "job_dead", + reason: "unparseable_payload", + jobId: job.id, + }, "unparseable_payload"); return true; } const jobTraceParent = message.type === "github-webhook" ? message.traceParent : undefined; @@ -1441,6 +1460,13 @@ export function createPgQueue( jobId: job.id, attempts, }, "job_dead"); + capturePostHogError(error, { + kind: "job_dead", + reason: "max_retries_exhausted", + jobType: extractPayloadType(job.payload), + jobId: job.id, + attempts, + }, "job_dead"); } else { const retryDelayMs = consumingRetryDelayMs(error, backoff(attempts)); await pool.query( @@ -1488,6 +1514,7 @@ export function createPgQueue( }), ); captureError(error, { kind: "queue_pump_crashed" }, "queue_pump_crashed"); + capturePostHogError(error, { kind: "queue_pump_crashed" }, "queue_pump_crashed"); } finally { active--; } diff --git a/src/selfhost/posthog.ts b/src/selfhost/posthog.ts new file mode 100644 index 0000000000..5edad7b25f --- /dev/null +++ b/src/selfhost/posthog.ts @@ -0,0 +1,342 @@ +// Self-host PostHog error tracking (#8287, epic #8286). Opt-in: a complete NO-OP unless POSTHOG_API_KEY is set, +// mirroring sentry.ts's env-gated posture exactly. posthog-node is dynamically imported inside initPostHog() +// so it never enters the Worker bundle when unconfigured, same discipline as @sentry/node in sentry.ts. +// +// PARALLEL-RUN (epic principle, confirmed 2026-07-25): this sink runs alongside sentry.ts, not instead of it. +// Both are active simultaneously when both are configured; Sentry is only removed once the gated decommission +// issue (#8298) says so, per-issue across all six Phase-1 surfaces at once -- never removed early just because +// this surface's PostHog sink works. +// +// Reuses the SAME pure redaction primitives sentry.ts uses (./redaction-scrub) -- one security-critical scrub +// implementation, two provider-specific event-shape orchestrators. PostHog's event shape (event name + a flat +// `properties` bag) is materially simpler than Sentry's (request/contexts/extra/tags/breadcrumbs/exception), +// so this file's own orchestrator (scrubPostHogEvent) is correspondingly smaller than sentry.ts's scrubEvent. +// +// Env-var decision (#8287's own deliverable): POSTHOG_API_KEY/POSTHOG_HOST are the SAME vars #6235's MCP +// telemetry (src/mcp/telemetry.ts) already reads off the typed Cloudflare Env -- one project key activates +// both surfaces, the MCP tool-call allowlist (#6228) is untouched. Everything else here (POSTHOG_MIN_SEVERITY, +// POSTHOG_REPO_MIN_SEVERITY, POSTHOG_ENVIRONMENT, POSTHOG_SERVER_NAME, POSTHOG_RELEASE) is self-host-only, +// read off real process.env exactly like sentry.ts's own SENTRY_MIN_SEVERITY/SENTRY_REPO_MIN_SEVERITY/etc -- +// never added to src/env.d.ts's typed Env, matching that file's precedent for self-host-exclusive config. +import { hostname } from "node:os"; +import { + currentOtelTraceIds, +} from "./otel"; +import { meetsSeverityThreshold, resolveSeverityThreshold, type LoopoverSeverity } from "../services/severity-threshold"; +import { SENTRY_OPERATIONAL_TAG_KEYS } from "./sentry"; +import { + hashedInstallationContext, + loadNodeHasher, + nonBlank, + REDACTED, + resetRedactionScrubForTest, + scrubRecord, + scrubString, + SECRET_KEY, +} from "./redaction-scrub"; + +type PostHogNs = typeof import("posthog-node"); +type PostHogClient = InstanceType; +type PostHogEventMessage = import("posthog-node").EventMessage; + +let client: PostHogClient | undefined; +let active = false; +let posthogEnvironment = "production"; + +/** No per-user identity is tracked by this sink (operational error events, not user analytics) -- every event + * shares one anonymous, constant distinct id, mirroring src/mcp/telemetry.ts's identical MCP_TELEMETRY_DISTINCT_ID + * choice for the same reason (#6228). */ +const POSTHOG_DISTINCT_ID = "loopover-selfhost"; + +/** PostHog US-cloud ingestion host, matching src/mcp/telemetry.ts's default. */ +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; + +/** Resolve a self-host-only config var: process.env only (unlike POSTHOG_API_KEY/POSTHOG_HOST, these have no + * Worker/Env-typed counterpart -- mirrors sentry.ts's own SENTRY_* self-host var reads verbatim). */ +function processEnvString(env: NodeJS.ProcessEnv, name: string): string | undefined { + return nonBlank(env[name]); +} + +/** Resolve the PostHog release id: explicit override first, then the image-baked self-host version, matching + * {@link resolveSentryRelease}'s identical precedence in sentry.ts. */ +export function resolvePostHogRelease(env: NodeJS.ProcessEnv): string | undefined { + return nonBlank(env.POSTHOG_RELEASE) ?? nonBlank(env.LOOPOVER_VERSION); +} + +/** The repo a capture's properties belong to, for per-repo severity-threshold lookup -- mirrors sentry.ts's + * contextRepoFullName's identical repo-over-repository normalization. */ +function contextRepoFullName(properties: Record | undefined): string { + if (!properties) return ""; + const repo = typeof properties.repo === "string" ? properties.repo : typeof properties.repository === "string" ? properties.repository : undefined; + return repo ?? ""; +} + +/** Resolve the minimum severity for `repoFullName`: POSTHOG_REPO_MIN_SEVERITY (a JSON `{repoFullName: severity}` + * map) wins, else the global POSTHOG_MIN_SEVERITY, else `"error"`. Separate knobs from Sentry's + * SENTRY_MIN_SEVERITY/SENTRY_REPO_MIN_SEVERITY on purpose: an operator running both sinks in parallel may + * legitimately want PostHog quieter/noisier than Sentry while comparing the two during the parallel-run window. */ +function resolvePostHogMinSeverity(repoFullName: string): LoopoverSeverity { + const processEnv = (globalThis as unknown as { process?: { env?: Record } }).process?.env ?? {}; + return resolveSeverityThreshold(processEnv as unknown as Env, repoFullName, "POSTHOG_MIN_SEVERITY", "POSTHOG_REPO_MIN_SEVERITY"); +} + +/** Map a structured log's own level onto the shared severity taxonomy, matching sentry.ts's + * normalizeLoopoverSeverity exactly (debug folds into info; an unrecognized word is treated as info, never + * promoted). */ +function normalizePostHogSeverity(level: string): LoopoverSeverity { + const lower = level.toLowerCase(); + if (lower === "critical" || lower === "fatal") return "critical"; + if (lower === "error") return "error"; + if (lower === "warning" || lower === "warn") return "warning"; + return "info"; +} + +/** before_send scrubber -- redact anything token/secret-like before an event leaves the box (privacy + * boundary), applied to every outgoing event including the ones captureException() builds internally. + * PostHog's event shape is just {event, properties, ...}, so unlike sentry.ts's scrubEvent this only needs to + * walk one bag, not five separately-shaped sub-objects. */ +export function scrubPostHogEvent(event: PostHogEventMessage | null): PostHogEventMessage | null { + if (!event) return event; + try { + if (event.properties) scrubRecord(event.properties, 0); + if (typeof event.event === "string") event.event = scrubString(event.event); + } catch { + return null; + } + return event; +} + +/** Build the properties bag for a captured error/log: hashes any installation id, then tags the shared + * operational key allowlist (reused from sentry.ts -- the same fields are exactly as safe to surface as a + * PostHog property as they are as a Sentry tag) alongside whatever else the caller passed. */ +function operationalProperties(context: Record | undefined): Record { + const safeContext = context ? hashedInstallationContext(context) : {}; + const normalized: Record = + typeof safeContext.repository === "string" && safeContext.repo === undefined + ? { ...safeContext, repo: safeContext.repository } + : { ...safeContext }; + const properties: Record = {}; + for (const key of SENTRY_OPERATIONAL_TAG_KEYS) { + const value = normalized[key]; + if (typeof value === "string" || typeof value === "number") properties[key] = value; + } + const trace = currentOtelTraceIds(); + if (trace) { + properties.trace_id = trace.trace_id; + properties.span_id = trace.span_id; + } + return properties; +} + +/** Initialize PostHog from the environment. Returns false (and stays a no-op) when POSTHOG_API_KEY is unset -- + * the SAME var #6235's MCP telemetry reads (env-var decision, #8287). `env` is real process.env, matching + * initSentry's identical NodeJS.ProcessEnv shape. */ +export async function initPostHog(env: NodeJS.ProcessEnv): Promise { + const apiKey = processEnvString(env, "POSTHOG_API_KEY"); + if (!apiKey) return false; + await loadNodeHasher(); + const { PostHog } = await import("posthog-node"); + posthogEnvironment = processEnvString(env, "POSTHOG_ENVIRONMENT") ?? "production"; + const host = processEnvString(env, "POSTHOG_HOST") ?? DEFAULT_POSTHOG_HOST; + client = new PostHog(apiKey, { + host, + // Long-running server, not a per-request/edge context (unlike src/mcp/telemetry.ts's ephemeral + // per-call client) -- default batching/flush interval is the right posture here; flushPostHog() below + // still exists for explicit drain-before-exit, and PostHog's own recommended client.shutdown() covers + // graceful process shutdown. + before_send: scrubPostHogEvent, + // Matches Sentry's own Node SDK default posture (Sentry.init() installs global uncaughtException/ + // unhandledRejection handlers unless explicitly disabled) -- a safety net for genuinely-unhandled cases + // beyond the explicit capturePostHogError/capturePostHogReviewFailure call sites below, per PostHog's + // own documented recommendation for Node.js error tracking. + enableExceptionAutocapture: true, + }); + active = true; + return true; +} + +/** Name a captured Error before capture so its PostHog issue title reads "eventName: message" instead of the + * generic "Error: message", mirroring sentry.ts's namedCaptureError exactly (including its never-mutate-the- + * caught-value care for read-only `name` on some runtime errors like DOMException). */ +function namedCaptureError(error: unknown, eventName?: string): Error { + const err = error instanceof Error ? error : new Error(String(error)); + if (!eventName) return err; + const namedError = new Error(err.message, { cause: err }); + namedError.name = eventName; + Object.defineProperty(namedError, "stack", { value: err.stack, configurable: true, writable: true }); + return namedError; +} + +/** Capture an error with optional structured context. No-op when PostHog is off OR the repo's resolved + * severity threshold is above `error`. Mirrors sentry.ts's captureError exactly, using PostHog's own + * documented captureException(error, distinctId, properties) instead of Sentry.withScope/captureException. */ +export function capturePostHogError(error: unknown, context?: Record, eventName?: string): void { + if (!active || !client) return; + if (!meetsSeverityThreshold("error", resolvePostHogMinSeverity(contextRepoFullName(context)))) return; + const properties = operationalProperties(context); + properties.server_name = nonBlank((globalThis as unknown as { process?: { env?: Record } }).process?.env?.POSTHOG_SERVER_NAME) ?? hostname(); + properties.environment = posthogEnvironment; + client.captureException(namedCaptureError(error, eventName), POSTHOG_DISTINCT_ID, properties); +} + +/** Capture a failed review at ERROR level, tagged by repo/PR/SHA for triage. Mirrors sentry.ts's + * captureReviewFailure exactly -- a review that cannot be produced is a real failure, always captured at + * error grade when the threshold allows it through at all. */ +export function capturePostHogReviewFailure(error: unknown, context?: Record, eventName?: string): void { + if (!active || !client) return; + if (!meetsSeverityThreshold("error", resolvePostHogMinSeverity(contextRepoFullName(context)))) return; + const properties = operationalProperties(context); + properties.kind = "review_failure"; + client.captureException(namedCaptureError(error, eventName), POSTHOG_DISTINCT_ID, properties); +} + +/** A SHORT location suffix for a no-message log's summary, matching sentry.ts's logLocation exactly. */ +function logLocation(obj: Record): string { + const repo = typeof obj.repository === "string" ? obj.repository : typeof obj.repo === "string" ? obj.repo : undefined; + if (!repo) return ""; + const pr = obj.pullNumber; + return typeof pr === "number" ? ` (${repo}#${pr})` : ` (${repo})`; +} + +const SUMMARY_SKIP_KEYS = new Set([ + "level", "event", "ts", "time", "timestamp", "msg", "ev", "message", "error", "repo", "repository", + "installationId", "installation_id", "installation_id_hash", "pullNumber", "deliveryId", "trace_id", "span_id", +]); + +function redactSummaryValue(value: unknown, depth = 0): unknown { + if (!value || typeof value !== "object") return value; + if (depth >= 6) return REDACTED; + if (Array.isArray(value)) return value.map((item) => redactSummaryValue(item, depth + 1)); + return Object.fromEntries( + Object.entries(value as Record).map(([key, nested]) => [key, SECRET_KEY.test(key) ? REDACTED : redactSummaryValue(nested, depth + 1)]), + ); +} + +/** Summarize a field-only log's salient scalars into the captured value, matching sentry.ts's + * summarizeLogFields exactly. */ +function summarizeLogFields(obj: Record): string { + return Object.entries(obj) + .filter(([k, v]) => !SUMMARY_SKIP_KEYS.has(k) && !SECRET_KEY.test(k) && v !== null) + .map(([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(redactSummaryValue(v)) : String(v)}`) + .filter((part) => part.length <= 90) + .slice(0, 5) + .join(", "); +} + +/** Forward a structured console line to PostHog when its level meets the repo's resolved severity threshold, + * matching sentry.ts's forwardStructuredLogToSentry exactly (same error-sink-defaults-to-error-level + * behavior, same synthetic-exception construction so the captured event always has a real type+value). */ +export function forwardStructuredLogToPostHog(line: unknown, fromErrorSink = false): void { + if (!active || !client) return; + if (typeof line !== "string" || line.charCodeAt(0) !== 123 /* "{" */) return; + let obj: Record; + try { + obj = JSON.parse(line) as Record; + } catch { + return; + } + const safeObj = hashedInstallationContext(obj); + const explicitLevel = typeof obj.level === "string" ? obj.level : undefined; + const level = explicitLevel ?? (fromErrorSink ? "error" : undefined); + if (!level) return; + const loopoverSeverity = normalizePostHogSeverity(level); + if (!meetsSeverityThreshold(loopoverSeverity, resolvePostHogMinSeverity(contextRepoFullName(safeObj)))) return; + const event = typeof obj.event === "string" ? obj.event : undefined; + const subEvent = typeof obj.ev === "string" ? obj.ev : undefined; + const detail = typeof obj.message === "string" ? obj.message : typeof obj.error === "string" ? obj.error : undefined; + const value = detail ?? ([logLocation(safeObj).trim(), summarizeLogFields(safeObj)].filter(Boolean).join(" ") || "(no message — see the log context)"); + const errorEvent = new Error(value); + errorEvent.name = event ? (subEvent ? `${event}/${subEvent}` : event) : "LoopOverLog"; + errorEvent.stack = `${errorEvent.name}: ${value}`; + const properties = operationalProperties(safeObj); + properties.severity = loopoverSeverity; + if (event) properties.event_slug = event; + if (subEvent) properties.event_sub_slug = subEvent; + client.captureException(errorEvent, POSTHOG_DISTINCT_ID, properties); +} + +interface StructuredLogConsole { + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +/** Install PostHog structured-log forwarding for both stdout and stderr sinks. Safe to call alongside + * sentry.ts's installStructuredLogForwarding (parallel run) -- each wraps whatever target.log/target.error + * currently is, so calling both chains correctly regardless of call order. */ +export function installPostHogStructuredLogForwarding(target: StructuredLogConsole = console): void { + const baseConsoleLog = target.log.bind(target); + const baseConsoleError = target.error.bind(target); + let forwarding = false; + const forward = (line: unknown, fromErrorSink: boolean): void => { + if (forwarding) return; + forwarding = true; + try { + forwardStructuredLogToPostHog(line, fromErrorSink); + } finally { + forwarding = false; + } + }; + target.log = (...args: unknown[]): void => { + baseConsoleLog(...args); + forward(args[0], false); + }; + target.error = (...args: unknown[]): void => { + baseConsoleError(...args); + forward(args[0], true); + }; +} + +/** Cron-monitor replacement (#8287 deliverable 4): PostHog has no native cron-monitor/check-in product, so + * this emits a plain heartbeat event per run instead -- start, then ok/error with duration -- rather than + * Sentry's structured monitor-slug + check-in-id + schedule-config concept, which has no PostHog equivalent + * to map onto. The "silent death is an alert" property is preserved at the ALERTING layer, not here: a + * PostHog insight alert on a no-data condition over `orb_monitor_heartbeat` (filtered to status:"ok" and a + * given monitor name) fires exactly when check-ins stop arriving, the same failure mode Sentry Crons' + * missed-check-in alerting catches. Configuring that alert is explicitly out of scope here -- it belongs to + * #8294 (the epic's insights/dashboards/alert-routing issue), which this event stream is built to feed. */ +export type PostHogMonitorName = "scheduled-loop" | "orb-export" | "orb-relay-drain" | "orb-relay-register" | "queue-dead-letter-revive"; +export const POSTHOG_MONITOR_HEARTBEAT_EVENT = "orb_monitor_heartbeat"; + +export async function withPostHogMonitor(name: PostHogMonitorName, context: Record | undefined, callback: () => Promise): Promise { + if (!active || !client) return callback(); + const startedAt = Date.now(); + try { + const result = await callback(); + client.capture({ + distinctId: POSTHOG_DISTINCT_ID, + event: POSTHOG_MONITOR_HEARTBEAT_EVENT, + properties: { monitor: name, status: "ok", duration_ms: Date.now() - startedAt, environment: posthogEnvironment }, + }); + return result; + } catch (error) { + client.capture({ + distinctId: POSTHOG_DISTINCT_ID, + event: POSTHOG_MONITOR_HEARTBEAT_EVENT, + properties: { monitor: name, status: "error", duration_ms: Date.now() - startedAt, environment: posthogEnvironment }, + }); + const properties = operationalProperties({ ...context, monitor: name, kind: `posthog_monitor_${name}`, subsystem: "scheduled" }); + client.captureException(error instanceof Error ? error : new Error(String(error)), POSTHOG_DISTINCT_ID, properties); + throw error; + } +} + +/** Flush buffered events before exit. No-op when off. */ +export async function flushPostHog(): Promise { + if (!active || !client) return; + await client.flush().catch(() => undefined); +} + +/** Gracefully shut the PostHog client down (flushes, then stops its internal timers) -- PostHog's own + * documented cleanup call for a long-running Node process, distinct from flushPostHog's mid-life drain. */ +export async function shutdownPostHog(): Promise { + if (!active || !client) return; + await client.shutdown().catch(() => undefined); +} + +/** Test-only: reset module state between cases. */ +export function resetPostHogForTest(): void { + client = undefined; + active = false; + posthogEnvironment = "production"; + resetRedactionScrubForTest(); +} diff --git a/src/selfhost/redaction-scrub.ts b/src/selfhost/redaction-scrub.ts new file mode 100644 index 0000000000..101c4f4677 --- /dev/null +++ b/src/selfhost/redaction-scrub.ts @@ -0,0 +1,196 @@ +// Shared, provider-agnostic redaction primitives (#8287) -- extracted out of src/selfhost/sentry.ts's +// beforeSend scrubber so the PostHog sink (src/selfhost/posthog.ts) can reuse the exact same secret/private- +// text detection without hand-duplicating a security-critical regex set into a second file. This module owns +// only the pure "is this key/value secret-shaped, and how do I redact it" logic; each provider's own +// event-SHAPE orchestration (Sentry's request/contexts/extra/tags/breadcrumbs vs PostHog's flat properties +// bag) stays in that provider's own file, since the two shapes are genuinely different and forcing them +// through one shared walker would make both providers depend on the same brittle shape-guessing. +import { + PUBLIC_LOCAL_PATH_SCRUB_PATTERN, + PUBLIC_UNSAFE_TERMS, +} from "../signals/redaction"; + +export const SECRET_KEY = + /(token|secret|key|password|passwd|authorization|auth|dsn|cookie|bearer|credential|private)/i; +export const PAYLOAD_KEY = + /(^|[_-])(body|payload|patch|diff|prompt|rubric|guardrail|headers?|cookies?|title|config|review[-_]?text|review[-_]?content|comment[-_]?text|comment[-_]?body)([_-]|$)|^(body|payload|patch|diff|prompt|rubric|guardrail|headers?|cookies?|title|config|review[-_]?text|review[-_]?content|comment[-_]?text|comment[-_]?body)$/i; +export const SECRET_VALUE = new RegExp( + [ + `${"github" + "_pat_"}[A-Za-z0-9_]+`, + String.raw`gh[opsru]_[A-Za-z0-9_]{20,}`, + String.raw`sk-[A-Za-z0-9_-]{20,}`, + String.raw`xox[baprs]-[A-Za-z0-9-]+`, + // LoopOver's own opaque tokens (createOpaqueToken, src/auth/security.ts): gts_ is the default session-token + // prefix, orbenr_/orbsec_ are the Orb broker's enrollment id/secret (#1825) -- a broker error message can quote + // these bare (no "secret"/"token"-named field for the key-based redaction below to catch), so the VALUE itself + // must be recognized here too. + String.raw`(?:gts|orbenr|orbsec)_[A-Za-z0-9_]{20,}`, + String.raw`Bearer\s+[A-Za-z0-9._~+/=-]{12,}`, + String.raw`-----BEGIN [^-]+ PRIVATE KEY-----[\s\S]*?-----END [^-]+ PRIVATE KEY-----`, + ].join("|"), + "gi", +); +export const JWT_VALUE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; +export const QUERY_SECRET_VALUE = + /([?&;][^=\s&#;]*(?:token|secret|key|password|passwd|authorization|auth|dsn|cookie|bearer|credential|private)[^=\s&#;]*=)[^&#\s;]+/gi; +export const PRIVATE_TEXT = + /\b(raw[-_\s]?score|scoring context|private rubric|gate prompt|review prompt|guardrail paths?|pull request body|pr body|pr title|raw diff)\b/gi; +export const PUBLIC_UNSAFE_SCRUB = new RegExp(String.raw`\b(${PUBLIC_UNSAFE_TERMS})\b`, "gi"); +export const REDACTED = "[redacted]"; + +export function nonBlank(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +type DigestHex = (input: string) => string; +let digestHexSync: DigestHex | undefined; + +/** Lazily load a SYNCHRONOUS sha256 hasher (node:crypto's createHash) -- kept out of the module's static + * import graph so this module stays safe to import from anywhere (including a Cloudflare Worker bundle) + * when the caller never actually invokes {@link installationIdHash}. Mirrors sentry.ts's identical + * loadNodeHasher discipline pre-extraction. A sync hasher is required because {@link scrubRecord} walks + * its object tree synchronously; the async Web-Crypto-based sha256Hex (src/utils/crypto.ts) can't be + * awaited mid-walk without restructuring every caller into an async tree-walk. */ +export async function loadNodeHasher(): Promise { + const { createHash } = await import("node:crypto"); + digestHexSync = (input: string): string => createHash("sha256").update(input).digest("hex"); +} + +/** Test-only: reset the lazily-loaded hasher between cases. */ +export function resetRedactionScrubForTest(): void { + digestHexSync = undefined; +} + +const INSTALLATION_HASH_SEED = "github-installation:"; + +function normalizeInstallationId(value: unknown): string | undefined { + if (typeof value === "number" && Number.isFinite(value)) return String(Math.trunc(value)); + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return /^[0-9]+$/.test(trimmed) ? trimmed : undefined; +} + +/** Hash a raw installation id into a short, non-reversible tag -- undefined when the hasher hasn't been + * loaded yet ({@link loadNodeHasher}) or the value isn't a real installation id. */ +export function installationIdHash(value: unknown): string | undefined { + if (!digestHexSync) return undefined; + const normalized = normalizeInstallationId(value); + if (!normalized) return undefined; + return digestHexSync(`${INSTALLATION_HASH_SEED}${normalized}`).slice(0, 16); +} + +export function isInstallationIdKey(key: string): boolean { + return key.replace(/[^A-Za-z0-9]/g, "").toLowerCase() === "installationid"; +} + +/** Replace a context's raw installation_id/installationId with its hash, matching sentry.ts's + * hashedInstallationContext. Returns the input unchanged when there's nothing to hash. */ +export function hashedInstallationContext(context: Record): Record { + const hasInstallationId = "installation_id" in context || "installationId" in context; + const hash = installationIdHash(context.installation_id ?? context.installationId); + if (!hash && !hasInstallationId) return context; + const safe: Record = { ...context }; + if (hash) safe.installation_id_hash = hash; + delete safe.installation_id; + delete safe.installationId; + return safe; +} + +export function shouldRedactKey(key: string): boolean { + const compact = key.replace(/[^A-Za-z0-9]/g, "").toLowerCase(); + return ( + SECRET_KEY.test(key) || + PAYLOAD_KEY.test(key) || + /(body|payload|patch|diff|prompt|rubric|guardrail|header|cookie|title|config|reviewtext|reviewcontent|prcontent|pullrequest)/.test(compact) + ); +} + +export function scrubString(value: string): string { + return value + .replace(QUERY_SECRET_VALUE, `$1${REDACTED}`) + .replace(SECRET_VALUE, REDACTED) + .replace(JWT_VALUE, REDACTED) + .replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "") + .replace(PUBLIC_UNSAFE_SCRUB, "private context") + .replace(PRIVATE_TEXT, "private context"); +} + +export function isUrlKey(key: string): boolean { + return key.replace(/[^A-Za-z0-9]/g, "").toLowerCase().endsWith("url"); +} + +export function isQueryKey(key: string): boolean { + const compact = key.replace(/[^A-Za-z0-9]/g, "").toLowerCase(); + return compact === "query" || compact === "querystring"; +} + +export function scrubQueryString(value: string): string { + const hasQuestionMark = value.startsWith("?"); + const source = hasQuestionMark ? value.slice(1) : value; + const params = new URLSearchParams(source); + for (const key of Array.from(new Set(params.keys()))) { + const values = params.getAll(key); + params.delete(key); + for (const entry of values) { + params.append(key, shouldRedactKey(key) ? REDACTED : scrubString(entry)); + } + } + const scrubbed = params.toString(); + return hasQuestionMark ? `?${scrubbed}` : scrubbed; +} + +export function scrubUrl(value: string): string { + const scrubbed = scrubString(value); + const queryStart = scrubbed.indexOf("?"); + if (queryStart === -1) return scrubbed; + try { + const parsed = new URL(scrubbed); + parsed.search = scrubQueryString(parsed.search); + return parsed.toString(); + } catch { + return `${scrubbed.slice(0, queryStart + 1)}${scrubQueryString(scrubbed.slice(queryStart + 1))}`; + } +} + +export function scrubStringField(key: string, value: string): string { + if (isUrlKey(key)) return scrubUrl(value); + if (isQueryKey(key)) return scrubQueryString(value); + return scrubString(value); +} + +/** Recursively redact secret-shaped keys/values in place, depth-bounded to avoid runaway recursion on a + * pathological/cyclic-looking structure. Shared verbatim by both providers' event orchestrators. */ +export function scrubRecord(obj: unknown, depth: number): void { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + const value = obj[i]; + if (typeof value === "string") obj[i] = scrubString(value); + else if (value && typeof value === "object") { + if (depth >= 6) obj[i] = REDACTED; + else scrubRecord(value, depth + 1); + } + } + return; + } + const rec = obj as Record; + for (const key of Object.keys(rec)) { + if (isInstallationIdKey(key)) { + const hash = installationIdHash(rec[key]); + if (hash) rec.installation_id_hash = hash; + delete rec[key]; + continue; + } + if (shouldRedactKey(key)) { + rec[key] = REDACTED; + continue; + } + const value = rec[key]; + if (typeof value === "string") rec[key] = scrubStringField(key, value); + else if (value && typeof value === "object") { + if (depth >= 6) rec[key] = REDACTED; + else scrubRecord(value, depth + 1); + } + } +} diff --git a/src/selfhost/sentry.ts b/src/selfhost/sentry.ts index b4e96cd64d..ee6f0b7154 100644 --- a/src/selfhost/sentry.ts +++ b/src/selfhost/sentry.ts @@ -2,19 +2,35 @@ // env-gated, dynamically-imported selfhost-integration pattern (Redis/Qdrant/embed-provider in server.ts). // @sentry/node is NEVER imported at module top level — it loads lazily inside initSentry(), so it never enters // the Worker bundle (src/index.ts) and cloudflare:* stubbing stays clean. All helpers are safe to call when off. -import { - PUBLIC_LOCAL_PATH_SCRUB_PATTERN, - PUBLIC_UNSAFE_TERMS, -} from "../signals/redaction"; +// +// The pure secret/private-text redaction primitives (SECRET_KEY, scrubString, scrubRecord, installation-id +// hashing, ...) live in ./redaction-scrub (#8287) so the PostHog sink (./posthog.ts) reuses the identical +// security-critical detection instead of a hand-duplicated second copy. Only this file's own Sentry-event- +// SHAPE orchestration (scrubEvent walking request/contexts/extra/tags/breadcrumbs) stays here. import { hostname } from "node:os"; import { currentOtelTraceIds, openTelemetryTraceExportEnabled, type OpenTelemetryBridge, } from "./otel"; -import { hashedInstallationIdWith } from "./review-tracing"; import { queueDeadLetterReviveIntervalMs } from "./queue-common"; import { meetsSeverityThreshold, resolveSeverityThreshold, type LoopoverSeverity } from "../services/severity-threshold"; +import { + hashedInstallationContext, + installationIdHash, + isInstallationIdKey, + loadNodeHasher, + nonBlank, + REDACTED, + resetRedactionScrubForTest, + scrubQueryString, + scrubRecord, + scrubString, + scrubStringField, + scrubUrl, + SECRET_KEY, + shouldRedactKey, +} from "./redaction-scrub"; type SentryNs = typeof import("@sentry/node"); type SentryClient = NonNullable>; @@ -27,40 +43,12 @@ type SentryScope = { setContext(name: string, context: Record): void; setTag(key: string, value: string): void; }; -type DigestHex = (input: string) => string; let Sentry: SentryNs | undefined; let sentryClient: SentryClient | undefined; let sentryTraceSampleRate: number | undefined; let active = false; let sentryEnvironment = "production"; -let digestHexSync: DigestHex | undefined; -const SECRET_KEY = - /(token|secret|key|password|passwd|authorization|auth|dsn|cookie|bearer|credential|private)/i; -const PAYLOAD_KEY = - /(^|[_-])(body|payload|patch|diff|prompt|rubric|guardrail|headers?|cookies?|title|config|review[-_]?text|review[-_]?content|comment[-_]?text|comment[-_]?body)([_-]|$)|^(body|payload|patch|diff|prompt|rubric|guardrail|headers?|cookies?|title|config|review[-_]?text|review[-_]?content|comment[-_]?text|comment[-_]?body)$/i; -const SECRET_VALUE = new RegExp( - [ - `${"github" + "_pat_"}[A-Za-z0-9_]+`, - String.raw`gh[opsru]_[A-Za-z0-9_]{20,}`, - String.raw`sk-[A-Za-z0-9_-]{20,}`, - String.raw`xox[baprs]-[A-Za-z0-9-]+`, - // LoopOver's own opaque tokens (createOpaqueToken, src/auth/security.ts): gts_ is the default session-token - // prefix, orbenr_/orbsec_ are the Orb broker's enrollment id/secret (#1825) — a broker error message can quote - // these bare (no "secret"/"token"-named field for the key-based redaction above to catch), so the VALUE itself - // must be recognized here too. - String.raw`(?:gts|orbenr|orbsec)_[A-Za-z0-9_]{20,}`, - String.raw`Bearer\s+[A-Za-z0-9._~+/=-]{12,}`, - String.raw`-----BEGIN [^-]+ PRIVATE KEY-----[\s\S]*?-----END [^-]+ PRIVATE KEY-----`, - ].join("|"), - "gi", -); -const JWT_VALUE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; -const QUERY_SECRET_VALUE = - /([?&;][^=\s&#;]*(?:token|secret|key|password|passwd|authorization|auth|dsn|cookie|bearer|credential|private)[^=\s&#;]*=)[^&#\s;]+/gi; -const PRIVATE_TEXT = - /\b(raw[-_\s]?score|scoring context|private rubric|gate prompt|review prompt|guardrail paths?|pull request body|pr body|pr title|raw diff)\b/gi; -const PUBLIC_UNSAFE_SCRUB = new RegExp(String.raw`\b(${PUBLIC_UNSAFE_TERMS})\b`, "gi"); const ALLOWED_CONTEXTS = new Set([ "loopover", "review", @@ -71,18 +59,6 @@ const ALLOWED_CONTEXTS = new Set([ "runtime", "os", ]); -const REDACTED = "[redacted]"; - -function nonBlank(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -async function loadNodeHasher(): Promise { - const { createHash } = await import("node:crypto"); - digestHexSync = (input: string): string => - createHash("sha256").update(input).digest("hex"); -} const SENTRY_MONITORS: Record SentryMonitorConfig) }> = { "scheduled-loop": { @@ -253,132 +229,12 @@ export function scrubEvent(event: T): T | null { return event; } -function shouldRedactKey(key: string): boolean { - const compact = key.replace(/[^A-Za-z0-9]/g, "").toLowerCase(); - return ( - SECRET_KEY.test(key) || - PAYLOAD_KEY.test(key) || - /(body|payload|patch|diff|prompt|rubric|guardrail|header|cookie|title|config|reviewtext|reviewcontent|prcontent|pullrequest)/.test(compact) - ); -} - -function isInstallationIdKey(key: string): boolean { - return key.replace(/[^A-Za-z0-9]/g, "").toLowerCase() === "installationid"; -} - -function installationIdHash(value: unknown): string | undefined { - if (!digestHexSync) return undefined; - return hashedInstallationIdWith(value, digestHexSync); -} - -function hashedInstallationContext( - context: Record, -): Record { - const hasInstallationId = - "installation_id" in context || "installationId" in context; - const hash = installationIdHash(context.installation_id ?? context.installationId); - if (!hash && !hasInstallationId) return context; - const safe: Record = { ...context }; - if (hash) safe.installation_id_hash = hash; - delete safe.installation_id; - delete safe.installationId; - return safe; -} - function tagHashedInstallation(scope: SentryScope, context: Record): void { const hash = installationIdHash(context.installation_id ?? context.installationId); if (hash) scope.setTag("installation_id_hash", hash); } function applyOperationalTags(scope: SentryScope, context: Record): void { const normalized: Record = typeof context.repository === "string" && context.repo === undefined ? { ...context, repo: context.repository } : { ...context }; tagHashedInstallation(scope, normalized); for (const key of SENTRY_OPERATIONAL_TAG_KEYS) { const tagValue = normalized[key]; if (typeof tagValue === "string" || typeof tagValue === "number") scope.setTag(key, String(tagValue)); } } -function scrubString(value: string): string { - return value - .replace(QUERY_SECRET_VALUE, `$1${REDACTED}`) - .replace(SECRET_VALUE, REDACTED) - .replace(JWT_VALUE, REDACTED) - .replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "") - .replace(PUBLIC_UNSAFE_SCRUB, "private context") - .replace(PRIVATE_TEXT, "private context"); -} - -function scrubRecord(obj: unknown, depth: number): void { - if (!obj || typeof obj !== "object") return; - if (Array.isArray(obj)) { - for (let i = 0; i < obj.length; i++) { - const value = obj[i]; - if (typeof value === "string") obj[i] = scrubString(value); - else if (value && typeof value === "object") { - if (depth >= 6) obj[i] = REDACTED; - else scrubRecord(value, depth + 1); - } - } - return; - } - const rec = obj as Record; - for (const key of Object.keys(rec)) { - if (isInstallationIdKey(key)) { - const hash = installationIdHash(rec[key]); - if (hash) rec.installation_id_hash = hash; - delete rec[key]; - continue; - } - if (shouldRedactKey(key)) { - rec[key] = REDACTED; - continue; - } - const value = rec[key]; - if (typeof value === "string") rec[key] = scrubStringField(key, value); - else if (value && typeof value === "object") { - if (depth >= 6) rec[key] = REDACTED; - else scrubRecord(value, depth + 1); - } - } -} - -function scrubStringField(key: string, value: string): string { - if (isUrlKey(key)) return scrubUrl(value); - if (isQueryKey(key)) return scrubQueryString(value); - return scrubString(value); -} - -function isUrlKey(key: string): boolean { - return key.replace(/[^A-Za-z0-9]/g, "").toLowerCase().endsWith("url"); -} - -function isQueryKey(key: string): boolean { - const compact = key.replace(/[^A-Za-z0-9]/g, "").toLowerCase(); - return compact === "query" || compact === "querystring"; -} - -function scrubUrl(value: string): string { - const scrubbed = scrubString(value); - const queryStart = scrubbed.indexOf("?"); - if (queryStart === -1) return scrubbed; - try { - const parsed = new URL(scrubbed); - parsed.search = scrubQueryString(parsed.search); - return parsed.toString(); - } catch { - return `${scrubbed.slice(0, queryStart + 1)}${scrubQueryString( - scrubbed.slice(queryStart + 1), - )}`; - } -} - -function scrubQueryString(value: string): string { - const hasQuestionMark = value.startsWith("?"); - const source = hasQuestionMark ? value.slice(1) : value; - const params = new URLSearchParams(source); - for (const key of Array.from(new Set(params.keys()))) { - const values = params.getAll(key); - params.delete(key); - for (const entry of values) { - params.append(key, shouldRedactKey(key) ? REDACTED : scrubString(entry)); - } - } - const scrubbed = params.toString(); - return hasQuestionMark ? `?${scrubbed}` : scrubbed; -} function scrubRequest(request: Record | undefined): void { if (!request) return; @@ -750,7 +606,7 @@ export function resetSentryForTest(): void { sentryTraceSampleRate = undefined; active = false; sentryEnvironment = "production"; - digestHexSync = undefined; + resetRedactionScrubForTest(); } interface StructuredLogConsole { diff --git a/src/selfhost/sqlite-queue.ts b/src/selfhost/sqlite-queue.ts index 6f7e13be58..1ca737647c 100644 --- a/src/selfhost/sqlite-queue.ts +++ b/src/selfhost/sqlite-queue.ts @@ -10,6 +10,7 @@ import { incr } from "./metrics"; import { withReviewSpan } from "./tracing"; import { withOtelSpan } from "./otel"; import { captureError, withSentryMonitor } from "./sentry"; +import { capturePostHogError, withPostHogMonitor } from "./posthog"; import { consumingRetryDelayMs, deterministicJitterMs, @@ -311,10 +312,15 @@ export function createSqliteQueue( * synchronous one it replaces -- see the call site). */ async function reviveDeadLetterJobsSafely(): Promise { try { - await withSentryMonitor( + await withPostHogMonitor( "queue-dead-letter-revive", { jobType: "queue-dead-letter-revive" }, - () => Promise.resolve(reviveDeadLetterJobs()), + () => + withSentryMonitor( + "queue-dead-letter-revive", + { jobType: "queue-dead-letter-revive" }, + () => Promise.resolve(reviveDeadLetterJobs()), + ), ); } catch (error) { console.error( @@ -325,6 +331,7 @@ export function createSqliteQueue( }), ); captureError(error, { kind: "queue_dead_letter_revive_crashed" }, "queue_dead_letter_revive_crashed"); + capturePostHogError(error, { kind: "queue_dead_letter_revive_crashed" }, "queue_dead_letter_revive_crashed"); } } @@ -446,6 +453,7 @@ export function createSqliteQueue( }), ); captureError(error, { kind: "queue_foreground_liveness_release_crashed" }, "queue_foreground_liveness_release_crashed"); + capturePostHogError(error, { kind: "queue_foreground_liveness_release_crashed" }, "queue_foreground_liveness_release_crashed"); } } @@ -831,6 +839,12 @@ export function createSqliteQueue( recovered, timeoutMs: processingTimeoutMs, }, "processing_timeout"); + capturePostHogError(new Error("self-host queue processing lease expired"), { + kind: "job_recovered", + reason: "processing_timeout", + recovered, + timeoutMs: processingTimeoutMs, + }, "processing_timeout"); } const job = claimNext(); if (!job) return false; @@ -859,6 +873,11 @@ export function createSqliteQueue( reason: "unparseable_payload", jobId: job.id, }, "unparseable_payload"); + capturePostHogError(new Error("unparseable queue payload"), { + kind: "job_dead", + reason: "unparseable_payload", + jobId: job.id, + }, "unparseable_payload"); return true; } const jobTraceParent = message.type === "github-webhook" ? message.traceParent : undefined; @@ -1118,6 +1137,13 @@ export function createSqliteQueue( jobId: job.id, attempts, }, "job_dead"); + capturePostHogError(error, { + kind: "job_dead", + reason: "max_retries_exhausted", + jobType: extractPayloadType(job.payload), + jobId: job.id, + attempts, + }, "job_dead"); } else { const retryDelayMs = consumingRetryDelayMs(error, backoff(attempts)); driver.query( @@ -1168,6 +1194,7 @@ export function createSqliteQueue( }), ); captureError(error, { kind: "queue_pump_crashed" }, "queue_pump_crashed"); + capturePostHogError(error, { kind: "queue_pump_crashed" }, "queue_pump_crashed"); } finally { active--; } diff --git a/src/server.ts b/src/server.ts index 4991d96214..38a77e48a9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -91,6 +91,13 @@ import { initSentry, installStructuredLogForwarding, } from "./selfhost/sentry"; +import { + capturePostHogError, + flushPostHog, + initPostHog, + installPostHogStructuredLogForwarding, + shutdownPostHog, +} from "./selfhost/posthog"; import { drainOrbRelayWithMonitor, registerOrbRelayWithMonitor, @@ -354,6 +361,17 @@ async function main(): Promise { // stderr. Wrap both sinks so every level:"error"/"fatal" line surfaces as a Sentry issue WITHOUT per-site wiring. installStructuredLogForwarding(); } + // PostHog error tracking (#8287, epic #8286): opt-in via POSTHOG_API_KEY -- the same var #6235's MCP telemetry + // already reads. PARALLEL-RUN: active alongside Sentry above, not instead of it, until the gated decommission + // issue (#8298) says otherwise. enableExceptionAutocapture (set inside initPostHog) already installs its own + // uncaughtException/unhandledRejection handlers per PostHog's own documented Node.js setup, so -- unlike + // Sentry's explicit process.on wiring above, added before that capability existed -- no manual mirror is + // needed here for the crash case; only structured-log forwarding needs an explicit install, same as Sentry. + const posthogEnabled = await initPostHog(process.env); + if (posthogEnabled) { + console.log(JSON.stringify({ event: "selfhost_posthog", environment: process.env.POSTHOG_ENVIRONMENT ?? "production" })); + installPostHogStructuredLogForwarding(); + } if (await initOpenTelemetry(process.env, sentryEnabled ? await buildSentryOpenTelemetryBridge() : undefined)) console.log(JSON.stringify({ event: "selfhost_otel", traces: openTelemetryTraceExportEnabled(process.env) ? "otlp" : "sentry" })); /* v8 ignore stop */ @@ -1221,7 +1239,10 @@ async function main(): Promise { state: orbRelayRegistrationState, register: registerOrbRelayTargetWithRetry, ...(relayDrainState ? { drainState: relayDrainState } : {}), - }).catch((error) => captureError(error, { kind: "orb_relay_register" }, "orb_relay_register")); + }).catch((error) => { + captureError(error, { kind: "orb_relay_register" }, "orb_relay_register"); + capturePostHogError(error, { kind: "orb_relay_register" }, "orb_relay_register"); + }); void attemptOrbRelayRegistration(); setInterval(() => void attemptOrbRelayRegistration(), 60_000); // Dashboard-visible counterparts to the streak/no-progress alert gate in isOrbRelayRegistrationAlerting: @@ -1245,7 +1266,11 @@ async function main(): Promise { }; if (isD1SizeProbeEnabled(d1ProbeEnv)) { /* v8 ignore start -- self-host entrypoint timer; probe logic itself is unit-tested in d1-size-probe.test.ts. */ - const runD1Probe = () => runD1SizeProbe(d1ProbeEnv).catch((error) => captureError(error, { kind: "d1_size_probe" }, "d1_size_probe")); + const runD1Probe = () => + runD1SizeProbe(d1ProbeEnv).catch((error) => { + captureError(error, { kind: "d1_size_probe" }, "d1_size_probe"); + capturePostHogError(error, { kind: "d1_size_probe" }, "d1_size_probe"); + }); void runD1Probe(); setInterval(runD1Probe, 900_000); /* v8 ignore stop */ @@ -1271,16 +1296,18 @@ async function main(): Promise { enqueue: enqueueWebhookByEnv, }), ); - void drainRelay().catch((error) => - captureError(error, { kind: "orb_relay_drain" }, "orb_relay_drain"), - ); + void drainRelay().catch((error) => { + captureError(error, { kind: "orb_relay_drain" }, "orb_relay_drain"); + capturePostHogError(error, { kind: "orb_relay_drain" }, "orb_relay_drain"); + }); // 30s matches broker-client's request timeout so a slow/degraded broker's in-flight drain has fully // timed out (or completed) before the next tick would otherwise pile another request on top of it. setInterval( () => - void drainRelay().catch((error) => - captureError(error, { kind: "orb_relay_drain" }, "orb_relay_drain"), - ), + void drainRelay().catch((error) => { + captureError(error, { kind: "orb_relay_drain" }, "orb_relay_drain"); + capturePostHogError(error, { kind: "orb_relay_drain" }, "orb_relay_drain"); + }), 30_000, ); /* v8 ignore stop */ @@ -1298,6 +1325,7 @@ async function main(): Promise { /* v8 ignore next -- graceful process signal path is not imported in unit tests; shutdown helper is covered. */ await shutdownOpenTelemetry(); await flushSentry(); + await shutdownPostHog(); process.exit(0); }; process.on("SIGTERM", () => void shutdown("SIGTERM")); @@ -1306,7 +1334,8 @@ async function main(): Promise { main().catch((error) => { captureError(error, { kind: "boot" }, "boot"); + capturePostHogError(error, { kind: "boot" }, "boot"); console.error(error); /* v8 ignore next -- boot failure exits the process; shutdown helper is covered independently. */ - void Promise.all([shutdownOpenTelemetry(), flushSentry()]).finally(() => process.exit(1)); + void Promise.all([shutdownOpenTelemetry(), flushSentry(), flushPostHog()]).finally(() => process.exit(1)); }); diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index d43636d927..5ea34b6d3d 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -41,6 +41,7 @@ import { import { incr } from "../selfhost/metrics"; import { shouldWaitForOlderSiblings } from "../review/merge-train"; import { captureError } from "../selfhost/sentry"; +import { capturePostHogError } from "../selfhost/posthog"; import { claimContributorCapLock, releaseContributorCapLock } from "../queue/transient-locks"; // The agent actor name on every audit record — the App acts on the maintainer's behalf per their configured @@ -639,6 +640,7 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE // for review-pass failures (selfhost/sentry.ts's captureReviewFailure, queue/processors.ts). Previously // this class of failure was audit-log-only, invisible without a manual audit_events query. captureError(error, { kind: "agent_action_execution_failed", repo: ctx.repoFullName, pr: ctx.pullNumber, installationId: ctx.installationId, actionClass: action.actionClass }, "agent_action_execution_failed"); + capturePostHogError(error, { kind: "agent_action_execution_failed", repo: ctx.repoFullName, pr: ctx.pullNumber, installationId: ctx.installationId, actionClass: action.actionClass }, "agent_action_execution_failed"); } // #2265: a permission-looking 403 on a PR-write mutation can mean the LOCAL installations.permissions // snapshot is stale after a maintainer-initiated downgrade (GitHub sends no downgrade webhook). Rate-limit @@ -941,6 +943,7 @@ export async function executeIssueMaintenanceActions(env: Env, ctx: IssueActionE // Mirrors executeAgentMaintenanceActions's non-merge capture below -- issue-side label/close has no retry // loop either, so a single failure here is already this pass's terminal outcome. captureError(error, { kind: "agent_issue_action_execution_failed", repo: ctx.repoFullName, issue: ctx.issueNumber, installationId: ctx.installationId, actionClass: action.actionClass }, "agent_issue_action_execution_failed"); + capturePostHogError(error, { kind: "agent_issue_action_execution_failed", repo: ctx.repoFullName, issue: ctx.issueNumber, installationId: ctx.installationId, actionClass: action.actionClass }, "agent_issue_action_execution_failed"); } } @@ -977,6 +980,7 @@ async function handleMergeFailure(env: Env, ctx: AgentActionExecutionContext, er // merge hold groups under one readable title regardless of which HTTP status caused it -- the specific // status/reason stays in the message and the "review" context object either way. captureError(error, { kind: "agent_merge_blocked", repo: ctx.repoFullName, pr: ctx.pullNumber, installationId: ctx.installationId, reason: reason.slice(0, 280) }, "agent_merge_blocked"); + capturePostHogError(error, { kind: "agent_merge_blocked", repo: ctx.repoFullName, pr: ctx.pullNumber, installationId: ctx.installationId, reason: reason.slice(0, 280) }, "agent_merge_blocked"); await recordAuditEvent(env, { eventType: "agent.action.merge_blocked", actor: AGENT_ACTOR, diff --git a/test/unit/docs-selfhost-posthog-observability.test.ts b/test/unit/docs-selfhost-posthog-observability.test.ts new file mode 100644 index 0000000000..d002c35143 --- /dev/null +++ b/test/unit/docs-selfhost-posthog-observability.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { POSTHOG_MONITOR_HEARTBEAT_EVENT } from "../../src/selfhost/posthog"; + +// Drift guard (#8287): self-host PostHog docs must stay aligned with the exported monitor-heartbeat event +// name, mirroring docs-selfhost-sentry-observability.test.ts's identical discipline for Sentry. + +const OPERATIONS = "apps/loopover-ui/content/docs/self-hosting-operations.mdx"; +const operations = readFileSync(OPERATIONS, "utf8"); + +describe("self-host PostHog observability docs (#8287)", () => { + it("documents enabling PostHog as opt-in and parallel-run alongside Sentry", () => { + expect(operations).toContain("Enabling PostHog error tracking"); + expect(operations).toContain("POSTHOG_API_KEY"); + expect(operations).toContain("opt-in and off by default"); + expect(operations).toContain("parallel with Sentry"); + }); + + it("documents the shared redaction module both sinks use", () => { + expect(operations).toContain("redaction-scrub.ts"); + }); + + it("documents exception autocapture", () => { + expect(operations).toContain("exception autocapture"); + }); + + it("documents the cron-monitor heartbeat replacement with the real exported event name", () => { + expect(operations).toContain("Cron Monitors"); + expect(operations).toContain(POSTHOG_MONITOR_HEARTBEAT_EVENT); + for (const monitor of ["scheduled-loop", "orb-export", "orb-relay-drain", "orb-relay-register", "queue-dead-letter-revive"]) { + expect(operations).toContain(monitor); + } + }); +}); diff --git a/test/unit/selfhost-posthog.test.ts b/test/unit/selfhost-posthog.test.ts new file mode 100644 index 0000000000..2b1b34161f --- /dev/null +++ b/test/unit/selfhost-posthog.test.ts @@ -0,0 +1,491 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock posthog-node so the dynamic import inside initPostHog() resolves to a spy-backed client. Hoisted so +// vi.mock can see it, mirroring selfhost-sentry.test.ts's identical @sentry/node mocking pattern. +const mocks = vi.hoisted(() => { + const captureException = vi.fn(); + const capture = vi.fn(); + const flush = vi.fn().mockResolvedValue(undefined); + const shutdown = vi.fn().mockResolvedValue(undefined); + let lastOptions: any; + const PostHog = vi.fn(function (this: any, _apiKey: string, options: any) { + lastOptions = options; + this.captureException = captureException; + this.capture = capture; + this.flush = flush; + this.shutdown = shutdown; + }); + return { captureException, capture, flush, shutdown, PostHog, getLastOptions: () => lastOptions }; +}); +const otelMocks = vi.hoisted(() => ({ currentOtelTraceIds: vi.fn() })); +vi.mock("posthog-node", () => ({ PostHog: mocks.PostHog })); +vi.mock("../../src/selfhost/otel", () => ({ + currentOtelTraceIds: otelMocks.currentOtelTraceIds, +})); + +import { + capturePostHogError, + capturePostHogReviewFailure, + flushPostHog, + forwardStructuredLogToPostHog, + initPostHog, + installPostHogStructuredLogForwarding, + POSTHOG_MONITOR_HEARTBEAT_EVENT, + resetPostHogForTest, + resolvePostHogRelease, + scrubPostHogEvent, + shutdownPostHog, + withPostHogMonitor, +} from "../../src/selfhost/posthog"; + +beforeEach(() => { + resetPostHogForTest(); + vi.clearAllMocks(); + otelMocks.currentOtelTraceIds.mockReturnValue(undefined); +}); + +const lastCapturedException = (): Error => mocks.captureException.mock.calls.at(-1)?.[0] as Error; +const lastCapturedProperties = (): Record => mocks.captureException.mock.calls.at(-1)?.[2] as Record; +const fakeClassicAccessToken = (): string => `${"github" + "_pat_"}${"a".repeat(24)}`; + +describe("initPostHog", () => { + it("stays a no-op when POSTHOG_API_KEY is unset", async () => { + const enabled = await initPostHog({} as unknown as NodeJS.ProcessEnv); + expect(enabled).toBe(false); + expect(mocks.PostHog).not.toHaveBeenCalled(); + }); + + it("stays a no-op when POSTHOG_API_KEY is blank/whitespace", async () => { + expect(await initPostHog({ POSTHOG_API_KEY: " " } as unknown as NodeJS.ProcessEnv)).toBe(false); + }); + + it("activates with the configured key and default host when POSTHOG_HOST is unset", async () => { + const enabled = await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + expect(enabled).toBe(true); + expect(mocks.PostHog).toHaveBeenCalledWith("phc_test_key", expect.objectContaining({ host: "https://us.i.posthog.com" })); + }); + + it("uses POSTHOG_HOST when set", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key", POSTHOG_HOST: "https://eu.i.posthog.com" } as unknown as NodeJS.ProcessEnv); + expect(mocks.PostHog).toHaveBeenCalledWith("phc_test_key", expect.objectContaining({ host: "https://eu.i.posthog.com" })); + }); + + it("enables exception autocapture and installs the before_send scrubber", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + const options = mocks.getLastOptions(); + expect(options.enableExceptionAutocapture).toBe(true); + expect(options.before_send).toBe(scrubPostHogEvent); + }); +}); + +describe("resolvePostHogRelease", () => { + it("prefers an explicit POSTHOG_RELEASE override", () => { + expect(resolvePostHogRelease({ POSTHOG_RELEASE: "1.2.3", LOOPOVER_VERSION: "9.9.9" } as unknown as NodeJS.ProcessEnv)).toBe("1.2.3"); + }); + it("falls back to LOOPOVER_VERSION when POSTHOG_RELEASE is unset", () => { + expect(resolvePostHogRelease({ LOOPOVER_VERSION: "9.9.9" } as unknown as NodeJS.ProcessEnv)).toBe("9.9.9"); + }); + it("is undefined when neither is set", () => { + expect(resolvePostHogRelease({} as unknown as NodeJS.ProcessEnv)).toBeUndefined(); + }); +}); + +describe("scrubPostHogEvent — redact secrets before an event leaves the box", () => { + it("passes null through unchanged", () => { + expect(scrubPostHogEvent(null)).toBeNull(); + }); + + it("redacts secret-keyed properties, recurses, and leaves safe fields", () => { + const event = scrubPostHogEvent({ + event: "$exception", + properties: { + jobId: "j1", + apiKey: "shh", + nested: { secretToken: "deep" }, + }, + } as any)!; + expect((event.properties as any).apiKey).toBe("[redacted]"); + expect((event.properties as any).jobId).toBe("j1"); + expect((event.properties as any).nested.secretToken).toBe("[redacted]"); + }); + + it("is safe when properties is absent", () => { + expect(() => scrubPostHogEvent({ event: "x" } as any)).not.toThrow(); + expect(scrubPostHogEvent({ event: "x" } as any)).toEqual({ event: "x" }); + }); + + it("redacts a real credential-shaped value embedded in a property string", () => { + const fakeToken = fakeClassicAccessToken(); + const event = scrubPostHogEvent({ + event: "$exception", + properties: { note: `token leaked: ${fakeToken} at /home/alice/project` }, + } as any)!; + expect((event.properties as any).note).not.toContain(fakeToken); + expect((event.properties as any).note).toContain(""); + }); + + it("scrubs the event name string too", () => { + const fakeToken = fakeClassicAccessToken(); + const event = scrubPostHogEvent({ event: `leaked ${fakeToken}`, properties: {} } as any)!; + expect(event.event).not.toContain(fakeToken); + }); + + it("leaves a non-string event name untouched", () => { + const event = scrubPostHogEvent({ event: 123 as unknown as string, properties: {} } as any)!; + expect(event.event).toBe(123); + }); + + it("returns null (fail-closed) when scrubbing itself throws", () => { + const properties: Record = {}; + Object.defineProperty(properties, "poison", { + enumerable: true, + get(): never { + throw new Error("boom during scrub"); + }, + }); + expect(scrubPostHogEvent({ event: "x", properties } as any)).toBeNull(); + }); +}); + +describe("capturePostHogError", () => { + it("is a no-op when PostHog is unconfigured", () => { + capturePostHogError(new Error("boom")); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("captures with a named error and operational properties when configured", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + capturePostHogError(new Error("boom"), { repo: "owner/repo", pull: 7 }, "my_event"); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + const captured = lastCapturedException(); + expect(captured.name).toBe("my_event"); + expect(captured.message).toBe("boom"); + const properties = lastCapturedProperties(); + expect(properties.repo).toBe("owner/repo"); + expect(properties.pull).toBe(7); + }); + + it("captures without a custom name when eventName is omitted", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + capturePostHogError(new Error("boom")); + expect(lastCapturedException().message).toBe("boom"); + }); + + it("wraps a non-Error thrown value", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + capturePostHogError("a string error", undefined, "my_event"); + expect(lastCapturedException()).toBeInstanceOf(Error); + expect(lastCapturedException().message).toBe("a string error"); + }); + + it("respects POSTHOG_MIN_SEVERITY -- suppressed above error", async () => { + process.env.POSTHOG_MIN_SEVERITY = "critical"; + try { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + capturePostHogError(new Error("boom")); + expect(mocks.captureException).not.toHaveBeenCalled(); + } finally { + delete process.env.POSTHOG_MIN_SEVERITY; + } + }); + + it("hashes an installation id in the context instead of leaking the raw value", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + capturePostHogError(new Error("boom"), { installation_id: 12345 }); + const properties = lastCapturedProperties(); + expect(properties.installation_id).toBeUndefined(); + expect(properties.installationId).toBeUndefined(); + }); + + it("attaches otel trace ids to properties when present", async () => { + otelMocks.currentOtelTraceIds.mockReturnValue({ trace_id: "t1", span_id: "s1" }); + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + capturePostHogError(new Error("boom")); + const properties = lastCapturedProperties(); + expect(properties.trace_id).toBe("t1"); + expect(properties.span_id).toBe("s1"); + }); + + it("does not overwrite an already-present repo when only repository is normalized", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + capturePostHogError(new Error("boom"), { repo: "explicit/repo", repository: "should-be-ignored/repo" }); + expect(lastCapturedProperties().repo).toBe("explicit/repo"); + }); + + it("resolves the per-repo severity threshold from a repository-only context (no repo field)", async () => { + process.env.POSTHOG_REPO_MIN_SEVERITY = JSON.stringify({ "owner/repo": "critical" }); + try { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + capturePostHogError(new Error("boom"), { repository: "owner/repo" }); + expect(mocks.captureException).not.toHaveBeenCalled(); + } finally { + delete process.env.POSTHOG_REPO_MIN_SEVERITY; + } + }); +}); + +describe("capturePostHogReviewFailure", () => { + it("is a no-op when PostHog is unconfigured", () => { + capturePostHogReviewFailure(new Error("review failed")); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("captures at error grade with kind: review_failure tagged", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + capturePostHogReviewFailure(new Error("review failed"), { repo: "owner/repo" }, "review_event"); + expect(lastCapturedException().name).toBe("review_event"); + expect(lastCapturedProperties().kind).toBe("review_failure"); + }); + + it("respects POSTHOG_MIN_SEVERITY -- suppressed above error", async () => { + process.env.POSTHOG_MIN_SEVERITY = "critical"; + try { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + capturePostHogReviewFailure(new Error("review failed")); + expect(mocks.captureException).not.toHaveBeenCalled(); + } finally { + delete process.env.POSTHOG_MIN_SEVERITY; + } + }); +}); + +describe("forwardStructuredLogToPostHog", () => { + it("is a no-op when PostHog is unconfigured", () => { + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", event: "x" })); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("ignores non-string and non-JSON-object lines", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(42); + forwardStructuredLogToPostHog("not json"); + forwardStructuredLogToPostHog("[1,2,3]"); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("ignores a line that starts with { but fails to parse", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog("{not valid json"); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("includes the repo#pr location suffix in a field-only log's summary", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", repo: "owner/repo", pullNumber: 5 })); + expect(lastCapturedException().message).toContain("(owner/repo#5)"); + }); + + it("includes just the repo when a field-only log has no pullNumber", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", repo: "owner/repo" })); + expect(lastCapturedException().message).toContain("(owner/repo)"); + }); + + it("redacts a secret-keyed field inside a nested object value when summarizing", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", event: "close_breaker_engaged", details: { apiKey: "shh", floor: 0.8 } })); + expect(lastCapturedException().message).toContain("[redacted]"); + expect(lastCapturedException().message).not.toContain("shh"); + }); + + it("redacts inside an array-valued summarized field", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", event: "x", affectedTargets: ["a", "b"] })); + expect(lastCapturedException().message).toContain("affectedTargets="); + }); + + it("caps deeply-nested summarized values at the redaction depth guard", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + let deep: unknown = { apiKey: "shh" }; + for (let i = 0; i < 8; i++) deep = { a: deep }; + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", event: "x", deep })); + expect(lastCapturedException().message).toContain("[redacted]"); + }); + + it("falls back to obj.error when message is absent", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", event: "job_dead", error: "the error string" })); + expect(lastCapturedException().message).toBe("the error string"); + }); + + it("forwards an explicit level:error JSON line", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", event: "job_dead", message: "bad thing" })); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + expect(lastCapturedException().name).toBe("job_dead"); + expect(lastCapturedException().message).toBe("bad thing"); + }); + + it("ignores a level:warn line when not from the error sink", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "warn", event: "x" }), false); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("defaults to error level when fromErrorSink is true and no explicit level is present", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ event: "x" }), true); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + }); + + it("is skipped entirely when there is no severity signal at all (no level, not from error sink)", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ event: "x" }), false); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("folds a sub-event (ev) into the synthetic error name", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", event: "rag_failure", ev: "upsert_error" })); + expect(lastCapturedException().name).toBe("rag_failure/upsert_error"); + }); + + it("summarizes salient fields when no message/error is present", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", event: "close_breaker_engaged", project: "x", closePrecision: 0.6 })); + expect(lastCapturedException().message).toContain("project=x"); + }); + + it("falls back to a context pointer when there is truly nothing to summarize", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "error" })); + expect(lastCapturedException().message).toBe("(no message — see the log context)"); + }); + + it("normalizes an unrecognized level word (e.g. a log CATEGORY, not a severity) to info, never promoting it to error", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "debug", event: "x" })); + // info < the default "error" threshold, so this stays suppressed -- proves debug did NOT get promoted. + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("normalizes fatal to critical severity", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "fatal", event: "x", message: "very bad" })); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + }); + + it("uses a repository-only field for the location suffix (no repo field)", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToPostHog(JSON.stringify({ level: "error", repository: "owner/repo" })); + expect(lastCapturedException().message).toContain("(owner/repo)"); + }); +}); + +describe("installPostHogStructuredLogForwarding", () => { + it("forwards console.error lines as error-sink by default", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + const target = { log: vi.fn(), error: vi.fn() }; + installPostHogStructuredLogForwarding(target); + target.error(JSON.stringify({ event: "x" })); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + }); + + it("forwards console.log lines only when they carry an explicit level:error", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + const target = { log: vi.fn(), error: vi.fn() }; + installPostHogStructuredLogForwarding(target); + target.log(JSON.stringify({ event: "x" })); + expect(mocks.captureException).not.toHaveBeenCalled(); + target.log(JSON.stringify({ level: "error", event: "y" })); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + }); + + it("still calls the original console method", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + const originalLog = vi.fn(); + const target = { log: originalLog, error: vi.fn() }; + installPostHogStructuredLogForwarding(target); + target.log("plain text"); + expect(originalLog).toHaveBeenCalledWith("plain text"); + }); + + it("guards against re-entrant forwarding when capture itself logs", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + mocks.captureException.mockImplementationOnce(() => { + target.error(JSON.stringify({ event: "recursive" })); + }); + const target = { log: vi.fn(), error: vi.fn() }; + installPostHogStructuredLogForwarding(target); + expect(() => target.error(JSON.stringify({ level: "error", event: "outer" }))).not.toThrow(); + }); +}); + +describe("withPostHogMonitor", () => { + it("just runs the callback when PostHog is unconfigured", async () => { + const result = await withPostHogMonitor("orb-export", undefined, async () => "ok"); + expect(result).toBe("ok"); + expect(mocks.capture).not.toHaveBeenCalled(); + }); + + it("emits an ok heartbeat on success", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + const result = await withPostHogMonitor("orb-export", { jobType: "orb-export" }, async () => "done"); + expect(result).toBe("done"); + expect(mocks.capture).toHaveBeenCalledWith( + expect.objectContaining({ event: POSTHOG_MONITOR_HEARTBEAT_EVENT, properties: expect.objectContaining({ monitor: "orb-export", status: "ok" }) }), + ); + }); + + it("emits an error heartbeat AND captures the exception on failure, then rethrows", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + const boom = new Error("orb export failed"); + await expect(withPostHogMonitor("orb-export", { jobType: "orb-export" }, async () => { throw boom; })).rejects.toThrow("orb export failed"); + expect(mocks.capture).toHaveBeenCalledWith( + expect.objectContaining({ event: POSTHOG_MONITOR_HEARTBEAT_EVENT, properties: expect.objectContaining({ monitor: "orb-export", status: "error" }) }), + ); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + }); + + it("wraps a non-Error thrown value on failure", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + await expect(withPostHogMonitor("orb-export", undefined, async () => { throw "a string failure"; })).rejects.toBe("a string failure"); + expect(lastCapturedException()).toBeInstanceOf(Error); + expect(lastCapturedException().message).toBe("a string failure"); + }); +}); + +describe("flushPostHog / shutdownPostHog", () => { + it("flushPostHog is a no-op when unconfigured", async () => { + await flushPostHog(); + expect(mocks.flush).not.toHaveBeenCalled(); + }); + + it("flushPostHog calls the client's flush when configured", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + await flushPostHog(); + expect(mocks.flush).toHaveBeenCalledTimes(1); + }); + + it("flushPostHog swallows a flush rejection", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + mocks.flush.mockRejectedValueOnce(new Error("flush failed")); + await expect(flushPostHog()).resolves.toBeUndefined(); + }); + + it("shutdownPostHog is a no-op when unconfigured", async () => { + await shutdownPostHog(); + expect(mocks.shutdown).not.toHaveBeenCalled(); + }); + + it("shutdownPostHog calls the client's shutdown when configured", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + await shutdownPostHog(); + expect(mocks.shutdown).toHaveBeenCalledTimes(1); + }); + + it("shutdownPostHog swallows a shutdown rejection", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + mocks.shutdown.mockRejectedValueOnce(new Error("shutdown failed")); + await expect(shutdownPostHog()).resolves.toBeUndefined(); + }); +}); + +describe("resetPostHogForTest", () => { + it("resets active state so a subsequent capture is a no-op again", async () => { + await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv); + resetPostHogForTest(); + capturePostHogError(new Error("boom")); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); +});