feat: add end-to-end observability (metrics, health, mobile RUM, dashboards, alerts)
Run Tests on Branches / Detect Changes (push) Successful in 10s
Run Tests on Branches / Backend Tests (push) Failing after 20s
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Failing after 20s

- Backend: OTel metrics via OTLP -> Alloy -> Prometheus (OTEL_METRICS_EXPORTER=otlp)
- New business metrics (src/metrics.js): searches, CIMA latency/errors, cache
  hits/misses, logins, rate-limits, pharmacy writes/links, push sent/failed,
  DB + Redis timings/errors, HTTP req count/duration, heartbeat
- Backend health endpoints /healthz and /readyz
- Mobile (Expo): Grafana Faro RUM via @grafana/faro-react-native
- redis/postgres exporters in docker-compose + Prometheus scrape jobs
- Grafana dashboards (backend, datastores, mobile RUM, overview)
- Prometheus alert rules (farmafinder_*) -> existing Alertmanager (Telegram)
- Design/spec saved to docs/superpowers/specs/
This commit is contained in:
Antoni Nuñez Romeu
2026-07-13 15:57:52 +02:00
parent 2713be85a3
commit 839c64c12a
16 changed files with 907 additions and 27 deletions
+32 -11
View File
@@ -1,9 +1,27 @@
import axios from 'axios';
import redisClient from './redis-client.js';
import * as appMetrics from './src/metrics.js';
const CIMA_API_BASE_URL = 'https://cima.aemps.es/cima/rest';
const CACHE_TTL = 3600;
/**
* Wraps a CIMA API GET with duration + success/error metrics.
*/
async function trackedCimaGet(url, params) {
const start = Date.now();
try {
const response = await axios.get(url, { params, timeout: 5000 });
appMetrics.cimaRequestDuration.record(Date.now() - start);
appMetrics.cimaRequestsTotal.add(1, { status: 'ok' });
return response;
} catch (err) {
appMetrics.cimaRequestDuration.record(Date.now() - start);
appMetrics.cimaRequestsTotal.add(1, { status: 'error' });
throw err;
}
}
/**
* Separa los términos de dosis (ej: "1g", "1000 mg") del nombre del
* medicamento, para poder buscar en CIMA solo con el nombre y luego
@@ -69,23 +87,24 @@ export async function searchMedicines(query) {
const cacheKey = `medicines:search:v3:${searchTerm}`;
try {
appMetrics.medicineSearchesTotal.add(1);
// Intentar obtener del caché
const cachedData = await redisClient.get(cacheKey);
if (cachedData) {
console.log(`📦 Cache hit for: ${searchTerm}`);
appMetrics.cacheHitsTotal.add(1);
return JSON.parse(cachedData);
}
appMetrics.cacheMissesTotal.add(1);
const parsed = parseSearchQuery(searchTerm);
const apiSearchTerm = parsed.nameQuery || searchTerm;
console.log(`🌐 Fetching from CIMA API: ${apiSearchTerm}`);
const response = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, {
params: {
nombre: apiSearchTerm
},
timeout: 5000
const response = await trackedCimaGet(`${CIMA_API_BASE_URL}/medicamentos`, {
nombre: apiSearchTerm,
});
if (response.data && response.data.resultados) {
@@ -143,20 +162,23 @@ export async function getMedicineDetails(nregistro) {
const cacheKey = `medicine:${nregistro}`;
try {
appMetrics.medicineSearchesTotal.add(1);
// Intentar obtener del caché
const cachedData = await redisClient.get(cacheKey);
if (cachedData) {
console.log(`📦 Cache hit for medicine: ${nregistro}`);
appMetrics.cacheHitsTotal.add(1);
return JSON.parse(cachedData);
}
appMetrics.cacheMissesTotal.add(1);
// Intentar obtener de la API de CIMA (endpoint individual puede no existir)
console.log(`🌐 Fetching medicine details from CIMA: ${nregistro}`);
try {
const response = await axios.get(`${CIMA_API_BASE_URL}/medicamento/${nregistro}`, {
timeout: 5000
});
const response = await trackedCimaGet(`${CIMA_API_BASE_URL}/medicamento/${nregistro}`);
if (response.data) {
const med = response.data;
@@ -185,9 +207,8 @@ export async function getMedicineDetails(nregistro) {
}
// Fallback: buscar por nregistro usando el endpoint de búsqueda
const searchResponse = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, {
const searchResponse = await trackedCimaGet(`${CIMA_API_BASE_URL}/medicamentos`, {
params: { nregistro },
timeout: 5000
});
if (searchResponse.data?.resultados?.length > 0) {
@@ -233,7 +254,7 @@ export async function getMedicineDetails(nregistro) {
/**
* Limpia el caché de búsquedas (útil para testing o mantenimiento)
* @param {string} pattern - Patrón de claves a eliminar (ej: 'medicines:search:*')
* @param {string} pattern - Patrón de claves a eliminar (ej: 'medicines:*')
* @returns {Promise<number>} - Número de claves eliminadas
*/
export async function clearCache(pattern = 'medicines:*') {