Skip to content

Commit a4b5da8

Browse files
committed
feat(ops): usage dashboard with SQLite (PRD-usage-dashboard-minimal)
- Shared USAGE_DATABASE_PATH volume for search, mcp-server, bot (WAL) - Record events: search (query/rate_limit/error), mcp HTTP paths, bot commands, tunnel connect, landing pixel - /ops/usage HTML + JSON summary (Basic Auth OPS_USAGE_USER/PASSWORD) - /ops/pixel/landing.gif for optional embed; hash actors with USAGE_ACTOR_SALT Made-with: Cursor
1 parent 90c2b6c commit a4b5da8

22 files changed

Lines changed: 1481 additions & 5 deletions

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ SEARCH_RANKER=minisearch
2626
SEARCH_RESPONSE_CACHE_MAX=64
2727
# Bust cache after deploy without filesystem mtime change (optional string).
2828
KNOWLEDGE_CACHE_REVISION=
29+
30+
# Usage dashboard (SQLite shared by search, mcp-server, bot). Set strong USAGE_ACTOR_SALT in production.
31+
USAGE_DATABASE_PATH=/data/usage/events.sqlite
32+
USAGE_ACTOR_SALT=replace-with-random-salt
33+
# HTTP Basic for https://<host>/ops/usage/ (panel is proxied to mcp-server:3000).
34+
OPS_USAGE_USER=
35+
OPS_USAGE_PASSWORD=
2936
# Public URL clients use (scheme + host, no trailing slash). With Caddy on 80/443 use http://your-host or https://your-host
3037
PUBLIC_ORIGIN=https://spawn-dock.w3voice.net
3138
# docker-compose.prod.yml mounts ./data/state → /app/.spawndock

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ npm-пакет `mcp-tma-client` в `packages/mcp-tma-client/`. Работает
263263
## Спецификации (PRD)
264264

265265
- `docs/PRD-public-knowledge-search-service.md` — публичный `/knowledge` API
266-
- `docs/PRD-usage-dashboard-minimal.md`минимальная панель использования (search, mcp, bot, tunnel, landing)
266+
- `docs/PRD-usage-dashboard-minimal.md` — панель использования (SQLite); UI: **`/ops/usage`** (Basic Auth `OPS_USAGE_*`), пиксель лендинга: **`/ops/pixel/landing.gif`**
267267

268268
## Структура проекта
269269

data/usage/.gitkeep

Whitespace-only changes.

docker-compose.prod.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@ services:
2020
QWEN_HTTP_PORT: "8790"
2121
QWEN_OAUTH: "true"
2222
KNOWLEDGE_ROOT: /corpus
23+
USAGE_DATABASE_PATH: /data/usage/events.sqlite
24+
USAGE_ACTOR_SALT: ${USAGE_ACTOR_SALT:-change-me-usage-salt}
2325
volumes:
2426
- ./knowledge:/corpus:ro
27+
- ./data/usage:/data/usage
2528
restart: unless-stopped
2629
healthcheck:
2730
test:
@@ -46,9 +49,14 @@ services:
4649
QWEN_API_URL: http://search:8790
4750
QWEN_CONTAINER_IMAGE: spawndock/search:prod
4851
QWEN_KNOWLEDGE_HOST_PATH: ${QWEN_KNOWLEDGE_HOST_PATH:-}
52+
USAGE_DATABASE_PATH: /data/usage/events.sqlite
53+
USAGE_ACTOR_SALT: ${USAGE_ACTOR_SALT:-change-me-usage-salt}
54+
OPS_USAGE_USER: ${OPS_USAGE_USER:-}
55+
OPS_USAGE_PASSWORD: ${OPS_USAGE_PASSWORD:-}
4956
volumes:
5057
- ./data/state:/app/.spawndock
5158
- ./knowledge:/app/knowledge:ro
59+
- ./data/usage:/data/usage
5260
depends_on:
5361
search:
5462
condition: service_healthy
@@ -69,6 +77,10 @@ services:
6977
- .env
7078
environment:
7179
CONTROL_PLANE_URL: http://mcp-server:3000
80+
USAGE_DATABASE_PATH: /data/usage/events.sqlite
81+
USAGE_ACTOR_SALT: ${USAGE_ACTOR_SALT:-change-me-usage-salt}
82+
volumes:
83+
- ./data/usage:/data/usage
7284
depends_on:
7385
mcp-server:
7486
condition: service_healthy

docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,13 @@ services:
4444
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
4545
TELEGRAM_MINI_APP_SHORT_NAME: ${TELEGRAM_MINI_APP_SHORT_NAME:-}
4646
HF_TOKEN: ${HF_TOKEN:-}
47+
USAGE_DATABASE_PATH: /data/usage/events.sqlite
48+
USAGE_ACTOR_SALT: ${USAGE_ACTOR_SALT:-dev-usage-salt}
49+
OPS_USAGE_USER: ${OPS_USAGE_USER:-}
50+
OPS_USAGE_PASSWORD: ${OPS_USAGE_PASSWORD:-}
4751
volumes:
4852
- ./data/state:/app/.spawndock
53+
- ./data/usage:/data/usage
4954
depends_on:
5055
qwen:
5156
condition: service_healthy
@@ -87,6 +92,10 @@ services:
8792
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
8893
TELEGRAM_MINI_APP_SHORT_NAME: ${TELEGRAM_MINI_APP_SHORT_NAME:-}
8994
HF_TOKEN: ${HF_TOKEN:-}
95+
USAGE_DATABASE_PATH: /data/usage/events.sqlite
96+
USAGE_ACTOR_SALT: ${USAGE_ACTOR_SALT:-dev-usage-salt}
97+
volumes:
98+
- ./data/usage:/data/usage
9099
depends_on:
91100
mcp-server:
92101
condition: service_healthy

docker/search/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ WORKDIR /opt/search
1818
COPY docker/search/package.json docker/search/package-lock.json ./
1919
RUN npm ci --omit=dev
2020

21-
COPY docker/search/http-server.mjs docker/search/knowledge-rank.mjs ./
21+
COPY docker/search/http-server.mjs docker/search/knowledge-rank.mjs docker/search/usage-log.mjs ./
2222
COPY openapi/knowledge-v1.yaml ./openapi.yaml
2323
COPY knowledge ./knowledge
2424

docker/search/http-server.mjs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { readFileSync, existsSync } from "node:fs";
99
import { fileURLToPath } from "node:url";
1010
import { dirname, join } from "node:path";
1111
import { corpusMaxMtime, rankKnowledgeForQuery } from "./knowledge-rank.mjs";
12+
import { hashActorValue, recordSearchUsage } from "./usage-log.mjs";
1213

1314
const __dirname = dirname(fileURLToPath(import.meta.url));
1415
const PORT = parseInt(process.env.SEARCH_HTTP_PORT || process.env.QWEN_HTTP_PORT || "8790", 10);
@@ -158,6 +159,21 @@ function resolveTier(req) {
158159
return "free";
159160
}
160161

162+
function searchUsageActor(req) {
163+
const auth = req.headers.authorization;
164+
if (typeof auth === "string" && auth.startsWith("Bearer ")) {
165+
const token = auth.slice(7).trim();
166+
if (token) {
167+
return { actorType: "api_token", actorKey: hashActorValue(token) };
168+
}
169+
}
170+
if (isPublicFacingRequest(req)) {
171+
return { actorType: "anonymous_ip", actorKey: hashActorValue(clientFacingIp(req)) };
172+
}
173+
const ra = req.socket.remoteAddress || "internal";
174+
return { actorType: "internal", actorKey: hashActorValue(`dock:${ra}`) };
175+
}
176+
161177
function checkRateLimit(tier, keyId) {
162178
const t = TIERS[tier];
163179
if (!t) return { ok: true };
@@ -405,11 +421,21 @@ async function handleRequest(req, res) {
405421
return;
406422
}
407423

424+
const rateTier = tier === "basic" ? "basic" : "free";
425+
const usageActor = searchUsageActor(req);
426+
408427
if (isPublicFacingRequest(req)) {
409-
const rateTier = tier === "basic" ? "basic" : "free";
410428
const keyId = `${rateTier}:${tier === "basic" ? "token" : clientFacingIp(req)}`;
411429
const rl = checkRateLimit(rateTier, keyId);
412430
if (!rl.ok) {
431+
recordSearchUsage({
432+
eventType: "search_rate_limit",
433+
actorType: usageActor.actorType,
434+
actorKey: usageActor.actorKey,
435+
tier: rateTier,
436+
status: 429,
437+
meta: { reason: rl.reason },
438+
});
413439
sendError(res, 429, "rate_limit", `Rate limit exceeded (${rl.reason})`, {
414440
tier: rateTier,
415441
limit: rl.limit,
@@ -437,9 +463,25 @@ async function handleRequest(req, res) {
437463

438464
try {
439465
const result = await runSearchQuery(query);
466+
recordSearchUsage({
467+
eventType: "search_query",
468+
actorType: usageActor.actorType,
469+
actorKey: usageActor.actorKey,
470+
tier: rateTier,
471+
status: 200,
472+
meta: { cache_hit: result.meta?.cache_hit === true },
473+
});
440474
sendJson(res, 200, result);
441475
} catch (err) {
442476
const message = err instanceof Error ? err.message : String(err);
477+
recordSearchUsage({
478+
eventType: "search_error",
479+
actorType: usageActor.actorType,
480+
actorKey: usageActor.actorKey,
481+
tier: rateTier,
482+
status: 502,
483+
meta: { message: message.slice(0, 500) },
484+
});
443485
sendError(res, 502, "search_failed", message);
444486
}
445487
return;

0 commit comments

Comments
 (0)