288 lines
8.5 KiB
TypeScript
288 lines
8.5 KiB
TypeScript
/**
|
|
* Sentry initialisation for the Electron **main** process.
|
|
*
|
|
* Must be imported at the very top of `src/main/index.ts` (and `standalone.ts`)
|
|
* so that Sentry captures errors from the earliest point possible.
|
|
*
|
|
* When `SENTRY_DSN` is not set (dev / self-builds), everything is a no-op.
|
|
*
|
|
* The @sentry/electron/main import is lazy so this module can be safely
|
|
* loaded in standalone (non-Electron) mode without crashing.
|
|
*/
|
|
|
|
import {
|
|
type AgentTeamsIdentitySource,
|
|
ensureAgentTeamsClientIdentity,
|
|
getSentryAnonymousUserId,
|
|
} from '@main/services/identity/AgentTeamsIdentityStore';
|
|
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
|
import {
|
|
filterSafeSentryIntegrations,
|
|
isValidDsn,
|
|
redactSentryEvent,
|
|
SENTRY_ENVIRONMENT,
|
|
SENTRY_RELEASE,
|
|
TRACES_SAMPLE_RATE,
|
|
} from '@shared/utils/sentryConfig';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Telemetry gate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CONFIG_FILENAME = 'agent-teams-config.json';
|
|
const LEGACY_CONFIG_FILENAMES = [
|
|
'claude-devtools-config.json',
|
|
'claude-code-context-config.json',
|
|
] as const;
|
|
|
|
export interface SentryTelemetryContext {
|
|
userId: string;
|
|
tags: Record<string, string>;
|
|
}
|
|
|
|
function readTelemetryFlagFromConfig(configPath: string): boolean | null {
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as unknown;
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
return null;
|
|
}
|
|
|
|
const general = (parsed as { general?: unknown }).general;
|
|
if (typeof general !== 'object' || general === null || Array.isArray(general)) {
|
|
return null;
|
|
}
|
|
|
|
const telemetryEnabled = (general as { telemetryEnabled?: unknown }).telemetryEnabled;
|
|
return typeof telemetryEnabled === 'boolean' ? telemetryEnabled : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function readPersistedTelemetryEnabled(basePath = getClaudeBasePath()): boolean {
|
|
const currentPath = path.join(basePath, CONFIG_FILENAME);
|
|
if (fs.existsSync(currentPath)) {
|
|
return readTelemetryFlagFromConfig(currentPath) ?? true;
|
|
}
|
|
|
|
const legacyPaths = LEGACY_CONFIG_FILENAMES.map((filename) => path.join(basePath, filename));
|
|
const readableLegacyPath =
|
|
legacyPaths.find((candidatePath) => readTelemetryFlagFromConfig(candidatePath) !== null) ??
|
|
legacyPaths.find((candidatePath) => fs.existsSync(candidatePath));
|
|
|
|
return readableLegacyPath ? (readTelemetryFlagFromConfig(readableLegacyPath) ?? true) : true;
|
|
}
|
|
|
|
// Module-level flag that `beforeSend` checks. Read persisted config before init
|
|
// so telemetry-disabled users do not start Sentry sessions on app startup.
|
|
let telemetryAllowed = readPersistedTelemetryEnabled();
|
|
let telemetryIdentitySyncToken = 0;
|
|
|
|
export function getSafeSentryTelemetryTags(
|
|
identitySource: AgentTeamsIdentitySource
|
|
): Record<string, string> {
|
|
return {
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
app_version: SENTRY_RELEASE ?? 'unknown',
|
|
identity_source: identitySource,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Call once ConfigManager is initialised to sync the opt-in flag.
|
|
* Also call whenever the config changes (e.g. user toggles telemetry in Settings).
|
|
*/
|
|
export function syncTelemetryFlag(enabled: boolean): void {
|
|
telemetryAllowed = enabled;
|
|
if (!enabled) {
|
|
telemetryIdentitySyncToken++;
|
|
shutdownSentry();
|
|
return;
|
|
}
|
|
|
|
initializeSentryIfAllowed();
|
|
void syncTelemetryIdentity();
|
|
}
|
|
|
|
export function filterSentryEventForTelemetry(event: unknown): unknown {
|
|
return telemetryAllowed ? redactSentryEvent(event) : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lazy Sentry import - safe in non-Electron environments
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface SentryMainApi {
|
|
init?: (options: SentryInitOptions) => void;
|
|
setUser?: (user: { id: string } | null) => void;
|
|
setTags?: (tags: Record<string, string>) => void;
|
|
close?: (timeout?: number) => PromiseLike<boolean> | boolean;
|
|
addBreadcrumb?: (breadcrumb: {
|
|
category: string;
|
|
message: string;
|
|
data?: Record<string, unknown>;
|
|
level: 'info';
|
|
}) => void;
|
|
startSpan?: <T>(context: { name: string; op: string }, callback: () => T) => T;
|
|
}
|
|
|
|
interface SentryInitOptions {
|
|
dsn: string;
|
|
release: string | undefined;
|
|
environment: string;
|
|
tracesSampleRate: number;
|
|
sendDefaultPii: false;
|
|
beforeSend: (event: unknown) => unknown;
|
|
beforeSendTransaction: (event: unknown) => unknown;
|
|
integrations: <TIntegration extends { name?: string }>(
|
|
integrations: TIntegration[]
|
|
) => TIntegration[];
|
|
}
|
|
|
|
let Sentry: SentryMainApi | null = null;
|
|
let initialized = false;
|
|
|
|
export function setMainSentryApiForTesting(sentryApi: SentryMainApi): void {
|
|
if (process.env.NODE_ENV !== 'test') return;
|
|
Sentry = sentryApi;
|
|
initialized = true;
|
|
}
|
|
|
|
function clearSentryUser(): void {
|
|
if (!initialized || !Sentry) return;
|
|
Sentry.setUser?.(null);
|
|
}
|
|
|
|
function shutdownSentry(): void {
|
|
const sentry = Sentry;
|
|
if (initialized && sentry) {
|
|
sentry.setUser?.(null);
|
|
try {
|
|
void Promise.resolve(sentry.close?.(2000)).catch(() => undefined);
|
|
} catch {
|
|
// Best effort only. The telemetry gate still blocks later events.
|
|
}
|
|
}
|
|
|
|
initialized = false;
|
|
Sentry = null;
|
|
}
|
|
|
|
export async function getCurrentSentryTelemetryContext(): Promise<SentryTelemetryContext | null> {
|
|
if (!telemetryAllowed) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const identity = await ensureAgentTeamsClientIdentity();
|
|
if (!telemetryAllowed) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
userId: getSentryAnonymousUserId(identity.clientId),
|
|
tags: getSafeSentryTelemetryTags(identity.source),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function syncTelemetryIdentity(): Promise<void> {
|
|
const syncToken = ++telemetryIdentitySyncToken;
|
|
if (!initialized || !Sentry) {
|
|
return;
|
|
}
|
|
|
|
if (!telemetryAllowed) {
|
|
clearSentryUser();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const context = await getCurrentSentryTelemetryContext();
|
|
if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) {
|
|
return;
|
|
}
|
|
|
|
if (!context) {
|
|
clearSentryUser();
|
|
return;
|
|
}
|
|
|
|
Sentry.setUser?.({ id: context.userId });
|
|
Sentry.setTags?.(context.tags);
|
|
} catch {
|
|
if (syncToken === telemetryIdentitySyncToken) {
|
|
clearSentryUser();
|
|
}
|
|
}
|
|
}
|
|
|
|
function initializeSentryIfAllowed(): void {
|
|
if (initialized || !telemetryAllowed) {
|
|
return;
|
|
}
|
|
|
|
const dsn = process.env.SENTRY_DSN;
|
|
if (!isValidDsn(dsn)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Dynamic import would be cleaner but top-level await is not available
|
|
// in all contexts. require() is synchronous and works in both Electron
|
|
// and Node.js - it simply throws in standalone mode where the electron
|
|
// module is not resolvable.
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy optional Electron runtime dependency.
|
|
Sentry = require('@sentry/electron/main') as SentryMainApi;
|
|
Sentry.init?.({
|
|
dsn,
|
|
release: SENTRY_RELEASE,
|
|
environment: SENTRY_ENVIRONMENT,
|
|
tracesSampleRate: TRACES_SAMPLE_RATE,
|
|
sendDefaultPii: false,
|
|
|
|
beforeSend: filterSentryEventForTelemetry,
|
|
beforeSendTransaction: filterSentryEventForTelemetry,
|
|
integrations: filterSafeSentryIntegrations,
|
|
});
|
|
initialized = true;
|
|
void syncTelemetryIdentity();
|
|
} catch {
|
|
Sentry = null;
|
|
initialized = false;
|
|
// @sentry/electron/main requires Electron runtime - not available in
|
|
// standalone (pure Node.js) mode. All exported helpers are no-ops when
|
|
// initialized is false, so this is safe to swallow.
|
|
}
|
|
}
|
|
|
|
initializeSentryIfAllowed();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public helpers (no-op when Sentry is not configured)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Record a breadcrumb visible in subsequent error events. */
|
|
export function addMainBreadcrumb(
|
|
category: string,
|
|
message: string,
|
|
_data?: Record<string, unknown>
|
|
): void {
|
|
if (!initialized) return;
|
|
Sentry?.addBreadcrumb?.({ category, message, level: 'info' });
|
|
}
|
|
|
|
/**
|
|
* Wrap a synchronous or async function in a Sentry performance span.
|
|
* Returns the function's return value transparently.
|
|
*/
|
|
export function startMainSpan<T>(name: string, op: string, fn: () => T): T {
|
|
if (!initialized) return fn();
|
|
if (!Sentry?.startSpan) return fn();
|
|
return Sentry.startSpan({ name, op }, fn);
|
|
}
|