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
466 changes: 466 additions & 0 deletions docs/OBSERVABILITY.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default [
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-wrapper-object-types': 'off',
'no-console': ['error', { allow: ['warn', 'error'] }],
},
},
]
39 changes: 38 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"node-cron": "^4.2.1",
"prom-client": "^15.1.3",
"twilio": "^4.11.0",
"winston": "^3.19.0",
"zod": "^4.3.6"
Expand Down
18 changes: 9 additions & 9 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

async function main() {
console.log('🌱 Seeding NeuroWealth database...')
console.warn('🌱 Seeding NeuroWealth database...')

// ── 1. Test User ─────────────────────────────────────────────────────────────
const user = await prisma.user.upsert({
Expand All @@ -18,7 +18,7 @@ async function main() {
riskTolerance: 6,
},
})
console.log(`βœ… User: ${user.displayName} (${user.id})`)
console.warn(`βœ… User: ${user.displayName} (${user.id})`)

// ── 2. Protocol Rates ─────────────────────────────────────────────────────────
const blendRate = await prisma.protocolRate.create({
Expand All @@ -40,7 +40,7 @@ async function main() {
network: 'TESTNET',
},
})
console.log(`βœ… Protocol rates: Blend ${Number(blendRate.supplyApy) * 100}% APY, Aqua ${Number(aquaRate.supplyApy) * 100}% APY`)
console.warn(`βœ… Protocol rates: Blend ${Number(blendRate.supplyApy) * 100}% APY, Aqua ${Number(aquaRate.supplyApy) * 100}% APY`)

// ── 3. Position ───────────────────────────────────────────────────────────────
const position = await prisma.position.create({
Expand All @@ -54,7 +54,7 @@ async function main() {
status: 'ACTIVE',
},
})
console.log(`βœ… Position: ${position.depositedAmount} USDC on ${position.protocolName}`)
console.warn(`βœ… Position: ${position.depositedAmount} USDC on ${position.protocolName}`)

// ── 4. Deposit Transaction ────────────────────────────────────────────────────
const depositTx = await prisma.transaction.create({
Expand All @@ -73,7 +73,7 @@ async function main() {
confirmedAt: new Date(),
},
})
console.log(`βœ… Transaction: ${depositTx.amount} ${depositTx.assetSymbol} (${depositTx.txHash?.slice(0, 16)}...)`)
console.warn(`βœ… Transaction: ${depositTx.amount} ${depositTx.assetSymbol} (${depositTx.txHash?.slice(0, 16)}...)`)

// ── 5. Yield Snapshot ─────────────────────────────────────────────────────────
const snapshot = await prisma.yieldSnapshot.create({
Expand All @@ -84,7 +84,7 @@ async function main() {
principalAmount: 5000,
},
})
console.log(`βœ… Yield snapshot: ${Number(snapshot.apy) * 100}% APY`)
console.warn(`βœ… Yield snapshot: ${Number(snapshot.apy) * 100}% APY`)

// ── 6. Agent Log ──────────────────────────────────────────────────────────────
await prisma.agentLog.create({
Expand All @@ -108,7 +108,7 @@ async function main() {
durationMs: 1240,
},
})
console.log(`βœ… Agent log: DEPOSIT β†’ SUCCESS`)
console.warn(`βœ… Agent log: DEPOSIT β†’ SUCCESS`)

// ── 7. Session ────────────────────────────────────────────────────────────────
await prisma.session.create({
Expand All @@ -122,9 +122,9 @@ async function main() {
userAgent: 'NeuroWealth/1.0 Seed',
},
})
console.log(`βœ… Session created`)
console.warn(`βœ… Session created`)

console.log('\nπŸŽ‰ Seed complete! Run: npx prisma studio')
console.warn('\nπŸŽ‰ Seed complete! Run: npx prisma studio')
}

main()
Expand Down
37 changes: 35 additions & 2 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import { scanAllProtocols } from './scanner';
import { executeRebalanceIfNeeded, getThresholds, logAgentAction } from './router';
import { captureAllUserBalances, cleanupOldSnapshots } from './snapshotter';
import { PrismaClient } from '@prisma/client';
import {
updateAgentHeartbeat,
updateAgentStatus,
recordRebalanceCheck,
recordRebalanceTriggered,
recordDbOperation
} from '../utils/metrics';

const prisma = new PrismaClient();

