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
63 changes: 63 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions docs/advanced-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ missing. Install only what you need:
| Azure Foundry | `CLAUDE_CODE_USE_FOUNDRY=1` | `npm i -g @anthropic-ai/foundry-sdk @azure/identity` |
| Claude on Vertex AI / Gemini ADC | `CLAUDE_CODE_USE_VERTEX=1` / Gemini ADC auth | `npm i -g google-auth-library` |
| Reading/processing images | reading an image file | `npm i -g sharp` |
| Optional error reporting | `SENTRY_DSN` is set | `npm i -g @sentry/node`. Without this package installed, setting `SENTRY_DSN` has no effect and reporting is silently disabled. |

When installing OpenClaude from source (`bun install`), all of these are
already present as dev dependencies, so source/dev builds need no extra steps.
Expand Down Expand Up @@ -592,6 +593,37 @@ reject accidental absurd values. An invalid pricing map is ignored without
discarding unrelated settings from the same file. `/config` does not currently
edit record-valued settings, so edit the JSON file directly.

## Optional Error Reporting (Sentry)

OpenClaude can optionally report sanitized error events to Sentry. This is
disabled by default and opt-in only.

```bash
export SENTRY_DSN=https://your-key@your-org.ingest.sentry.io/your-project
openclaude
```

Notes:

