feat: add end-to-end observability (metrics, health, mobile RUM, dashboards, alerts)
- 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:
@@ -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:*') {
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.52.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.55.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.55.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.55.0",
|
||||
"@opentelemetry/instrumentation-pino": "^0.45.0",
|
||||
"@opentelemetry/resources": "^1.28.0",
|
||||
"@opentelemetry/sdk-logs": "^0.55.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.28.0",
|
||||
"@opentelemetry/sdk-node": "^0.55.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.28.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.28.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from 'redis';
|
||||
import * as appMetrics from './src/metrics.js';
|
||||
|
||||
// Create Redis client
|
||||
const redisClient = createClient({
|
||||
@@ -11,6 +12,7 @@ const redisClient = createClient({
|
||||
|
||||
// Error handler
|
||||
redisClient.on('error', (err) => {
|
||||
appMetrics.redisErrorsTotal.add(1);
|
||||
console.error('Redis Client Error:', err);
|
||||
});
|
||||
|
||||
@@ -19,6 +21,27 @@ redisClient.on('connect', () => {
|
||||
console.log('✅ Connected to Redis');
|
||||
});
|
||||
|
||||
// Instrument get/setEx with command duration (used by the CIMA cache path).
|
||||
const origGet = redisClient.get.bind(redisClient);
|
||||
redisClient.get = async (...args) => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await origGet(...args);
|
||||
} finally {
|
||||
appMetrics.redisCmdDuration.record(performance.now() - start);
|
||||
}
|
||||
};
|
||||
|
||||
const origSetEx = redisClient.setEx.bind(redisClient);
|
||||
redisClient.setEx = async (...args) => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await origSetEx(...args);
|
||||
} finally {
|
||||
appMetrics.redisCmdDuration.record(performance.now() - start);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to Redis
|
||||
await redisClient.connect();
|
||||
|
||||
|
||||
+102
-16
@@ -2,6 +2,8 @@
|
||||
import './src/tracing.js';
|
||||
|
||||
import express from 'express';
|
||||
import redisClient from './redis-client.js';
|
||||
import * as appMetrics from './src/metrics.js';
|
||||
import cors from 'cors';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { promisify } from 'util';
|
||||
@@ -56,6 +58,19 @@ app.use(cors({
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(pinoHttp({ logger }));
|
||||
|
||||
// Explicit HTTP request metrics (count by route + status class, duration).
|
||||
// Auto-instrumentation gives duration histograms but no discrete error count.
|
||||
app.use((req, res, next) => {
|
||||
const start = performance.now();
|
||||
res.on('finish', () => {
|
||||
const route = req.route?.path || req.path;
|
||||
const statusClass = `${Math.floor(res.statusCode / 100)}xx`;
|
||||
appMetrics.httpRequestsTotal.add(1, { route, status_class: statusClass });
|
||||
appMetrics.httpRequestDuration.record(performance.now() - start, { route });
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
const PG_URL = process.env.PG_URL;
|
||||
let pgPool = null;
|
||||
if (PG_URL) {
|
||||
@@ -88,10 +103,16 @@ if (process.env.NODE_ENV !== 'test') {
|
||||
// Configure session
|
||||
app.use(session(sessionConfig));
|
||||
|
||||
const searchLimiter = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false });
|
||||
const loginLimiter = rateLimit({ windowMs: 60_000, max: 5, standardHeaders: true, legacyHeaders: false });
|
||||
const registerLimiter = rateLimit({ windowMs: 60 * 60_000, max: 10, standardHeaders: true, legacyHeaders: false });
|
||||
const geocodeLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false });
|
||||
// Shared handler so every limiter emits a counter (route label) on rejection.
|
||||
const limitHandler = (route) => (req, res, next) => {
|
||||
appMetrics.rateLimitRejectedTotal.add(1, { route });
|
||||
res.status(429).json({ error: 'Too many requests, please try again later.' });
|
||||
};
|
||||
|
||||
const searchLimiter = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false, handler: limitHandler('search') });
|
||||
const loginLimiter = rateLimit({ windowMs: 60_000, max: 5, standardHeaders: true, legacyHeaders: false, handler: limitHandler('login') });
|
||||
const registerLimiter = rateLimit({ windowMs: 60 * 60_000, max: 10, standardHeaders: true, legacyHeaders: false, handler: limitHandler('register') });
|
||||
const geocodeLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false, handler: limitHandler('geocode') });
|
||||
|
||||
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || '';
|
||||
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
|
||||
@@ -128,27 +149,54 @@ function toPositional(sql) {
|
||||
}
|
||||
|
||||
async function userDbGet(sql, params = []) {
|
||||
if (pgPool) {
|
||||
const res = await pgPool.query(toPositional(sql), params);
|
||||
return res.rows[0] ?? null;
|
||||
const engine = pgPool ? 'pg' : 'sqlite';
|
||||
const start = performance.now();
|
||||
try {
|
||||
if (pgPool) {
|
||||
const res = await pgPool.query(toPositional(sql), params);
|
||||
return res.rows[0] ?? null;
|
||||
}
|
||||
return await dbGet(sql, params);
|
||||
} catch (err) {
|
||||
appMetrics.dbErrorsTotal.add(1, { engine });
|
||||
throw err;
|
||||
} finally {
|
||||
appMetrics.dbQueryDuration.record(performance.now() - start, { engine });
|
||||
}
|
||||
return dbGet(sql, params);
|
||||
}
|
||||
|
||||
async function userDbRun(sql, params = []) {
|
||||
if (pgPool) {
|
||||
const res = await pgPool.query(toPositional(sql), params);
|
||||
return { lastID: res.rows[0]?.id, changes: res.rowCount };
|
||||
const engine = pgPool ? 'pg' : 'sqlite';
|
||||
const start = performance.now();
|
||||
try {
|
||||
if (pgPool) {
|
||||
const res = await pgPool.query(toPositional(sql), params);
|
||||
return { lastID: res.rows[0]?.id, changes: res.rowCount };
|
||||
}
|
||||
return await dbRun(sql.replace(/\s+RETURNING\s+\w+\s*$/i, ''), params);
|
||||
} catch (err) {
|
||||
appMetrics.dbErrorsTotal.add(1, { engine });
|
||||
throw err;
|
||||
} finally {
|
||||
appMetrics.dbQueryDuration.record(performance.now() - start, { engine });
|
||||
}
|
||||
return dbRun(sql.replace(/\s+RETURNING\s+\w+\s*$/i, ''), params);
|
||||
}
|
||||
|
||||
async function userDbAll(sql, params = []) {
|
||||
if (pgPool) {
|
||||
const res = await pgPool.query(toPositional(sql), params);
|
||||
return res.rows;
|
||||
const engine = pgPool ? 'pg' : 'sqlite';
|
||||
const start = performance.now();
|
||||
try {
|
||||
if (pgPool) {
|
||||
const res = await pgPool.query(toPositional(sql), params);
|
||||
return res.rows;
|
||||
}
|
||||
return await dbAll(sql, params);
|
||||
} catch (err) {
|
||||
appMetrics.dbErrorsTotal.add(1, { engine });
|
||||
throw err;
|
||||
} finally {
|
||||
appMetrics.dbQueryDuration.record(performance.now() - start, { engine });
|
||||
}
|
||||
return dbAll(sql, params);
|
||||
}
|
||||
|
||||
// Accept opening_hours as either an object or a JSON string from the client
|
||||
@@ -861,6 +909,7 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
appMetrics.loginFailureTotal.add(1);
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
@@ -868,6 +917,7 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
appMetrics.loginFailureTotal.add(1);
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
@@ -876,6 +926,7 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
||||
req.session.username = user.username;
|
||||
req.session.isAdmin = Boolean(user.is_admin);
|
||||
|
||||
appMetrics.loginSuccessTotal.add(1);
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
user: {
|
||||
@@ -1583,6 +1634,7 @@ app.post('/api/admin/pharmacies', requireAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
res.status(201).json(newPharmacy);
|
||||
appMetrics.pharmacyWriteTotal.add(1, { op: 'create' });
|
||||
} catch (error) {
|
||||
console.error('Error adding pharmacy:', error);
|
||||
if (error.message.includes('UNIQUE constraint')) {
|
||||
@@ -1620,6 +1672,7 @@ app.put('/api/admin/pharmacies/:id', requireAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
res.json(updatedPharmacy);
|
||||
appMetrics.pharmacyWriteTotal.add(1, { op: 'update' });
|
||||
} catch (error) {
|
||||
console.error('Error updating pharmacy:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -1638,6 +1691,7 @@ app.delete('/api/admin/pharmacies/:id', requireAdmin, async (req, res) => {
|
||||
await userDbRun('DELETE FROM pharmacies WHERE id = ?', [pharmacyId]);
|
||||
|
||||
res.json({ message: 'Pharmacy deleted successfully' });
|
||||
appMetrics.pharmacyWriteTotal.add(1, { op: 'delete' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting pharmacy:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -1807,6 +1861,7 @@ app.post('/api/admin/pharmacy-medicines', requireAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
res.status(201).json(relationship);
|
||||
appMetrics.pharmacyMedicineLinkTotal.add(1, { op: 'create' });
|
||||
} catch (error) {
|
||||
console.error('Error adding medicine to pharmacy:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -1834,6 +1889,7 @@ app.put('/api/admin/pharmacy-medicines/:id', requireAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
appMetrics.pharmacyMedicineLinkTotal.add(1, { op: 'update' });
|
||||
} catch (error) {
|
||||
console.error('Error updating pharmacy-medicine:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -1846,6 +1902,7 @@ app.delete('/api/admin/pharmacy-medicines/:id', requireAdmin, async (req, res) =
|
||||
const id = parseInt(req.params.id);
|
||||
await userDbRun('DELETE FROM pharmacy_medicines WHERE id = ?', [id]);
|
||||
res.json({ message: 'Medicine removed from pharmacy successfully' });
|
||||
appMetrics.pharmacyMedicineLinkTotal.add(1, { op: 'delete' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting pharmacy-medicine:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -2157,8 +2214,10 @@ async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy
|
||||
payload
|
||||
);
|
||||
sent++;
|
||||
appMetrics.pushSentTotal.add(1, { channel: 'web' });
|
||||
} catch (err) {
|
||||
failed++;
|
||||
appMetrics.pushFailedTotal.add(1, { channel: 'web' });
|
||||
const status = err?.statusCode;
|
||||
if (status === 404 || status === 410) {
|
||||
try {
|
||||
@@ -2214,8 +2273,10 @@ async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy
|
||||
for (const ticket of result.data) {
|
||||
if (ticket.status === 'ok') {
|
||||
sent++;
|
||||
appMetrics.pushSentTotal.add(1, { channel: 'expo' });
|
||||
} else {
|
||||
failed++;
|
||||
appMetrics.pushFailedTotal.add(1, { channel: 'expo' });
|
||||
// Prune invalid tokens
|
||||
if (ticket.message?.includes('InvalidCredentials') || ticket.message?.includes('DeviceNotRegistered')) {
|
||||
const expoEndpoint = `expo://${ticket.id || ''}`;
|
||||
@@ -2230,13 +2291,16 @@ async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy
|
||||
}
|
||||
} else {
|
||||
sent += chunk.length;
|
||||
appMetrics.pushSentTotal.add(chunk.length, { channel: 'expo' });
|
||||
}
|
||||
} else {
|
||||
failed += chunk.length;
|
||||
appMetrics.pushFailedTotal.add(chunk.length, { channel: 'expo' });
|
||||
console.error('[push] expo send failed:', response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
failed += chunk.length;
|
||||
appMetrics.pushFailedTotal.add(chunk.length, { channel: 'expo' });
|
||||
console.error('[push] expo send error:', err.message);
|
||||
}
|
||||
}
|
||||
@@ -2245,6 +2309,28 @@ async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy
|
||||
return { sent, failed, pruned };
|
||||
}
|
||||
|
||||
// --- Health & readiness probes --------------------------------------------
|
||||
// /healthz: liveness (process is up). /readyz: dependencies reachable.
|
||||
app.get('/healthz', (_req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
app.get('/readyz', async (_req, res) => {
|
||||
const checks = { redis: 'ok', db: 'ok' };
|
||||
try {
|
||||
await redisClient.ping();
|
||||
} catch {
|
||||
checks.redis = 'fail';
|
||||
}
|
||||
try {
|
||||
await userDbGet('SELECT 1');
|
||||
} catch {
|
||||
checks.db = 'fail';
|
||||
}
|
||||
const ready = checks.redis === 'ok' && checks.db === 'ok';
|
||||
res.status(ready ? 200 : 503).json({ status: ready ? 'ok' : 'degraded', checks });
|
||||
});
|
||||
|
||||
export { app, initDatabase, db };
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// OpenTelemetry metrics for the FarmaFinder backend.
|
||||
//
|
||||
// Uses the global MeterProvider configured in `tracing.js` (imported first in
|
||||
// server.js). Metrics flow OTLP → Alloy → Prometheus remote_write, exactly like
|
||||
// the tracing pipeline, so no extra Prometheus scrape job is required.
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
|
||||
const meter = metrics.getMeter('farmafinder-backend', '1.0.0');
|
||||
|
||||
// --- Heartbeat -------------------------------------------------------------
|
||||
// Emitted continuously so we can alert on "backend not sending telemetry"
|
||||
// (remote_write has no `up` metric).
|
||||
export const heartbeatTotal = meter.createCounter('app_heartbeat_total', {
|
||||
description: 'Heartbeat ticks from the backend process (liveness signal).',
|
||||
});
|
||||
|
||||
// --- Medicine search / CIMA upstream --------------------------------------
|
||||
export const medicineSearchesTotal = meter.createCounter('medicine_searches_total', {
|
||||
description: 'Total medicine searches handled (cache or upstream).',
|
||||
});
|
||||
|
||||
export const cimaRequestsTotal = meter.createCounter('cima_requests_total', {
|
||||
description: 'Total upstream CIMA API requests.',
|
||||
});
|
||||
|
||||
export const cimaRequestDuration = meter.createHistogram('cima_request_duration_ms', {
|
||||
description: 'Duration of upstream CIMA API requests.',
|
||||
unit: 'ms',
|
||||
});
|
||||
|
||||
export const cacheHitsTotal = meter.createCounter('cache_hits_total', {
|
||||
description: 'Total Redis cache hits.',
|
||||
});
|
||||
|
||||
export const cacheMissesTotal = meter.createCounter('cache_misses_total', {
|
||||
description: 'Total Redis cache misses (fell through to upstream).',
|
||||
});
|
||||
|
||||
// --- Auth ------------------------------------------------------------------
|
||||
export const loginSuccessTotal = meter.createCounter('login_success_total', {
|
||||
description: 'Successful login attempts.',
|
||||
});
|
||||
|
||||
export const loginFailureTotal = meter.createCounter('login_failure_total', {
|
||||
description: 'Failed login attempts.',
|
||||
});
|
||||
|
||||
export const rateLimitRejectedTotal = meter.createCounter('rate_limit_rejected_total', {
|
||||
description: 'Requests rejected by a rate limiter.',
|
||||
});
|
||||
|
||||
// --- Admin operations ------------------------------------------------------
|
||||
export const pharmacyWriteTotal = meter.createCounter('pharmacy_write_total', {
|
||||
description: 'Admin pharmacy write operations.',
|
||||
});
|
||||
|
||||
export const pharmacyMedicineLinkTotal = meter.createCounter('pharmacy_medicine_link_total', {
|
||||
description: 'Pharmacy-medicine link operations.',
|
||||
});
|
||||
|
||||
// --- Push notifications ----------------------------------------------------
|
||||
export const pushSentTotal = meter.createCounter('push_sent_total', {
|
||||
description: 'Push notifications successfully sent.',
|
||||
});
|
||||
|
||||
export const pushFailedTotal = meter.createCounter('push_failed_total', {
|
||||
description: 'Push notifications that failed to send.',
|
||||
});
|
||||
|
||||
// --- Datastores ------------------------------------------------------------
|
||||
export const dbQueryDuration = meter.createHistogram('db_query_duration_ms', {
|
||||
description: 'Duration of user DB queries.',
|
||||
unit: 'ms',
|
||||
});
|
||||
|
||||
export const dbErrorsTotal = meter.createCounter('db_errors_total', {
|
||||
description: 'User DB query errors.',
|
||||
});
|
||||
|
||||
export const redisErrorsTotal = meter.createCounter('redis_errors_total', {
|
||||
description: 'Redis client errors.',
|
||||
});
|
||||
|
||||
export const redisCmdDuration = meter.createHistogram('redis_cmd_duration_ms', {
|
||||
description: 'Duration of Redis commands.',
|
||||
unit: 'ms',
|
||||
});
|
||||
|
||||
// --- HTTP (explicit, so we get status class + per-route breakdowns) --------
|
||||
// OTel's http auto-instrumentation records duration but not a discrete
|
||||
// error count, so we track requests explicitly for error-rate alerting.
|
||||
export const httpRequestsTotal = meter.createCounter('http_requests_total', {
|
||||
description: 'Total HTTP requests, labelled by route and status class.',
|
||||
});
|
||||
|
||||
export const httpRequestDuration = meter.createHistogram('http_request_duration_ms', {
|
||||
description: 'HTTP request duration.',
|
||||
unit: 'ms',
|
||||
});
|
||||
|
||||
// --- Heartbeat loop (guarded against --watch duplicate intervals) ----------
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
if (process.env.NODE_ENV !== 'test' && !globalThis.__farmafinderHeartbeat) {
|
||||
globalThis.__farmafinderHeartbeat = setInterval(() => {
|
||||
heartbeatTotal.add(1);
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
globalThis.__farmafinderHeartbeat?.unref?.();
|
||||
}
|
||||
@@ -9,8 +9,10 @@
|
||||
// routed to Tempo.
|
||||
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
|
||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
|
||||
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
|
||||
import * as resources from '@opentelemetry/resources';
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions';
|
||||
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
|
||||
@@ -28,9 +30,18 @@ const resource = typeof resources.resourceFromAttributes === 'function'
|
||||
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
|
||||
});
|
||||
|
||||
// Metrics → OTLP → Alloy → Prometheus remote_write. The PeriodicExportingMetricReader
|
||||
// makes the auto-instrumentations (http/express/redis) emit request duration/error
|
||||
// metrics with no extra code, on top of the custom business metrics in src/metrics.js.
|
||||
const metricReader = new PeriodicExportingMetricReader({
|
||||
exportIntervalMillis: 15_000,
|
||||
exporter: new OTLPMetricExporter({ url: otlpEndpoint }),
|
||||
});
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
resource,
|
||||
traceExporter: new OTLPTraceExporter({ url: otlpEndpoint }),
|
||||
metricReader,
|
||||
instrumentations: [
|
||||
getNodeAutoInstrumentations({
|
||||
// Disable fs by default — it is noisy and rarely useful.
|
||||
|
||||
@@ -6,3 +6,8 @@ EXPO_PUBLIC_API_URL=http://localhost:3001/api
|
||||
|
||||
# For production builds, update this to:
|
||||
# EXPO_PUBLIC_API_URL=https://api.yourdomain.com/api
|
||||
|
||||
# Grafana Alloy OTLP/HTTP endpoint for Faro RUM (must be reachable from the
|
||||
# device — use a LAN IP / public hostname, NOT localhost).
|
||||
# Open router :4318 → srv84-macos:4318, or proxy /faro via Nginx Proxy Manager.
|
||||
EXPO_PUBLIC_FARO_URL=http://grafana.hacecalor.net:4318
|
||||
|
||||
@@ -8,6 +8,10 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications';
|
||||
import { ThemeProvider, useThemeContext } from '../components/ThemeProvider';
|
||||
import { initFaro } from '../services/faro';
|
||||
|
||||
// Boot Faro RUM once, as early as possible.
|
||||
initFaro();
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"expo-constants": "~57.0.3",
|
||||
"expo-dev-client": "~57.0.5",
|
||||
"expo-device": "~7.0.2",
|
||||
"@grafana/faro-react-native": "^1.2.1",
|
||||
"expo-image-picker": "~57.0.2",
|
||||
"expo-linking": "~57.0.1",
|
||||
"expo-local-authentication": "~57.0.0",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { initializeFaro } from '@grafana/faro-react-native';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Initializes Grafana Faro RUM for the React Native app.
|
||||
*
|
||||
* Telemetry (crashes, JS errors, console, app-start, memory/ANR vitals,
|
||||
* session + screen tracking) is sent OTLP/HTTP to the Alloy collector.
|
||||
*
|
||||
* NOTE: `@grafana/faro-react-native` ships a native module, so it requires a
|
||||
* custom dev build / EAS build (not Expo Go). Initialization is wrapped in a
|
||||
* try/catch so a collector outage never crashes the app.
|
||||
*/
|
||||
export function initFaro(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
try {
|
||||
const url =
|
||||
process.env.EXPO_PUBLIC_FARO_URL || 'http://grafana.hacecalor.net:4318';
|
||||
|
||||
initializeFaro({
|
||||
app: {
|
||||
name: 'farmafinder-mobile',
|
||||
version: '1.0.0',
|
||||
environment: __DEV__ ? 'development' : 'production',
|
||||
},
|
||||
url,
|
||||
sessionTracking: { enabled: true },
|
||||
// Crash/JS error/console capture are on by default.
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[faro] initialization failed:', err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user