Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/betterstack-error-reporting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-doctor": patch
---

Add opt-in crash reporting to Better Stack (via the Sentry SDK). Set `REACT_DOCTOR_ERROR_REPORTING=1` to send unhandled CLI errors; nothing is reported otherwise and `@sentry/node` is never even loaded. Reports are enriched with non-source context (CI provider, coding agent, command, platform, and where the error originated) to aid debugging. Releases upload source maps to Better Stack (matched by debug ID, not shipped in the npm tarball) so reported stack traces de-minify to the original TypeScript.
35 changes: 35 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: true

# The published bundle already carries debug IDs (`pnpm release` runs
# `sentry-cli sourcemaps inject` during the build); here we ship the
# matching source maps to Better Stack so stack traces de-minify.
# Maps are matched by debug ID, never bundled into the npm tarball.
- name: Upload source maps to Better Stack
if: ${{ steps.changesets.outputs.published == 'true' }}
continue-on-error: true
working-directory: packages/react-doctor
env:
SENTRY_AUTH_TOKEN: ${{ secrets.BETTER_STACK_SOURCEMAP_TOKEN }}
SENTRY_ORG: ${{ vars.BETTER_STACK_SOURCEMAP_ORG }}
SENTRY_PROJECT: ${{ vars.BETTER_STACK_SOURCEMAP_PROJECT }}
SENTRY_URL: ${{ vars.BETTER_STACK_SOURCEMAP_URL }}
run: |
if [ -z "$SENTRY_AUTH_TOKEN" ]; then
echo "BETTER_STACK_SOURCEMAP_TOKEN not set; skipping source map upload."
exit 0
fi
pnpm exec sentry-cli sourcemaps upload dist

publish-dev:
name: Publish @dev snapshot
needs: publish
Expand Down Expand Up @@ -90,6 +110,21 @@ jobs:
VERSION: ${{ steps.dev-version.outputs.version }}
run: pnpm build

- name: Upload source maps to Better Stack
continue-on-error: true
working-directory: packages/react-doctor
env:
SENTRY_AUTH_TOKEN: ${{ secrets.BETTER_STACK_SOURCEMAP_TOKEN }}
SENTRY_ORG: ${{ vars.BETTER_STACK_SOURCEMAP_ORG }}
SENTRY_PROJECT: ${{ vars.BETTER_STACK_SOURCEMAP_PROJECT }}
SENTRY_URL: ${{ vars.BETTER_STACK_SOURCEMAP_URL }}
run: |
if [ -z "$SENTRY_AUTH_TOKEN" ]; then
echo "BETTER_STACK_SOURCEMAP_TOKEN not set; skipping source map upload."
exit 0
fi
pnpm exec sentry-cli sourcemaps upload dist

- name: Publish to npm under the dev tag
env:
NPM_CONFIG_PROVENANCE: true
Expand Down
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,27 @@ for this codebase) for canonical examples.
`Effect.withSpan("...")` ships to the configured backend. Eval reference:
`react-doctor-evals/src/Observability.ts → layerAxiom`.

### Error reporting (CLI crash tracking)

