From 04da5bece9e8de9c6f4a483212870ce8c25113a5 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Wed, 14 Jan 2026 17:24:48 -0500 Subject: [PATCH] feat: Add support for multiple issue types with N+1 performance issues Extend the error generator to support multiple Sentry issue types beyond errors. Adds an issue type selector with Error and Performance options (user feedback and dead clicks shown as coming soon). The Performance issue type generates N+1 API call detection by making parallel GET requests to a target endpoint and sending the transaction envelope directly to Sentry's API. This allows testing performance issue detection in any Sentry project via DSN. New files: - Issue type definitions and configs - Performance target endpoint with configurable delay - Issue type selector and performance fields components Config storage includes backwards-compatible migration for existing saved configurations. Co-Authored-By: Claude --- app/api/performance-target/route.ts | 18 ++ app/components/ErrorGenerator.tsx | 238 ++++++++++++++++++---- app/components/ErrorPreview.tsx | 36 ++-- app/components/form/IssueTypeSelector.tsx | 41 ++++ app/components/form/PerformanceFields.tsx | 65 ++++++ app/hooks/useConfigStorage.ts | 47 ++++- app/hooks/useErrorForm.ts | 70 +++++-- app/types/issueTypes.ts | 47 +++++ 8 files changed, 489 insertions(+), 73 deletions(-) create mode 100644 app/api/performance-target/route.ts create mode 100644 app/components/form/IssueTypeSelector.tsx create mode 100644 app/components/form/PerformanceFields.tsx create mode 100644 app/types/issueTypes.ts diff --git a/app/api/performance-target/route.ts b/app/api/performance-target/route.ts new file mode 100644 index 0000000..4c8e0df --- /dev/null +++ b/app/api/performance-target/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'edge'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const delay = parseInt(searchParams.get('delay') || '50', 10); + const id = searchParams.get('id') || '0'; + + const clampedDelay = Math.min(Math.max(delay, 0), 5000); + await new Promise((resolve) => setTimeout(resolve, clampedDelay)); + + return NextResponse.json({ + id, + timestamp: Date.now(), + delay: clampedDelay, + }); +} diff --git a/app/components/ErrorGenerator.tsx b/app/components/ErrorGenerator.tsx index 0777a74..9ff1c97 100644 --- a/app/components/ErrorGenerator.tsx +++ b/app/components/ErrorGenerator.tsx @@ -12,9 +12,12 @@ import { FormFields } from '@/app/components/form/FormFields'; import { TagInput } from '@/app/components/form/TagInput'; import { BatchModePanel } from '@/app/components/form/BatchModePanel'; import { SkipConfirm } from '@/app/components/form/SkipConfirm'; +import { IssueTypeSelector } from '@/app/components/form/IssueTypeSelector'; +import { PerformanceFields } from '@/app/components/form/PerformanceFields'; import { useErrorForm } from '@/app/hooks/useErrorForm'; import { useBatchMode } from '@/app/hooks/useBatchMode'; import { useConfigStorage, ConfigData } from '@/app/hooks/useConfigStorage'; +import { IssueType } from '@/app/types/issueTypes'; const ErrorGenerator = () => { const [toasts, setToasts] = useState([]); @@ -36,6 +39,7 @@ const ErrorGenerator = () => { if (configStorage.mounted && !initialLoadDone) { const config = configStorage.currentConfig; loadFormConfig({ + issueType: config.issueType, dsn: config.dsn, message: config.message, priority: config.priority, @@ -43,6 +47,7 @@ const ErrorGenerator = () => { errorCount: config.errorCount, errorsToGenerate: config.errorsToGenerate, fingerprintID: config.fingerprintID, + performance: config.performance, }); loadBatchConfig(config.batch); // Set this after state updates are queued, so auto-save waits for next render @@ -77,6 +82,7 @@ const ErrorGenerator = () => { } const newConfig: ConfigData = { + issueType: form.issueType, dsn: form.dsn, message: form.message, priority: form.priority, @@ -89,12 +95,14 @@ const ErrorGenerator = () => { frequency: batch.frequency, repeatCount: batch.repeatCount, }, + performance: form.performance, }; updateCurrentConfig(newConfig); }, [ configStorage.mounted, initialLoadDone, updateCurrentConfig, + form.issueType, form.dsn, form.message, form.priority, @@ -102,6 +110,7 @@ const ErrorGenerator = () => { form.errorCount, form.errorsToGenerate, form.fingerprintID, + form.performance, batch.enabled, batch.frequency, batch.repeatCount, @@ -118,6 +127,7 @@ const ErrorGenerator = () => { skipNextAutoSave.current = true; configStorage.loadConfig(name); form.loadConfig({ + issueType: config.issueType, dsn: config.dsn, message: config.message, priority: config.priority, @@ -125,6 +135,7 @@ const ErrorGenerator = () => { errorCount: config.errorCount, errorsToGenerate: config.errorsToGenerate, fingerprintID: config.fingerprintID, + performance: config.performance, }); batch.loadConfig(config.batch); showToast('Loaded', `Config "${name}" loaded`, 'success'); @@ -136,7 +147,7 @@ const ErrorGenerator = () => { showToast('Deleted', `Config "${name}" deleted`, 'warning'); }; - const sendBatch = async () => { + const sendErrorBatch = async () => { const response = await fetch('/api/generate-errors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -147,40 +158,158 @@ const ErrorGenerator = () => { return data; }; - const generateErrors = async () => { - if (!form.validate()) return; - if (batch.enabled && !batch.validate()) { - showToast('Invalid', 'Enter positive interval values', 'error'); - return; + const generatePerformanceIssue = async () => { + const config = form.getPerformancePayload(); + const targetEndpoint = `/api/performance-target?delay=${config.targetDelay}`; + + const startTime = Date.now() / 1000; + const spanTimings: Array<{ id: string; start: number; end: number }> = []; + + const promises = Array.from({ length: config.callCount }, async (_, i) => { + const spanStart = Date.now() / 1000; + const url = config.customEndpoint + ? `${config.customEndpoint}?id=${i}` + : `${targetEndpoint}&id=${i}`; + await fetch(url, { method: 'GET' }); + const spanEnd = Date.now() / 1000; + const spanId = crypto.randomUUID().replace(/-/g, '').slice(0, 16); + spanTimings.push({ id: spanId, start: spanStart, end: spanEnd }); + }); + await Promise.all(promises); + + const endTime = Date.now() / 1000; + const traceId = crypto.randomUUID().replace(/-/g, ''); + const transactionId = crypto.randomUUID().replace(/-/g, '').slice(0, 16); + + const dsnParts = form.dsn.split('@'); + const publicKey = dsnParts[0].split('://')[1]; + const hostProject = dsnParts[1].split('/'); + const host = hostProject[0]; + const projectId = hostProject[1]; + + const spans = spanTimings.map((timing, i) => ({ + span_id: timing.id, + trace_id: traceId, + parent_span_id: transactionId, + op: 'http.client', + description: `GET /api/performance-target?id=${i}`, + start_timestamp: timing.start, + timestamp: timing.end, + status: 'ok', + data: { + 'http.method': 'GET', + 'http.url': + config.customEndpoint || `${window.location.origin}${targetEndpoint}&id=${i}`, + 'http.status_code': 200, + }, + })); + + const transaction = { + type: 'transaction', + event_id: crypto.randomUUID(), + timestamp: endTime, + start_timestamp: startTime, + platform: 'javascript', + transaction: 'N+1 API Calls Test', + op: 'ui.action', + trace_id: traceId, + span_id: transactionId, + spans, + contexts: { + trace: { + trace_id: traceId, + span_id: transactionId, + op: 'ui.action', + status: 'ok', + }, + }, + tags: { + generated_by: 'error-generator.sentry.dev', + environment: 'error-generator', + }, + }; + + const envelopeHeader = JSON.stringify({ + event_id: transaction.event_id, + sent_at: new Date().toISOString(), + dsn: form.dsn, + }); + const itemHeader = JSON.stringify({ type: 'transaction' }); + const envelope = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(transaction)}`; + + const response = await fetch(`https://${host}/api/${projectId}/envelope/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + 'X-Sentry-Auth': `Sentry sentry_version=7, sentry_client=error-generator/1.0, sentry_key=${publicKey}`, + }, + body: envelope, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to send transaction: ${text}`); } - setIsLoading(true); - try { - if (batch.enabled) { - await batch.execute(sendBatch); - } else { - const data = await sendBatch(); - showToast('Sent!', data.message || 'Errors sent to Sentry', 'success'); + return { + message: `Generated N+1 API calls performance issue (${config.callCount} calls, ${spans.length} spans)`, + }; + }; + + const generateIssue = async () => { + if (!form.validate()) return; + + if (form.issueType === 'error') { + if (batch.enabled && !batch.validate()) { + showToast('Invalid', 'Enter positive interval values', 'error'); + return; + } + + setIsLoading(true); + try { + if (batch.enabled) { + await batch.execute(sendErrorBatch); + } else { + const data = await sendErrorBatch(); + showToast('Sent!', data.message || 'Errors sent to Sentry', 'success'); + } + } catch (error) { + showToast('Error', error instanceof Error ? error.message : 'Failed', 'error'); + } finally { + setIsLoading(false); + } + } else if (form.issueType === 'performance') { + setIsLoading(true); + try { + const data = await generatePerformanceIssue(); + if (data) { + showToast('Sent!', data.message, 'success'); + } + } catch (error) { + showToast('Error', error instanceof Error ? error.message : 'Failed', 'error'); + } finally { + setIsLoading(false); } - } catch (error) { - showToast('Error', error instanceof Error ? error.message : 'Failed', 'error'); - } finally { - setIsLoading(false); } }; - const handleSubmit = () => (skipConfirm ? generateErrors() : setIsOpen(true)); + const handleSubmit = () => (skipConfirm ? generateIssue() : setIsOpen(true)); const isDisabled = isLoading || batch.isRunning; - const buttonText = batch.isRunning - ? `Running (${batch.currentRepeat}/${batch.totalRepeats})` - : isLoading - ? batch.enabled - ? 'Starting...' - : 'Generating...' - : batch.enabled - ? 'Start Interval' - : 'Generate Errors'; + const getButtonText = () => { + if (form.issueType === 'performance') { + return isLoading ? 'Generating...' : 'Generate N+1 Issue'; + } + if (batch.isRunning) { + return `Running (${batch.currentRepeat}/${batch.totalRepeats})`; + } + if (isLoading) { + return batch.enabled ? 'Starting...' : 'Generating...'; + } + return batch.enabled ? 'Start Interval' : 'Generate Errors'; + }; + + const buttonText = getButtonText(); if (!configStorage.mounted) { return ( @@ -194,6 +323,26 @@ const ErrorGenerator = () => { ); } + const handleIssueTypeChange = (type: IssueType) => { + form.setField('issueType', type); + }; + + const getConfirmTitle = () => { + if (form.issueType === 'performance') return 'Generate Performance Issue?'; + if (batch.enabled) return 'Start Batch Mode?'; + return 'Generate Errors?'; + }; + + const getConfirmMessage = () => { + if (form.issueType === 'performance') { + return `This will generate ${form.performance.callCount} API calls to trigger an N+1 performance issue detection.`; + } + if (batch.enabled) { + return `This will generate real errors and use your Sentry quota. Sending every ${batch.frequency}s, ${batch.repeatCount} times.`; + } + return 'This will generate real errors and use your Sentry quota.'; + }; + return ( <> @@ -205,10 +354,19 @@ const ErrorGenerator = () => { animate="animate" className="flex flex-col gap-4" > + + - - - + + {form.issueType === 'error' && ( + <> + + + + + )} + + {form.issueType === 'performance' && } { handleToggle={() => setSkipConfirm(!skipConfirm)} />
- {batch.isRunning && ( + {batch.isRunning && form.issueType === 'error' && (
setIsOpen(false)} - onConfirm={generateErrors} - title={batch.enabled ? 'Start Batch Mode?' : 'Generate Errors?'} - message={ - batch.enabled - ? `This will generate real errors and use your Sentry quota. Sending every ${batch.frequency}s, ${batch.repeatCount} times.` - : 'This will generate real errors and use your Sentry quota.' - } + onConfirm={generateIssue} + title={getConfirmTitle()} + message={getConfirmMessage()} /> ); diff --git a/app/components/ErrorPreview.tsx b/app/components/ErrorPreview.tsx index fdabf51..c0d4d3c 100644 --- a/app/components/ErrorPreview.tsx +++ b/app/components/ErrorPreview.tsx @@ -7,18 +7,24 @@ interface ErrorPreviewProps { payload: object; } -export const ErrorPreview = ({ payload }: ErrorPreviewProps) => ( - -

- Error Preview -

-
-            {JSON.stringify(payload, null, 2)}
-        
-
-); +export const ErrorPreview = ({ payload }: ErrorPreviewProps) => { + const isPerformance = + 'type' in payload && (payload as { type: string }).type === 'N+1 API Calls'; + const title = isPerformance ? 'Performance Issue Preview' : 'Error Preview'; + + return ( + +

+ {title} +

+
+                {JSON.stringify(payload, null, 2)}
+            
+
+ ); +}; diff --git a/app/components/form/IssueTypeSelector.tsx b/app/components/form/IssueTypeSelector.tsx new file mode 100644 index 0000000..779abe9 --- /dev/null +++ b/app/components/form/IssueTypeSelector.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { fadeInUp } from '@/app/styles/animations'; +import { IssueType, ISSUE_TYPE_CONFIGS } from '@/app/types/issueTypes'; + +interface IssueTypeSelectorProps { + selected: IssueType; + onSelect: (type: IssueType) => void; +} + +export const IssueTypeSelector = ({ selected, onSelect }: IssueTypeSelectorProps) => { + return ( + + +
+ {ISSUE_TYPE_CONFIGS.map((config) => ( + + ))} +
+
+ ); +}; diff --git a/app/components/form/PerformanceFields.tsx b/app/components/form/PerformanceFields.tsx new file mode 100644 index 0000000..28e265f --- /dev/null +++ b/app/components/form/PerformanceFields.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { fadeInUp } from '@/app/styles/animations'; +import { ErrorForm } from '@/app/hooks/useErrorForm'; + +interface PerformanceFieldsProps { + form: ErrorForm; +} + +export const PerformanceFields = ({ form }: PerformanceFieldsProps) => { + const { performance, setField } = form; + + const updatePerformance = (key: keyof typeof performance, value: string) => { + setField('performance', { ...performance, [key]: value }); + }; + + return ( + <> + + +
+
+ + updatePerformance('callCount', e.target.value)} + className="input-brutal w-full px-3 py-2 text-sm" + placeholder="15" + /> +

Minimum 10 for detection

+
+
+ + updatePerformance('targetDelay', e.target.value)} + className="input-brutal w-full px-3 py-2 text-sm" + placeholder="50" + /> +

Delay per API call

+
+
+
+ + + + updatePerformance('customEndpoint', e.target.value)} + className="input-brutal w-full px-3 py-2 text-sm" + placeholder="Leave empty to use internal endpoint" + /> +

+ Must be a GET endpoint. Uses /api/performance-target if empty. +

+
+ + ); +}; diff --git a/app/hooks/useConfigStorage.ts b/app/hooks/useConfigStorage.ts index 0897e83..68ce7b2 100644 --- a/app/hooks/useConfigStorage.ts +++ b/app/hooks/useConfigStorage.ts @@ -1,6 +1,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { IssueType, PerformanceConfig, DEFAULT_PERFORMANCE_CONFIG } from '@/app/types/issueTypes'; export interface ConfigData { + issueType: IssueType; dsn: string; message: string; priority: 'HIGH' | 'MEDIUM' | 'LOW'; @@ -13,6 +15,7 @@ export interface ConfigData { frequency: string; repeatCount: string; }; + performance: PerformanceConfig; } interface SavedConfigs { @@ -48,6 +51,7 @@ const safeRemoveItem = (key: string): void => { }; const defaultConfig: ConfigData = { + issueType: 'error', dsn: '', message: '', priority: 'HIGH', @@ -60,12 +64,13 @@ const defaultConfig: ConfigData = { frequency: '30', repeatCount: '5', }, + performance: { ...DEFAULT_PERFORMANCE_CONFIG }, }; const isValidConfig = (data: unknown): data is ConfigData => { if (!data || typeof data !== 'object') return false; const d = data as Record; - return ( + const baseValid = typeof d.dsn === 'string' && typeof d.message === 'string' && ['HIGH', 'MEDIUM', 'LOW'].includes(d.priority as string) && @@ -74,10 +79,29 @@ const isValidConfig = (data: unknown): data is ConfigData => { typeof d.errorsToGenerate === 'string' && typeof d.fingerprintID === 'string' && d.batch !== null && - typeof d.batch === 'object' - ); + typeof d.batch === 'object'; + + if (!baseValid) return false; + + // issueType and performance are optional for backwards compatibility + if ( + d.issueType !== undefined && + !['error', 'performance', 'user_feedback', 'dead_click'].includes(d.issueType as string) + ) { + return false; + } + + return true; }; +// Migrate old configs that don't have new fields +const migrateConfig = (config: Partial): ConfigData => ({ + ...defaultConfig, + ...config, + issueType: config.issueType || 'error', + performance: config.performance || { ...DEFAULT_PERFORMANCE_CONFIG }, +}); + export const useConfigStorage = () => { const [mounted, setMounted] = useState(false); const [currentConfig, setCurrentConfig] = useState(defaultConfig); @@ -91,7 +115,7 @@ export const useConfigStorage = () => { if (storedCurrent) { try { const parsed = JSON.parse(storedCurrent); - if (isValidConfig(parsed)) setCurrentConfig(parsed); + if (isValidConfig(parsed)) setCurrentConfig(migrateConfig(parsed)); } catch { // Invalid JSON } @@ -100,7 +124,15 @@ export const useConfigStorage = () => { if (storedSaved) { try { const parsed = JSON.parse(storedSaved); - if (parsed && typeof parsed === 'object') setSavedConfigs(parsed); + if (parsed && typeof parsed === 'object') { + const migratedConfigs: SavedConfigs = {}; + for (const [name, config] of Object.entries(parsed)) { + if (isValidConfig(config)) { + migratedConfigs[name] = migrateConfig(config as ConfigData); + } + } + setSavedConfigs(migratedConfigs); + } } catch { // Invalid JSON } @@ -143,9 +175,10 @@ export const useConfigStorage = () => { (name: string) => { const config = savedConfigs[name]; if (config) { - setCurrentConfig(config); + const migrated = migrateConfig(config); + setCurrentConfig(migrated); setActiveConfigName(name); - persistCurrent(config); + persistCurrent(migrated); safeSetItem(STORAGE_KEY_ACTIVE, name); } }, diff --git a/app/hooks/useErrorForm.ts b/app/hooks/useErrorForm.ts index d43e5b4..990756b 100644 --- a/app/hooks/useErrorForm.ts +++ b/app/hooks/useErrorForm.ts @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import { IssueType, PerformanceConfig, DEFAULT_PERFORMANCE_CONFIG } from '@/app/types/issueTypes'; export type Priority = 'HIGH' | 'MEDIUM' | 'LOW'; @@ -10,6 +11,7 @@ export interface CustomTag { export type ErrorForm = ReturnType; interface FormState { + issueType: IssueType; dsn: string; errorCount: string; errorsToGenerate: string; @@ -18,12 +20,14 @@ interface FormState { message: string; tags: CustomTag[]; dsnError: string; + performance: PerformanceConfig; } export const useErrorForm = ( showToast: (title: string, desc: string, status: 'success' | 'error' | 'warning') => void ) => { const [form, setForm] = useState({ + issueType: 'error', dsn: '', errorCount: '1', errorsToGenerate: '1', @@ -32,6 +36,7 @@ export const useErrorForm = ( message: '', tags: [], dsnError: '', + performance: { ...DEFAULT_PERFORMANCE_CONFIG }, }); const [newTagKey, setNewTagKey] = useState(''); @@ -83,18 +88,34 @@ export const useErrorForm = ( }; const validate = (): boolean => { - if (!validateDsn(form.dsn)) return false; - - const eventsPerError = parseInt(form.errorCount, 10); - const numErrors = form.fingerprintID ? 1 : parseInt(form.errorsToGenerate, 10); - - if (isNaN(eventsPerError) || eventsPerError <= 0) { - showToast('Invalid', 'Enter positive event count', 'error'); - return false; - } - if (!form.fingerprintID && (isNaN(numErrors) || numErrors <= 0)) { - showToast('Invalid', 'Enter positive error count', 'error'); - return false; + if (form.issueType === 'error') { + if (!validateDsn(form.dsn)) return false; + + const eventsPerError = parseInt(form.errorCount, 10); + const numErrors = form.fingerprintID ? 1 : parseInt(form.errorsToGenerate, 10); + + if (isNaN(eventsPerError) || eventsPerError <= 0) { + showToast('Invalid', 'Enter positive event count', 'error'); + return false; + } + if (!form.fingerprintID && (isNaN(numErrors) || numErrors <= 0)) { + showToast('Invalid', 'Enter positive error count', 'error'); + return false; + } + } else if (form.issueType === 'performance') { + if (!validateDsn(form.dsn)) return false; + + const callCount = parseInt(form.performance.callCount, 10); + const targetDelay = parseInt(form.performance.targetDelay, 10); + + if (isNaN(callCount) || callCount < 10) { + showToast('Invalid', 'API call count must be at least 10', 'error'); + return false; + } + if (isNaN(targetDelay) || targetDelay < 0) { + showToast('Invalid', 'Target delay must be non-negative', 'error'); + return false; + } } return true; }; @@ -124,7 +145,25 @@ export const useErrorForm = ( }, }); + const getPerformancePayload = () => ({ + callCount: parseInt(form.performance.callCount, 10), + targetDelay: parseInt(form.performance.targetDelay, 10), + customEndpoint: form.performance.customEndpoint, + }); + + const getPerformancePreviewPayload = () => ({ + type: 'N+1 API Calls', + transaction: 'N+1 API Calls Test', + op: 'ui.action', + spans: { + count: parseInt(form.performance.callCount, 10) || 15, + op: 'http.client', + targetDelay: `${form.performance.targetDelay}ms`, + }, + }); + const getConfig = () => ({ + issueType: form.issueType, dsn: form.dsn, message: form.message, priority: form.priority, @@ -132,9 +171,11 @@ export const useErrorForm = ( errorCount: form.errorCount, errorsToGenerate: form.errorsToGenerate, fingerprintID: form.fingerprintID, + performance: form.performance, }); const loadConfig = (config: { + issueType?: IssueType; dsn: string; message: string; priority: Priority; @@ -142,9 +183,11 @@ export const useErrorForm = ( errorCount: string; errorsToGenerate: string; fingerprintID: string; + performance?: PerformanceConfig; }) => { setForm((f) => ({ ...f, + issueType: config.issueType || 'error', dsn: config.dsn, message: config.message, priority: config.priority, @@ -152,6 +195,7 @@ export const useErrorForm = ( errorCount: config.errorCount, errorsToGenerate: config.errorsToGenerate, fingerprintID: config.fingerprintID, + performance: config.performance || { ...DEFAULT_PERFORMANCE_CONFIG }, dsnError: '', })); }; @@ -169,6 +213,8 @@ export const useErrorForm = ( validate, getPayload, getPreviewPayload, + getPerformancePayload, + getPerformancePreviewPayload, getConfig, loadConfig, }; diff --git a/app/types/issueTypes.ts b/app/types/issueTypes.ts new file mode 100644 index 0000000..8e58be8 --- /dev/null +++ b/app/types/issueTypes.ts @@ -0,0 +1,47 @@ +export type IssueType = 'error' | 'performance' | 'user_feedback' | 'dead_click'; + +export interface IssueTypeConfig { + id: IssueType; + label: string; + description: string; + enabled: boolean; +} + +export const ISSUE_TYPE_CONFIGS: IssueTypeConfig[] = [ + { + id: 'error', + label: 'Error', + description: 'Generate error events', + enabled: true, + }, + { + id: 'performance', + label: 'Performance', + description: 'Generate N+1 API call issues', + enabled: true, + }, + { + id: 'user_feedback', + label: 'Feedback', + description: 'User feedback submissions', + enabled: false, + }, + { + id: 'dead_click', + label: 'Dead Click', + description: 'Dead click detection', + enabled: false, + }, +]; + +export interface PerformanceConfig { + callCount: string; + targetDelay: string; + customEndpoint: string; +} + +export const DEFAULT_PERFORMANCE_CONFIG: PerformanceConfig = { + callCount: '15', + targetDelay: '50', + customEndpoint: '', +};