- Reporting only activates when `SENTRY_DSN` is set. Unset, this is a no-op.
- Reporting is skipped even when `SENTRY_DSN` is set if `DISABLE_TELEMETRY` or
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` is set, matching the same privacy
levels used for existing telemetry (see [Runtime Hardening](#runtime-hardening)).
- Only sanitized, telemetry-safe error messages are sent — never raw error
messages, which may contain file paths or other identifying information.
- `@sentry/node` is an optional dev dependency and is **not included** in the
default `npm install -g @gitlawb/openclaude` install (see
[Optional provider packages](#optional-provider-packages)). If you set
`SENTRY_DSN` without installing it separately, reporting is silently
disabled (no error, no crash). Install it explicitly with:

```bash
npm i -g @sentry/node
```

Source builds (`bun install`) already include it as a dev dependency, so no
extra step is needed there.

## Safety strictness

OpenClaude runs several "safety" checks: a model-level refusal directive, bash
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"devDependencies": {
"@alcalzone/ansi-tokenize": "0.3.0",
"@anthropic-ai/bedrock-sdk": "0.29.1",
"@sentry/node": "^10.70.0",
"@anthropic-ai/foundry-sdk": "0.2.3",
"@anthropic-ai/sandbox-runtime": "0.0.55",
"@anthropic-ai/sdk": "0.94.0",
Expand Down
7 changes: 7 additions & 0 deletions scripts/externals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
export const COMMON_EXTERNALS: string[] = [
// Native image processing
'sharp',
// Optional Sentry error reporting — dynamically required in utils/sentry.ts
// only when SENTRY_DSN is set. Not shipped by default; kept external so
// esbuild doesn't try to inline it.
'@sentry/node',
// Cloud provider SDKs
'@aws-sdk/client-bedrock',
'@aws-sdk/client-bedrock-runtime',
Expand Down Expand Up @@ -88,6 +92,9 @@ export const OPTIONAL_RUNTIME_EXTERNALS: string[] = [
// Optional: only image reads need it, and it carries a native install
// script. Kept opt-in so default installs run no install scripts.
'sharp',
// Sentry error reporting — loaded via require() in utils/sentry.ts only
// when SENTRY_DSN is set. Optional: most users never enable this.
'@sentry/node',
]

// OPTIONAL_RUNTIME_EXTERNALS that are loaded ONLY through the runtime importer
Expand Down
8 changes: 5 additions & 3 deletions src/entrypoints/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
} from '../utils/permissions/filesystem.js'
import { configureGlobalAgents } from '../utils/proxy.js'
import { setShellIfWindows } from '../utils/windowsPaths.js'
import { initializeSentry } from '../utils/sentry.js'


export const init = memoize(async (): Promise<void> => {
Expand Down Expand Up @@ -216,9 +217,10 @@ export const init = memoize(async (): Promise<void> => {
})

/**
* No-op — telemetry initialization has been removed.
* Kept as an empty function for API compatibility with callers.
* Initializes optional, env-driven Sentry error reporting after the user
* has trusted the working directory. No-op unless SENTRY_DSN is set and
* telemetry is not disabled.
*/
export function initializeTelemetryAfterTrust(): void {
// Telemetry is no longer initialized; this is a no-op.
void initializeSentry()
}
11 changes: 10 additions & 1 deletion src/utils/gracefulShutdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
wrapForMultiplexer,
} from '../ink/termio/osc.js'
import { shutdownDatadog } from '../services/analytics/datadog.js'
import { reportErrorToSentry } from './sentry.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
Expand Down Expand Up @@ -311,7 +312,7 @@ export const setupGracefulShutdown = memoize(() => {

// Log uncaught exceptions for container observability and analytics
// Error names (e.g., "TypeError") are not sensitive - safe to log
process.on('uncaughtException', error => {
process.on('uncaughtException', error => {
logForDiagnosticsNoPII('error', 'uncaught_exception', {
error_name: error.name,
error_message: error.message.slice(0, 2000),
Expand All @@ -320,6 +321,10 @@ export const setupGracefulShutdown = memoize(() => {
error_name:
error.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
// Optional Sentry reporting (env-driven, opt-in). No-op unless
// SENTRY_DSN is set; only sends the sanitized message for
// TelemetrySafeError instances, never this raw error.
reportErrorToSentry(error)
})

// Log unhandled promise rejections for container observability and analytics
Expand All @@ -343,6 +348,10 @@ export const setupGracefulShutdown = memoize(() => {
error_name:
errorName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
// Optional Sentry reporting (env-driven, opt-in). No-op unless
// SENTRY_DSN is set; only sends the sanitized message for
// TelemetrySafeError instances, never this raw rejection reason.
reportErrorToSentry(reason)
})
})

Expand Down
87 changes: 87 additions & 0 deletions src/utils/sentry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'

const ORIGINAL_SENTRY_DSN = process.env.SENTRY_DSN
const ORIGINAL_DISABLE_TELEMETRY = process.env.DISABLE_TELEMETRY

beforeEach(() => {
delete process.env.SENTRY_DSN
delete process.env.DISABLE_TELEMETRY
})

afterEach(() => {
if (ORIGINAL_SENTRY_DSN === undefined) {
delete process.env.SENTRY_DSN
} else {
process.env.SENTRY_DSN = ORIGINAL_SENTRY_DSN
}
if (ORIGINAL_DISABLE_TELEMETRY === undefined) {
delete process.env.DISABLE_TELEMETRY
} else {
process.env.DISABLE_TELEMETRY = ORIGINAL_DISABLE_TELEMETRY
}
mock.restore()
})

test('isSentryEnabled is false when SENTRY_DSN is unset', async () => {
const { isSentryEnabled } = await import('./sentry.js')
expect(isSentryEnabled()).toBe(false)
})

test('isSentryEnabled is true when SENTRY_DSN is set and telemetry is not disabled', async () => {
process.env.SENTRY_DSN = 'https://example@o0.ingest.sentry.io/0'
const { isSentryEnabled } = await import('./sentry.js')
expect(isSentryEnabled()).toBe(true)
})

test('isSentryEnabled is false when SENTRY_DSN is set but DISABLE_TELEMETRY is set', async () => {
process.env.SENTRY_DSN = 'https://example@o0.ingest.sentry.io/0'
process.env.DISABLE_TELEMETRY = '1'
const { isSentryEnabled } = await import('./sentry.js')
expect(isSentryEnabled()).toBe(false)
})

test('reportErrorToSentry does not throw when Sentry is disabled', async () => {
const { reportErrorToSentry } = await import('./sentry.js')
const { TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: TelemetrySafeError } =
await import('./errors.js')

expect(() =>
reportErrorToSentry(
new TelemetrySafeError('full message with /some/file/path', 'sanitized message'),
),
).not.toThrow()
})

test('reportErrorToSentry does not throw for a plain (non-sanitized) Error', async () => {
const { reportErrorToSentry } = await import('./sentry.js')

expect(() =>
reportErrorToSentry(new Error('raw error with /some/file/path')),
).not.toThrow()
})

test('reportErrorToSentry only reports TelemetrySafeError, never a raw Error message', async () => {
process.env.SENTRY_DSN = 'https://example@o0.ingest.sentry.io/0'

const captureMessage = mock(() => {})
mock.module('@sentry/node', () => ({
init: mock(() => {}),
captureMessage,
}))

const { initializeSentry, reportErrorToSentry } = await import('./sentry.js')
const { TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: TelemetrySafeError } =
await import('./errors.js')

await initializeSentry()

// A plain Error must never be reported — its message may contain file paths.
reportErrorToSentry(new Error('raw error with /some/file/path'))
expect(captureMessage).not.toHaveBeenCalled()

// A TelemetrySafeError reports only its sanitized telemetryMessage.
reportErrorToSentry(
new TelemetrySafeError('full message with /some/file/path', 'sanitized message'),
)
expect(captureMessage).toHaveBeenCalledWith('sanitized message', 'error')
})
72 changes: 72 additions & 0 deletions src/utils/sentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Optional, env-driven Sentry error reporting.
*
* Disabled by default. Enabled only when SENTRY_DSN is set AND telemetry
* is not disabled via DISABLE_TELEMETRY / CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC.
*
* Only TelemetrySafeError.telemetryMessage (never raw error.message) is sent,
* to avoid leaking file paths or other PII into Sentry.
Comment thread
anushkadas-coder marked this conversation as resolved.
*/
import { isTelemetryDisabled } from './privacyLevel.js'
import { TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as TelemetrySafeError } from './errors.js'

let sentryInitialized = false
let sentryModule: typeof import('@sentry/node') | null = null

export function isSentryEnabled(): boolean {
return Boolean(process.env.SENTRY_DSN) && !isTelemetryDisabled()
}

/**
* Lazily initializes Sentry. No-op if SENTRY_DSN is unset or telemetry is disabled.
* Safe to call multiple times; only initializes once. Async because @sentry/node
* is loaded via dynamic import — this bundle is ESM and does not define require().
*/
export async function initializeSentry(): Promise<void> {
if (sentryInitialized || !isSentryEnabled()) {
return
}
sentryInitialized = true

try {
// Dynamic import so @sentry/node is never loaded (or its startup cost
// paid) when the feature is off, and so it works under ESM where
// require() is not defined.
sentryModule = await import('@sentry/node')
sentryModule.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV ?? 'production',
tracesSampleRate: 0,
// Disable Sentry's automatic uncaughtException/unhandledRejection
// integrations. Those hooks report raw error content, bypassing the
// TelemetrySafeError sanitization in reportErrorToSentry(). Only
// explicit reportErrorToSentry() calls should ever send data.
defaultIntegrations: false,
})
} catch {
// Never let Sentry setup crash the CLI.
sentryModule = null
}
}

/**
* Reports an error to Sentry if enabled. Only sends the sanitized
* telemetryMessage for TelemetrySafeError instances. Errors that are not
* TelemetrySafeError are NOT reported, since their raw message may contain
* file paths or other PII — never send an implicit raw error message.
*/
export function reportErrorToSentry(error: unknown): void {
if (!sentryModule || !isSentryEnabled()) {
return
}

try {
if (error instanceof TelemetrySafeError) {
sentryModule.captureMessage(error.telemetryMessage, 'error')
}
// Non-TelemetrySafeError errors are intentionally not reported — their
// message has not been vetted as safe to send.
} catch {
// Reporting must never throw.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading