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
13 changes: 11 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ ATLASCODE_FX3_TIMEOUT="2000"
#

# Syntax: comma separated settings in the format <name>=<boolean>
ATLASCODE_FF_OVERRIDES="feature_flag_1=true, feature_flag_2=false"
ATLASCODE_FF_OVERRIDES="atlascode-sentry-logging=true, feature_flag_2=false"

# This is for experiments that controls a boolean value.
# Syntax: comma separated settings in the format <name>=<boolean>
Expand All @@ -20,4 +20,13 @@ ATLASCODE_EXP_OVERRIDES_BOOL="exp1_name=true, exp2_name=false"
# This is for experiments that controls a string value.
# Syntax: comma separated settings in the format <name>=<string>
# NOTE: the string is unquoted, and a comma within the string is not supported.
ATLASCODE_EXP_OVERRIDES_STRING="exp3_name=something, exp4_name=something else"
ATLASCODE_EXP_OVERRIDES_STRING="exp3_name=something, exp4_name=something else"

#
# Sentry Error Tracking Configuration (optional)
# Only set these values if you want to enable Sentry error tracking
#
SENTRY_ENABLED=false
SENTRY_DSN=https://example.com
SENTRY_ENVIRONMENT=production
SENTRY_SAMPLE_RATE=1.0
24 changes: 24 additions & 0 deletions __mocks__/@sentry/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Mock for @sentry/node
export const init = jest.fn();
export const captureException = jest.fn();
export const setTag = jest.fn();
export const setExtra = jest.fn();
export const addBreadcrumb = jest.fn();
export const configureScope = jest.fn((callback) =>
callback({
setTag: jest.fn(),
setExtra: jest.fn(),
addBreadcrumb: jest.fn(),
}),
);
export const Integrations = {
Http: jest.fn(),
Console: jest.fn(),
// Add other integrations if needed
};
export const Handlers = {
requestHandler: jest.fn(),
tracingHandler: jest.fn(),
errorHandler: jest.fn(),
};
export const close = jest.fn();
1,179 changes: 1,104 additions & 75 deletions package-lock.json

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 @@ -1676,6 +1676,7 @@
"@mui/styles": "^5.18.0",
"@mui/utils": "^5.17.1",
"@segment/analytics-node": "^2.1.3",
"@sentry/node": "^9.40.0",
"@speed-highlight/core": "^1.2.7",
"@vscode/codicons": "^0.0.40",
"@vscode/webview-ui-toolkit": "^1.4.0",
Expand Down
4 changes: 4 additions & 0 deletions src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,10 @@ export async function issueSuggestionFailedEvent(error: string): Promise<TrackEv
return trackEvent('failed', 'issueSuggestion', { attributes: { error } });
}

export async function sentryCapturedExceptionFailedEvent(error: string): Promise<TrackEvent> {
return trackEvent('failed', 'captureException', { attributes: { error } });
}

export async function issueSuggestionSettingsChangeEvent(settings: IssueSuggestionSettings): Promise<TrackEvent> {
return trackEvent('changed', 'issueSuggestionSettings', { attributes: { ...settings } });
}
Expand Down
30 changes: 19 additions & 11 deletions src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ import { RovoDevLanguageServerProvider } from './rovo-dev/rovoDevLanguageServerP
import { RovoDevProcessManager } from './rovo-dev/rovoDevProcessManager';
import { RovoDevWebviewProvider } from './rovo-dev/rovoDevWebviewProvider';
import { RovoDevLogger } from './rovo-dev/util/rovoDevLogger';
import { SentryConfig, SentryService } from './sentry';
import { SiteManager } from './siteManager';
import { AtlascodeUriHandler, SETTINGS_URL } from './uriHandler';
import { Experiments, FeatureFlagClient, FeatureFlagClientInitError, Features } from './util/featureFlags';
import { isDebugging } from './util/isDebugging';
import { RovoDevEntitlementChecker } from './util/rovo-dev-entitlement/rovoDevEntitlementChecker';
import { AuthStatusBar } from './views/authStatusBar';
import { HelpExplorer } from './views/HelpExplorer';
Expand Down Expand Up @@ -75,7 +77,6 @@ import { CreateIssueWebview } from './webviews/createIssueWebview';
import { JiraIssueViewManager } from './webviews/jiraIssueViewManager';
import { CreateWorkItemWebviewProvider } from './work-items/create-work-item/createWorkItemWebviewProvider';

