Skip to content

Commit 1d2a0c2

Browse files
Merge pull request #1124 from supreme2580/task/concurrency-stat
feat: add per-developer concurrency stat endpoint at /api/admin/metrics
2 parents e178e7d + dbb6f86 commit 1d2a0c2

3 files changed

Lines changed: 224 additions & 0 deletions

File tree

src/routes/admin/metrics.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,120 @@ function buildApp(developerSemaphore: DeveloperSemaphore) {
3535
return app;
3636
}
3737

38+
// ---------------------------------------------------------------------------
39+
// GET /api/admin/metrics — per-developer concurrency stats overview
40+
// ---------------------------------------------------------------------------
41+
42+
describe('GET /api/admin/metrics', () => {
43+
let developerSemaphore: DeveloperSemaphore;
44+
45+
beforeEach(() => {
46+
developerSemaphore = new DeveloperSemaphore(5, 1000);
47+
});
48+
49+
afterEach(() => {
50+
developerSemaphore.clear();
51+
});
52+
53+
it('returns empty stats when no developers are active', async () => {
54+
const app = buildApp(developerSemaphore);
55+
56+
const res = await request(app)
57+
.get('/api/admin/metrics')
58+
.set('x-admin-api-key', ADMIN_KEY);
59+
60+
expect(res.status).toBe(200);
61+
expect(res.body.data).toMatchObject({
62+
totalActive: 0,
63+
activeDeveloperCount: 0,
64+
perDeveloper: [],
65+
campaign: 'GrantFox FWC26',
66+
});
67+
expect(res.body.data.maxConcurrencyPerDeveloper).toBe(5);
68+
});
69+
70+
it('returns per-developer stats with correct utilization', async () => {
71+
const app = buildApp(developerSemaphore);
72+
73+
await developerSemaphore.withSlot('dev_abc', async () => {
74+
await developerSemaphore.withSlot('dev_abc', async () => {
75+
await developerSemaphore.withSlot('dev_def', async () => {
76+
const res = await request(app)
77+
.get('/api/admin/metrics')
78+
.set('x-admin-api-key', ADMIN_KEY);
79+
80+
expect(res.status).toBe(200);
81+
expect(res.body.data.totalActive).toBe(3);
82+
expect(res.body.data.activeDeveloperCount).toBe(2);
83+
expect(res.body.data.perDeveloper).toHaveLength(2);
84+
expect(res.body.data.perDeveloper[0]).toMatchObject({
85+
developerId: 'dev_abc',
86+
activeCount: 2,
87+
atLimit: false,
88+
utilizationPercent: 40,
89+
});
90+
expect(res.body.data.perDeveloper[1]).toMatchObject({
91+
developerId: 'dev_def',
92+
activeCount: 1,
93+
atLimit: false,
94+
utilizationPercent: 20,
95+
});
96+
});
97+
});
98+
});
99+
});
100+
101+
it('reports atLimit and 100% utilization when exactly at the ceiling', async () => {
102+
const sem = new DeveloperSemaphore(1, 1000);
103+
const app = buildApp(sem);
104+
105+
await sem.withSlot('dev_maxed', async () => {
106+
const res = await request(app)
107+
.get('/api/admin/metrics')
108+
.set('x-admin-api-key', ADMIN_KEY);
109+
110+
expect(res.status).toBe(200);
111+
expect(res.body.data.perDeveloper[0]).toMatchObject({
112+
developerId: 'dev_maxed',
113+
activeCount: 1,
114+
atLimit: true,
115+
utilizationPercent: 100,
116+
});
117+
});
118+
sem.clear();
119+
});
120+
121+
it('sorts per-developer results by active count descending', async () => {
122+
const app = buildApp(developerSemaphore);
123+
124+
await developerSemaphore.withSlot('dev_light', async () => {
125+
await developerSemaphore.withSlot('dev_heavy', async () => {
126+
await developerSemaphore.withSlot('dev_heavy', async () => {
127+
await developerSemaphore.withSlot('dev_heavy', async () => {
128+
const res = await request(app)
129+
.get('/api/admin/metrics')
130+
.set('x-admin-api-key', ADMIN_KEY);
131+
132+
expect(res.status).toBe(200);
133+
expect(res.body.data.perDeveloper[0].developerId).toBe('dev_heavy');
134+
expect(res.body.data.perDeveloper[0].activeCount).toBe(3);
135+
expect(res.body.data.perDeveloper[1].developerId).toBe('dev_light');
136+
expect(res.body.data.perDeveloper[1].activeCount).toBe(1);
137+
});
138+
});
139+
});
140+
});
141+
});
142+
143+
it('requires admin authentication — 401 without credentials', async () => {
144+
const app = buildApp(developerSemaphore);
145+
146+
const res = await request(app).get('/api/admin/metrics');
147+
148+
expect(res.status).toBe(401);
149+
});
150+
});
151+
38152
// ---------------------------------------------------------------------------
39153
// GET /api/admin/metrics/concurrency — collection endpoint
40154
// ---------------------------------------------------------------------------

src/routes/admin/metrics.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getClientIp } from '../../lib/clientIp.js';
66
import { logger } from '../../logger.js';
77
import { validate } from '../../middleware/validate.js';
88
import { DeveloperSemaphore, sharedDeveloperSemaphore } from '../../utils/developerSemaphore.js';
9+
import { computeConcurrencyStats } from '../../services/concurrency.js';
910

