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
31 changes: 31 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Release

on:
workflow_dispatch:

jobs:
mac:
runs-on: macos-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.29.2
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm check
- run: pnpm build:release --publish always
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ pnpm build

API keys for embedding providers can be configured in **Settings** (Cmd+,). Keys are stored encrypted at rest.

Error reporting is enabled by default and can be disabled from **Settings > Privacy**. See `docs/TELEMETRY.md` for release configuration and privacy rules.

### Feedback Collection

The in-app feedback form posts directly to Forminit form `742yqqcnm5o`. To
Expand Down
91 changes: 91 additions & 0 deletions docs/TELEMETRY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Telemetry

Chroma Explorer keeps product analytics and error reporting separate.

## Product Analytics

`electron/analytics.ts` owns Aptabase event tracking. Analytics is disabled unless
`APTABASE_APP_KEY` is provided at build time.

Only coarse product events should be tracked. Do not include user document
contents, collection data, embeddings, metadata values, connection URLs, API
keys, auth tokens, or provider credentials in event names or properties.

## Error Reporting

`electron/error-monitoring.ts` owns Sentry initialization for the Electron main
process. `src/error-monitoring.ts` owns renderer initialization.

Error reporting is enabled by default and can be disabled from
**Settings > Privacy**. The setting is stored as `errorReportingEnabled` in
`electron/settings-store.ts` and exposed through the canonical IPC contract in
`electron/ipc-contract.ts`.

Release builds must provide a public DSN at build time:

```bash
SENTRY_DSN=https://public-key@o0.ingest.sentry.io/project-id pnpm build:release
```

`vite.config.js` embeds `SENTRY_DSN` for the main process and renderer bridge.
Renderer events are forwarded through the Electron SDK preload integration once
the renderer initializes.

Sentry releases use `chroma-explorer@<package version>` by default. Set
`SENTRY_RELEASE` only when the Sentry upload release and runtime
`Sentry.init({ release })` value need to be overridden together.

Readable production stack traces require source map upload. Release builds
generate hidden source maps when a DSN is present. They upload maps only when all
of these build-time values are present:

```bash
SENTRY_AUTH_TOKEN=sntrys_...
SENTRY_ORG=your-org-slug
SENTRY_PROJECT=your-project-slug
```

The manual GitHub Actions release workflow (`.github/workflows/release.yml`)
passes those secrets into `pnpm build:release --publish always`. Configure the
same repository secrets there, along with the existing Apple notarization
secrets, before publishing production artifacts.

If you do not have source-map upload credentials yet, run Sentry's wizard and use
the auth token it configures:

```bash
npx @sentry/wizard@latest -i sourcemaps --saas --org arsent --project electron
```

## Production Validation

Before shipping a release:

1. Run the manual `Release` workflow from GitHub Actions.
2. Install the produced app artifact.
3. Confirm **Settings > Privacy > Error reporting** is enabled.
4. Trigger a known renderer and main-process failure in a test build.
5. Confirm Sentry receives events under the expected
`chroma-explorer@<package version>` release with readable stack traces.

## Privacy Rules

Sentry events are scrubbed before sending:

- URL-like values are replaced with `[redacted-url]`.
- Long token-like strings are replaced with `[redacted-token]`.
- Keys containing API key, token, secret, password, credential, authorization,
document, metadata, embedding, query, or URL are replaced with `[redacted]`.
- Event `request` and `user` fields are removed.

Do not add manual Sentry context that contains:

- document text;
- metadata values;
- embeddings;
- collection names when they may reveal user data;
- connection URLs;
- auth headers, tokens, credentials, or API keys.

Prefer tags with stable categories such as window type, operation category,
platform, packaged status, and app version.
141 changes: 141 additions & 0 deletions electron/error-monitoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { app } from 'electron'
import * as Sentry from '@sentry/electron/main'

const SENSITIVE_KEY_PATTERN = /(api[-_]?key|token|secret|password|credential|authorization|document|metadata|embedding|query|url)/i
const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi
const LONG_TOKEN_PATTERN = /\b[A-Za-z0-9_-]{32,}\b/g
const MAX_STRING_LENGTH = 500
const MAX_DEBUG_STRING_LENGTH = 10_000
const MAX_SANITIZE_DEPTH = 12
const DEBUG_STRING_KEY_PATTERN = /^(message|stack|componentStack|exception|value|type)$/i

let initialized = false

