Skip to content

Commit 8dda6d9

Browse files
Added metrics, OpenAPI, CI, etc.
X-Lovable-Edit-ID: edt-db639756-1dfa-4b59-b4e0-e5227f89ddba Co-authored-by: Mattral <88831350+Mattral@users.noreply.github.com>
2 parents f0260f2 + d6e9390 commit 8dda6d9

7 files changed

Lines changed: 498 additions & 63 deletions

File tree

.github/workflows/ci.yml

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,20 @@ jobs:
1717
timeout-minutes: 15
1818
steps:
1919
- uses: actions/checkout@v4
20-
21-
- name: Setup Bun
22-
uses: oven-sh/setup-bun@v2
20+
- uses: oven-sh/setup-bun@v2
2321
with:
2422
bun-version: 1.1.34
25-
26-
- name: Cache Bun dependencies
23+
- name: Cache Bun deps
2724
uses: actions/cache@v4
2825
with:
2926
path: |
3027
~/.bun/install/cache
3128
node_modules
3229
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
33-
restore-keys: |
34-
${{ runner.os }}-bun-
35-
36-
- name: Install dependencies
37-
run: bun install --frozen-lockfile
38-
39-
- name: Lint
40-
run: bun run lint
41-
42-
- name: Typecheck
43-
run: bunx tsc --noEmit
44-
30+
restore-keys: ${{ runner.os }}-bun-
31+
- run: bun install --frozen-lockfile
32+
- run: bun run lint
33+
- run: bunx tsc --noEmit
4534
- name: Verify face-api model assets
4635
run: |
4736
set -e
@@ -51,21 +40,62 @@ jobs:
5140
face_expression_model.bin; do
5241
test -s "public/models/$f" || { echo "::error::missing public/models/$f"; exit 1; }
5342
done
54-
echo "all face-api models present"
43+
- run: bun run smoke:vision-worker
44+
- run: bun run build
45+
- uses: actions/upload-artifact@v4
46+
with:
47+
name: dist
48+
path: dist
49+
retention-days: 7
50+
51+
tests:
52+
name: ${{ matrix.suite }}
53+
runs-on: ubuntu-latest
54+
needs: quality
55+
timeout-minutes: 20
56+
strategy:
57+
fail-fast: false
58+
matrix:
59+
suite: [unit, api, e2e]
60+
steps:
61+
- uses: actions/checkout@v4
62+
- uses: oven-sh/setup-bun@v2
63+
with:
64+
bun-version: 1.1.34
5565

56-
- name: Vision worker smoke test (bundle + resolve model URLs)
57-
run: bun run smoke:vision-worker
66+
- name: Cache Bun deps
67+
uses: actions/cache@v4
68+
with:
69+
path: |
70+
~/.bun/install/cache
71+
node_modules
72+
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
73+
restore-keys: ${{ runner.os }}-bun-
74+
75+
- run: bun install --frozen-lockfile
5876

59-
- name: Build
60-
run: bun run build
77+
# --- Frontend unit (Vitest) -----------------------------------
78+
- if: matrix.suite == 'unit'
79+
run: bunx vitest run --reporter=default
6180