1011
const TRUST_PROXY = process.env.TRUST_PROXY_HEADERS === 'true';
1112
const GRANTFOX_FWC26_CAMPAIGN = 'GrantFox FWC26';
@@ -30,6 +31,10 @@ export interface AdminDevMetricsRouterDeps {
3031
* traffic.
3132
*
3233
* @example
34+
* // Per-developer concurrency stats overview
35+
* GET /api/admin/metrics
36+
* // → { data: { totalActive: 3, activeDeveloperCount: 2, perDeveloper: [...], ... } }
37+
*
3338
* // Active slot counts for all developers
3439
* GET /api/admin/metrics/concurrency
3540
* // → { data: { devCounts: { "dev_abc": 2 }, totalActive: 2 } }
@@ -44,6 +49,53 @@ export function createAdminDevMetricsRouter(
4449
const router = Router();
4550
const developerSemaphore = deps.developerSemaphore ?? sharedDeveloperSemaphore;
4651

52+
/**
53+
* GET /
54+
*
55+
* Returns a per-developer concurrency stats overview. This is a
56+
* dashboard-friendly summary that includes per-developer breakdown with
57+
* utilisation percentages, total active slots, and system-level counts.
58+
*
59+
* Response shape:
60+
* ```json
61+
* {
62+
* "data": {
63+
* "totalActive": 3,
64+
* "maxConcurrencyPerDeveloper": 1,
65+
* "activeDeveloperCount": 2,
66+
* "perDeveloper": [
67+
* { "developerId": "dev_abc", "activeCount": 2, "atLimit": true, "utilizationPercent": 200 },
68+
* { "developerId": "dev_def", "activeCount": 1, "atLimit": false, "utilizationPercent": 100 }
69+
* ],
70+
* "campaign": "GrantFox FWC26"
71+
* }
72+
* }
73+
* ```
74+
*/
75+
router.get('/', (req, res, next) => {
76+
try {
77+
const stats = computeConcurrencyStats(developerSemaphore);
78+
79+
logger.audit('READ_DEV_CONCURRENCY_STATS', res.locals.adminActor, {
80+
campaign: GRANTFOX_FWC26_CAMPAIGN,
81+
totalActive: stats.totalActive,
82+
activeDeveloperCount: stats.activeDeveloperCount,
83+
clientIp: getClientIp(req, TRUST_PROXY),
84+
userAgent: req.get('User-Agent'),
85+
correlationId: req.headers['x-request-id'] ?? req.headers['x-correlation-id'],
86+
});
87+
88+
res.json({ data: stats });
89+
} catch (error) {
90+
if (error instanceof AppError) {
91+
next(error);
92+
return;
93+
}
94+
logger.error('Failed to read developer concurrency stats', { error });
95+
next(new InternalServerError());
96+
}
97+
});
98+
4799
/**
48100
* GET /concurrency
49101
*

src/services/concurrency.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { DeveloperSemaphore } from '../utils/developerSemaphore.js';
2+
3+
export interface PerDeveloperConcurrencyStat {
4+
developerId: string;
5+
activeCount: number;
6+
atLimit: boolean;
7+
utilizationPercent: number;
8+
}
9+
10+
export interface ConcurrencyStats {
11+
totalActive: number;
12+
maxConcurrencyPerDeveloper: number;
13+
activeDeveloperCount: number;
14+
perDeveloper: PerDeveloperConcurrencyStat[];
15+
campaign: string;
16+
}
17+
18+
const GRANTFOX_FWC26_CAMPAIGN = 'GrantFox FWC26';
19+
20+
/**
21+
* Computes enriched per-developer concurrency statistics from the shared
22+
* DeveloperSemaphore.
23+
*
24+
* This service exists as a single place to derive stats (utilization %, counts,
25+
* summary fields) so that the admin metrics route stays thin and the logic can
26+
* be unit-tested independently.
27+
*/
28+
export function computeConcurrencyStats(
29+
semaphore: DeveloperSemaphore,
30+
): ConcurrencyStats {
31+
const devCounts = semaphore.getCurrentActiveSlotCounts();
32+
const totalActive = semaphore.getTotalActiveSlotCount();
33+
const maxConcurrencyPerDeveloper = semaphore.maxConcurrency;
34+
const developerIds = Object.keys(devCounts);
35+
36+
const perDeveloper: PerDeveloperConcurrencyStat[] = developerIds.map((developerId) => {
37+
const activeCount = devCounts[developerId];
38+
return {
39+
developerId,
40+
activeCount,
41+
atLimit: activeCount >= maxConcurrencyPerDeveloper,
42+
utilizationPercent:
43+
maxConcurrencyPerDeveloper > 0
44+
? Math.round((activeCount / maxConcurrencyPerDeveloper) * 100)
45+
: 0,
46+
};
47+
});
48+
49+
perDeveloper.sort((a, b) => b.activeCount - a.activeCount || a.developerId.localeCompare(b.developerId));
50+
51+
return {
52+
totalActive,
53+
maxConcurrencyPerDeveloper,
54+
activeDeveloperCount: perDeveloper.length,
55+
perDeveloper,
56+
campaign: GRANTFOX_FWC26_CAMPAIGN,
57+
};
58+
}

0 commit comments

Comments
 (0)