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
34 changes: 34 additions & 0 deletions src/components/dashboard/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ALERT_RULE_TYPE, ALERT_CHANNEL } from "../../lib/alerts";
import PluginRegistryView from "./PluginRegistryView";
import DataExport from "./DataExport";
import LanguageSettings from "./LanguageSettings";
import { revokeSentryConsent } from "../../utils/monitoring";

const SESSION_API_KEY = 'stellar_custom_api_key';

Expand Down Expand Up @@ -404,6 +405,39 @@ export default function Settings() {
</button>
</div>

<div style={styles.section}>
<p style={styles.sectionTitle}>Privacy & Diagnostics</p>
<div style={styles.card}>
<div style={styles.row}>
<div>
<p style={styles.label}>Allow diagnostic data collection</p>
<p style={styles.description}>
Share crash reports and performance data to help improve Stellar Dev Dashboard.
</p>
<p style={{ ...styles.description, marginTop: "4px", fontSize: "11px", color: "var(--text-muted)" }}>
<strong>Data Retention Policy:</strong> Diagnostic data is anonymized and retained for a maximum of 30 days. It is used exclusively to improve application reliability.
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={!!preferences.diagnosticsConsent}
onChange={(e) => {
const consent = e.target.checked;
setPreference('diagnosticsConsent', consent);
if (!consent) {
revokeSentryConsent();
}
}}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
aria-label="Toggle diagnostics consent"
/>
<span style={{ fontSize: '13px', fontWeight: 600 }}>{preferences.diagnosticsConsent ? 'Enabled' : 'Disabled'}</span>
</div>
</div>
</div>
</div>

<div style={styles.section}>
<p style={styles.sectionTitle}>Extensions</p>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '10px' }}>
Expand Down
74 changes: 74 additions & 0 deletions src/utils/__tests__/monitoring.consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as Sentry from '@sentry/react';
import * as preferences from '../preferences';

vi.mock('@sentry/react', () => ({
init: vi.fn(),
getClient: vi.fn(() => ({
close: vi.fn(),
})),
browserTracingIntegration: vi.fn(),
replayIntegration: vi.fn(),
breadcrumbsIntegration: vi.fn(),
withScope: vi.fn(),
captureException: vi.fn(),
setUser: vi.fn(),
startSpan: vi.fn(),
ErrorBoundary: vi.fn(),
}));

vi.mock('../preferences', () => ({
loadPreferences: vi.fn(),
}));

vi.mock('../logger', () => ({
createLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));

describe('monitoring Sentry consent', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('initializes Sentry when diagnosticsConsent is true', async () => {
vi.mocked(preferences.loadPreferences).mockReturnValue({ diagnosticsConsent: true });

const monitoring = await import('../monitoring');
monitoring.initMonitoring({ sentryDsn: 'http://test-dsn@sentry.io/1' });

expect(Sentry.init).toHaveBeenCalled();
const initArgs = vi.mocked(Sentry.init).mock.calls[0][0];
expect(initArgs?.dsn).toBe('http://test-dsn@sentry.io/1');
});

it('does not initialize Sentry when diagnosticsConsent is false (defaults to no consent)', async () => {
vi.mocked(preferences.loadPreferences).mockReturnValue({ diagnosticsConsent: false });

const monitoring = await import('../monitoring');
monitoring.initMonitoring({ sentryDsn: 'http://test-dsn@sentry.io/1' });

expect(Sentry.init).not.toHaveBeenCalled();
});

it('closes Sentry client when consent is revoked', async () => {
vi.mocked(preferences.loadPreferences).mockReturnValue({ diagnosticsConsent: true });

const monitoring = await import('../monitoring');
monitoring.revokeSentryConsent();

const getClient = vi.mocked(Sentry.getClient);
expect(getClient).toHaveBeenCalled();

const client = getClient();
expect(client?.close).toHaveBeenCalledWith(2000);
});
});
17 changes: 17 additions & 0 deletions src/utils/monitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from '../lib/errorReporting';
import { initPerformanceMonitoring } from '../lib/performance';
import { createLogger } from './logger';
import { loadPreferences } from './preferences';

export {
collectHealthSnapshot,
Expand Down Expand Up @@ -65,6 +66,12 @@ let _initialised = false;
// ─── Sentry init ──────────────────────────────────────────────────────────────

function initialiseSentry(cfg: MonitoringConfig): void {
const prefs = loadPreferences();
if (prefs.diagnosticsConsent !== true) {
logger.info('Sentry initialization skipped: user has not granted diagnostics consent.');
return;
}

if (!cfg.sentryDsn) {
logger.warn('Sentry DSN not set – error tracking disabled.', { env: cfg.environment });
return;
Expand Down Expand Up @@ -261,6 +268,15 @@ export function initMonitoring(userConfig: Partial<MonitoringConfig> = {}): void

logger.info('Monitoring stack initialised', { env: cfg.environment });
}

export function revokeSentryConsent(): void {
logger.info('User revoked diagnostics consent, closing Sentry client.');
const client = Sentry.getClient();
if (client) {
client.close(2000); // 2 second flush then close
}
}

// ─── Sentry user context helpers ─────────────────────────────────────────────

/**
Expand Down Expand Up @@ -402,4 +418,5 @@ export default {
withSpan,
captureError,
SentryErrorBoundary,
revokeSentryConsent,
};
1 change: 1 addition & 0 deletions src/utils/preferences.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const DEFAULT_PREFERENCES = {
showAdvancedPanels: true,
autoRefreshDashboard: true,
defaultSearchScope: "all",
diagnosticsConsent: false,
};

export function loadPreferences() {
Expand Down