- `react-doctor/src/cli/utils/error-tracking.ts` reports unhandled CLI errors to
Better Stack via the Sentry SDK (`@sentry/node`). Same strictly-opt-in posture
as `layerOtlp`: nothing is sent unless the user sets
`REACT_DOCTOR_ERROR_REPORTING=1` (or `=true`), and `@sentry/node` is lazy-imported
only on that opt-in so the common path pays zero cost. The DSN is a public ingest
key in `cli/utils/constants.ts` (`BETTER_STACK_ERROR_TRACKING_DSN`), not a secret.
- `initErrorTracking()` runs once at CLI startup; `captureCliError(error)` is awaited
at every fatal chokepoint (`index.ts` top-level `.catch`, plus the `inspect`/`install`
command catch blocks) so the event flushes before `process.exit`. Sentry is
initialized with `defaultIntegrations: false` + `skipOpenTelemetrySetup: true` so it
never installs global process handlers (the CLI owns its own SIGINT/EPIPE/exit) and
never spins up a second OpenTelemetry SDK alongside the Effect tracer.
- **Source maps**: the production `build` script runs `sentry-cli sourcemaps inject dist`
so the shipped bundle carries debug IDs; `.github/workflows/publish.yml` uploads the
matching maps to Better Stack at publish time (gated on `BETTER_STACK_SOURCEMAP_TOKEN`).
Maps are matched by debug ID and never bundled into the npm tarball (the package
`files` allowlist ships `dist/**/*.js`, not `*.map`). `@sentry/cli` is in the root
`onlyBuiltDependencies` so its binary postinstall is allowed under pnpm.

### Console / logging

- ALWAYS: `import * as Console from "effect/Console"` and `yield* Console.log(...)` /
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"@sentry/cli",
"esbuild",
"unrs-resolver"
],
Expand Down
12 changes: 12 additions & 0 deletions packages/react-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ React Doctor scans the files changed in the pull request, emits inline annotatio

[Add GitHub Action →](https://github.com/marketplace/actions/react-doctor)

## Error reporting

React Doctor does **not** phone home by default. If you'd like to help fix crashes
you hit, opt in to anonymous error reporting (sent to Better Stack) by setting:

```bash
REACT_DOCTOR_ERROR_REPORTING=1
```

When unset, no crash data leaves your machine. When set, only unhandled errors
(stack traces + runtime versions) are reported — never your source code.

## Contributing

[Issues welcome!](https://github.com/millionco/react-doctor/issues)
Expand Down
4 changes: 3 additions & 1 deletion packages/react-doctor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,13 @@
},
"scripts": {
"dev": "vp pack --watch",
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && cross-env NODE_ENV=production vp pack",
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && cross-env NODE_ENV=production vp pack && sentry-cli sourcemaps inject dist",
"typecheck": "tsc --noEmit",
"test": "vp test run"
},
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.70",
"@sentry/node": "^10.18.0",
"agent-install": "0.0.5",
"conf": "^15.1.0",
"deslop-js": "^0.0.13",
Expand All @@ -69,6 +70,7 @@
"devDependencies": {
"@react-doctor/api": "workspace:*",
"@react-doctor/core": "workspace:*",
"@sentry/cli": "^3.4.0",
"@types/prompts": "^2.4.9",
"commander": "^14.0.3",
"ora": "^9.4.0"
Expand Down
2 changes: 2 additions & 0 deletions packages/react-doctor/src/cli/commands/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
} from "@react-doctor/core";
import { cliLogger as logger } from "../utils/cli-logger.js";
import { STAGED_FILES_TEMP_DIR_PREFIX } from "../utils/constants.js";
import { captureCliError } from "../utils/error-tracking.js";
import { getStagedSourceFiles, materializeStagedFiles } from "../utils/get-staged-files.js";
import type { InspectFlags } from "../utils/inspect-flags.js";
import { handleError } from "../utils/handle-error.js";
Expand Down Expand Up @@ -380,6 +381,7 @@ export const inspectAction = async (directory: string, flags: InspectFlags): Pro
});
}
} catch (error) {
await captureCliError(error, "command");
if (isJsonMode) {
writeJsonErrorReport(error);
process.exitCode = 1;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-doctor/src/cli/commands/install.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Effect from "effect/Effect";
import { captureCliError } from "../utils/error-tracking.js";
import { handleError } from "../utils/handle-error.js";
import { runInstallReactDoctor } from "../utils/install-react-doctor.js";
import { printBrandedHeader } from "../utils/print-branded-header.js";
Expand Down Expand Up @@ -35,6 +36,7 @@ export const installAction = async (
projectRoot: options.cwd ?? process.cwd(),
});
} catch (error) {
await captureCliError(error, "command");
handleError(error);
}
};
43 changes: 36 additions & 7 deletions packages/react-doctor/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,51 @@ import { Command } from "commander";
import { CANONICAL_GITHUB_URL, highlighter } from "@react-doctor/core";
import { inspectAction } from "./commands/inspect.js";
import { installAction } from "./commands/install.js";
import { captureCliError, initErrorTracking } from "./utils/error-tracking.js";
import type { ErrorReportOrigin } from "./utils/error-tracking.js";
import { exitGracefully } from "./utils/exit-gracefully.js";
import { handleError } from "./utils/handle-error.js";
import { isJsonModeActive, writeJsonErrorReport } from "./utils/json-mode.js";
import { stripUnknownCliFlags } from "./utils/strip-unknown-cli-flags.js";
import { unrefStdin } from "./utils/unref-stdin.js";
import { VERSION } from "./utils/version.js";

