Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
118 changes: 118 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
name: API Performance Benchmark

on:
pull_request:
branches: [main, develop]
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 6am UTC
workflow_dispatch:
inputs:
iterations:
description: 'Number of iterations per endpoint'
required: false
default: '50'

jobs:
benchmark:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379

steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Generate Prisma Client
run: npx prisma generate
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test

- name: Sync database schema
run: npx prisma db push --skip-generate --accept-data-loss
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test

- name: Build application
run: npm run build

- name: Start application
run: |
npm run start:dist &
for i in $(seq 1 30); do
if curl -s http://localhost:3000/api > /dev/null 2>&1; then
echo "Server is up"
break
fi
echo "Waiting for server... ($i)"
sleep 2
done
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
REDIS_URL: redis://localhost:6379
JWT_SECRET: bench-secret
JWT_REFRESH_SECRET: bench-refresh-secret
NODE_ENV: production

- name: Run benchmarks
run: npx ts-node scripts/benchmark.ts
env:
BENCHMARK_BASE_URL: http://localhost:3000/api
BENCHMARK_ITERATIONS: ${{ github.event.inputs.iterations || '100' }}

- name: Upload benchmark results
uses: actions/upload-artifact@v4
if: always()
with:
name: benchmark-results
path: benchmark-results.json

- name: Comment on PR with results
if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
try {
const report = JSON.parse(fs.readFileSync('benchmark-results.json', 'utf8'));
const lines = ['## API Benchmark Results\n'];
lines.push(`| Endpoint | p50 (ms) | p95 (ms) | p99 (ms) | Budget (ms) | Status |`);
lines.push(`|----------|----------|----------|----------|-------------|--------|`);
for (const r of report.results) {
const status = r.passed ? 'PASS' : 'FAIL';
lines.push(`| ${r.endpoint} | ${r.latencyMs.p50} | ${r.latencyMs.p95} | ${r.latencyMs.p99} | ${r.budgetMs} | ${status} |`);
}
lines.push(`\n**Summary:** ${report.summary.passed}/${report.summary.total} passed`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: lines.join('\n'),
});
} catch (e) {
console.log('Could not post benchmark results:', e.message);
}
55 changes: 41 additions & 14 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -292,20 +292,22 @@ model BackupScheduleConfig {
}

