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
14 changes: 4 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# patchwave-analysis

A diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. Runs in your environment and writes a self-contained HTML report plus a raw-data zip. No data leaves your network unless you choose to share the generated artifacts.
A diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. It writes a self-contained HTML report plus a raw-data zip.

## What it tells you

Expand Down Expand Up @@ -86,7 +86,7 @@ data/contributors.json — active human committers per repo
data/warnings.json — per-collector warnings suppressed during the crawl
```

Nothing in the report or bundle leaves your machine unless you choose to share it. At the end of a run, you can keep everything local, share only the HTML report, or share the HTML report plus the raw-data zip. The archive does not include tokens, secrets, or repository file contents.
The report and bundle are not uploaded unless you choose to share them. The archive does not include tokens, secrets, or repository file contents.

## What it does not do

Expand All @@ -96,15 +96,9 @@ Nothing in the report or bundle leaves your machine unless you choose to share i

## Telemetry & privacy

The CLI reports anonymous usage analytics and crash diagnostics so we can improve it. Both share a random UUID stored at `$XDG_CONFIG_HOME/contextbridge/anonymous_id` (or `~/.config/contextbridge/anonymous_id`) and used across contextbridge tools. **Org names, repo names, tokens, report contents, and your machine's hostname are never sent.**
Official binaries send product analytics and crash diagnostics so we can improve the tool. Builds from source do not include telemetry keys.

**Product analytics (PostHog)** — coarse usage events only: when a run starts, finishes, or fails, and the choices you make at the share and open prompts, along with aggregate counts (repos, PRs, warnings), durations, and error kinds.

**Error reporting (Sentry)** — only when the CLI hits an _unexpected_ crash. It sends the error and stack trace, the release version, the anonymous id, and generic environment context (OS, CPU architecture, runtime version, locale/timezone). To keep the guarantee above, breadcrumbs and request data are dropped and the hostname is stripped before anything is sent. Expected problems — bad arguments, missing GitHub auth, GitHub API errors, or failed writes — are shown to you in the prompt and recorded as anonymous analytics, but are **not** sent to Sentry.

Telemetry keys are baked only into official released binaries, so builds you make yourself from source send nothing.

Both are disabled together by setting any of:
Disable telemetry with any of:

- `DO_NOT_TRACK=1`
- `CONTEXTBRIDGE_TELEMETRY_DISABLED=1`
Expand Down
73 changes: 72 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"open": "^11.0.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"posthog-js": "^1.370.0",
"posthog-node": "^5.35.1",
"react": "^19",
"react-dom": "^19",
Expand Down
13 changes: 10 additions & 3 deletions scripts/build-report-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const result = await Bun.build({
minify: true,
define: {
'process.env.NODE_ENV': '"production"',
// Same build-time telemetry injection as the CLI binary (see .goreleaser.yaml). The CLI
// compile gets these via `--define`; the web bundle is a separate build, so it reads the same
// env vars here. Absent (local/dev) they fall back to '', which disables report analytics.
__PW_POSTHOG_KEY__: JSON.stringify(process.env['__PW_POSTHOG_KEY__'] ?? ''),
__PW_POSTHOG_HOST__: JSON.stringify(process.env['__PW_POSTHOG_HOST__'] ?? ''),
},
plugins: [tailwindPlugin],
});
Expand All @@ -19,9 +24,11 @@ if (!result.success) {

const out = result.outputs[0]?.path ?? 'dist/report-web/index.html';
const html = await Bun.file('./dist/report-web/index.html').text();
if (!html.includes('__PATCHWAVE_DATA__')) {
console.error('dist/report-web/index.html is missing __PATCHWAVE_DATA__');
process.exit(1);
for (const placeholder of ['__PATCHWAVE_DATA__', '__PATCHWAVE_ANALYTICS__']) {
if (!html.includes(placeholder)) {
console.error(`dist/report-web/index.html is missing ${placeholder}`);
process.exit(1);
}
}
if (html.includes('jsxDEV')) {
console.error('dist/report-web/index.html contains React development markers');
Expand Down
3 changes: 2 additions & 1 deletion src/Analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { AnalyticsImpl, NoopAnalytics, type PostHogClient } from './Analytics.ts';
import { NoopAnalytics } from './Analytics.ts';
import { AnalyticsImpl, type PostHogClient } from './PostHogAnalytics.ts';

interface RecordedIdentify {
readonly distinctId: string | undefined;
Expand Down
75 changes: 2 additions & 73 deletions src/Analytics.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import { ResultAsync, fromThrowable } from 'neverthrow';
import { PostHog } from 'posthog-node';
import { POSTHOG_HOST, POSTHOG_KEY } from './buildInfo.ts';

export interface Analytics {
identify(distinctId: string, properties?: Record<string, unknown>): void;
capture(event: string, properties?: Record<string, unknown>): void;
Expand All @@ -10,68 +6,6 @@ export interface Analytics {
shutdown(): Promise<void>;
}

export type PostHogClient = Pick<PostHog, 'identify' | 'capture' | 'flush' | 'shutdown'>;

export interface AnalyticsImplOptions {
readonly distinctId: string;
readonly version: string;
readonly client?: PostHogClient;
}

export class AnalyticsImpl implements Analytics {
readonly #distinctId: string;
readonly #client: PostHogClient;
readonly #superProperties: Record<string, unknown>;
readonly #safeIdentify: (input: Parameters<PostHogClient['identify']>[0]) => void;
readonly #safeCapture: (input: Parameters<PostHogClient['capture']>[0]) => void;

constructor(options: AnalyticsImplOptions) {
this.#distinctId = options.distinctId;
this.#client = options.client ?? createDefaultClient();
this.#superProperties = {
pw_surface: 'cli',
pw_version: options.version,
};
// Wrap PostHog calls in neverthrow so a telemetry failure (network, bad
// payload) is explicit and can never escape into the CLI's control flow.
const safeIdentify = fromThrowable(this.#client.identify.bind(this.#client));
const safeCapture = fromThrowable(this.#client.capture.bind(this.#client));
this.#safeIdentify = (input) => {
void safeIdentify(input);
};
this.#safeCapture = (input) => {
void safeCapture(input);
};
}

identify(distinctId: string, properties?: Record<string, unknown>): void {
this.#safeIdentify({
distinctId,
properties: { ...this.#superProperties, ...properties },
});
}

capture(event: string, properties?: Record<string, unknown>): void {
this.#safeCapture({
distinctId: this.#distinctId,
event,
properties: { ...this.#superProperties, ...properties },
});
}

register(properties: Record<string, unknown>): void {
Object.assign(this.#superProperties, properties);
}

async flush(): Promise<void> {
await ResultAsync.fromPromise(this.#client.flush(), (err: unknown) => err).unwrapOr(undefined);
}

async shutdown(): Promise<void> {
await ResultAsync.fromPromise(this.#client.shutdown(), (err: unknown) => err).unwrapOr(undefined);
}
}

export class NoopAnalytics implements Analytics {
identify(_distinctId: string, _properties?: Record<string, unknown>): void {}
capture(_event: string, _properties?: Record<string, unknown>): void {}
Expand All @@ -80,11 +14,6 @@ export class NoopAnalytics implements Analytics {
async shutdown(): Promise<void> {}
}

function createDefaultClient(): PostHog {
return new PostHog(POSTHOG_KEY, {
host: POSTHOG_HOST,
// Short flush window for short-lived CLI processes; flushAt=1 sends eagerly.
flushAt: 1,
flushInterval: 1000,
});
export function createNoopAnalytics(): Analytics {
return new NoopAnalytics();
}
75 changes: 75 additions & 0 deletions src/PostHogAnalytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ResultAsync, fromThrowable } from 'neverthrow';
import { PostHog } from 'posthog-node';
import type { Analytics } from './Analytics.ts';
import { POSTHOG_HOST, POSTHOG_KEY } from './buildInfo.ts';

export type PostHogClient = Pick<PostHog, 'identify' | 'capture' | 'flush' | 'shutdown'>;

export interface AnalyticsImplOptions {
readonly distinctId: string;
readonly version: string;
readonly client?: PostHogClient;
}

export class AnalyticsImpl implements Analytics {
readonly #distinctId: string;
readonly #client: PostHogClient;
readonly #superProperties: Record<string, unknown>;
readonly #safeIdentify: (input: Parameters<PostHogClient['identify']>[0]) => void;
readonly #safeCapture: (input: Parameters<PostHogClient['capture']>[0]) => void;

constructor(options: AnalyticsImplOptions) {
this.#distinctId = options.distinctId;
this.#client = options.client ?? createDefaultClient();
this.#superProperties = {
pw_surface: 'cli',
pw_version: options.version,
};
// Wrap PostHog calls in neverthrow so a telemetry failure (network, bad
// payload) is explicit and can never escape into the CLI's control flow.
const safeIdentify = fromThrowable(this.#client.identify.bind(this.#client));
const safeCapture = fromThrowable(this.#client.capture.bind(this.#client));
this.#safeIdentify = (input) => {
void safeIdentify(input);
};
this.#safeCapture = (input) => {
void safeCapture(input);
};
}

identify(distinctId: string, properties?: Record<string, unknown>): void {
this.#safeIdentify({
distinctId,
properties: { ...this.#superProperties, ...properties },
});
}

capture(event: string, properties?: Record<string, unknown>): void {
this.#safeCapture({
distinctId: this.#distinctId,
event,
properties: { ...this.#superProperties, ...properties },
});
}

register(properties: Record<string, unknown>): void {
Object.assign(this.#superProperties, properties);
}

async flush(): Promise<void> {
await ResultAsync.fromPromise(this.#client.flush(), (err: unknown) => err).unwrapOr(undefined);
}

async shutdown(): Promise<void> {
await ResultAsync.fromPromise(this.#client.shutdown(), (err: unknown) => err).unwrapOr(undefined);
}
}

function createDefaultClient(): PostHog {
return new PostHog(POSTHOG_KEY, {
host: POSTHOG_HOST,
// Short flush window for short-lived CLI processes; flushAt=1 sends eagerly.
flushAt: 1,
flushInterval: 1000,
});
}
23 changes: 20 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { formatPromptError } from './prompt/Prompter.ts';
import { type ReportBundle, aggregate } from './report/aggregate.ts';
import { type BundleMeta, buildBundleFiles, zipBundleFiles } from './report/bundle.ts';
import { type RenderError, renderHtml } from './report/html.ts';
import type { ReportAnalyticsConfig } from './report/reportAnalyticsConfig.ts';
import { type Instant, Temporal } from './time.ts';
import type {
BranchProtectionSlice,
Expand Down Expand Up @@ -83,6 +84,11 @@ export async function main(ctx: Context, argv: readonly string[]): Promise<MainR
const flagOpts = parsed.value;
const startedAt = ctx.clock.now();

// One id per run, registered as a super-property so every CLI event carries it and joins to the
// report-view events the frontend sends under the same pw_report_id.
const reportId = crypto.randomUUID();
ctx.analytics.register({ pw_report_id: reportId });

let target: string;
if (flagOpts.target === null) {
const targetResult = await promptForTarget({ prompter: ctx.prompter, githubClient: ctx.githubClient });
Expand Down Expand Up @@ -123,7 +129,14 @@ export async function main(ctx: Context, argv: readonly string[]): Promise<MainR
const spinner = ctx.prompter.spinner();
spinner.start(`Scanning ${opts.target} (last ${opts.windowDays} days)...`);

const renderResult = await renderReport(ctx, opts);
const analyticsEmbed: ReportAnalyticsConfig = {
telemetryDisabled: ctx.telemetryDisabled,
reportId,
generatedByAnonId: ctx.distinctId,
version: ctx.appVersion,
};

const renderResult = await renderReport(ctx, opts, analyticsEmbed);
if (renderResult.isErr()) {
const error = renderResult.error;
spinner.stop('Scan failed.');
Expand Down Expand Up @@ -222,7 +235,11 @@ function elapsedMs(start: Instant, end: Instant): number {
return Number(end.epochMilliseconds - start.epochMilliseconds);
}

function renderReport(ctx: Context, opts: ResolvedOptions): ResultAsync<RenderedReport, GithubError | RenderError> {
function renderReport(
ctx: Context,
opts: ResolvedOptions,
analytics: ReportAnalyticsConfig,
): ResultAsync<RenderedReport, GithubError | RenderError> {
ctx.logger.info(
{ target: opts.target, windowDays: opts.windowDays },
`scanning ${opts.target} (${opts.windowDays}-day window)`,
Expand Down Expand Up @@ -250,7 +267,7 @@ function renderReport(ctx: Context, opts: ResolvedOptions): ResultAsync<Rendered
);
}
const bundle = aggregate(data);
return renderHtml(bundle).map(
return renderHtml(bundle, analytics).map(
(report): RenderedReport => ({
report,
collected: data,
Expand Down
29 changes: 25 additions & 4 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import type { Analytics } from './Analytics.ts';
import { NoopAnalytics } from './Analytics.ts';
import { type Analytics, NoopAnalytics } from './Analytics.ts';
import type { Io } from './BaseIo.ts';
import type { BrowserOpener } from './BrowserOpener.ts';
import { BrowserOpenerImpl } from './BrowserOpener.ts';
import type { Clock } from './Clock.ts';
import { ClockImpl } from './Clock.ts';
import { type Environment, getEnvironment } from './environment.ts';
import { type Environment, getEnvironment, isTelemetryDisabled } from './environment.ts';
import type { FileSystem } from './FileSystem.ts';
import { FileSystemImpl } from './FileSystem.ts';
import type { GithubClient } from './github/GithubClient.ts';
Expand All @@ -29,6 +28,10 @@ export interface Context {
readonly uploader: Uploader;
readonly browserOpener: BrowserOpener;
readonly appVersion: string;
// Anonymous telemetry id of this machine ('' when telemetry is disabled). Embedded into the
// report so report-view events can be tied back to the run that produced them.
readonly distinctId: string;
readonly telemetryDisabled: boolean;
}

export interface CreateContextOptions {
Expand All @@ -44,6 +47,8 @@ export interface CreateContextOptions {
readonly prompter?: Prompter;
readonly uploader?: Uploader;
readonly browserOpener?: BrowserOpener;
readonly distinctId?: string;
readonly telemetryDisabled?: boolean;
}

export function createContext(options: CreateContextOptions): Context {
Expand All @@ -58,8 +63,24 @@ export function createContext(options: CreateContextOptions): Context {
prompter = new PrompterImpl(),
uploader = new UploaderImpl(),
browserOpener = new BrowserOpenerImpl(),
distinctId = '',
} = options;
const logger = options.logger ?? createLogger({ level: env.LOG_LEVEL, destination: io.stderr });
const githubClient = options.githubClient ?? new GithubClientImpl({ token, logger });
return { io, logger, env, clock, fs, githubClient, analytics, prompter, uploader, browserOpener, appVersion };
const telemetryDisabled = options.telemetryDisabled ?? isTelemetryDisabled(env);
return {
io,
logger,
env,
clock,
fs,
githubClient,
analytics,
prompter,
uploader,
browserOpener,
appVersion,
distinctId,
telemetryDisabled,
};
}
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
import pkg from '../package.json' with { type: 'json' };
import { type Analytics, AnalyticsImpl, NoopAnalytics } from './Analytics.ts';
import { type Analytics, NoopAnalytics } from './Analytics.ts';
import { getOrCreateAnonymousId } from './anonymousId.ts';
import { POSTHOG_KEY, SENTRY_DSN } from './buildInfo.ts';
import { main, parseCli } from './cli.ts';
Expand All @@ -13,6 +13,7 @@ import { formatInteractiveTokenError, interactiveResolveToken } from './interact
import { enforceTty } from './interactive/ttyGate.ts';
import { IoImpl } from './IoImpl.ts';
import { createLogger } from './logger.ts';
import { AnalyticsImpl } from './PostHogAnalytics.ts';
import { PrompterImpl } from './prompt/Prompter.ts';
import { NoopTelemetry, type Telemetry, createSentryTelemetry } from './Telemetry.ts';
import { UploaderImpl } from './upload/Uploader.ts';
Expand Down Expand Up @@ -81,6 +82,8 @@ const ctx = createContext({
analytics,
prompter,
uploader: new UploaderImpl(),
distinctId,
telemetryDisabled,
});

const result = await main(ctx, argv);
Expand Down
Loading