81+
# --- Backend API (bun test) -----------------------------------
82+
- if: matrix.suite == 'api'
83+
working-directory: apps/api
84+
run: |
85+
bun install
86+
bun test
6287
63-
- name: Upload build artifact
64-
uses: actions/upload-artifact@v4
88+
# --- Playwright E2E -------------------------------------------
89+
- if: matrix.suite == 'e2e'
90+
uses: actions/cache@v4
6591
with:
66-
name: dist
67-
path: dist
68-
retention-days: 7
92+
path: ~/.cache/ms-playwright
93+
key: ${{ runner.os }}-pw-${{ hashFiles('bun.lockb') }}
94+
restore-keys: ${{ runner.os }}-pw-
95+
- if: matrix.suite == 'e2e'
96+
run: |
97+
bunx playwright install --with-deps chromium
98+
bunx playwright test -c playwright.config.ts
6999
70100
docker:
71101
name: docker build (smoke)
@@ -82,5 +112,14 @@ jobs:
82112
file: Dockerfile
83113
push: false
84114
tags: simulated-self-web:ci
85-
cache-from: type=gha
86-
cache-to: type=gha,mode=max
115+
cache-from: type=gha,scope=web
116+
cache-to: type=gha,scope=web,mode=max
117+
- name: Build api image
118+
uses: docker/build-push-action@v6
119+
with:
120+
context: .
121+
file: Dockerfile.api
122+
push: false
123+
tags: simulated-self-api:ci
124+
cache-from: type=gha,scope=api
125+
cache-to: type=gha,scope=api,mode=max

apps/api/src/metrics.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Prometheus metrics — zero-dependency text exposition format.
3+
*
4+
* We intentionally avoid `prom-client` to keep the API image lean and
5+
* the cold-start fast. The format is stable and trivially correct for
6+
* the small surface area we expose:
7+
*
8+
* redis_ping_latency_ms (histogram, ms)
9+
* model_preflight_duration_ms (histogram, ms, labels: result)
10+
* vision_preflight_failures_total (counter)
11+
* websocket_active_connections (gauge)
12+
* websocket_sessions_total (counter)
13+
*
14+
* See https://prometheus.io/docs/instrumenting/exposition_formats/
15+
*/
16+
17+
type Histogram = { buckets: number[]; counts: number[]; sum: number; count: number };
18+
19+
const LATENCY_BUCKETS_MS = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
20+
21+
function newHist(): Histogram {
22+
return { buckets: LATENCY_BUCKETS_MS, counts: new Array(LATENCY_BUCKETS_MS.length).fill(0), sum: 0, count: 0 };
23+
}
24+
25+
function observe(h: Histogram, valueMs: number) {
26+
h.sum += valueMs;
27+
h.count += 1;
28+
for (let i = 0; i < h.buckets.length; i++) {
29+
if (valueMs <= h.buckets[i]) h.counts[i] += 1;
30+
}
31+
}
32+
33+
const redisPing = newHist();
34+
const modelPreflight: Record<string, Histogram> = { ok: newHist(), fail: newHist() };
35+
let visionFailures = 0;
36+
let wsActive = 0;
37+
let wsTotal = 0;
38+
39+
export function recordRedisPing(ms: number) { observe(redisPing, ms); }
40+
export function recordModelPreflight(ms: number, ok: boolean) {
41+
observe(modelPreflight[ok ? 'ok' : 'fail'], ms);
42+
if (!ok) visionFailures += 1;
43+
}
44+
export function setWsActive(n: number) { wsActive = n; }
45+
export function incWsTotal() { wsTotal += 1; }
46+
47+
function renderHistogram(name: string, help: string, h: Histogram, labels = ''): string {
48+
const lp = labels ? `,${labels}` : '';
49+
const lOnly = labels ? `{${labels}}` : '';
50+
const lines = [
51+
`# HELP ${name} ${help}`,
52+
`# TYPE ${name} histogram`,
53+
];
54+
let cumulative = 0;
55+
for (let i = 0; i < h.buckets.length; i++) {
56+
cumulative = h.counts[i]; // counts already cumulative-per-bucket via observe()
57+
lines.push(`${name}_bucket{le="${h.buckets[i]}"${lp}} ${cumulative}`);
58+
}
59+
lines.push(`${name}_bucket{le="+Inf"${lp}} ${h.count}`);
60+
lines.push(`${name}_sum${lOnly} ${h.sum}`);
61+
lines.push(`${name}_count${lOnly} ${h.count}`);
62+
return lines.join('\n');
63+
}
64+
65+
export function renderMetrics(): string {
66+
const out: string[] = [];
67+
out.push(renderHistogram('redis_ping_latency_ms', 'Redis PING round-trip latency in ms.', redisPing));
68+
out.push(renderHistogram('model_preflight_duration_ms', 'Vision model preflight duration in ms.', modelPreflight.ok, 'result="ok"'));
69+
out.push(renderHistogram('model_preflight_duration_ms', 'Vision model preflight duration in ms.', modelPreflight.fail, 'result="fail"'));
70+
out.push(
71+
'# HELP vision_preflight_failures_total Total failed vision preflight runs.',
72+
'# TYPE vision_preflight_failures_total counter',
73+
`vision_preflight_failures_total ${visionFailures}`,
74+
);
75+
out.push(
76+
'# HELP websocket_active_connections Currently open WebSocket sessions.',
77+
'# TYPE websocket_active_connections gauge',
78+
`websocket_active_connections ${wsActive}`,
79+
);
80+
out.push(
81+
'# HELP websocket_sessions_total Cumulative WebSocket sessions accepted.',
82+
'# TYPE websocket_sessions_total counter',
83+
`websocket_sessions_total ${wsTotal}`,
84+
);
85+
return out.join('\n') + '\n';
86+
}

apps/api/src/openapi.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* OpenAPI 3.1 spec for the Simulated-Self API gateway.
3+
* Served at /openapi.json; Swagger UI at /docs.
4+
*/
5+
export const openapiSpec = {
6+
openapi: '3.1.0',
7+
info: {
8+
title: 'Simulated-Self API',
9+
version: '0.1.0',
10+
description:
11+
'Health, readiness, vision diagnostics, and Prometheus metrics for the Simulated-Self gateway.',
12+
},
13+
servers: [{ url: '/', description: 'current host' }],
14+
paths: {
15+
'/healthz': {
16+
get: {
17+
summary: 'Liveness probe',
18+
responses: {
19+
200: {
20+
description: 'Process alive',
21+
content: {
22+
'application/json': {
23+
schema: {
24+
type: 'object',
25+
required: ['ok', 'ts'],
26+
properties: { ok: { const: true }, ts: { type: 'integer' } },
27+
},
28+
},
29+
},
30+
},
31+
},
32+
},
33+
},
34+
'/readyz': {
35+
get: {
36+
summary: 'Deep readiness probe',
37+
description:
38+
'Aggregates Redis ping, WebSocket session pressure, and model manifest availability.',
39+
responses: {
40+
200: { description: 'Ready', content: { 'application/json': { schema: { $ref: '#/components/schemas/Readiness' } } } },
41+
503: { description: 'Not ready', content: { 'application/json': { schema: { $ref: '#/components/schemas/Readiness' } } } },
42+
},
43+
},
44+
},
45+
'/diagnostics/vision': {
46+
get: {
47+
summary: 'Vision worker model preflight',
48+
parameters: [
49+
{
50+
name: 'modelBaseUrl',
51+
in: 'query',
52+
required: false,
53+
schema: { type: 'string', format: 'uri' },
54+
description: 'Override the model base URL (defaults to <origin>/models).',
55+
},
56+
],
57+
responses: {
58+
200: { description: 'All model manifests reachable', content: { 'application/json': { schema: { $ref: '#/components/schemas/VisionPreflight' } } } },
59+
503: { description: 'One or more model manifests failed to resolve', content: { 'application/json': { schema: { $ref: '#/components/schemas/VisionPreflight' } } } },
60+
},
61+
},
62+
},
63+
'/metrics': {
64+
get: {
65+
summary: 'Prometheus metrics',
66+
responses: {
67+
200: {
68+
description: 'text/plain; version=0.0.4',
69+
content: { 'text/plain': { schema: { type: 'string' } } },
70+
},
71+
},
72+
},
73+
},
74+
},
75+
components: {
76+
schemas: {
77+
Readiness: {
78+
type: 'object',
79+
required: ['ok', 'ts', 'checks'],
80+
properties: {
81+
ok: { type: 'boolean' },
82+
ts: { type: 'integer' },
83+
checks: {
84+
type: 'object',
85+
properties: {
86+
redis: { type: 'object', properties: { ok: { type: 'boolean' }, latencyMs: { type: 'integer' }, error: { type: 'string' } } },
87+
ws: { type: 'object', properties: { ok: { type: 'boolean' }, active: { type: 'integer' }, softCap: { type: 'integer' } } },
88+
models: { type: 'object', properties: { ok: { type: 'boolean' }, failed: { type: 'array', items: { type: 'string' } } } },
89+
},
90+
},
91+
},
92+
},
93+
VisionPreflight: {
94+
type: 'object',
95+
required: ['ok', 'modelBaseUrl', 'models', 'message'],
96+
properties: {
97+
ok: { type: 'boolean' },
98+
modelBaseUrl: { type: 'string' },
99+
worker: { type: 'string' },
100+
message: { type: 'string' },
101+
models: {
102+
type: 'array',
103+
items: {
104+
type: 'object',
105+
required: ['file', 'url', 'ok'],
106+
properties: {
107+
file: { type: 'string' },
108+
url: { type: 'string' },
109+
ok: { type: 'boolean' },
110+
status: { type: 'integer' },
111+
error: { type: 'string' },
112+
},
113+
},
114+
},
115+
},
116+
},
117+
},
118+
},
119+
} as const;
120+
121+
export const swaggerHtml = `<!doctype html>
122+
<html lang="en">
123+
<head>
124+
<meta charset="utf-8" />
125+
<title>Simulated-Self API · Swagger UI</title>
126+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
127+
</head>
128+
<body>
129+
<div id="ui"></div>
130+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
131+
<script>
132+
window.ui = SwaggerUIBundle({ url: '/openapi.json', dom_id: '#ui', deepLinking: true });
133+
</script>
134+
</body>
135+
</html>`;

apps/api/src/readiness.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* kubelet logs contain root-cause context.
1616
*/
1717
import Redis from 'ioredis';
18+
import { recordRedisPing, recordModelPreflight } from './metrics';
1819

1920
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
2021
const MODEL_BASE_URL = process.env.MODEL_BASE_URL ?? '';
@@ -67,8 +68,11 @@ async function pingRedis(): Promise<ReadinessReport['checks']['redis']> {
6768
const r = getRedis();
6869
if (r.status === 'end' || r.status === 'wait') await r.connect().catch(() => {});
6970
const pong = await withTimeout(r.ping(), 250, 'redis.ping');
70-
return { ok: pong === 'PONG', latencyMs: Math.round(performance.now() - t0) };
71+
const latencyMs = Math.round(performance.now() - t0);
72+
recordRedisPing(latencyMs);
73+
return { ok: pong === 'PONG', latencyMs };
7174
} catch (e) {
75+
recordRedisPing(Math.round(performance.now() - t0));
7276
return { ok: false, error: (e as Error).message };
7377
}
7478
}
@@ -77,6 +81,7 @@ async function checkModels(): Promise<ReadinessReport['checks']['models']> {
7781
if (!MODEL_BASE_URL) return undefined;
7882
const base = MODEL_BASE_URL.replace(/\/$/, '');
7983
const failed: string[] = [];
84+
const t0 = performance.now();
8085
await Promise.all(MODEL_MANIFESTS.map(async (m) => {
8186
try {
8287
const res = await withTimeout(fetch(`${base}/${m}`, { method: 'HEAD' }), 500, `model.${m}`);
@@ -85,6 +90,7 @@ async function checkModels(): Promise<ReadinessReport['checks']['models']> {
8590
failed.push(`${m} (${(e as Error).message})`);
8691
}
8792
}));
93+
recordModelPreflight(performance.now() - t0, failed.length === 0);
8894
return { ok: failed.length === 0, failed: failed.length ? failed : undefined };
8995
}
9096

0 commit comments

Comments
 (0)