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
18 changes: 18 additions & 0 deletions app/api/performance-target/route.ts
Original file line number Diff line number Diff line change
@@ -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));

Check failure

Code scanning / CodeQL

Resource exhaustion High

This creates a timer with a user-controlled duration from a
user-provided value
.

Copilot Autofix

AI about 1 month ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.


return NextResponse.json({
id,
timestamp: Date.now(),
delay: clampedDelay,
});
}
238 changes: 199 additions & 39 deletions app/components/ErrorGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToastData[]>([]);
Expand All @@ -36,13 +39,15 @@ const ErrorGenerator = () => {
if (configStorage.mounted && !initialLoadDone) {
const config = configStorage.currentConfig;
loadFormConfig({
issueType: config.issueType,
dsn: config.dsn,
message: config.message,
priority: config.priority,
tags: config.tags,
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
Expand Down Expand Up @@ -77,6 +82,7 @@ const ErrorGenerator = () => {
}

const newConfig: ConfigData = {
issueType: form.issueType,
dsn: form.dsn,
message: form.message,
priority: form.priority,
Expand All @@ -89,19 +95,22 @@ 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,
form.tags,
form.errorCount,
form.errorsToGenerate,
form.fingerprintID,
form.performance,
batch.enabled,
batch.frequency,
batch.repeatCount,
Expand All @@ -118,13 +127,15 @@ const ErrorGenerator = () => {
skipNextAutoSave.current = true;
configStorage.loadConfig(name);
form.loadConfig({
issueType: config.issueType,
dsn: config.dsn,
message: config.message,
priority: config.priority,
tags: config.tags,
errorCount: config.errorCount,
errorsToGenerate: config.errorsToGenerate,
fingerprintID: config.fingerprintID,
performance: config.performance,
});
batch.loadConfig(config.batch);
showToast('Loaded', `Config "${name}" loaded`, 'success');
Expand All @@ -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' },
Expand All @@ -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 (
Expand All @@ -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 (
<>
<ToastContainer toasts={toasts} />
Expand All @@ -205,18 +354,27 @@ const ErrorGenerator = () => {
animate="animate"
className="flex flex-col gap-4"
>
<IssueTypeSelector selected={form.issueType} onSelect={handleIssueTypeChange} />

<DsnInput form={form} />
<FormFields form={form} />
<TagInput form={form} />
<BatchModePanel batch={batch} />

{form.issueType === 'error' && (
<>
<FormFields form={form} />
<TagInput form={form} />
<BatchModePanel batch={batch} />
</>
)}

{form.issueType === 'performance' && <PerformanceFields form={form} />}

<motion.div variants={fadeInUp} className="flex items-center justify-between">
<SkipConfirm
enabled={skipConfirm}
handleToggle={() => setSkipConfirm(!skipConfirm)}
/>
<div className="flex gap-3">
{batch.isRunning && (
{batch.isRunning && form.issueType === 'error' && (
<button
onClick={() => {
setIsLoading(false);
Expand Down Expand Up @@ -246,20 +404,22 @@ const ErrorGenerator = () => {
onLoad={handleLoadConfig}
onDelete={handleDeleteConfig}
/>
<ErrorPreview payload={form.getPreviewPayload()} />
<ErrorPreview
payload={
form.issueType === 'performance'
? form.getPerformancePreviewPayload()
: form.getPreviewPayload()
}
/>
</div>
</div>

<ConfirmModal
isOpen={isOpen}
onClose={() => 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()}
/>
</>
);
Expand Down
Loading