From f93328195738f9814233e75e9ba7f5785755e51c Mon Sep 17 00:00:00 2001 From: Victor Speed Date: Thu, 28 May 2026 13:34:53 +0000 Subject: [PATCH 1/4] feat(#712): Add SLO thresholds to k6 tests with centralized configuration - Create slo-thresholds.js with centralized SLO threshold configuration - Update all k6 tests to use getThresholdsForTest() for consistent SLO enforcement - Add checkThresholdBreaches() utility for threshold validation - Tests now fail CI if SLO thresholds are breached - Thresholds derived from performance/config/slo.json --- .../backend/k6/blockchain-load-test.js | 19 ++- performance/backend/k6/cache-test.js | 5 +- performance/backend/k6/load-test.js | 5 +- performance/backend/k6/rate-limit-test.js | 5 +- performance/backend/k6/slo-thresholds.js | 114 ++++++++++++++++++ performance/backend/k6/smoke-test.js | 6 +- performance/backend/k6/spike-test.js | 6 +- performance/backend/k6/stress-test.js | 6 +- 8 files changed, 135 insertions(+), 31 deletions(-) create mode 100644 performance/backend/k6/slo-thresholds.js diff --git a/performance/backend/k6/blockchain-load-test.js b/performance/backend/k6/blockchain-load-test.js index f07d77b2..bf60a6d9 100644 --- a/performance/backend/k6/blockchain-load-test.js +++ b/performance/backend/k6/blockchain-load-test.js @@ -17,6 +17,7 @@ import http from "k6/http"; import { check, group, sleep } from "k6"; import { Counter, Rate, Trend } from "k6/metrics"; import { randomIntBetween } from "https://jslib.k6.io/k6-utils/1.2.0/index.js"; +import { getThresholdsForTest } from './slo-thresholds.js'; // --------------------------------------------------------------------------- // Custom metrics @@ -100,23 +101,21 @@ export const options = { }, thresholds: { - // Overall error budget - blockchain_errors: ["rate<0.01"], - + ...getThresholdsForTest('blockchain'), // Cache effectiveness — warmed traffic should hit cache >80% of the time - blockchain_cache_hit_rate: ["rate>0.8"], + blockchain_cache_hit_rate: ['rate>0.8'], // Per-group latency targets - blockchain_market_data_duration: ["p(95)<200", "p(99)<400"], - blockchain_stats_duration: ["p(95)<150", "p(99)<300"], - blockchain_user_bets_duration: ["p(95)<250", "p(99)<500"], - blockchain_outcome_stake_duration: ["p(95)<150", "p(99)<300"], + blockchain_market_data_duration: ['p(95)<200', 'p(99)<400'], + blockchain_stats_duration: ['p(95)<150', 'p(99)<300'], + blockchain_user_bets_duration: ['p(95)<250', 'p(99)<500'], + blockchain_outcome_stake_duration: ['p(95)<150', 'p(99)<300'], // Warmed-scenario latency should be tighter (cache serving responses) - "http_req_duration{scenario:cache_warmed}": ["p(95)<150"], + 'http_req_duration{scenario:cache_warmed}': ['p(95)<150'], // Cold-scenario latency is allowed to be higher (cache miss penalty) - "http_req_duration{scenario:cold_cache}": ["p(95)<400"], + 'http_req_duration{scenario:cold_cache}': ['p(95)<400'], }, }; diff --git a/performance/backend/k6/cache-test.js b/performance/backend/k6/cache-test.js index 1b3fc529..f358107d 100644 --- a/performance/backend/k6/cache-test.js +++ b/performance/backend/k6/cache-test.js @@ -1,6 +1,7 @@ import http from 'k6/http'; import { check } from 'k6'; import { Counter, Rate } from 'k6/metrics'; +import { getThresholdsForTest } from './slo-thresholds.js'; const cacheHits = new Counter('cache_hits'); const cacheMisses = new Counter('cache_misses'); @@ -9,9 +10,7 @@ const cacheHitRate = new Rate('cache_hit_rate'); export const options = { vus: 50, duration: '2m', - thresholds: { - cache_hit_rate: ['rate>0.8'], // Expect >80% cache hit rate - }, + thresholds: getThresholdsForTest('cache'), }; const BASE_URL = __ENV.API_URL || 'http://localhost:8080'; diff --git a/performance/backend/k6/load-test.js b/performance/backend/k6/load-test.js index d36d7705..9ed595e5 100644 --- a/performance/backend/k6/load-test.js +++ b/performance/backend/k6/load-test.js @@ -2,6 +2,7 @@ import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate, Trend, Counter } from 'k6/metrics'; import { randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; +import { getThresholdsForTest } from './slo-thresholds.js'; const errorRate = new Rate('errors'); const marketLoadTime = new Trend('market_load_time'); @@ -15,10 +16,8 @@ export const options = { { duration: '2m', target: 0 }, // Ramp down ], thresholds: { + ...getThresholdsForTest('load'), errors: ['rate<0.001'], - http_req_duration: ['p(95)<200', 'p(99)<500'], - 'http_req_duration{endpoint:health}': ['p(95)<50'], - 'http_req_duration{endpoint:markets}': ['p(95)<200'], market_load_time: ['p(95)<200'], bet_placement_time: ['p(95)<250'], }, diff --git a/performance/backend/k6/rate-limit-test.js b/performance/backend/k6/rate-limit-test.js index 4fbcf9d9..d198e910 100644 --- a/performance/backend/k6/rate-limit-test.js +++ b/performance/backend/k6/rate-limit-test.js @@ -1,6 +1,7 @@ import http from 'k6/http'; import { check } from 'k6'; import { Rate, Counter } from 'k6/metrics'; +import { getThresholdsForTest } from './slo-thresholds.js'; const rateLimitHits = new Counter('rate_limit_hits'); const successfulRequests = new Counter('successful_requests'); @@ -8,9 +9,7 @@ const successfulRequests = new Counter('successful_requests'); export const options = { vus: 10, duration: '30s', - thresholds: { - rate_limit_hits: ['count>0'], // Expect to hit rate limits - }, + thresholds: getThresholdsForTest('rate-limit'), }; const BASE_URL = __ENV.API_URL || 'http://localhost:8080'; diff --git a/performance/backend/k6/slo-thresholds.js b/performance/backend/k6/slo-thresholds.js new file mode 100644 index 00000000..c54ec696 --- /dev/null +++ b/performance/backend/k6/slo-thresholds.js @@ -0,0 +1,114 @@ +/** + * SLO Thresholds Configuration + * + * Centralized SLO thresholds derived from performance/config/slo.json + * Used by all k6 tests to ensure consistent SLO enforcement + */ + +export const sloThresholds = { + // API Availability: 99.9% target + 'http_req_failed': ['rate<0.001'], // 0.1% error rate + + // API Latency P95: 200ms target + 'http_req_duration': ['p(95)<200', 'p(99)<500'], + + // Endpoint-specific thresholds + 'http_req_duration{endpoint:health}': ['p(95)<50'], + 'http_req_duration{endpoint:markets}': ['p(95)<200'], + 'http_req_duration{endpoint:bets}': ['p(95)<250'], + 'http_req_duration{endpoint:users}': ['p(95)<200'], + + // Cache availability: 99.95% target + 'cache_hit_rate': ['rate>0.8'], +}; + +/** + * Get thresholds for a specific test + * @param {string} testName - Name of the test (e.g., 'smoke', 'load', 'cache') + * @returns {Object} Thresholds configuration + */ +export function getThresholdsForTest(testName) { + const baseThresholds = { + 'http_req_failed': ['rate<0.001'], + 'http_req_duration': ['p(95)<200', 'p(99)<500'], + }; + + switch (testName) { + case 'smoke': + return { + ...baseThresholds, + 'http_req_duration': ['p(95)<200'], + }; + case 'load': + return { + ...baseThresholds, + 'http_req_duration{endpoint:health}': ['p(95)<50'], + 'http_req_duration{endpoint:markets}': ['p(95)<200'], + 'http_req_duration{endpoint:bets}': ['p(95)<250'], + }; + case 'cache': + return { + 'cache_hit_rate': ['rate>0.8'], + 'http_req_duration': ['p(95)<100'], + }; + case 'stress': + return { + ...baseThresholds, + 'http_req_duration': ['p(95)<300'], // Relaxed for stress test + }; + case 'spike': + return { + ...baseThresholds, + 'http_req_duration': ['p(95)<300'], // Relaxed for spike test + }; + case 'rate-limit': + return { + 'http_req_failed': ['rate<0.05'], // Allow higher error rate for rate limit test + }; + case 'blockchain': + return { + ...baseThresholds, + 'http_req_duration': ['p(95)<500'], // Blockchain operations may be slower + }; + default: + return baseThresholds; + } +} + +/** + * Check if thresholds were breached and return details + * @param {Object} data - k6 summary data + * @returns {Object} Breach details + */ +export function checkThresholdBreaches(data) { + const breaches = []; + + if (data.metrics.http_req_failed && data.metrics.http_req_failed.values.rate > 0.001) { + breaches.push({ + metric: 'Error Rate', + threshold: '< 0.1%', + actual: `${(data.metrics.http_req_failed.values.rate * 100).toFixed(2)}%`, + severity: 'critical', + }); + } + + if (data.metrics.http_req_duration && data.metrics.http_req_duration.values['p(95)'] > 200) { + breaches.push({ + metric: 'P95 Response Time', + threshold: '< 200ms', + actual: `${data.metrics.http_req_duration.values['p(95)'].toFixed(2)}ms`, + severity: 'warning', + }); + } + + if (data.metrics.http_req_duration && data.metrics.http_req_duration.values['p(99)'] > 500) { + breaches.push({ + metric: 'P99 Response Time', + threshold: '< 500ms', + actual: `${data.metrics.http_req_duration.values['p(99)'].toFixed(2)}ms`, + severity: 'warning', + }); + } + + return breaches; +} diff --git a/performance/backend/k6/smoke-test.js b/performance/backend/k6/smoke-test.js index 12d9011a..b46732ac 100644 --- a/performance/backend/k6/smoke-test.js +++ b/performance/backend/k6/smoke-test.js @@ -1,16 +1,14 @@ import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate } from 'k6/metrics'; +import { getThresholdsForTest } from './slo-thresholds.js'; const errorRate = new Rate('errors'); export const options = { vus: 1, duration: '1m', - thresholds: { - errors: ['rate<0.01'], - http_req_duration: ['p(95)<200'], - }, + thresholds: getThresholdsForTest('smoke'), }; const BASE_URL = __ENV.API_URL || 'http://localhost:8080'; diff --git a/performance/backend/k6/spike-test.js b/performance/backend/k6/spike-test.js index 0ceabc4d..43249c8a 100644 --- a/performance/backend/k6/spike-test.js +++ b/performance/backend/k6/spike-test.js @@ -1,6 +1,7 @@ import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate } from 'k6/metrics'; +import { getThresholdsForTest } from './slo-thresholds.js'; const errorRate = new Rate('errors'); @@ -14,10 +15,7 @@ export const options = { { duration: '3m', target: 100 }, // Recover { duration: '10s', target: 0 }, // Ramp down ], - thresholds: { - errors: ['rate<0.1'], - http_req_duration: ['p(95)<1000'], - }, + thresholds: getThresholdsForTest('spike'), }; const BASE_URL = __ENV.API_URL || 'http://localhost:8080'; diff --git a/performance/backend/k6/stress-test.js b/performance/backend/k6/stress-test.js index 4aeb711e..0070acf1 100644 --- a/performance/backend/k6/stress-test.js +++ b/performance/backend/k6/stress-test.js @@ -2,6 +2,7 @@ import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate, Trend } from 'k6/metrics'; import { randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; +import { getThresholdsForTest } from './slo-thresholds.js'; const errorRate = new Rate('errors'); const responseTime = new Trend('response_time'); @@ -18,10 +19,7 @@ export const options = { { duration: '5m', target: 400 }, // Stay at 400 { duration: '10m', target: 0 }, // Ramp down ], - thresholds: { - errors: ['rate<0.05'], // Allow higher error rate in stress test - http_req_duration: ['p(95)<500', 'p(99)<1000'], - }, + thresholds: getThresholdsForTest('stress'), }; const BASE_URL = __ENV.API_URL || 'http://localhost:8080'; From 1a2e4ab7b1e620f1fef8930168a744a0d452d018 Mon Sep 17 00:00:00 2001 From: Victor Speed Date: Thu, 28 May 2026 13:35:45 +0000 Subject: [PATCH 2/4] feat(#713): Add error budget history persistence and trend analysis - Add saveErrorBudgetSnapshot() to persist error budget snapshots with timestamps - Add loadErrorBudgetHistory() to load all historical snapshots - Add calculateErrorBudgetTrend() to analyze error budget trends over N runs - Update calculate-error-budget.js to save snapshots automatically - Add displayErrorBudgetTrend() to compare-results.js for trend visualization - Snapshots stored in .performance-baselines/ with ISO timestamps - Trend analysis shows improving/degrading/stable status per SLO --- performance/scripts/calculate-error-budget.js | 144 ++++++++++++++++++ performance/scripts/compare-results.js | 34 +++++ 2 files changed, 178 insertions(+) diff --git a/performance/scripts/calculate-error-budget.js b/performance/scripts/calculate-error-budget.js index c4d916a4..ec5b2422 100755 --- a/performance/scripts/calculate-error-budget.js +++ b/performance/scripts/calculate-error-budget.js @@ -5,6 +5,7 @@ * * Calculates SLO compliance and error budget consumption based on metrics. * Supports multiple SLOs and generates reports. + * Persists error budget snapshots for historical tracking. */ const fs = require('fs'); @@ -15,6 +16,124 @@ const sloConfig = JSON.parse( fs.readFileSync(path.join(__dirname, '../config/slo.json'), 'utf8') ); +const BASELINES_DIR = path.join(__dirname, '../.performance-baselines'); + +/** + * Save error budget snapshot with timestamp + * @param {Array} results - Error budget calculation results + * @returns {string} Path to saved snapshot + */ +function saveErrorBudgetSnapshot(results) { + if (!fs.existsSync(BASELINES_DIR)) { + fs.mkdirSync(BASELINES_DIR, { recursive: true }); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const snapshotFile = path.join(BASELINES_DIR, `error-budget-${timestamp}.json`); + + const snapshot = { + timestamp: new Date().toISOString(), + results, + summary: { + total_slos: results.length, + healthy: results.filter(r => r.status === 'healthy').length, + warning: results.filter(r => r.status === 'warning').length, + alert: results.filter(r => r.status === 'alert').length, + critical: results.filter(r => r.status === 'critical').length, + emergency: results.filter(r => r.status === 'emergency').length, + }, + }; + + fs.writeFileSync(snapshotFile, JSON.stringify(snapshot, null, 2)); + return snapshotFile; +} + +/** + * Load all error budget snapshots for trend analysis + * @returns {Array} Array of snapshots sorted by timestamp + */ +function loadErrorBudgetHistory() { + if (!fs.existsSync(BASELINES_DIR)) { + return []; + } + + const files = fs.readdirSync(BASELINES_DIR) + .filter(f => f.startsWith('error-budget-') && f.endsWith('.json')) + .sort(); + + return files.map(file => { + try { + return JSON.parse(fs.readFileSync(path.join(BASELINES_DIR, file), 'utf8')); + } catch (e) { + console.error(`Failed to parse ${file}:`, e.message); + return null; + } + }).filter(Boolean); +} + +/** + * Calculate error budget trend over N runs + * @param {number} runs - Number of recent runs to analyze + * @returns {Object} Trend analysis + */ +function calculateErrorBudgetTrend(runs = 10) { + const history = loadErrorBudgetHistory(); + const recent = history.slice(-runs); + + if (recent.length === 0) { + return { message: 'No historical data available' }; + } + + const trends = {}; + + // Analyze each SLO + recent.forEach(snapshot => { + snapshot.results.forEach(result => { + if (!trends[result.slo_name]) { + trends[result.slo_name] = { + slo_name: result.slo_name, + samples: [], + avg_remaining: 0, + min_remaining: 100, + max_remaining: 0, + trend: 'stable', + }; + } + + const remaining = parseFloat(result.error_budget_remaining); + trends[result.slo_name].samples.push({ + timestamp: snapshot.timestamp, + remaining, + }); + trends[result.slo_name].min_remaining = Math.min(trends[result.slo_name].min_remaining, remaining); + trends[result.slo_name].max_remaining = Math.max(trends[result.slo_name].max_remaining, remaining); + }); + }); + + // Calculate averages and trends + Object.keys(trends).forEach(sloName => { + const data = trends[sloName]; + data.avg_remaining = (data.samples.reduce((sum, s) => sum + s.remaining, 0) / data.samples.length).toFixed(2); + + // Determine trend direction + if (data.samples.length >= 2) { + const first = data.samples[0].remaining; + const last = data.samples[data.samples.length - 1].remaining; + const change = last - first; + + if (change < -5) { + data.trend = 'degrading'; + } else if (change > 5) { + data.trend = 'improving'; + } else { + data.trend = 'stable'; + } + } + }); + + return trends; +} + /** * Calculate error budget for a given SLO * @param {Object} slo - SLO configuration @@ -244,12 +363,34 @@ function main() { const report = generateReport(results); console.log(report); + // Save error budget snapshot for historical tracking + const snapshotPath = saveErrorBudgetSnapshot(results); + console.log(`\nāœ“ Error budget snapshot saved to: ${snapshotPath}`); + // Save report to file const reportPath = path.join(__dirname, '../reports/slo-report.txt'); fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, report); console.log(`Report saved to: ${reportPath}`); + // Calculate and display trend analysis + const trends = calculateErrorBudgetTrend(10); + if (trends.message) { + console.log(`\n${trends.message}`); + } else { + console.log('\nšŸ“Š Error Budget Trend (Last 10 Runs):'); + console.log('─'.repeat(80)); + Object.values(trends).forEach(trend => { + const trendEmoji = { + improving: 'šŸ“ˆ', + degrading: 'šŸ“‰', + stable: 'āž”ļø', + }[trend.trend] || 'ā“'; + console.log(`${trendEmoji} ${trend.slo_name}`); + console.log(` Avg Remaining: ${trend.avg_remaining}% | Min: ${trend.min_remaining.toFixed(2)}% | Max: ${trend.max_remaining.toFixed(2)}%`); + }); + } + // Exit with error code if any SLO is in critical/emergency state const hasCritical = results.some(r => ['critical', 'emergency'].includes(r.status)); process.exit(hasCritical ? 1 : 0); @@ -264,4 +405,7 @@ module.exports = { calculateErrorBudget, checkBurnRateAlerts, generateReport, + saveErrorBudgetSnapshot, + loadErrorBudgetHistory, + calculateErrorBudgetTrend, }; diff --git a/performance/scripts/compare-results.js b/performance/scripts/compare-results.js index c1865947..17ecd088 100644 --- a/performance/scripts/compare-results.js +++ b/performance/scripts/compare-results.js @@ -260,6 +260,37 @@ function compareResults(testName, currentResults) { return { hasRegression, regressions }; } +/** + * Load and display error budget trend + */ +function displayErrorBudgetTrend() { + try { + const errorBudgetModule = require('./calculate-error-budget.js'); + const trends = errorBudgetModule.calculateErrorBudgetTrend(10); + + if (trends.message) { + console.log(`\n${trends.message}`); + return; + } + + console.log('\nšŸ“Š Error Budget Trend (Last 10 Runs):'); + console.log('─'.repeat(90)); + + Object.values(trends).forEach(trend => { + const trendEmoji = { + improving: 'šŸ“ˆ', + degrading: 'šŸ“‰', + stable: 'āž”ļø', + }[trend.trend] || 'ā“'; + + console.log(`${trendEmoji} ${trend.slo_name}`); + console.log(` Avg Remaining: ${trend.avg_remaining}% | Min: ${trend.min_remaining.toFixed(2)}% | Max: ${trend.max_remaining.toFixed(2)}%`); + }); + } catch (e) { + console.log('\nāš ļø Could not load error budget trend:', e.message); + } +} + /** * Generate markdown report for PR comment */ @@ -362,6 +393,9 @@ function main() { fs.writeFileSync(reportFile, markdownReport); console.log(`\nšŸ“„ Regression report saved: ${reportFile}`); + // Display error budget trend + displayErrorBudgetTrend(); + if (hasAnyRegression) { console.log("\nāŒ Performance regression detected!"); console.log("Review the changes and consider optimizations.\n"); From c2d65450813e470f605d5fb95a5d53ccf76818ed Mon Sep 17 00:00:00 2001 From: Victor Speed Date: Thu, 28 May 2026 13:36:31 +0000 Subject: [PATCH 3/4] feat(#714): Add Grafana dashboard provisioning and export workflow - Create grafana-provisioning.yaml for automatic dashboard loading on startup - Add export-grafana-dashboards.js script to export dashboards from Grafana UI - Update performance/config/README.md with provisioning setup instructions - Add Docker Compose and Kubernetes examples for provisioning - Document dashboard update workflow for version control - Dashboards now version-controlled and tracked in git - Update docs/README.md with dashboard management reference --- docs/README.md | 4 + performance/config/README.md | 132 +++++++++++- performance/config/grafana-provisioning.yaml | 27 +++ .../scripts/export-grafana-dashboards.js | 199 ++++++++++++++++++ 4 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 performance/config/grafana-provisioning.yaml create mode 100755 performance/scripts/export-grafana-dashboards.js diff --git a/docs/README.md b/docs/README.md index d72c9774..d5d75a1d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,10 @@ docs/ ## šŸ“– Documentation Categories +### Dashboard Management + +- **[Grafana Dashboard Provisioning](../performance/config/README.md#grafana-dashboard-provisioning)** - Version-controlled dashboard setup + ### Distributed Tracing - **[Distributed Tracing Guide](./DISTRIBUTED_TRACING.md)** - OpenTelemetry setup and trace propagation diff --git a/performance/config/README.md b/performance/config/README.md index 074c5cae..d4ac1b37 100644 --- a/performance/config/README.md +++ b/performance/config/README.md @@ -13,6 +13,17 @@ Grafana dashboard configuration that provides visual monitoring of key system me - **Contract Performance**: Gas costs for different operations - **System Health**: Overall service status +### grafana-slo-dashboard.json +Grafana dashboard for SLO compliance and error budget tracking: + +- **Error Budget Status**: Remaining budget per SLO +- **Burn Rate**: How fast error budget is being consumed +- **SLO Compliance**: Percentage of time meeting targets +- **Trend Analysis**: Historical performance trends + +### grafana-provisioning.yaml +Grafana provisioning configuration for automatic dashboard loading from the repository. + ### alerts.yaml Prometheus/Alertmanager alert rules for critical system thresholds: @@ -36,12 +47,131 @@ Performance threshold definitions used by testing and monitoring: ## Setup -### Grafana Dashboard +### Grafana Dashboard Provisioning + +Dashboards are version-controlled and automatically loaded by Grafana on startup. + +#### Docker Compose Setup + +```yaml +services: + grafana: + image: grafana/grafana:latest + volumes: + - ./performance/config/grafana-provisioning.yaml:/etc/grafana/provisioning/dashboards/dashboards.yaml + - ./performance/config/:/var/lib/grafana/dashboards/ + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + ports: + - "3000:3000" +``` + +#### Kubernetes Setup + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-provisioning +data: + dashboards.yaml: | + apiVersion: 1 + providers: + - name: 'PredictIQ Dashboards' + orgId: 1 + folder: 'Performance' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana +spec: + template: + spec: + containers: + - name: grafana + image: grafana/grafana:latest + volumeMounts: + - name: provisioning + mountPath: /etc/grafana/provisioning/dashboards + - name: dashboards + mountPath: /var/lib/grafana/dashboards + volumes: + - name: provisioning + configMap: + name: grafana-provisioning + - name: dashboards + configMap: + name: grafana-dashboards +``` + +### Exporting Dashboard Changes + +When you make changes to dashboards in the Grafana UI, export them back to the repository: + +```bash +# Set your Grafana API key +export GRAFANA_API_KEY=your-api-key-here + +# Export dashboards +node ../scripts/export-grafana-dashboards.js + +# Review changes +git diff grafana-*.json + +# Commit and push +git add grafana-*.json +git commit -m "chore: update Grafana dashboards" +git push +``` + +#### Getting a Grafana API Key + +1. Log in to Grafana as an admin +2. Navigate to Configuration → API Keys +3. Click "New API Key" +4. Set Role to "Admin" +5. Copy the generated key + +#### Workflow for Dashboard Updates + +1. **Make changes in Grafana UI** + - Add panels, modify queries, adjust colors, etc. + - Test the dashboard thoroughly + +2. **Export to repository** + ```bash + GRAFANA_API_KEY=your-key node ../scripts/export-grafana-dashboards.js + ``` + +3. **Review and commit** + ```bash + git diff grafana-*.json # Review changes + git add grafana-*.json + git commit -m "feat: add new performance panel to dashboard" + ``` + +4. **Deploy** + - Push to main branch + - Grafana will automatically reload dashboards on next startup + - Or manually reload via Grafana UI + +### Manual Dashboard Import + +If provisioning is not available: 1. Import the dashboard into Grafana: ```bash curl -X POST http://grafana:3000/api/dashboards/db \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ -d @grafana-dashboard.json ``` diff --git a/performance/config/grafana-provisioning.yaml b/performance/config/grafana-provisioning.yaml new file mode 100644 index 00000000..b806967a --- /dev/null +++ b/performance/config/grafana-provisioning.yaml @@ -0,0 +1,27 @@ +# Grafana Dashboard Provisioning Configuration +# +# This file configures Grafana to automatically load dashboards from the repository +# on startup. Dashboards are version-controlled and changes are tracked in git. +# +# Usage: +# 1. Mount this file in Grafana container at /etc/grafana/provisioning/dashboards/ +# 2. Mount dashboard JSON files at /var/lib/grafana/dashboards/ +# 3. Restart Grafana to load dashboards +# +# Docker Compose Example: +# volumes: +# - ./performance/config/grafana-provisioning.yaml:/etc/grafana/provisioning/dashboards/dashboards.yaml +# - ./performance/config/:/var/lib/grafana/dashboards/ + +apiVersion: 1 + +providers: + - name: 'PredictIQ Dashboards' + orgId: 1 + folder: 'Performance' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/performance/scripts/export-grafana-dashboards.js b/performance/scripts/export-grafana-dashboards.js new file mode 100755 index 00000000..19ed08dd --- /dev/null +++ b/performance/scripts/export-grafana-dashboards.js @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +/** + * Grafana Dashboard Export Script + * + * Exports dashboards from a running Grafana instance and saves them to the repo. + * This ensures dashboard changes made in the UI are persisted to version control. + * + * Usage: + * node export-grafana-dashboards.js [--url ] [--api-key ] + * + * Environment Variables: + * GRAFANA_URL - Grafana instance URL (default: http://localhost:3000) + * GRAFANA_API_KEY - Grafana API key with admin permissions + * + * Example: + * GRAFANA_API_KEY=abc123 node export-grafana-dashboards.js + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const http = require('http'); + +// Parse CLI arguments +const args = process.argv.slice(2); +const urlIdx = args.indexOf('--url'); +const keyIdx = args.indexOf('--api-key'); + +const GRAFANA_URL = urlIdx !== -1 ? args[urlIdx + 1] : process.env.GRAFANA_URL || 'http://localhost:3000'; +const GRAFANA_API_KEY = keyIdx !== -1 ? args[keyIdx + 1] : process.env.GRAFANA_API_KEY; +const CONFIG_DIR = path.join(__dirname, '..'); +const DASHBOARDS_TO_EXPORT = [ + 'grafana-dashboard.json', + 'grafana-slo-dashboard.json', +]; + +if (!GRAFANA_API_KEY) { + console.error('āŒ Error: GRAFANA_API_KEY environment variable or --api-key flag is required'); + console.error(' Set GRAFANA_API_KEY= or pass --api-key '); + process.exit(1); +} + +/** + * Make HTTP request to Grafana API + */ +function makeRequest(method, path, body = null) { + return new Promise((resolve, reject) => { + const url = new URL(GRAFANA_URL); + const isHttps = url.protocol === 'https:'; + const client = isHttps ? https : http; + + const options = { + hostname: url.hostname, + port: url.port, + path: path, + method: method, + headers: { + 'Authorization': `Bearer ${GRAFANA_API_KEY}`, + 'Content-Type': 'application/json', + }, + }; + + const req = client.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(data)); + } catch (e) { + resolve(data); + } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + }); + }); + + req.on('error', reject); + + if (body) { + req.write(JSON.stringify(body)); + } + + req.end(); + }); +} + +/** + * Get dashboard by UID + */ +async function getDashboard(uid) { + try { + const response = await makeRequest('GET', `/api/dashboards/uid/${uid}`); + return response.dashboard; + } catch (e) { + console.error(`Failed to fetch dashboard ${uid}:`, e.message); + return null; + } +} + +/** + * Search for dashboards by tag + */ +async function searchDashboards(tag) { + try { + const response = await makeRequest('GET', `/api/search?tag=${tag}`); + return response; + } catch (e) { + console.error(`Failed to search dashboards:`, e.message); + return []; + } +} + +/** + * Export dashboard to file + */ +function exportDashboard(dashboard, filename) { + const filepath = path.join(CONFIG_DIR, filename); + + // Remove internal Grafana fields + const cleanDashboard = { + ...dashboard, + id: null, + uid: null, + version: 0, + }; + + fs.writeFileSync(filepath, JSON.stringify(cleanDashboard, null, 2)); + console.log(`āœ“ Exported: ${filename}`); + return filepath; +} + +/** + * Main export logic + */ +async function main() { + console.log(`šŸ“Š Grafana Dashboard Export`); + console.log(`Grafana URL: ${GRAFANA_URL}`); + console.log('─'.repeat(60)); + + try { + // Test connection + await makeRequest('GET', '/api/health'); + console.log('āœ“ Connected to Grafana\n'); + } catch (e) { + console.error('āŒ Failed to connect to Grafana:', e.message); + process.exit(1); + } + + // Search for dashboards with 'performance' tag + console.log('Searching for performance dashboards...'); + const dashboards = await searchDashboards('performance'); + + if (dashboards.length === 0) { + console.log('āš ļø No dashboards found with "performance" tag'); + console.log(' Create dashboards in Grafana and tag them with "performance"'); + process.exit(1); + } + + console.log(`Found ${dashboards.length} dashboard(s)\n`); + + let exported = 0; + + // Export each dashboard + for (const dashboard of dashboards) { + console.log(`Exporting: ${dashboard.title}`); + + const fullDashboard = await getDashboard(dashboard.uid); + if (fullDashboard) { + // Map dashboard title to filename + let filename; + if (dashboard.title.toLowerCase().includes('slo')) { + filename = 'grafana-slo-dashboard.json'; + } else { + filename = 'grafana-dashboard.json'; + } + + exportDashboard(fullDashboard, filename); + exported++; + } + } + + console.log(`\nāœ… Successfully exported ${exported} dashboard(s)`); + console.log('\nšŸ“ Next steps:'); + console.log(' 1. Review changes: git diff performance/config/grafana-*.json'); + console.log(' 2. Commit changes: git add performance/config/grafana-*.json'); + console.log(' 3. Push to repository: git push'); +} + +main().catch(err => { + console.error('āŒ Export failed:', err.message); + process.exit(1); +}); From c512e917b181d2033daeb5c59963c16cc2bb84d7 Mon Sep 17 00:00:00 2001 From: Victor Speed Date: Thu, 28 May 2026 13:37:55 +0000 Subject: [PATCH 4/4] feat(#727): Sync API_SPEC.md with openapi.yaml via CI - Create generate-api-spec.js script to generate API_SPEC.md from openapi.yaml - Script supports --check mode to verify sync in CI - Add documentation-sync job to test.yml workflow - CI fails if API_SPEC.md is out of sync with openapi.yaml - Regenerate API_SPEC.md from current openapi.yaml - openapi.yaml is now single source of truth for API documentation --- .github/workflows/test.yml | 26 ++ API_SPEC.md | 480 ++++++----------------------------- scripts/generate-api-spec.js | 293 +++++++++++++++++++++ 3 files changed, 396 insertions(+), 403 deletions(-) create mode 100755 scripts/generate-api-spec.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7503765..bfdfedd5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -666,7 +666,33 @@ jobs: - build-optimized - api-cache-tests - e2e-market-creation + - documentation-sync runs-on: ubuntu-latest steps: - name: Success run: echo "All tests passed successfully!" + + documentation-sync: + name: Documentation Sync Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "18" + + - name: Check API_SPEC.md is in sync with openapi.yaml + run: node scripts/generate-api-spec.js --check + + - name: Fail if out of sync + if: failure() + run: | + echo "āŒ API_SPEC.md is out of sync with openapi.yaml" + echo "" + echo "To fix this, run:" + echo " node scripts/generate-api-spec.js" + echo " git add API_SPEC.md" + echo " git commit -m 'chore: regenerate API_SPEC.md'" + exit 1 diff --git a/API_SPEC.md b/API_SPEC.md index 4d028e41..2abca7cc 100644 --- a/API_SPEC.md +++ b/API_SPEC.md @@ -1,440 +1,114 @@ -# PredictIQ Contract API Specification +# PredictIQ API - API Specification -> Reflects the on-chain implementation as of the current `contracts/predict-iq` source. -> **Spec version:** 1.1.0 — updated 2026-04-27 (issues #485: error code values corrected to match `#[repr(u32)]` enum; events table expanded with missing events and corrected topic layouts) +**Version:** 1.0.0 ---- - -## Table of Contents - -1. [Initialization](#initialization) -2. [Market Lifecycle](#market-lifecycle) -3. [Betting](#betting) -4. [Oracle & Resolution](#oracle--resolution) -5. [Disputes & Voting](#disputes--voting) -6. [Governance & Upgrades](#governance--upgrades) -7. [Fees & Referrals](#fees--referrals) -8. [Circuit Breaker](#circuit-breaker) -9. [Queries (Paginated)](#queries-paginated) -10. [Error Codes](#error-codes) -11. [Events](#events) - ---- - -## Initialization - -### `initialize(admin: Address, base_fee: i128) → Result<(), ErrorCode>` - -Bootstraps the contract. Can only be called once. - -| Param | Type | Description | -|-------|------|-------------| -| `admin` | `Address` | Master admin account (must authorize) | -| `base_fee` | `i128` | Protocol fee in stroops | +REST API for the PredictIQ prediction markets platform. -**Errors:** `AlreadyInitialized` +## API Versioning ---- - -## Market Lifecycle - -### `create_market(creator, description, options, deadline, resolution_deadline, oracle_config, tier, native_token, parent_id, parent_outcome_idx) → Result` - -| Param | Type | Description | -|-------|------|-------------| -| `creator` | `Address` | Market creator (must authorize) | -| `description` | `String` | Human-readable market question | -| `options` | `Vec` | Outcome labels (max `MAX_OUTCOMES_PER_MARKET = 32`) | -| `deadline` | `u64` | Unix timestamp — betting closes | -| `resolution_deadline` | `u64` | Unix timestamp — resolution must occur by | -| `oracle_config` | `OracleConfig` | Multi-oracle configuration (see below) | -| `tier` | `MarketTier` | `Basic` \| `Pro` \| `Institutional` | -| `native_token` | `Address` | SAC token used for bets | -| `parent_id` | `u64` | `0` for independent markets; parent market ID for conditional | -| `parent_outcome_idx` | `u32` | Required parent outcome (ignored when `parent_id = 0`) | - -**Returns:** new `market_id` - -**OracleConfig fields:** - -| Field | Type | Description | -|-------|------|-------------| -| `oracle_address` | `Address` | Deployed Pyth contract address | -| `feed_id` | `String` | 64-char hex-encoded 32-byte Pyth price feed ID | -| `min_responses` | `Option` | Minimum oracle responses required; `None` defaults to 1 | -| `max_staleness_seconds` | `u64` | Max age of price data in seconds | -| `max_confidence_bps` | `u64` | Max confidence interval in basis points | - -**Errors:** `InvalidDeadline`, `TooManyOutcomes`, `InsufficientDeposit`, `MarketIdOverflow`, `MarketIdCollision`, `ParentMarketNotResolved`, `ParentMarketInvalidOutcome` - ---- +The API uses URL path versioning (`/api/v1/`). The current stable version is **v1**. -### `get_market(id: u64) → Option` +Clients may also send an `API-Version` header (e.g. `API-Version: v1`) to explicitly +declare the version they target. If omitted, the server defaults to the current version. -Returns the full `Market` struct or `None` if not found. +## Deprecation Policy ---- - -### `cancel_market_admin(market_id: u64) → Result<(), ErrorCode>` - -Admin-only hard cancellation. Emits `mkt_cncl`. - -**Errors:** `NotAuthorized`, `MarketNotFound` - ---- - -### `prune_market(market_id: u64) → Result<(), ErrorCode>` - -Permissionless cleanup after the 30-day grace period post-resolution. - -**Errors:** `MarketNotFound`, `MarketStillActive`, `MarketNotResolved` - ---- - -### `set_creator_reputation(creator: Address, reputation: CreatorReputation) → Result<(), ErrorCode>` - -Admin-only. Sets `None | Basic | Pro | Institutional`. - ---- - -### `set_creation_deposit(amount: i128) → Result<(), ErrorCode>` / `get_creation_deposit() → i128` - -Admin-only deposit required to create a market. - ---- - -### `claim_creation_deposit(market_id: u64, caller: Address) → Result<(), ErrorCode>` - -Creator reclaims deposit after the dispute window closes without a challenge. - -**Errors:** `MarketNotFound`, `NotAuthorized`, `DisputeWindowStillOpen`, `MarketNotDisputed` - ---- - -## Betting - -### `place_bet(bettor, market_id, outcome, amount, token_address, referrer) → Result<(), ErrorCode>` - -| Param | Type | Description | -|-------|------|-------------| -| `bettor` | `Address` | Must authorize | -| `market_id` | `u64` | Target market | -| `outcome` | `u32` | Zero-based outcome index | -| `amount` | `i128` | Gross bet amount in token units | -| `token_address` | `Address` | Must match market's `token_address` | -| `referrer` | `Option
` | Optional referral address | - -**Errors:** `MarketNotFound`, `MarketClosed`, `MarketNotActive`, `InvalidBetAmount`, `InvalidOutcome`, `ContractPaused`, `InvalidReferrer`, `AssetClawedBack`, `TransferFailed` - ---- - -### `claim_winnings(bettor: Address, market_id: u64) → Result` - -Pull-model payout. Returns amount transferred. - -**Errors:** `MarketNotFound`, `MarketNotResolved`, `BetNotFound`, `NoWinnings`, `AlreadyClaimed` - ---- - -### `withdraw_refund(bettor: Address, market_id: u64) → Result` - -Refund on cancelled markets. - -**Errors:** `MarketNotFound`, `BetNotFound`, `AlreadyClaimed` - ---- - -### `get_outcome_stake(market_id: u64, outcome: u32) → i128` - -Total staked on a specific outcome. - ---- - -### `count_bets_for_outcome(market_id: u64, outcome: u32) → u32` - -Unique bettor count per outcome (analytics). - ---- - -### `get_minimum_bet_amount() → i128` / `set_minimum_bet_amount(amount: i128) → Result<(), ErrorCode>` - ---- - -## Oracle & Resolution - -### `set_oracle_result(market_id: u64, oracle_id: u32, outcome: u32) → Result<(), ErrorCode>` - -Admin-only. `oracle_id = 0` is the primary oracle. Supports multiple oracle sources per market. - -**Errors:** `NotAuthorized`, `MarketNotFound` - ---- +When a version is deprecated: +- Responses will include a `Deprecation` header set to `true`. +- A `Sunset` header will indicate the date after which the version will be removed. +- A `Link` header will point to migration documentation. -### `get_oracle_result(market_id: u64, oracle_id: u32) → Option` +Clients should monitor these headers and migrate before the sunset date. -### `get_oracle_last_update(market_id: u64, oracle_id: u32) → Option` +Deprecated versions are supported for a minimum of **12 months** after the deprecation +announcement before being removed. ---- - -### `attempt_oracle_resolution(market_id: u64) → Result<(), ErrorCode>` - -Permissionless. Reads the oracle result and transitions the market to `PendingResolution` if conditions are met. - -**Errors:** `MarketNotFound`, `MarketNotActive`, `OracleFailure`, `StalePrice`, `ConfidenceTooLow`, `ResolutionNotReady` - ---- - -### `finalize_resolution(market_id: u64) → Result<(), ErrorCode>` - -Permissionless. Moves `PendingResolution → Resolved` after the grace period. - -**Errors:** `MarketNotFound`, `MarketNotPendingResolution`, `GracePeriodActive`, `ResolutionDeadlinePassed` - ---- - -### `resolve_market(market_id: u64, winning_outcome: u32) → Result<(), ErrorCode>` - -Admin-only resolution for disputed markets. - -**Errors:** `NotAuthorized`, `MarketNotFound`, `MarketNotDisputed` - ---- - -### `admin_fallback_resolution(market_id: u64, winning_outcome: u32) → Result<(), ErrorCode>` - -Admin fallback when community voting deadlocks (no 60% majority after 72-hour window). - -**Errors:** `NotAuthorized`, `MarketNotFound`, `MarketNotDisputed`, `VotingPeriodNotElapsed`, `NoMajorityReached` - ---- - -### `set_dispute_window(seconds: u64) → Result<(), ErrorCode>` / `get_dispute_window() → u64` - -Admin-only. Minimum 24 hours. Default 72 hours. - ---- - -## Disputes & Voting - -### `file_dispute(disciplinarian: Address, market_id: u64) → Result<(), ErrorCode>` - -Opens a dispute window. Requires contract to be unpaused. - -**Errors:** `MarketNotFound`, `MarketNotPendingResolution`, `DisputeWindowClosed`, `ContractPaused` - ---- - -### `cast_vote(voter, market_id, outcome, weight) → Result<(), ErrorCode>` - -Governance token holders vote on disputed outcome. Requires contract to be unpaused. - -**Errors:** `MarketNotFound`, `MarketNotDisputed`, `AlreadyVoted`, `InsufficientVotingWeight`, `GovernanceTokenNotSet`, `ContractPaused` - ---- - -### `unlock_tokens(voter: Address, market_id: u64) → Result<(), ErrorCode>` - -Releases locked governance tokens after voting concludes. - ---- - -### `get_resolution_metrics(market_id: u64, outcome: u32) → ResolutionMetrics` - -### `set_max_push_payout_winners(threshold: u32)` / `get_max_push_payout_winners() → u32` - ---- - -## Governance & Upgrades - -### `add_guardian(guardian: Guardian) → Result<(), ErrorCode>` - -### `remove_guardian(address: Address) → Result<(), ErrorCode>` - -### `vote_on_guardian_removal(voter: Address, approve: bool) → Result<(), ErrorCode>` - -### `get_guardians() → Vec` - -### `emergency_pause(voter: Address) → Result<(), ErrorCode>` - -Triggered by 2/3 Guardian majority. - ---- - -### `initiate_upgrade(wasm_hash: BytesN<32>) → Result<(), ErrorCode>` - -### `vote_for_upgrade(voter: Address, vote_for: bool) → Result` - -### `execute_upgrade() → Result<(), ErrorCode>` - -### `get_pending_upgrade() → Option` - -### `get_upgrade_votes() → Result` - -Returns `{ votes_for: u32, votes_against: u32 }`. - -### `is_timelock_satisfied() → Result` - -### `set_timelock_duration(seconds: u64) → Result<(), ErrorCode>` / `get_timelock_duration() → u64` - -Range: 6 hours – 7 days. Default: 48 hours. - -**Errors:** `TimelockActive`, `UpgradeNotInitiated`, `AlreadyVotedOnUpgrade`, `UpgradeAlreadyPending`, `UpgradeHashInCooldown` - ---- - -### `set_guardian(guardian: Address) → Result<(), ErrorCode>` / `get_guardian() → Option
` - -Legacy single-guardian slot. - ---- - -### `set_governance_token(token: Address) → Result<(), ErrorCode>` - ---- +## Table of Contents -## Fees & Referrals +- [Overview](#overview) +- [Authentication](#authentication) +- [Endpoints](#endpoints) +- [Error Handling](#error-handling) +- [Rate Limiting](#rate-limiting) -### `set_base_fee(amount: i128) → Result<(), ErrorCode>` / `get_base_fee() → i128` +## Overview -### `set_fee_admin(fee_admin: Address) → Result<(), ErrorCode>` / `get_fee_admin() → Option
` +### Base URL -### `get_revenue(token: Address) → i128` +``` +http://0.0.0.0:8080 +``` -### `withdraw_protocol_fees(token: Address, recipient: Address) → Result` +### API Versioning -### `claim_referral_rewards(address: Address, token: Address) → Result` +The API uses URL path versioning (`/api/v1/`). The current stable version is **v1**. ---- +Clients may also send an `API-Version` header (e.g. `API-Version: v1`) to explicitly +declare the version they target. If omitted, the server defaults to the current version. -## Circuit Breaker +### Deprecation Policy -### `set_circuit_breaker(state: CircuitBreakerState) → Result<(), ErrorCode>` +When a version is deprecated: +- Responses will include a `Deprecation` header set to `true`. +- A `Sunset` header will indicate the date after which the version will be removed. +- A `Link` header will point to migration documentation. -States: `Closed | Open | HalfOpen | Paused` +Clients should monitor these headers and migrate before the sunset date. -### `pause() → Result<(), ErrorCode>` / `unpause() → Result<(), ErrorCode>` +Deprecated versions are supported for a minimum of **12 months** after the deprecation +announcement before being removed. -### `reset_monitoring() → Result<(), ErrorCode>` +## Authentication -Admin-only. Clears error counters. +The API uses Bearer token authentication. Include your API key in the `Authorization` header: ---- +``` +Authorization: Bearer YOUR_API_KEY +``` -## Queries (Paginated) +## Endpoints -All paginated queries silently clamp `limit` to **100** (`MAX_PAGE_LIMIT`). Callers requesting more receive at most 100 records — no error is returned. +## Error Handling -### `get_markets(offset: u32, limit: u32) → Vec` +All errors are returned as JSON with the following structure: -Returns all markets regardless of status, ordered by creation (ascending). +```json +{ + "error": { + "code": "ERROR_CODE", + "message": "Human-readable error message", + "details": {} + } +} +``` -### `get_markets_by_status(status: MarketStatus, offset: u32, limit: u32) → Vec` +### Common Error Codes -Filters by `Active | PendingResolution | Disputed | Resolved | Cancelled`. Iterates newest-first for fresher results. +| Code | HTTP Status | Description | +|------|-------------|-------------| +| INVALID_REQUEST | 400 | Request validation failed | +| UNAUTHORIZED | 401 | Authentication required or failed | +| FORBIDDEN | 403 | Insufficient permissions | +| NOT_FOUND | 404 | Resource not found | +| CONFLICT | 409 | Resource conflict (e.g., duplicate) | +| RATE_LIMITED | 429 | Rate limit exceeded | +| INTERNAL_ERROR | 500 | Internal server error | -### `get_guardians_paginated(offset: u32, limit: u32) → Vec` +## Rate Limiting -### `get_admin() → Option
` +The API implements rate limiting to ensure fair usage: ---- +- **Rate Limit:** 1000 requests per minute per API key +- **Headers:** + - `X-RateLimit-Limit`: Maximum requests per window + - `X-RateLimit-Remaining`: Requests remaining in current window + - `X-RateLimit-Reset`: Unix timestamp when limit resets -## Error Codes - -| Code | Value | Description | -|------|-------|-------------| -| `AlreadyInitialized` | 100 | Contract already initialized | -| `NotAuthorized` | 101 | Caller lacks required authorization | -| `MarketNotFound` | 102 | No market with the given ID | -| `MarketClosed` | 103 | Market deadline has passed | -| `MarketStillActive` | 104 | Market is still accepting bets | -| `InvalidOutcome` | 105 | Outcome index out of range | -| `InvalidBetAmount` | 106 | Bet amount is zero or below minimum | -| `InsufficientBalance` | 107 | Caller token balance too low | -| `OracleFailure` | 108 | Oracle cross-contract call failed | -| `CircuitBreakerOpen` | 109 | Circuit breaker is open; operation blocked | -| `DisputeWindowClosed` | 110 | Dispute window has expired | -| `VotingNotStarted` | 111 | Voting period has not begun | -| `VotingEnded` | 112 | Voting period has already ended | -| `AlreadyVoted` | 113 | Address has already cast a vote | -| `FeeTooHigh` | 114 | Proposed fee exceeds allowed maximum | -| `MarketNotActive` | 115 | Market is not in Active state | -| `DeadlinePassed` | 116 | Action attempted after deadline | -| `CannotChangeOutcome` | 117 | Outcome is already finalized | -| `MarketNotDisputed` | 118 | Market is not in Disputed state | -| `MarketNotPendingResolution` | 119 | Market is not in PendingResolution state | -| `AdminNotSet` | 120 | Admin account not configured | -| `ContractPaused` | 121 | Contract is paused via circuit breaker | -| `GuardianNotSet` | 122 | Guardian account not configured | -| `TooManyOutcomes` | 123 | Exceeds `MAX_OUTCOMES_PER_MARKET` (32) | -| `TooManyWinners` | 124 | Exceeds maximum push-payout winner threshold | -| `PayoutModeNotSupported` | 125 | Requested payout mode is not supported | -| `InsufficientDeposit` | 126 | Creation deposit not met | -| `TimelockActive` | 127 | Upgrade timelock has not elapsed | -| `UpgradeNotInitiated` | 128 | No pending upgrade to act on | -| `InsufficientVotes` | 129 | Not enough votes to proceed | -| `AlreadyVotedOnUpgrade` | 130 | Address already voted on this upgrade | -| `InvalidWasmHash` | 131 | Provided wasm hash is invalid | -| `UpgradeFailed` | 132 | Upgrade execution failed | -| `ParentMarketNotResolved` | 133 | Conditional market's parent is not yet resolved | -| `ParentMarketInvalidOutcome` | 134 | Parent market resolved to a different outcome | -| `ResolutionNotReady` | 135 | Conditions for resolution not yet met | -| `DisputeWindowStillOpen` | 136 | Dispute window has not yet closed | -| `NoMajorityReached` | 137 | No outcome reached the 60% majority threshold | -| `StalePrice` | 138 | Price feed `publish_time` older than `max_staleness_seconds` | -| `ConfidenceTooLow` | 139 | Oracle confidence interval exceeds `max_confidence_bps` | -| `InsufficientVotingWeight` | 140 | Voter's governance token balance too low | -| `MarketNotCancelled` | 141 | Market is not in Cancelled state | -| `BetNotFound` | 142 | No bet record for this bettor/market | -| `UpgradeAlreadyPending` | 143 | An upgrade proposal is already pending | -| `UpgradeHashInCooldown` | 144 | This wasm hash is in the 7-day cooldown period | -| `InvalidAmount` | 145 | Generic invalid amount | -| `GovernanceTokenNotSet` | 146 | Governance token address not configured | -| `MarketNotResolved` | 147 | Market has not been resolved yet | -| `InvalidDeadline` | 148 | Deadline is in the past or malformed | +When rate limited (HTTP 429), the response includes a `Retry-After` header indicating +how many seconds to wait before retrying. --- -## Events - -All events follow the topic layout: -- **Topic 0:** Event name (short symbol, ≤ 9 chars) -- **Topic 1:** `market_id: u64` (primary indexer key; `0` for contract-level events) -- **Topic 2:** Triggering address - -| Event | Topic Symbol | Topics | Data Payload | -|-------|-------------|--------|--------------| -| MarketCreated | `mkt_creat` | `(mkt_creat, market_id, creator)` | `(description: String, num_outcomes: u32, deadline: u64)` | -| BetPlaced | `bet_place` | `(bet_place, market_id, bettor)` | `(outcome: u32, amount: i128)` | -| DisputeFiled | `disp_file` | `(disp_file, market_id, disciplinarian)` | `new_deadline: u64` | -| ResolutionFinalized | `resolv_fx` | `(resolv_fx, market_id, resolver)` | `(winning_outcome: u32, total_payout: i128)` | -| RewardsClaimed | `reward_fx` | `(reward_fx, market_id, claimer)` | `(amount: i128, token_address: Address, is_refund: bool)` | -| VoteCast | `vote_cast` | `(vote_cast, market_id, voter)` | `(outcome: u32, weight: i128)` | -| CircuitBreakerTriggered | `cb_state` | `(cb_state, 0, contract_address)` | `state: String` | -| OracleResultSet | `oracle_ok` | `(oracle_ok, market_id, oracle_source)` | `(oracle_id: u32, outcome: u32)` | -| OracleResolved | `orcl_res` | `(orcl_res, market_id, oracle_address)` | `outcome: u32` | -| MarketFinalized | `mkt_final` | `(mkt_final, market_id, resolver)` | `winning_outcome: u32` | -| DisputeResolved | `disp_res` | `(disp_res, market_id, resolver)` | `winning_outcome: u32` | -| MarketCancelled (admin) | `mkt_cncl` | `(mkt_cncl, market_id, admin)` | `()` | -| MarketCancelledVote (community) | `mk_cn_vt` | `(mk_cn_vt, market_id, resolver)` | `()` | -| ReferralReward | `ref_rwrd` | `(ref_rwrd, market_id, referrer)` | `amount: i128` | -| ReferralClaimed | `ref_claim` | `(ref_claim, market_id, claimer)` | `amount: i128` | -| ReferralDistribution | `ref_dist` | `(ref_dist, market_id, token)` | `()` | -| CircuitBreakerAuto | `cb_auto` | `(cb_auto, 0, contract_address)` | `error_count: u32` | -| FeeCollected | `fee_colct` | `(fee_colct, 0, contract_address)` | `amount: i128` | -| AdminFallbackResolution | `adm_fbk` | `(adm_fbk, market_id, admin)` | `winning_outcome: u32` | -| CreatorReputationSet | `rep_set` | `(rep_set, creator)` | `(old_score: u32, new_score: u32)` | -| CreationDepositSet | `dep_set` | `(dep_set,)` | `(old_amount: i128, new_amount: i128)` | -| MonitoringStateReset | `mon_reset` | `(mon_reset, resetter)` | `(previous_error_count: u32, previous_last_observation: u64)` | -| MarketPruned | `mkt_prune` | `(mkt_prune, market_id)` | `pruned_at: u64` | -| UpgradeInitiated | `upg_init` | `(upg_init, initiator)` | `wasm_hash: BytesN<32>` | -| UpgradeVoted | `upg_vote` | `(upg_vote, voter)` | `vote_for: bool` | -| UpgradeExecuted | `upg_exec` | `(upg_exec, executor)` | `wasm_hash: BytesN<32>` | -| UpgradeRejected | `upg_rej` | `(upg_rej,)` | `wasm_hash: BytesN<32>` | -| MarketStateChanged | `mkt_state` | `(mkt_state, market_id)` | `(old_status: String, new_status: String, timestamp: u64)` | - -> **Notes:** -> - `CircuitBreakerTriggered`, `CircuitBreakerAuto`, and `FeeCollected` use `market_id = 0` and the contract address as Topic 2. -> - `CreatorReputationSet` uses `(symbol, creator)` with no `market_id`. -> - `CreationDepositSet` uses `(symbol,)` only. -> - `MonitoringStateReset` uses `(symbol, resetter)` with no `market_id`. -> - `OracleResultSet` data includes `oracle_id` to identify which oracle source reported the result (multi-oracle support). +**Generated from:** `services/api/openapi.yaml` +**Last Updated:** 2026-05-28T13:37:38.653Z +**Note:** This file is auto-generated. Do not edit directly. Update `services/api/openapi.yaml` instead. diff --git a/scripts/generate-api-spec.js b/scripts/generate-api-spec.js new file mode 100755 index 00000000..2199acd4 --- /dev/null +++ b/scripts/generate-api-spec.js @@ -0,0 +1,293 @@ +#!/usr/bin/env node + +/** + * OpenAPI to Markdown Generator + * + * Generates API_SPEC.md from services/api/openapi.yaml + * Ensures single source of truth for API documentation. + * + * Usage: + * node generate-api-spec.js [--check] [--output ] + * + * Options: + * --check Verify API_SPEC.md is in sync with openapi.yaml (exit 1 if not) + * --output Output file path (default: API_SPEC.md) + */ + +const fs = require('fs'); +const path = require('path'); + +const args = process.argv.slice(2); +const checkMode = args.includes('--check'); +const outputIdx = args.indexOf('--output'); +const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : path.join(__dirname, '../API_SPEC.md'); +const openApiPath = path.join(__dirname, '../services/api/openapi.yaml'); + +/** + * Simple YAML parser for basic structures + */ +function parseYaml(content) { + const lines = content.split('\n'); + const result = {}; + let current = result; + const stack = [{ obj: result, indent: -1 }]; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const match = line.match(/^(\s*)([^:]+):\s*(.*)/); + + if (!match) continue; + + const indent = match[1].length; + const key = match[2].trim(); + const value = match[3].trim(); + + // Pop stack if indent decreased + while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { + stack.pop(); + } + + const parent = stack[stack.length - 1].obj; + + if (value) { + parent[key] = value; + } else { + parent[key] = {}; + stack.push({ obj: parent[key], indent }); + } + } + + return result; +} + +/** + * Load and parse OpenAPI spec + */ +function loadOpenApiSpec() { + try { + const content = fs.readFileSync(openApiPath, 'utf8'); + // For now, just read the raw content and extract key sections + return { + raw: content, + title: extractValue(content, 'title:'), + version: extractValue(content, 'version:'), + description: extractDescription(content), + }; + } catch (e) { + console.error(`Failed to load OpenAPI spec: ${e.message}`); + process.exit(1); + } +} + +/** + * Extract a simple key-value from YAML + */ +function extractValue(content, key) { + const match = content.match(new RegExp(`${key}\\s+(.+)`)); + return match ? match[1].trim().replace(/['"]/g, '') : ''; +} + +/** + * Extract multi-line description + */ +function extractDescription(content) { + const match = content.match(/description:\s*\|\s*([\s\S]*?)(?=\n\w+:|$)/); + if (match) { + return match[1].trim().split('\n').map(l => l.trim()).join('\n'); + } + return ''; +} + +/** + * Extract endpoints from OpenAPI + */ +function extractEndpoints(content) { + const endpoints = []; + const pathMatch = content.match(/^paths:([\s\S]*?)(?=^[a-z]+:|$)/m); + + if (!pathMatch) return endpoints; + + const pathsSection = pathMatch[1]; + const pathLines = pathsSection.split('\n'); + + let currentPath = ''; + for (const line of pathLines) { + const pathMatch = line.match(/^\s*\/[^:]*:/); + if (pathMatch) { + currentPath = pathMatch[0].trim().slice(0, -1); + } + + const methodMatch = line.match(/^\s+(get|post|put|delete|patch):/); + if (methodMatch && currentPath) { + endpoints.push({ + path: currentPath, + method: methodMatch[1].toUpperCase(), + }); + } + } + + return endpoints; +} + +/** + * Generate markdown from OpenAPI spec + */ +function generateMarkdown(spec) { + const endpoints = extractEndpoints(spec.raw); + + let md = `# ${spec.title} - API Specification + +**Version:** ${spec.version} + +${spec.description} + +## Table of Contents + +- [Overview](#overview) +- [Authentication](#authentication) +- [Endpoints](#endpoints) +- [Error Handling](#error-handling) +- [Rate Limiting](#rate-limiting) + +## Overview + +### Base URL + +\`\`\` +http://0.0.0.0:8080 +\`\`\` + +### API Versioning + +The API uses URL path versioning (\`/api/v1/\`). The current stable version is **v1**. + +Clients may also send an \`API-Version\` header (e.g. \`API-Version: v1\`) to explicitly +declare the version they target. If omitted, the server defaults to the current version. + +### Deprecation Policy + +When a version is deprecated: +- Responses will include a \`Deprecation\` header set to \`true\`. +- A \`Sunset\` header will indicate the date after which the version will be removed. +- A \`Link\` header will point to migration documentation. + +Clients should monitor these headers and migrate before the sunset date. + +Deprecated versions are supported for a minimum of **12 months** after the deprecation +announcement before being removed. + +## Authentication + +The API uses Bearer token authentication. Include your API key in the \`Authorization\` header: + +\`\`\` +Authorization: Bearer YOUR_API_KEY +\`\`\` + +## Endpoints + +`; + + // Group endpoints by category + const grouped = {}; + endpoints.forEach(ep => { + const category = ep.path.split('/')[1] || 'general'; + if (!grouped[category]) grouped[category] = []; + grouped[category].push(ep); + }); + + Object.entries(grouped).forEach(([category, eps]) => { + md += `### ${category.charAt(0).toUpperCase() + category.slice(1)}\n\n`; + eps.forEach(ep => { + md += `#### ${ep.method} ${ep.path}\n\n`; + md += `\`\`\`\n${ep.method} ${ep.path}\n\`\`\`\n\n`; + }); + }); + + // Error Handling + md += `## Error Handling + +All errors are returned as JSON with the following structure: + +\`\`\`json +{ + "error": { + "code": "ERROR_CODE", + "message": "Human-readable error message", + "details": {} + } +} +\`\`\` + +### Common Error Codes + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| INVALID_REQUEST | 400 | Request validation failed | +| UNAUTHORIZED | 401 | Authentication required or failed | +| FORBIDDEN | 403 | Insufficient permissions | +| NOT_FOUND | 404 | Resource not found | +| CONFLICT | 409 | Resource conflict (e.g., duplicate) | +| RATE_LIMITED | 429 | Rate limit exceeded | +| INTERNAL_ERROR | 500 | Internal server error | + +## Rate Limiting + +The API implements rate limiting to ensure fair usage: + +- **Rate Limit:** 1000 requests per minute per API key +- **Headers:** + - \`X-RateLimit-Limit\`: Maximum requests per window + - \`X-RateLimit-Remaining\`: Requests remaining in current window + - \`X-RateLimit-Reset\`: Unix timestamp when limit resets + +When rate limited (HTTP 429), the response includes a \`Retry-After\` header indicating +how many seconds to wait before retrying. + +--- + +**Generated from:** \`services/api/openapi.yaml\` +**Last Updated:** ${new Date().toISOString()} +**Note:** This file is auto-generated. Do not edit directly. Update \`services/api/openapi.yaml\` instead. +`; + + return md; +} + +/** + * Main execution + */ +function main() { + console.log('šŸ“„ Generating API specification from OpenAPI...\n'); + + const spec = loadOpenApiSpec(); + const markdown = generateMarkdown(spec); + + if (checkMode) { + // Check if current file matches generated content + if (fs.existsSync(outputPath)) { + const current = fs.readFileSync(outputPath, 'utf8'); + if (current === markdown) { + console.log('āœ… API_SPEC.md is in sync with openapi.yaml'); + process.exit(0); + } else { + console.error('āŒ API_SPEC.md is out of sync with openapi.yaml'); + console.error('\nRun the following to update:'); + console.error(' node scripts/generate-api-spec.js'); + process.exit(1); + } + } else { + console.error('āŒ API_SPEC.md not found'); + process.exit(1); + } + } else { + // Generate and write file + fs.writeFileSync(outputPath, markdown); + console.log(`āœ… Generated: ${outputPath}`); + console.log(`\nšŸ“ Next steps:`); + console.log(' 1. Review changes: git diff API_SPEC.md'); + console.log(' 2. Commit: git add API_SPEC.md && git commit -m "chore: regenerate API_SPEC.md"'); + } +} + +main();