/**
* Single fatal-error sink: report to Better Stack (a no-op unless the
* user opted in via REACT_DOCTOR_ERROR_REPORTING) and flush, then render
* through the same JSON / pretty paths the command bodies use and exit
* non-zero. Wired into the top-level promise rejection AND the
* process-level `uncaughtException` / `unhandledRejection` nets so no
* crash — handled or not — escapes reporting.
*/
const reportFatalError = async (
error: unknown,
origin: ErrorReportOrigin = "top-level",
): Promise<void> => {
await captureCliError(error, origin);
if (isJsonModeActive()) {
writeJsonErrorReport(error);
process.exit(1);
}
handleError(error);
};

process.on("SIGINT", exitGracefully);
process.on("SIGTERM", exitGracefully);
// Safety nets for anything that bypasses the command-level try/catch: a
// sync throw in a callback/timer, a throw during program construction, or
// a fire-and-forget promise rejection. (SIGINT/SIGTERM and the stdout
// EPIPE handler below are intentional non-errors and stay out of this.)
process.on(
"uncaughtException",
(error: unknown) => void reportFatalError(error, "uncaughtException"),
);
process.on(
"unhandledRejection",
(reason: unknown) => void reportFatalError(reason, "unhandledRejection"),
);
unrefStdin();
await initErrorTracking();

const program = new Command()
.name("react-doctor")
Expand Down Expand Up @@ -95,10 +130,4 @@ process.stdout.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EPIPE") process.exit(0);
});

program.parseAsync(stripUnknownCliFlags(process.argv)).catch((error: unknown) => {
if (isJsonModeActive()) {
writeJsonErrorReport(error);
process.exit(1);
}
handleError(error);
});
program.parseAsync(stripUnknownCliFlags(process.argv)).catch(reportFatalError);
12 changes: 12 additions & 0 deletions packages/react-doctor/src/cli/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,15 @@ export const PERFECT_SCORE_RAINBOW_FRAME_DELAY_MS = 50;
// stdout valid JSON so downstream parsers don't see a half-written report.
export const INTERNAL_ERROR_JSON_FALLBACK =
'{"schemaVersion":1,"ok":false,"error":{"message":"Internal error","name":"Error","chain":[]}}\n';

// Better Stack (Sentry-compatible) error-tracking ingest DSN. This is a
// public ingest key, not a secret — it only authorizes sending crash
// events, never reading them. Reporting is strictly opt-in; see
// error-tracking.ts.
export const BETTER_STACK_ERROR_TRACKING_DSN =
"https://wWK3Nv2j8X2w8gLBDSYrDWQw@s2476923.eu-fsn-3.betterstackdata.com/2476923";

// Max time to wait for queued crash events to reach Better Stack before
// the CLI exits. The process tears down immediately after an error, so
// the capture path must flush within this window or drop the event.
export const ERROR_TRACKING_FLUSH_TIMEOUT_MS = 2000;
Loading
Loading