const isDebuggingRegex = /^--(debug|inspect)\b(-brk\b|(?!-))=?/;
const ConfigTargetKey = 'configurationTarget';

export class Container {
Expand Down Expand Up @@ -261,6 +262,22 @@ export class Container {
this._onboardingProvider = new OnboardingProvider();

this.refreshRovoDev(context);

// Initialize Sentry for error tracking

const sentryConfig: SentryConfig = {
enabled: process.env.SENTRY_ENABLED === 'true',
featureFlagEnabled: this.featureFlagClient.checkGate(Features.SentryLogging),
dsn: process.env.SENTRY_DSN,
environment: process.env.SENTRY_ENVIRONMENT || 'development',
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE || '1.0'),
atlasCodeVersion: version,
};

await SentryService.getInstance().initialize(sentryConfig, (error: string) => {
this.analyticsApi.fireSentryCapturedExceptionFailedEvent({ error });
});
Logger.info('Sentry initialized successfully');
}

private static async initializeFeatureFlagClient() {
Expand Down Expand Up @@ -491,17 +508,8 @@ export class Container {
return env.uiKind === UIKind.Web;
}

private static _isDebugging: boolean | undefined;
public static get isDebugging() {
if (this._isDebugging === undefined) {
try {
const args = process.execArgv;

this._isDebugging = args ? args.some((arg) => isDebuggingRegex.test(arg)) : false;
} catch {}
}

return !!this._isDebugging;
return isDebugging();
}

public static get isBoysenberryMode() {
Expand Down
9 changes: 9 additions & 0 deletions src/errorReporting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ jest.mock('./analytics', () => ({
errorEvent: () => Promise.resolve({ userId: 'id', anonymousId: 'anonId' }),
}));

jest.mock('@sentry/node');
jest.mock('./container', () => ({
Container: {
analyticsApi: {
fireSentryCapturedExceptionFailedEvent: jest.fn(),
},
},
}));

describe('errorReporting', () => {
beforeEach(() => {
jest.spyOn(Logger, 'onError').mockImplementation(jest.fn());
Expand Down
124 changes: 116 additions & 8 deletions src/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { ConfigurationChangeEvent, Disposable, ExtensionContext, LogOutputChanne

import { configuration, OutputLevel } from './config/configuration';
import { extensionOutputChannelName } from './constants';
import { Container } from './container';
import { ErrorEvent, Logger } from './logger';
import { isDebugging } from './util/isDebugging';

// Mock configuration
jest.mock('./config/configuration', () => {
Expand All @@ -26,15 +26,20 @@ jest.mock('./config/configuration', () => {
};
});

// Mock Container
jest.mock('./container', () => ({
Container: {
isDebugging: false,
// Mock isDebugging
jest.mock('./util/isDebugging', () => ({
isDebugging: jest.fn().mockReturnValue(false),
}));

// Mock SentryService
jest.mock('./sentry', () => ({
SentryService: {
getInstance: jest.fn(),
},
}));

const mockContainerIsDebugging = () => {
(Container.isDebugging as any) = true;
(isDebugging as jest.Mock).mockReturnValue(true);
};

const deleteLoggerInstance = () => {
Expand All @@ -50,6 +55,13 @@ describe('Logger', () => {
// Reset mocks
jest.clearAllMocks();

// Setup default SentryService mock to avoid crashes in other tests
const { SentryService } = require('./sentry');
(SentryService.getInstance as jest.Mock).mockReturnValue({
isInitialized: jest.fn().mockReturnValue(false),
captureException: jest.fn(),
});

mockOutputChannel = expansionCastTo<LogOutputChannel>({
dispose: jest.fn(),
append: jest.fn(),
Expand All @@ -71,8 +83,8 @@ describe('Logger', () => {

afterEach(() => {
deleteLoggerInstance();
// Reset Container debugging state
(Container.isDebugging as any) = false;
// Reset debugging state
(isDebugging as jest.Mock).mockReturnValue(false);
});

describe('configure', () => {
Expand Down Expand Up @@ -323,4 +335,100 @@ describe('Logger', () => {
}
});
});

describe('Sentry integration', () => {
let mockSentryService: any;

beforeEach(() => {
// Set up Logger with Error level
(configuration.initializing as jest.Mock).mockReturnValue(true);
(configuration.get as jest.Mock).mockReturnValue(OutputLevel.Errors);
Logger.configure(expansionCastTo<ExtensionContext>({ subscriptions: [] }));

// Setup the mocked Sentry service to be returned by getInstance
mockSentryService = {
isInitialized: jest.fn(),
captureException: jest.fn(),
};
const { SentryService } = require('./sentry');
(SentryService.getInstance as jest.Mock).mockReturnValue(mockSentryService);
mockSentryService.isInitialized.mockReturnValue(true);
});

it('should not capture exception to Sentry when not initialized', () => {
mockSentryService.isInitialized.mockReturnValue(false);

const testError = new Error('test error');
Logger.error(testError, 'Error message');

expect(mockSentryService.captureException).not.toHaveBeenCalled();
});

it('should include capturedBy in Sentry tags', () => {
mockSentryService.isInitialized.mockReturnValue(true);

const testError = new Error('test error');
Logger.error(testError, 'Error message');

expect(mockSentryService.captureException).toHaveBeenCalledWith(
testError,
expect.objectContaining({
tags: expect.objectContaining({
capturedBy: expect.any(String),
}),
}),
);
});

it('should include params in Sentry extra context', () => {
mockSentryService.isInitialized.mockReturnValue(true);

const testError = new Error('test error');
Logger.error(testError, 'Error message', 'param1', 'param2');

expect(mockSentryService.captureException).toHaveBeenCalledWith(
testError,
expect.objectContaining({
extra: expect.objectContaining({
params: ['param1', 'param2'],
}),
}),
);
});

it('should handle Sentry errors gracefully and continue logging', () => {
mockSentryService.isInitialized.mockReturnValue(true);
mockSentryService.captureException.mockImplementation(() => {
throw new Error('Sentry failed');
});

const testError = new Error('test error');

// Should not throw
expect(() => {
Logger.error(testError, 'Error message');
}).not.toThrow();

// Should still log to output channel
expect(mockOutputChannel.appendLine).toHaveBeenCalled();
});

it('should call Sentry before logging to output channel', () => {
mockSentryService.isInitialized.mockReturnValue(true);
const callOrder: string[] = [];

mockSentryService.captureException.mockImplementation(() => {
callOrder.push('sentry');
});

(mockOutputChannel.appendLine as jest.Mock).mockImplementation(() => {
callOrder.push('output');
});

const testError = new Error('test error');
Logger.error(testError, 'Error message');

expect(callOrder).toEqual(['sentry', 'output']);
});
});
});
29 changes: 19 additions & 10 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,8 @@ import { EventEmitter } from 'vscode';
import { ErrorProductArea } from './analyticsTypes';
import { configuration, OutputLevel } from './config/configuration';
import { extensionOutputChannelName } from './constants';

// Let's prevent a circular dependency with Container and the other modules that it imports
function isDebugging(): boolean {
try {
const { Container } = require('./container');
return Container.isDebugging;
} catch {
return false;
}
}
import { SentryService } from './sentry';
import { isDebugging } from './util/isDebugging';

function getConsolePrefix(productArea?: string) {
return productArea ? `[${extensionOutputChannelName} ${productArea}]` : `[${extensionOutputChannelName}]`;
Expand Down Expand Up @@ -156,6 +148,23 @@ export class Logger {
): void {
Logger._onError.fire({ error: ex, errorMessage, capturedBy, params, productArea });

if (SentryService.getInstance().isInitialized()) {
try {
SentryService.getInstance().captureException(ex, {
tags: {
productArea: productArea || 'unknown',
capturedBy: capturedBy || 'unknown',
},
extra: {
errorMessage,
params,
},
});
} catch (err) {
console.error('Error reporting to Sentry:', err);
}
}

if (this.level === OutputLevel.Silent) {
return;
}
Expand Down
Loading
Loading