Expand Down Expand Up @@ -64,6 +71,8 @@ async function rebalanceCheckJob(): Promise<void> {

try {
logger.info(`${jobName} started`);
// Update heartbeat
updateAgentHeartbeat();

// Get all active positions
const positions = await prisma.position.findMany({
Expand All @@ -77,6 +86,7 @@ async function rebalanceCheckJob(): Promise<void> {

if (positions.length === 0) {
logger.info('No active positions to rebalance');
recordRebalanceCheck('success');
return;
}

Expand Down Expand Up @@ -110,6 +120,7 @@ async function rebalanceCheckJob(): Promise<void> {
lastRebalanceAt = new Date();
currentProtocol = result.toProtocol;
currentApy = result.improvedBy;
recordRebalanceTriggered();
}
}

Expand All @@ -121,6 +132,10 @@ async function rebalanceCheckJob(): Promise<void> {
duration,
});

// Record Prometheus metrics
recordRebalanceCheck('success');
recordDbOperation('rebalance_check', duration / 1000);

logger.info(`${jobName} completed`, {
duration,
positionsChecked: positions.length,
Expand All @@ -132,11 +147,16 @@ async function rebalanceCheckJob(): Promise<void> {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
lastError = errorMessage;

const duration = Date.now() - startTime;
logger.error(`${jobName} failed`, {
error: errorMessage,
duration: Date.now() - startTime,
duration,
});

// Record Prometheus metrics
recordRebalanceCheck('failed');
recordDbOperation('rebalance_check', duration / 1000);

await logAgentAction('ANALYZE', 'FAILED', {
error: errorMessage,
});
Expand All @@ -152,6 +172,8 @@ async function snapshotJob(): Promise<void> {

try {
logger.info(`${jobName} started`);
// Update heartbeat
updateAgentHeartbeat();

// Run snapshot in background to avoid blocking rebalance checks
captureAllUserBalances().catch(error => {
Expand All @@ -171,13 +193,18 @@ async function snapshotJob(): Promise<void> {
}

const duration = Date.now() - startTime;
// Record Prometheus metrics
recordDbOperation('snapshot_job', duration / 1000);
logger.info(`${jobName} scheduled`, { duration });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const duration = Date.now() - startTime;
logger.error(`${jobName} failed`, {
error: errorMessage,
duration: Date.now() - startTime,
duration,
});
// Record Prometheus metrics
recordDbOperation('snapshot_job', duration / 1000);
}
}

Expand All @@ -193,6 +220,9 @@ export async function startAgentLoop(): Promise<void> {

try {
logger.info('πŸ€– Starting NeuroWealth Agent Loop');
// Update Prometheus metrics
updateAgentStatus('running');
updateAgentHeartbeat();

// Run jobs immediately on startup
logger.info('Running initial jobs...');
Expand All @@ -217,6 +247,7 @@ export async function startAgentLoop(): Promise<void> {
const scanJob = cron.schedule('0 2 * * *', async () => {
try {
logger.info('Daily protocol scan started');
updateAgentHeartbeat();
const protocols = await scanAllProtocols();
logger.info('Daily protocol scan complete', {
protocolsScanned: protocols.length,
Expand All @@ -238,6 +269,8 @@ export async function startAgentLoop(): Promise<void> {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
lastError = errorMessage;
// Update Prometheus metrics for error state
updateAgentStatus('degraded');
logger.error('Failed to start agent loop', { error: errorMessage });
throw error;
}
Expand Down
3 changes: 2 additions & 1 deletion src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import dotenv from 'dotenv'
import { logger } from '../utils/logger'
dotenv.config()

function requireEnv(key: string): string {
Expand Down Expand Up @@ -109,7 +110,7 @@ function validateStellarKey(secretKey: string, network: 'testnet' | 'mainnet' |
}

const env = process.env.NODE_ENV || 'development'
console.log(`βœ“ Stellar Agent configured for ${network.toUpperCase()} (NODE_ENV=${env})`)
logger.info(`βœ“ Stellar Agent configured for ${network.toUpperCase()} (NODE_ENV=${env})`)

if (network === 'mainnet' && env !== 'production') {
console.warn(
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import withdrawRouter from './routes/withdraw'
import vaultRouter from './routes/vault'
import analyticsRouter from './routes/analytics'
import adminRouter from './routes/admin'
import metricsRouter from './routes/metrics'
import { corsMiddleware, jsonBodyParser, payloadSizeErrorHandler, urlencodedBodyParser } from './middleware/corsandbody'

// ── Readiness state ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -118,6 +119,7 @@ app.use('/api/withdraw', withdrawRouter)
app.use('/api/vault', vaultRouter)
app.use('/api/analytics', analyticsRouter)

app.use('/metrics', metricsRouter)
// Admin routes (protected, strictest rate limit)
app.use('/api/admin', adminRateLimiter, adminRouter)

Expand Down
3 changes: 2 additions & 1 deletion src/middleware/authenticate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextFunction, Request, Response } from 'express';
import { JwtAdapter } from '../config';
import db from '../db';
import { logger } from '../utils/logger';

export class AuthMiddleware {
/**
Expand Down Expand Up @@ -77,7 +78,7 @@ export class AuthMiddleware {

next();
} catch (error) {
console.error('[Auth] Middleware error:', error);
logger.error('[Auth] Middleware error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
Expand Down
33 changes: 33 additions & 0 deletions src/routes/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Router, Request, Response } from 'express'
import { getMetrics } from '../utils/metrics'

const router = Router()

/**
* GET /metrics
* Prometheus-compatible metrics endpoint for observability
*
* Exposes all registered metrics in Prometheus format for scraping by
* Prometheus, Grafana, or other monitoring systems.
*
* Metrics include:
* - Event processing counters and histograms
* - Failure metrics
* - DLQ size
* - Cursor lag
* - Agent loop heartbeat
* - Database operation metrics
* - HTTP request metrics
* - Analytics API metrics
*/
router.get('/', async (_req: Request, res: Response) => {
try {
const metrics = await getMetrics()
res.set('Content-Type', 'text/plain')
res.status(200).send(metrics)
} catch (error) {
res.status(500).json({ error: 'Failed to retrieve metrics' })
}
})

export default router
3 changes: 2 additions & 1 deletion src/routes/whatsapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import express, { Request, Response } from 'express'
import { validateRequest, twiml } from 'twilio'
import { handleWhatsAppMessage } from '../whatsapp/handler'
import { config } from '../config/env'
import { logger } from '../utils/logger'

const router = express.Router()

Expand Down Expand Up @@ -40,7 +41,7 @@ router.post('/webhook', async (req: Request, res: Response) => {
responseTwiml.message(response.body)
res.type('text/xml').send(responseTwiml.toString())
} catch (error) {
console.error('[WhatsApp webhook] error handling message:', error)
logger.error('[WhatsApp webhook] error handling message:', error)
const errorTwiml = new twiml.MessagingResponse()
errorTwiml.message('Sorry, something went wrong processing your request.')
res.type('text/xml').send(errorTwiml.toString())
Expand Down
Loading
Loading