pediatric-ai-scribe-v3/src/utils/metrics.js
Daniel 012346528c
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 51s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m35s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
fix: generation stopped working whenever the slide reviewer was switched off
savedFigureIds was declared inside the review branch, so with no reviewer
configured — the default, and what everyone is running — it was undefined by the
time the INSERT stringified it. JSON.stringify(undefined) is not a string, the
column is NOT NULL, and every generation failed with "Generation failed". `var`
is function-scoped, so nothing complained until the database did.

This is the second bug of exactly this shape in this file, so the test asserts
position rather than presence: the value must be declared before both the review
and the insert read it.

Found by the logging added in the same change, which is the other half of this
commit. Every modification now says what it did:

  [my-resources] refine id=29 path=deck outcome=applied 13→14 slides changed=yes
  [my-resources] refine id=37 path=markdown outcome=applied 2635→3018 chars changed=yes

CHANGED=no is warn-level and deliberately shouty, because that is the failure
worth catching: the response says success either way, the row updates, and the
download is identical — which is exactly how the deck bug went unnoticed. A
refusal logs its reason. ped_ai_resource_refine_total{path,outcome} counts the
same thing over time, so "did that modification do anything" is answerable
without watching logs live.

Verified across every path rather than the one that was broken: a deck
presentation modified and exported to both pptx and docx carries the change; a
legacy presentation with no stored deck still takes the markdown path and
carries it; an article generates, modifies and exports; and a presentation
generates with the reviewer off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 00:57:30 +02:00

100 lines
3.1 KiB
JavaScript

const client = require('prom-client');
const register = new client.Registry();
client.collectDefaultMetrics({
prefix: 'ped_ai_',
register
});
const httpRequestsTotal = new client.Counter({
name: 'ped_ai_http_requests_total',
help: 'Total HTTP requests received by Ped-AI',
labelNames: ['method', 'route', 'status_code'],
registers: [register]
});
const httpRequestDuration = new client.Histogram({
name: 'ped_ai_http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [register]
});
const httpRequestsInProgress = new client.Gauge({
name: 'ped_ai_http_requests_in_progress',
help: 'Number of Ped-AI HTTP requests currently in progress',
labelNames: ['method', 'route'],
registers: [register]
});
function normalizeRoute(req, statusCode) {
var routePath = req.route && req.route.path;
var baseUrl = req.baseUrl || '';
if (Array.isArray(routePath)) return baseUrl + routePath[0];
if (routePath) return baseUrl + routePath;
if (statusCode === 404) return 'unmatched';
var path = req.path || req.url || 'unknown';
return path
.replace(/\b[0-9a-f]{8,}\b/gi, ':id')
.replace(/\b\d+\b/g, ':id')
.replace(/\?.*$/, '');
}
function metricsMiddleware(req, res, next) {
if (req.path === '/metrics') return next();
var route = req.route ? normalizeRoute(req) : 'pending';
var method = req.method;
var endTimer = httpRequestDuration.startTimer({ method, route });
httpRequestsInProgress.inc({ method, route });
res.on('finish', function() {
var finalRoute = normalizeRoute(req, res.statusCode);
var labels = {
method,
route: finalRoute,
status_code: String(res.statusCode)
};
httpRequestsTotal.inc(labels);
endTimer({ route: finalRoute, status_code: String(res.statusCode) });
httpRequestsInProgress.dec({ method, route });
});
next();
}
async function metricsHandler(req, res) {
res.setHeader('Content-Type', register.contentType);
res.end(await register.metrics());
}
// What a model reached for and could not have when laying out a slide. The
// vocabulary of shapes is deliberately small; this is how it learns what to grow
// into, from what people actually ask for rather than from guesses. A label per
// distinct thing wanted, so it can be counted over time in Grafana.
const deckVocabularyGaps = new client.Counter({
name: 'ped_ai_deck_vocabulary_gap_total',
help: 'Times a generated deck asked for a shape, chart or slide type that does not exist',
labelNames: ['wanted'],
registers: [register]
});
// What modifying a resource did. "unchanged" is the one to watch: it means a
// person was told their change was applied and got back exactly what they had.
const resourceRefines = new client.Counter({
name: 'ped_ai_resource_refine_total',
help: 'Modifications to a generated resource, by path taken and what happened',
labelNames: ['path', 'outcome'],
registers: [register]
});
module.exports = {
metricsHandler,
metricsMiddleware,
deckVocabularyGaps,
resourceRefines,
register
};