function redactString(value: string, maxLength = MAX_STRING_LENGTH): string {
const redacted = value
.replace(URL_PATTERN, '[redacted-url]')
.replace(LONG_TOKEN_PATTERN, '[redacted-token]')

return redacted.length > maxLength ? redacted.slice(0, maxLength) : redacted
}

function sanitizeValue(
value: unknown,
key = '',
depth = 0,
seen = new WeakSet<object>()
): unknown {
if (depth >= MAX_SANITIZE_DEPTH) {
return '[max-depth-exceeded]'
}

if (SENSITIVE_KEY_PATTERN.test(key)) {
return '[redacted]'
}

if (typeof value === 'string') {
const maxLength = DEBUG_STRING_KEY_PATTERN.test(key) ? MAX_DEBUG_STRING_LENGTH : MAX_STRING_LENGTH
return redactString(value, maxLength)
}

if (Array.isArray(value)) {
if (seen.has(value)) {
return '[circular]'
}
seen.add(value)
return value.slice(0, 10).map((item) => sanitizeValue(item, '', depth + 1, seen))
}

if (value && typeof value === 'object') {
if (seen.has(value)) {
return '[circular]'
}
seen.add(value)
const entries = Object.entries(value as Record<string, unknown>).slice(0, 30)
return Object.fromEntries(entries.map(([entryKey, entryValue]) => [
entryKey,
sanitizeValue(entryValue, entryKey, depth + 1, seen),
]))
}

return value
}

function getSentryDsn(): string {
return process.env.SENTRY_DSN || process.env.VITE_SENTRY_DSN || ''
}

function getSentryRelease(): string {
return process.env.SENTRY_RELEASE || `chroma-explorer@${app.getVersion()}`
}

export function initErrorMonitoring(enabled: boolean): void {
if (initialized || Sentry.isInitialized()) {
initialized = true
return
}

const dsn = getSentryDsn()
if (!enabled || !dsn) {
if (!dsn) {
console.log('[ErrorMonitoring] SENTRY_DSN not configured, error reporting disabled')
}
return
}

Sentry.init({
dsn,
environment: app.isPackaged ? 'production' : 'development',
release: getSentryRelease(),
sendDefaultPii: false,
tracesSampleRate: 0,
beforeBreadcrumb(breadcrumb) {
return sanitizeValue(breadcrumb) as Sentry.Breadcrumb
},
beforeSend(event) {
const sanitized = sanitizeValue(event) as Sentry.ErrorEvent
delete sanitized.request
delete sanitized.user
return sanitized
},
})

Sentry.setTags({
packaged: String(app.isPackaged),
platform: process.platform,
arch: process.arch,
electron: process.versions.electron || 'unknown',
})

initialized = true
console.log('[ErrorMonitoring] Initialized successfully')
}

export function setErrorMonitoringEnabled(enabled: boolean): void {
if (enabled) {
initErrorMonitoring(true)
return
}

if (initialized || Sentry.isInitialized()) {
void Sentry.close(2000).finally(() => {
initialized = false
console.log('[ErrorMonitoring] Disabled')
})
}
}

export function captureMainError(error: unknown, context?: Record<string, string | number | boolean>): void {
if (!initialized && !Sentry.isInitialized()) {
return
}

Sentry.withScope((scope) => {
if (context) {
scope.setTags(Object.fromEntries(
Object.entries(context).map(([key, value]) => [key, String(value)])
))
}
Sentry.captureException(error)
})
}
8 changes: 8 additions & 0 deletions electron/ipc-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ export interface ElectronAPI {
setApiKeys: (apiKeys: Record<string, string>) => Promise<void>
getTheme: () => Promise<'light' | 'dark' | 'system'>
setTheme: (theme: 'light' | 'dark' | 'system') => Promise<void>
getErrorReportingEnabled: () => Promise<boolean>
setErrorReportingEnabled: (enabled: boolean) => Promise<void>
onErrorReportingChange: (callback: (enabled: boolean) => void) => () => void
onThemeChange: (callback: (theme: string) => void) => () => void
openWindow: () => Promise<void>
onSwitchTab: (callback: (tab: string) => void) => () => void
Expand Down Expand Up @@ -483,6 +486,11 @@ export function parseTheme(value: unknown): 'light' | 'dark' | 'system' {
return parseEnum(value, 'theme', ['light', 'dark', 'system'])
}

export function parseErrorReportingEnabled(value: unknown): boolean {
if (typeof value !== 'boolean') throw new Error('errorReportingEnabled must be a boolean')
return value
}

export function validateExternalUrl(value: unknown): string {
const url = parseString(value, 'url')
const parsed = new URL(url)
Expand Down
Loading
Loading