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: '', +};