model ApiKey {
id String @id @default(uuid())
userId String @map("user_id")
name String
keyPrefix String @map("key_prefix")
keyHash String @unique @map("key_hash")
permissions String[] @default([])
usageCount Int @default(0) @map("usage_count")
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at")
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(uuid())
userId String @map("user_id")
name String
keyPrefix String @map("key_prefix")
keyHash String @unique @map("key_hash")
permissions String[] @default([])
usageCount Int @default(0) @map("usage_count")
monthlyQuota Int? @map("monthly_quota")
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at")
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
usageDaily ApiKeyUsageDaily[]

@@index([userId])
@@index([keyPrefix])
Expand Down Expand Up @@ -1137,13 +1139,15 @@ model EmailBounce {
bounceType BounceType @map("bounce_type")
reason String?
rawEvent Json? @map("raw_event")
spamAction SpamAction @default(NONE) @map("spam_action")
createdAt DateTime @default(now()) @map("created_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
@@index([email])
@@index([bounceType])
@@index([spamAction])
@@map("email_bounces")
}

Expand Down Expand Up @@ -1328,4 +1332,27 @@ enum JobStatus {
PROCESSING
COMPLETED
FAILED
}

enum SpamAction {
NONE
COMPLAINED
UNSUBSCRIBED
}

// Daily API key usage aggregation for analytics (#945)
model ApiKeyUsageDaily {
id String @id @default(uuid())
apiKeyId String @map("api_key_id")
date DateTime @db.Date
requestCount Int @default(0) @map("request_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)

@@unique([apiKeyId, date])
@@index([apiKeyId, date])
@@index([date])
@@map("api_key_usage_daily")
}
162 changes: 162 additions & 0 deletions scripts/benchmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// @ts-nocheck

/**
* Performance Benchmark Suite for PropChain API Endpoints (#946)
*
* Benchmarks the 10 most-used endpoints and records p50/p95/p99 latencies.
* Performance budget: p95 < 500ms for list endpoints, p95 < 200ms for detail endpoints.
*
* Usage: npx ts-node scripts/benchmark.ts [--base-url=http://localhost:3000] [--iterations=100]
*/

const BASE_URL = process.env.BENCHMARK_BASE_URL || 'http://localhost:3000/api';
const ITERATIONS = parseInt(process.env.BENCHMARK_ITERATIONS || '100', 10);
const WARMUP_ROUNDS = 5;

interface BenchmarkResult {
endpoint: string;
method: string;
category: 'list' | 'detail' | 'search';
latencyMs: { p50: number; p95: number; p99: number; max: number; avg: number };
statusCodes: Record<number, number>;
passed: boolean;
budgetMs: number;
}

interface BenchmarkReport {
timestamp: string;
baseUrl: string;
iterations: number;
results: BenchmarkResult[];
summary: { total: number; passed: number; failed: number };
}

function percentile(sorted: number[], p: number): number {
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, idx)];
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function benchmarkEndpoint(
name: string,
method: string,
path: string,
category: 'list' | 'detail' | 'search',
budgetMs: number,
): Promise<BenchmarkResult> {
const latencies: number[] = [];
const statusCodes: Record<number, number> = {};

// Warmup
for (let i = 0; i < WARMUP_ROUNDS; i++) {
try {
await fetch(`${BASE_URL}${path}`, { method });
} catch {
// ignore warmup errors
}
}

// Benchmark
for (let i = 0; i < ITERATIONS; i++) {
const start = performance.now();
try {
const res = await fetch(`${BASE_URL}${path}`, { method });
const elapsed = performance.now() - start;
latencies.push(elapsed);
const status = res.status;
statusCodes[status] = (statusCodes[status] || 0) + 1;
} catch {
const elapsed = performance.now() - start;
latencies.push(elapsed);
statusCodes[0] = (statusCodes[0] || 0) + 1;
}
}

latencies.sort((a, b) => a - b);

const p50 = Math.round(percentile(latencies, 50) * 100) / 100;
const p95 = Math.round(percentile(latencies, 95) * 100) / 100;
const p99 = Math.round(percentile(latencies, 99) * 100) / 100;
const max = Math.round(latencies[latencies.length - 1] * 100) / 100;
const avg = Math.round((latencies.reduce((a, b) => a + b, 0) / latencies.length) * 100) / 100;

const passed = p95 < budgetMs;

return {
endpoint: `${name} (${method} ${path})`,
method,
category,
latencyMs: { p50, p95, p99, max, avg },
statusCodes,
passed,
budgetMs,
};
}

async function runBenchmark(): Promise<BenchmarkReport> {
console.log(`\n PropChain API Benchmark Suite`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` Iterations: ${ITERATIONS} (warmup: ${WARMUP_ROUNDS})\n`);

const endpoints = [
{ name: 'Properties List', method: 'GET', path: '/properties?limit=20', category: 'list' as const, budget: 500 },
{ name: 'Property Detail', method: 'GET', path: '/properties/test-id', category: 'detail' as const, budget: 200 },
{ name: 'Transactions List', method: 'GET', path: '/transactions?limit=20', category: 'list' as const, budget: 500 },
{ name: 'Transaction Detail', method: 'GET', path: '/transactions/test-id', category: 'detail' as const, budget: 200 },
{ name: 'Search Properties', method: 'GET', path: '/search?q=apartment', category: 'search' as const, budget: 500 },
{ name: 'User Profile (me)', method: 'GET', path: '/auth/me', category: 'detail' as const, budget: 200 },
{ name: 'List API Keys', method: 'GET', path: '/auth/api-keys', category: 'list' as const, budget: 500 },
{ name: 'Dashboard Stats', method: 'GET', path: '/admin/dashboard', category: 'detail' as const, budget: 200 },
{ name: 'Email Reputation', method: 'GET', path: '/email/reputation', category: 'detail' as const, budget: 200 },
{ name: 'Queue Metrics', method: 'GET', path: '/admin/queues/metrics', category: 'detail' as const, budget: 200 },
];

const results: BenchmarkResult[] = [];

for (const ep of endpoints) {
process.stdout.write(` Benchmarking: ${ep.name} ... `);
const result = await benchmarkEndpoint(ep.name, ep.method, ep.path, ep.category, ep.budget);
results.push(result);
const status = result.passed ? 'PASS' : 'FAIL';
console.log(`${status} (p95=${result.latencyMs.p95}ms, budget=${result.budgetMs}ms)`);
}

const passed = results.filter((r) => r.passed).length;

const report: BenchmarkReport = {
timestamp: new Date().toISOString(),
baseUrl: BASE_URL,
iterations: ITERATIONS,
results,
summary: { total: results.length, passed, failed: results.length - passed },
};

console.log(`\n Summary: ${passed}/${results.length} passed\n`);

if (results.some((r) => !r.passed)) {
console.log(' Failed benchmarks:');
for (const r of results.filter((r) => !r.passed)) {
console.log(` - ${r.endpoint}: p95=${r.latencyMs.p95}ms > budget=${r.budgetMs}ms`);
}
console.log('');
}

return report;
}

async function main() {
const report = await runBenchmark();
const outputPath = 'benchmark-results.json';
const fs = require('fs');
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
console.log(` Results written to ${outputPath}\n`);
process.exit(report.summary.failed > 0 ? 1 : 0);
}

main().catch((err) => {
console.error('Benchmark failed:', err);
process.exit(1);
});
3 changes: 2 additions & 1 deletion src/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import { FraudModule } from '../fraud/fraud.module';
import { BackupModule } from '../backup/backup.module';
import { TransactionsModule } from '../transactions/transactions.module';
import { SessionsModule } from '../sessions/sessions.module';
import { QueueModule } from './queue/queue.module';

@Module({
imports: [PrismaModule, FraudModule, BackupModule, TransactionsModule, SessionsModule],
imports: [PrismaModule, FraudModule, BackupModule, TransactionsModule, SessionsModule, QueueModule],
controllers: [AdminController],
providers: [AdminService, AdminAuditInterceptor],
exports: [AdminService],
Expand Down
Loading
Loading