Cleanup modified & misisng files
This commit is contained in:
@@ -7,6 +7,7 @@ build/
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
scripts/
|
||||||
|
|
||||||
# Local Android SDK path — machine-specific
|
# Local Android SDK path — machine-specific
|
||||||
android/local.properties
|
android/local.properties
|
||||||
|
|||||||
@@ -1,363 +1,276 @@
|
|||||||
# FarmaFinder — Feature Plan
|
# Monitoring Integration Plan for Pharmacy Integration Platform (PIP)
|
||||||
|
|
||||||
## Overview
|
This document outlines how to integrate the PIP application with an existing Prometheus/Grafana/Alertmanager stack running on a remote host. The PIP application already exposes Prometheus metrics at `/metrics` and includes OpenTelemetry instrumentation for tracing.
|
||||||
|
|
||||||
Three features in recommended implementation order. Each is self-contained and can be shipped independently.
|
## Prerequisites
|
||||||
|
|
||||||
---
|
1. **Running PIP instance** - The application must be accessible from the monitoring host (via network).
|
||||||
|
2. **Existing monitoring stack** - Prometheus server, Grafana, and Alertmanager already deployed and reachable.
|
||||||
|
3. **Network access** - Ensure the monitoring host can scrape the PIP application's metrics endpoint (default port 8000).
|
||||||
|
4. **Credentials** (if applicable) - Any authentication tokens or basic auth required for scraping.
|
||||||
|
|
||||||
## Step 1 — "Near Me" Sorting (~2 hrs)
|
## 1. Metrics Endpoint
|
||||||
|
|
||||||
### Goal
|
The PIP application exposes Prometheus-formatted metrics at:
|
||||||
Sort pharmacy results by distance from user's current location. Works in browser today, same logic reusable in React Native later.
|
|
||||||
|
|
||||||
### Backend changes
|
```
|
||||||
None. `latitude` and `longitude` already returned by `GET /api/medicines/:nregistro/pharmacies`.
|
http://<pip-host>:8000/metrics
|
||||||
|
|
||||||
### Frontend changes
|
|
||||||
|
|
||||||
**`frontend/src/utils/geo.js`** (new)
|
|
||||||
```js
|
|
||||||
export function haversineKm(lat1, lon1, lat2, lon2) {
|
|
||||||
const R = 6371;
|
|
||||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
|
||||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
|
||||||
const a =
|
|
||||||
Math.sin(dLat / 2) ** 2 +
|
|
||||||
Math.cos((lat1 * Math.PI) / 180) *
|
|
||||||
Math.cos((lat2 * Math.PI) / 180) *
|
|
||||||
Math.sin(dLon / 2) ** 2;
|
|
||||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUserPosition() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!navigator.geolocation) return reject(new Error('Geolocation not supported'));
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
|
|
||||||
reject,
|
|
||||||
{ timeout: 8000 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**`frontend/src/views/PublicView.jsx`**
|
This endpoint is already mounted in the FastAPI application (see `src/main.py` line 64-65). No further changes are required unless you need to change the path or add authentication.
|
||||||
- Add state: `userPosition` (null | { lat, lon }), `sortByDistance` (bool)
|
|
||||||
- Add "Sort by distance" button — on click calls `getUserPosition()`, sets `userPosition`, sets `sortByDistance = true`
|
|
||||||
- Before rendering pharmacies, if `sortByDistance && userPosition`:
|
|
||||||
```js
|
|
||||||
const sorted = [...pharmacies].sort((a, b) => {
|
|
||||||
if (!a.latitude) return 1;
|
|
||||||
if (!b.latitude) return -1;
|
|
||||||
return haversineKm(userPosition.lat, userPosition.lon, a.latitude, a.longitude)
|
|
||||||
- haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
- Pass `sorted` (or original `pharmacies`) to `PharmacyList` and `PharmacyMap`
|
|
||||||
|
|
||||||
**`frontend/src/components/PharmacyList.jsx`**
|
### Optional: Add Basic Auth to Metrics Endpoint
|
||||||
- Accept optional `userPosition` prop
|
If your Prometheus server requires authentication, you can wrap the metrics endpoint with middleware. Example:
|
||||||
- When present and pharmacy has lat/lng, show distance badge: `1.2 km` next to pharmacy name
|
|
||||||
|
|
||||||
### Verification
|
```python
|
||||||
|
# In src/main.py after creating the app
|
||||||
|
from starlette.basic_auth import BasicAuthMiddleware
|
||||||
|
from src.infrastructure.config.settings import settings
|
||||||
|
|
||||||
|
if settings.METRICS_AUTH_USERNAME and settings.METRICS_AUTH_PASSWORD:
|
||||||
|
app.add_middleware(
|
||||||
|
BasicAuthMiddleware,
|
||||||
|
username=settings.METRICS_AUTH_USERNAME,
|
||||||
|
password=settings.METRICS_AUTH_PASSWORD,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the corresponding environment variables to your deployment.
|
||||||
|
|
||||||
|
## 2. Prometheus Configuration
|
||||||
|
|
||||||
|
Add a scrape job for the PIP service in your Prometheus configuration file (typically `prometheus.yml`).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'pip-api'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['<pip-host>:8000'] # replace with actual host/IP
|
||||||
|
# Optional: if you enabled basic auth on /metrics
|
||||||
|
# basic_auth:
|
||||||
|
# username: <username>
|
||||||
|
# password: <password>
|
||||||
|
# Optional: TLS configuration if using HTTPS
|
||||||
|
# scheme: https
|
||||||
|
# tls_config:
|
||||||
|
# ca_file: /path/to/ca.pem
|
||||||
|
# cert_file: /path/to/client-cert.pem
|
||||||
|
# key_file: /path/to/client-key.pem
|
||||||
|
metric_relabel_configs:
|
||||||
|
# Optional: filter or relabel metrics as needed
|
||||||
|
- source_labels: [__name__]
|
||||||
|
regex: 'pip_.*'
|
||||||
|
action: keep
|
||||||
|
```
|
||||||
|
|
||||||
|
After updating the configuration, reload Prometheus:
|
||||||
```bash
|
```bash
|
||||||
# Start app, search a medicine, click "Sort by distance", allow geolocation
|
curl -X POST http://<prometheus-host>:9090/-/reload
|
||||||
# Pharmacies reorder, distance badges appear
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 3. Grafana Dashboards
|
||||||
|
|
||||||
## Step 2 — Pharmacy Hours (~3 hrs)
|
You can import a pre-built dashboard JSON (provided below) or create your own panels using the exposed metrics.
|
||||||
|
|
||||||
### Goal
|
### Example Dashboard JSON
|
||||||
Store structured opening hours per pharmacy. Show "Open now" / "Closed" / "Opens at HH:MM" badge on pharmacy cards.
|
Save this as `pip-dashboard.json` and import via Grafana UI (`+ Import` → Paste JSON).
|
||||||
|
|
||||||
### Data model
|
|
||||||
|
|
||||||
Store hours as JSON in a single column — structured enough to parse, simple enough to edit manually in admin:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mon": ["09:00", "21:00"],
|
"dashboard": {
|
||||||
"tue": ["09:00", "21:00"],
|
"id": null,
|
||||||
"wed": ["09:00", "21:00"],
|
"title": "Pharmacy Integration Platform",
|
||||||
"thu": ["09:00", "21:00"],
|
"timezone": "browser",
|
||||||
"fri": ["09:00", "21:00"],
|
"schemaVersion": 38,
|
||||||
"sat": ["09:00", "14:00"],
|
"version": 1,
|
||||||
"sun": null
|
"refresh": "10s",
|
||||||
}
|
"panels": [
|
||||||
```
|
{
|
||||||
|
"type": "graph",
|
||||||
`null` = closed that day. 24h format strings. Only one time range per day (no split shifts) — extend later if needed.
|
"title": "HTTP Requests Rate",
|
||||||
|
"datasource": "Prometheus",
|
||||||
### Backend changes
|
"targets": [
|
||||||
|
{
|
||||||
**`backend/server.js`** — `initDatabase()`
|
"expr": "sum by (method, endpoint, http_status) (rate(pip_http_requests_total[5m]))",
|
||||||
```sql
|
"legendFormat": "{{method}} {{endpoint}} {{http_status}}",
|
||||||
ALTER TABLE pharmacies ADD COLUMN opening_hours TEXT;
|
"refId": "A"
|
||||||
```
|
}
|
||||||
Wrap in try/catch — `ALTER TABLE` throws if column already exists.
|
],
|
||||||
|
"yAxes": [
|
||||||
**`GET /api/medicines/:nregistro/pharmacies`** — already returns `SELECT *`, no change needed.
|
{
|
||||||
|
"format": "ops",
|
||||||
**`POST /api/admin/pharmacies`** and **`PUT /api/admin/pharmacies/:id`**
|
"label": "req/s"
|
||||||
- Accept `opening_hours` in request body (JSON string or object)
|
}
|
||||||
- Serialize to string before INSERT/UPDATE: `JSON.stringify(opening_hours) || null`
|
],
|
||||||
|
"gridPos": { "x": 0, "y": 0, "w": 12, "h": 8 }
|
||||||
### Frontend changes
|
},
|
||||||
|
{
|
||||||
**`frontend/src/utils/hours.js`** (new)
|
"type": "graph",
|
||||||
```js
|
"title": "Request Duration (seconds)",
|
||||||
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
"datasource": "Prometheus",
|
||||||
|
"targets": [
|
||||||
export function getOpenStatus(openingHoursJson) {
|
{
|
||||||
if (!openingHoursJson) return null;
|
"expr": "histogram_quantile(0.95, sum by (le, method, endpoint) (rate(pip_http_request_duration_seconds_bucket[5m])))",
|
||||||
let hours;
|
"legendFormat": "{{method}} {{endpoint}} 95th percentile",
|
||||||
try { hours = typeof openingHoursJson === 'string' ? JSON.parse(openingHoursJson) : openingHoursJson; }
|
"refId": "A"
|
||||||
catch { return null; }
|
}
|
||||||
|
],
|
||||||
const now = new Date();
|
"yAxes": [
|
||||||
const day = DAYS[now.getDay()];
|
{
|
||||||
const range = hours[day];
|
"format": "seconds",
|
||||||
|
"label": "seconds"
|
||||||
if (!range) return { status: 'closed', label: 'Closed today' };
|
}
|
||||||
|
],
|
||||||
const [openStr, closeStr] = range;
|
"gridPos": { "x": 12, "y": 0, "w": 12, "h": 8 }
|
||||||
const [oh, om] = openStr.split(':').map(Number);
|
},
|
||||||
const [ch, cm] = closeStr.split(':').map(Number);
|
{
|
||||||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
"type": "graph",
|
||||||
const openMins = oh * 60 + om;
|
"title": "Database Query Duration",
|
||||||
const closeMins = ch * 60 + cm;
|
"datasource": "Prometheus",
|
||||||
|
"targets": [
|
||||||
if (nowMins < openMins) return { status: 'closed', label: `Opens at ${openStr}` };
|
{
|
||||||
if (nowMins >= closeMins) return { status: 'closed', label: `Closed · Opens ${getNextOpen(hours, now)}` };
|
"expr": "histogram_quantile(0.95, sum by (le, operation) (rate(pip_db_query_duration_seconds_bucket[5m])))",
|
||||||
return { status: 'open', label: `Open · Closes at ${closeStr}` };
|
"legendFormat": "{{operation}} 95th percentile",
|
||||||
}
|
"refId": "A"
|
||||||
```
|
}
|
||||||
|
],
|
||||||
**`frontend/src/components/PharmacyList.jsx`**
|
"yAxes": [
|
||||||
- Import `getOpenStatus`, render status badge per pharmacy card
|
{
|
||||||
- CSS: `.badge-open { color: green }` / `.badge-closed { color: #999 }`
|
"format": "seconds",
|
||||||
|
"label": "seconds"
|
||||||
**`frontend/src/components/admin/PharmacyManagement.jsx`**
|
}
|
||||||
- Add hours editor: 7 rows (Mon–Sun), each with two time inputs (open/close) + "Closed" checkbox
|
],
|
||||||
- Serialize to JSON on save
|
"gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 }
|
||||||
|
},
|
||||||
### Verification
|
{
|
||||||
```bash
|
"type": "graph",
|
||||||
# Add pharmacy with hours via admin panel
|
"title": "Redis Operations",
|
||||||
# Search medicine linked to that pharmacy
|
"datasource": "Prometheus",
|
||||||
# Card shows "Open · Closes at 21:00" or "Closed · Opens at 09:00"
|
"targets": [
|
||||||
# Change system clock or test at different times
|
{
|
||||||
```
|
"expr": "sum by (operation) (rate(pip_redis_operations_total[5m]))",
|
||||||
|
"legendFormat": "{{operation}}",
|
||||||
---
|
"refId": "A"
|
||||||
|
}
|
||||||
## Step 3 — PWA + Push Notifications / Medicine Alerts (~1 day)
|
],
|
||||||
|
"yAxes": [
|
||||||
### Goal
|
{
|
||||||
User subscribes to a medicine. When any pharmacy links that medicine (via admin panel), the user receives a push notification in the browser. Foundation reusable for React Native (swap `web-push` for FCM).
|
"format": "ops",
|
||||||
|
"label": "ops/s"
|
||||||
### Architecture
|
}
|
||||||
|
],
|
||||||
```
|
"gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 }
|
||||||
User visits app
|
},
|
||||||
→ service worker registered (PWA)
|
{
|
||||||
→ user searches medicine, clicks "Notify me"
|
"type": "graph",
|
||||||
→ browser prompts for notification permission
|
"title": "RabbitMQ Messages Published",
|
||||||
→ browser generates push subscription (endpoint + keys)
|
"datasource": "Prometheus",
|
||||||
→ POST /api/notifications/subscribe { medicine_nregistro, subscription }
|
"targets": [
|
||||||
→ stored in DB
|
{
|
||||||
|
"expr": "sum by (exchange, routing_key) (rate(pip_rabbitmq_messages_published_total[5m]))",
|
||||||
Admin links medicine to pharmacy (POST /api/admin/pharmacy-medicines)
|
"legendFormat": "{{exchange}} -> {{routing_key}}",
|
||||||
→ backend queries notifications table for medicine_nregistro
|
"refId": "A"
|
||||||
→ fires web-push to each subscription endpoint
|
}
|
||||||
→ user receives notification: "Ibuprofeno 400mg now available at Farmacia Sol"
|
],
|
||||||
```
|
"yAxes": [
|
||||||
|
{
|
||||||
### Backend changes
|
"format": "ops",
|
||||||
|
"label": "msgs/s"
|
||||||
**Dependencies**
|
}
|
||||||
```bash
|
],
|
||||||
npm install web-push
|
"gridPos": { "x": 0, "y": 16, "w": 24, "h": 8 }
|
||||||
```
|
}
|
||||||
|
],
|
||||||
Generate VAPID keys (one-time, store in `.env`):
|
"templating": {
|
||||||
```bash
|
"list": []
|
||||||
node -e "const wp = require('web-push'); const k = wp.generateVAPIDKeys(); console.log(k)"
|
},
|
||||||
```
|
"annotations": {
|
||||||
Add to `.env.example`:
|
"list": []
|
||||||
```
|
}
|
||||||
VAPID_PUBLIC_KEY=
|
|
||||||
VAPID_PRIVATE_KEY=
|
|
||||||
VAPID_EMAIL=mailto:admin@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**`backend/server.js`** — `initDatabase()`
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
medicine_nregistro TEXT NOT NULL,
|
|
||||||
endpoint TEXT NOT NULL,
|
|
||||||
p256dh TEXT NOT NULL,
|
|
||||||
auth TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(medicine_nregistro, endpoint)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**New routes**
|
|
||||||
```
|
|
||||||
POST /api/notifications/subscribe
|
|
||||||
body: { medicine_nregistro, medicine_name, subscription: { endpoint, keys: { p256dh, auth } } }
|
|
||||||
→ upsert into push_subscriptions
|
|
||||||
→ 201
|
|
||||||
|
|
||||||
DELETE /api/notifications/unsubscribe
|
|
||||||
body: { medicine_nregistro, endpoint }
|
|
||||||
→ delete from push_subscriptions
|
|
||||||
→ 204
|
|
||||||
|
|
||||||
GET /api/notifications/vapid-public-key
|
|
||||||
→ returns { publicKey: process.env.VAPID_PUBLIC_KEY }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hook into `POST /api/admin/pharmacy-medicines`**
|
|
||||||
After successful insert:
|
|
||||||
```js
|
|
||||||
const subs = await dbAll(
|
|
||||||
'SELECT * FROM push_subscriptions WHERE medicine_nregistro = ?',
|
|
||||||
[medicine_nregistro]
|
|
||||||
);
|
|
||||||
for (const sub of subs) {
|
|
||||||
await webpush.sendNotification(
|
|
||||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
|
||||||
JSON.stringify({
|
|
||||||
title: 'Medicine available',
|
|
||||||
body: `${medicine_name} now available at ${pharmacyName}`,
|
|
||||||
url: '/'
|
|
||||||
})
|
|
||||||
).catch(() => {
|
|
||||||
// subscription expired — delete it
|
|
||||||
dbRun('DELETE FROM push_subscriptions WHERE id = ?', [sub.id]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend changes
|
|
||||||
|
|
||||||
**Dependencies**
|
|
||||||
```bash
|
|
||||||
npm install vite-plugin-pwa
|
|
||||||
```
|
|
||||||
|
|
||||||
**`frontend/vite.config.js`**
|
|
||||||
```js
|
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
|
||||||
|
|
||||||
// Add to plugins array:
|
|
||||||
VitePWA({
|
|
||||||
registerType: 'autoUpdate',
|
|
||||||
manifest: {
|
|
||||||
name: 'FarmaFinder',
|
|
||||||
short_name: 'FarmaFinder',
|
|
||||||
theme_color: '#2563eb',
|
|
||||||
icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }],
|
|
||||||
},
|
},
|
||||||
workbox: {
|
"overwrite": true
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**`frontend/src/utils/notifications.js`** (new)
|
|
||||||
```js
|
|
||||||
export async function getVapidKey() {
|
|
||||||
const res = await fetch('/api/notifications/vapid-public-key');
|
|
||||||
const { publicKey } = await res.json();
|
|
||||||
return publicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function urlBase64ToUint8Array(base64String) {
|
|
||||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
||||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
return Uint8Array.from(atob(base64), c => c.charCodeAt(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subscribeToPush(medicineNregistro, medicineName) {
|
|
||||||
const reg = await navigator.serviceWorker.ready;
|
|
||||||
const publicKey = await getVapidKey();
|
|
||||||
const subscription = await reg.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
|
||||||
});
|
|
||||||
await fetch('/api/notifications/subscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
medicine_nregistro: medicineNregistro,
|
|
||||||
medicine_name: medicineName,
|
|
||||||
subscription,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**`frontend/src/components/MedicineResults.jsx`**
|
### Import Steps
|
||||||
- Add "Notify me" bell button per medicine result
|
1. In Grafana, click the **+** icon → **Import**.
|
||||||
- On click: call `subscribeToPush(medicine.id, medicine.name)`
|
2. Paste the JSON above or upload the file.
|
||||||
- Toggle state: subscribed / not subscribed (persist in `localStorage`)
|
3. Select your Prometheus data source.
|
||||||
|
4. Click **Import**.
|
||||||
|
|
||||||
**Service worker push handler** — `vite-plugin-pwa` injects the SW; add a custom handler:
|
## 4. Alerting Rules (Optional)
|
||||||
```js
|
|
||||||
// frontend/src/sw.js (custom SW additions via injectManifest mode)
|
|
||||||
self.addEventListener('push', event => {
|
|
||||||
const data = event.data.json();
|
|
||||||
event.waitUntil(
|
|
||||||
self.registration.showNotification(data.title, {
|
|
||||||
body: data.body,
|
|
||||||
icon: '/icon-192.png',
|
|
||||||
data: { url: data.url },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', event => {
|
Create a rule file for Alertmanager (e.g., `pip_alerts.yml`) and add it to your Prometheus server.
|
||||||
event.notification.close();
|
|
||||||
event.waitUntil(clients.openWindow(event.notification.data.url));
|
```yaml
|
||||||
});
|
groups:
|
||||||
|
- name: pip-alerts
|
||||||
|
rules:
|
||||||
|
- alert: HighRequestLatency
|
||||||
|
expr: histogram_quantile(0.95, sum by (le, method, endpoint) (rate(pip_http_request_duration_seconds_bucket[5m]))) > 2
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "High request latency on {{ $labels.endpoint }}"
|
||||||
|
description: "95th percentile latency is above 2 seconds for {{ $labels.method }} {{ $labels.endpoint }}."
|
||||||
|
|
||||||
|
- alert: APIErrorRateHigh
|
||||||
|
expr: sum by (http_status) (rate(pip_http_requests_total{http_status=~"5.."}[5m])) / sum by (http_status) (rate(pip_http_requests_total[5m])) > 0.05
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "High error rate ({{ $value | printf \"%.2f\" }})"
|
||||||
|
description: "More than 5% of requests are returning 5xx errors."
|
||||||
|
|
||||||
|
- alert: RedisDown
|
||||||
|
expr: up{job="pip-api"} == 0
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "PIP application scrape failed"
|
||||||
|
description: "Prometheus cannot scrape the PIP application metrics endpoint."
|
||||||
|
|
||||||
|
- alert: DatabaseConnectionFailures
|
||||||
|
expr: increase(pip_db_connection_errors_total[5m]) > 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Database connection errors detected"
|
||||||
|
description: "There have been {{ $value }} database connection errors in the last 5 minutes."
|
||||||
```
|
```
|
||||||
|
|
||||||
### React Native path (future)
|
Add the file to your Prometheus server's rule files configuration and reload.
|
||||||
- Replace `web-push` with Firebase Admin SDK (`firebase-admin`)
|
|
||||||
- Replace `push_subscriptions.endpoint/p256dh/auth` with `fcm_token`
|
## 5. Verification Steps
|
||||||
- Frontend subscription logic moves to Expo `registerForPushNotificationsAsync()`
|
|
||||||
- Backend notification dispatch abstracted into `notify(medicineNregistro, message)` function — same interface, different transport
|
1. **Verify Metrics Endpoint**
|
||||||
|
From the monitoring host, run:
|
||||||
|
```bash
|
||||||
|
curl -s http://<pip-host>:8000/metrics | head -20
|
||||||
|
```
|
||||||
|
You should see lines like `# HELP pip_http_requests_total ...` and `# TYPE pip_http_requests_total counter`.
|
||||||
|
|
||||||
|
2. **Check Prometheus Targets**
|
||||||
|
In Prometheus UI (`http://<prometheus-host>:9090/targets`), ensure the `pip-api` job shows `UP`.
|
||||||
|
|
||||||
|
3. **View Dashboard**
|
||||||
|
Open the imported dashboard in Grafana and verify panels populate with data.
|
||||||
|
|
||||||
|
4. **Test Alerts (Optional)**
|
||||||
|
You can temporarily trigger an alert by modifying the threshold (e.g., set `HighRequestLatency` to `> 0`) and verify Alertmanager fires.
|
||||||
|
|
||||||
|
## 6. Cleanup / Roll‑needed) later you decide howto monitor within the docker compose stack again, just edit `docker-compose.yml` and uncomment the monitoring services (prometheus, grafana, alertmanager) and the related volumes, then run:
|
||||||
|
|
||||||
### Verification
|
|
||||||
```bash
|
|
||||||
# Start app over HTTPS (required for push — use ngrok or deploy)
|
|
||||||
# Search medicine, click bell icon
|
|
||||||
# Browser prompts notification permission — allow
|
|
||||||
# In admin: link that medicine to a pharmacy
|
|
||||||
# Browser notification fires: "Ibuprofeno 400mg now available at Farmacia Sol"
|
|
||||||
```
|
```
|
||||||
|
docker compose -f pip-platform/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure the healthcheck in the `pip-api` service still points to `/api/v1/system/health` (already updated).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recommended Order
|
*This plan is intended to be a one‑time setup guide. Adjust hostnames, ports, authentication, and resource limits to match your production environment.*
|
||||||
|
|
||||||
| Step | Time | Depends on |
|
|
||||||
|------|------|-----------|
|
|
||||||
| 1. Near me sorting | ~2 hrs | Nothing |
|
|
||||||
| 2. Pharmacy hours | ~3 hrs | Nothing (can run parallel with step 1) |
|
|
||||||
| 3. PWA + alerts | ~1 day | Steps 1 and 2 done (better UX in notification: "open now, 1.2 km away") |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Push notifications require HTTPS in production. In development use ngrok or set `CHROME_FLAGS=--unsafely-treat-insecure-origin-as-secure=http://localhost:3000` for testing.
|
|
||||||
- VAPID keys are per-environment — generate once, never rotate unless necessary (rotation invalidates all existing subscriptions).
|
|
||||||
- The `UNIQUE(medicine_nregistro, endpoint)` constraint on `push_subscriptions` prevents duplicate alerts if user clicks "Notify me" multiple times.
|
|
||||||
Binary file not shown.
@@ -0,0 +1,115 @@
|
|||||||
|
# Pharmacy Integration Platform (PIP) — State Log
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Build a Python/FastAPI backend that abstracts pharmacy ERPs behind a unified REST API. Currently implementing Phase 3 (Inventory & Medication APIs).
|
||||||
|
|
||||||
|
## Constraints & Preferences
|
||||||
|
- Backend only, no UI/mobile. Python 3.13, FastAPI, SQLAlchemy async, Alembic, PostgreSQL, Redis, RabbitMQ.
|
||||||
|
- Clean Architecture, SOLID, Repository Pattern, DI, DDD where valuable, Event Driven, CQRS where needed.
|
||||||
|
- Versioned API at `/api/v1/`. JWT+OAuth2 auth. RBAC with role hierarchy and scopes. API Keys for third parties.
|
||||||
|
- No code secrets. Structured JSON logs. OpenTelemetry, Prometheus, Grafana.
|
||||||
|
- Docker Compose with health checks; Kubernetes-migratable.
|
||||||
|
- Monitoring (Prometheus, Grafana, OTEL Collector) omitted from Docker stack; remote monitoring stack exists. PLAN.md provided for external integration.
|
||||||
|
- Incremental phases — wait for validation before advancing.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### Done
|
||||||
|
- **Phase 1 complete**: full project structure, domain entities, ORM models, repositories, auth, security, API endpoints, middleware, Docker, CLI, Alembic, tests scaffolding
|
||||||
|
- **Phase 2 complete**: Connector Framework + SDK + Concrete Connectors + API Endpoints + Event Publishers + Tests
|
||||||
|
- **Phase 3 (medication/inventory)**: Medication & Inventory API endpoints — all E2E validated:
|
||||||
|
- Medications: CRUD, search by name/ingredient/nregistro/atc, nregistro lookup, duplicate 409
|
||||||
|
- Catalog: per-pharmacy upsert, list, get
|
||||||
|
- Inventory: per-pharmacy upsert with history tracking, list, get, history endpoint
|
||||||
|
- Reservations: create, idempotency key, list, get, confirm, cancel, insufficient stock 422
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
- Phase 3 remaining items: Sync Job management, Audit logging integration, API Key auth flow, Rate limiting
|
||||||
|
|
||||||
|
### Blocked
|
||||||
|
- (none)
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
- Monitoring removed from Docker stack; `PLAN.md` provides instructions for external Prometheus/Grafana integration
|
||||||
|
- `docker-compose.runtime.yml` is the lean compose file for local dev
|
||||||
|
- Connector Registry uses singleton pattern with lazy loading via `importlib` for builtin connector types
|
||||||
|
- SDK architecture: `SDKConnector` base class composes auth handler + retry policy + circuit breaker + httpx client; concrete connectors extend it
|
||||||
|
- `_BUILTIN_CONNECTORS` registry maps ERPType values to `module.Class` strings for dynamic import
|
||||||
|
- Circuit breaker: 5 failures → open → 30s recovery → half_open (1 probe call) → close on success / reopen on failure
|
||||||
|
- OAuth2 handler fetches client_credentials tokens with 30s expiry buffer
|
||||||
|
- Replaced `passlib` with direct `bcrypt` library due to Python 3.13 incompatibility
|
||||||
|
- Connector events published to RabbitMQ with `connector.{event}` routing keys via `ConnectorService._publish_event()`
|
||||||
|
- `test_connection` sets connector status to ACTIVE on success, ERROR on failure; `deploy` refuses if already active
|
||||||
|
- Catalog entries are per-pharmacy, keyed by `(pharmacy_id, medication_id)` — upsert pattern
|
||||||
|
- Inventory records use same composite key pattern as catalog — upsert with history tracking
|
||||||
|
- Reservations support idempotency via `idempotency_key`, auto-expire, stock reservation/release on confirm/cancel
|
||||||
|
- Event publishing uses `event_type.replace(".", "_")` to map dotted event types to RabbitMQ routing keys
|
||||||
|
- Auth token endpoint expects JSON body (`LoginRequest`), not OAuth2 form data
|
||||||
|
- `InventoryHistoryEntity` has `inventory_record_id` field — must be set from service when creating history records
|
||||||
|
- Reservation `expires_at` uses `timedelta` (not timestamp arithmetic — float type error with Python 3.13 datetime)
|
||||||
|
|
||||||
|
## Critical Bugs Fixed (this session)
|
||||||
|
1. `inventory_repository.py:add_history()` was using `entity.id` (None) instead of `entity.inventory_record_id` for the FK
|
||||||
|
2. `inventory_service.py:upsert_inventory_record()` wasn't passing `inventory_record_id=upserted.id` to `InventoryHistoryEntity`
|
||||||
|
3. `inventory_service.py:_release_reserved_stock()` wasn't passing `inventory_record_id` either
|
||||||
|
4. `inventory_service.py:create_reservation()` used `now.__class__(now.timestamp() + ttl_minutes * 60, tz=timezone.utc)` which fails with TypeError on float — replaced with `now + timedelta(minutes=ttl_minutes)`
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
- Implement Sync Job management (create, list, get, trigger, cancel sync jobs)
|
||||||
|
- Add Audit logging integration
|
||||||
|
- Implement API Key auth flow
|
||||||
|
- Add Rate limiting
|
||||||
|
- Replace `Base.metadata.create_all` with Alembic migrations
|
||||||
|
- Add more connector implementations (NixFara, Unycop, CSV, FTP, SQL)
|
||||||
|
- Enhance test coverage
|
||||||
|
- Set up CI/CD pipeline
|
||||||
|
|
||||||
|
## Critical Context
|
||||||
|
- `bcrypt` library used directly (not via passlib) — must keep `bcrypt>=4.2.0` in dependencies
|
||||||
|
- OTel collector reference in docker-compose points to `otel-collector:4317` which doesn't exist in runtime stack — harmless warning logs
|
||||||
|
- System router has `/system` prefix; health at `/api/v1/system/health`
|
||||||
|
- Auth: register at `/api/v1/auth/register`, token at `/api/v1/auth/token` (JSON body, not form data)
|
||||||
|
- Generic exception handler in `main.py` swallows all errors as `{"error": {"code": "INTERNAL_ERROR", "message": "Internal server error"}}` — no traceback in response; check container logs or test repo/service directly for debugging
|
||||||
|
- Inventory endpoint uses `PUT /pharmacies/{pharmacy_id}/inventory` for upsert
|
||||||
|
- Reservation endpoints: `POST .../reservations` (create), `POST .../reservations/{id}/confirm`, `POST .../reservations/{id}/cancel`## Recent changes (OTel migration in progress)
|
||||||
|
|
||||||
|
- **Observability now lives on srv84-macos.** The in-stack otel-collector / prometheus / grafana services have been stripped from `pip-platform/docker-compose.yml`, the orphaned `pip-platform/monitoring/otel-collector-config.yaml` has been deleted, and `pip-platform/docker-compose.runtime.yml` was previously pointing at a non-existent `otel-collector:4317` — fixed to `host.docker.localhost:4317` to route through Grafana Alloy. See PLAN.md for the external-integration rationale.
|
||||||
|
- **`pip-platform/monitoring/prometheus/prometheus.yml` is now documentation-only** — kept as a reference; the active scraper is the Prometheus on srv84-macos.
|
||||||
|
- **Monitoring stack (~/Projects/Monitoring/)**: Telegram bot token was externalized from `alertmanager/config.yml` (was plaintext literal) to `${TG_BOT_TOKEN}` resolved by an `envsubst` entrypoint on the remote. `OTel→Alloy` swap is local-canonical; srv84-macos merge still pending (see PLAN.md).
|
||||||
|
|
||||||
|
## Relevant Files
|
||||||
|
|
||||||
|
|
||||||
|
### Domain
|
||||||
|
- `pip-platform/src/domain/entities/entities.py` — All domain entities (including `inventory_record_id` on `InventoryHistoryEntity`)
|
||||||
|
- `pip-platform/src/domain/entities/enums.py` — All enums
|
||||||
|
|
||||||
|
### Repositories
|
||||||
|
- `pip-platform/src/infrastructure/database/repositories/medication_repository.py` — Medication CRUD + search
|
||||||
|
- `pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py` — Per-pharmacy catalog upsert
|
||||||
|
- `pip-platform/src/infrastructure/database/repositories/inventory_repository.py` — Inventory upsert + history
|
||||||
|
- `pip-platform/src/infrastructure/database/repositories/reservation_repository.py` — Reservation CRUD + idempotency
|
||||||
|
- `pip-platform/src/infrastructure/database/repositories/__init__.py` — All repo exports
|
||||||
|
|
||||||
|
### Services
|
||||||
|
- `pip-platform/src/services/medication/medication_service.py` — Medication + catalog business logic + events
|
||||||
|
- `pip-platform/src/services/inventory/inventory_service.py` — Inventory + reservation business logic (stock reservation/release, confirm, cancel, idempotency) + events
|
||||||
|
- `pip-platform/src/services/connector_service.py` — Connector lifecycle + event publishing
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
- `pip-platform/src/api/v1/endpoints/medications.py` — Medication CRUD + search
|
||||||
|
- `pip-platform/src/api/v1/endpoints/catalog.py` — Per-pharmacy catalog list/get/upsert
|
||||||
|
- `pip-platform/src/api/v1/endpoints/inventory.py` — Per-pharmacy inventory list/get/upsert + history
|
||||||
|
- `pip-platform/src/api/v1/endpoints/reservations.py` — Per-pharmacy reservation create/confirm/cancel/list/get
|
||||||
|
- `pip-platform/src/api/v1/endpoints/connectors.py` — Connector CRUD + test/deploy/deactivate/configs
|
||||||
|
- `pip-platform/src/api/v1/endpoints/erp_systems.py` — ERP System CRUD
|
||||||
|
- `pip-platform/src/api/v1/router.py` — All routers wired
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- `pip-platform/src/infrastructure/messaging/rabbitmq.py` — RabbitMQ client with routing keys
|
||||||
|
- `pip-platform/src/connectors/registry.py` — ConnectorRegistry singleton
|
||||||
|
- `pip-platform/src/connectors/sdk/base_sdk.py` — SDKConnector base class
|
||||||
|
- `pip-platform/src/services/auth/security.py` — Fixed bcrypt usage (no passlib)
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- `pip-platform/docker-compose.runtime.yml` — Lean runtime stack
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.hacecalor.farmafinder;
|
PRODUCT_BUNDLE_IDENTIFIER = net.hacecalor.farmaclic;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -373,7 +373,7 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.hacecalor.farmafinder;
|
PRODUCT_BUNDLE_IDENTIFIER = net.hacecalor.farmaclic;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>FarmaFinder</string>
|
<string>FarmaClic</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -46,11 +46,11 @@
|
|||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>FarmaFinder uses your location to sort nearby pharmacies by distance.</string>
|
<string>FarmaClic uses your location to sort nearby pharmacies by distance.</string>
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>FarmaFinder uses your location to sort nearby pharmacies by distance.</string>
|
<string>FarmaClic uses your location to sort nearby pharmacies by distance.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>FarmaFinder uses the camera to scan your TSI health card barcode and QR code.</string>
|
<string>FarmaClic uses the camera to scan your TSI health card barcode and QR code.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
@@ -0,0 +1,639 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"_postman_id": "8f8e8a60-a292-4c28-9892-a1f727de5a6b",
|
||||||
|
"name": "FarmaFinder API Monitoring Collection",
|
||||||
|
"description": "Comprehensive integration testing and monitoring collection for FarmaFinder's Express API. Includes automated lifecycle tests for pharmacies and medicine links.",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Public APIs",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Search Medicines",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/medicines/search?q=Paracetamol",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"medicines",
|
||||||
|
"search"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "q",
|
||||||
|
"value": "Paracetamol"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Searches medicines via the CIMA API with Redis cache."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"pm.test(\"Response is an array\", function () {",
|
||||||
|
" var jsonData = pm.response.json();",
|
||||||
|
" pm.expect(jsonData).to.be.an('array');",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"pm.test(\"Response returns valid medicine objects\", function () {",
|
||||||
|
" var jsonData = pm.response.json();",
|
||||||
|
" if (jsonData.length > 0) {",
|
||||||
|
" pm.expect(jsonData[0]).to.have.property('nregistro');",
|
||||||
|
" pm.expect(jsonData[0]).to.have.property('name');",
|
||||||
|
" }",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Medicine Details",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/medicines/51346",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"medicines",
|
||||||
|
"51346"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Retrieves medicine metadata from CIMA using its registration number (nregistro)."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200 or 404\", function () {",
|
||||||
|
" pm.expect(pm.response.code).to.be.oneOf([200, 404]);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"pm.test(\"Response time is acceptable\", function () {",
|
||||||
|
" pm.expect(pm.response.responseTime).to.be.below(3000);",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Pharmacies Selling Medicine",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/medicines/51346/pharmacies",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"medicines",
|
||||||
|
"51346",
|
||||||
|
"pharmacies"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Retrieves the list of pharmacies offering a specific medicine."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"pm.test(\"Response is an array\", function () {",
|
||||||
|
" pm.expect(pm.response.json()).to.be.an('array');",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get All Pharmacies",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/pharmacies",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"pharmacies"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Retrieves all registered pharmacies."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"pm.test(\"Response is a list of pharmacies\", function () {",
|
||||||
|
" var jsonData = pm.response.json();",
|
||||||
|
" pm.expect(jsonData).to.be.an('array');",
|
||||||
|
" if (jsonData.length > 0) {",
|
||||||
|
" pm.expect(jsonData[0]).to.have.property('name');",
|
||||||
|
" pm.expect(jsonData[0]).to.have.property('address');",
|
||||||
|
" }",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authentication APIs",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Login",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/auth/login",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"auth",
|
||||||
|
"login"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Authenticates the admin user and receives session cookie."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"pm.test(\"Verify user is admin\", function () {",
|
||||||
|
" var jsonData = pm.response.json();",
|
||||||
|
" pm.expect(jsonData.message).to.equal(\"Login successful\");",
|
||||||
|
" pm.expect(jsonData.user.is_admin).to.be.true;",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Check Authentication Status",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/auth/check",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"auth",
|
||||||
|
"check"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Checks if the session cookie is valid and active."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"pm.test(\"Session is authenticated\", function () {",
|
||||||
|
" var jsonData = pm.response.json();",
|
||||||
|
" pm.expect(jsonData.authenticated).to.be.true;",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Admin Profile",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/users/me",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"users",
|
||||||
|
"me"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Loads profile details for the authenticated user."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Logout",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/auth/logout",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"auth",
|
||||||
|
"logout"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Destroys the current authenticated session."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Admin APIs (Transactional)",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Create Pharmacy",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"Postman Monitor Pharmacy\",\n \"address\": \"Calle del Monitor 99, Madrid\",\n \"phone\": \"+34 900 123 456\",\n \"latitude\": 40.4168,\n \"longitude\": -3.7038,\n \"opening_hours\": \"24h\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/admin/pharmacies",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"admin",
|
||||||
|
"pharmacies"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Creates a new pharmacy for testing."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 201\", function () {",
|
||||||
|
" pm.response.to.have.status(201);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"var jsonData = pm.response.json();",
|
||||||
|
"pm.test(\"Response contains created pharmacy ID\", function () {",
|
||||||
|
" pm.expect(jsonData.id).to.exist;",
|
||||||
|
" pm.collectionVariables.set(\"temp_pharmacy_id\", jsonData.id);",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Update Pharmacy",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"Postman Monitor Pharmacy - Updated\",\n \"address\": \"Calle del Monitor 99, Madrid\",\n \"phone\": \"+34 900 123 456\",\n \"latitude\": 40.4170,\n \"longitude\": -3.7040,\n \"opening_hours\": \"9:00 - 22:00\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/admin/pharmacies/{{temp_pharmacy_id}}",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"admin",
|
||||||
|
"pharmacies",
|
||||||
|
"{{temp_pharmacy_id}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Updates the properties of the temporary pharmacy."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"pm.test(\"Fields were successfully updated\", function () {",
|
||||||
|
" var jsonData = pm.response.json();",
|
||||||
|
" pm.expect(jsonData.name).to.include(\"Updated\");",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Pharmacy Medicines",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/admin/pharmacies/{{temp_pharmacy_id}}/medicines",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"admin",
|
||||||
|
"pharmacies",
|
||||||
|
"{{temp_pharmacy_id}}",
|
||||||
|
"medicines"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Retrieves the list of medicines linked to the temporary pharmacy."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"pm.test(\"Response is an array\", function () {",
|
||||||
|
" pm.expect(pm.response.json()).to.be.an('array');",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Link Medicine to Pharmacy",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"pharmacy_id\": {{temp_pharmacy_id}},\n \"medicine_nregistro\": \"51346\",\n \"medicine_name\": \"Paracetamol 500mg\",\n \"price\": 4.75,\n \"stock\": 100\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/admin/pharmacy-medicines",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"admin",
|
||||||
|
"pharmacy-medicines"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Links a medicine to the temporary pharmacy with stock and price details."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 201\", function () {",
|
||||||
|
" pm.response.to.have.status(201);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"var jsonData = pm.response.json();",
|
||||||
|
"pm.test(\"Link relationship created\", function () {",
|
||||||
|
" pm.expect(jsonData.id).to.exist;",
|
||||||
|
" pm.collectionVariables.set(\"temp_relation_id\", jsonData.id);",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Update Pharmacy Medicine Relation",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"price\": 5.25,\n \"stock\": 95\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/admin/pharmacy-medicines/{{temp_relation_id}}",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"admin",
|
||||||
|
"pharmacy-medicines",
|
||||||
|
"{{temp_relation_id}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Updates price and stock properties on the relation."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unlink Medicine from Pharmacy",
|
||||||
|
"request": {
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/admin/pharmacy-medicines/{{temp_relation_id}}",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"admin",
|
||||||
|
"pharmacy-medicines",
|
||||||
|
"{{temp_relation_id}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Removes the relation between the medicine and pharmacy."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Delete Pharmacy",
|
||||||
|
"request": {
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/admin/pharmacies/{{temp_pharmacy_id}}",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"admin",
|
||||||
|
"pharmacies",
|
||||||
|
"{{temp_pharmacy_id}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Deletes the temporary pharmacy to restore database clean state."
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 200\", function () {",
|
||||||
|
" pm.response.to.have.status(200);",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "temp_pharmacy_id",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "temp_relation_id",
|
||||||
|
"value": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"id": "a988d8db-6be5-4cf5-8025-a131843b0d4d",
|
||||||
|
"name": "FarmaFinder Local Environment",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"key": "baseUrl",
|
||||||
|
"value": "http://localhost:3001",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "username",
|
||||||
|
"value": "admin",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "password",
|
||||||
|
"value": "admin123",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_postman_variable_scope": "environment",
|
||||||
|
"_postman_exported_at": "2026-06-30T13:00:00.000Z",
|
||||||
|
"_postman_exported_using": "Postman/10.0.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.farmafinder.monitoring</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<!-- REPLACE WITH YOUR ACTUAL CLONED PATH ON srv84-macos -->
|
||||||
|
<string>/Users/username/FarmaFinder/scripts/run-newman-monitoring.sh</string>
|
||||||
|
</array>
|
||||||
|
<key>StartInterval</key>
|
||||||
|
<integer>3600</integer> <!-- Run every 3600 seconds (1 hour) -->
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/Users/username/FarmaFinder/logs/launchd-stdout.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/Users/username/FarmaFinder/logs/launchd-stderr.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
# Plan: Grafana FARO + Alloy for FarmaFinder
|
||||||
|
|
||||||
|
> **Status**: Approved (user answers 2026-06-30).
|
||||||
|
> **Path note**: User said `/root/Monitoring/` but the stack lives at `/home/f80ans0/Projects/Monitoring/`.
|
||||||
|
|
||||||
|
## User decisions
|
||||||
|
1. **Network**: FarmaFinder runs on the same host as srv84-macos → use `host.docker.internal:4317/4318`.
|
||||||
|
2. **Auth**: Open on `back-tier` only, no auth.
|
||||||
|
3. **Existing otel-collector**: Remove it. Alloy is the only collector.
|
||||||
|
4. **Faro env**: Build-time via Vite `VITE_FARO_*` build args.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
```
|
||||||
|
Browser (FarmaFinder PWA, Faro SDK)
|
||||||
|
│ OTLP HTTP
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Grafana Alloy :4317 gRPC :4318 HTTP :8889│
|
||||||
|
│ otel.receiver → batch → exporters │
|
||||||
|
│ prometheus.exporter.self :12345 │
|
||||||
|
└──┬─────────────┬──────────────┬─────────────┘
|
||||||
|
│ traces │ logs │ metrics (:8889 scraped)
|
||||||
|
▼ ▼ ▼
|
||||||
|
Tempo:4317 Loki:3100/otlp Prometheus:9090
|
||||||
|
|
||||||
|
backend (Express) ── OTLP gRPC ─▶ Alloy:4317 (host.docker.internal)
|
||||||
|
pip-platform (FastAPI) ── OTLP gRPC ─▶ Alloy:4317 (host.docker.internal)
|
||||||
|
scraper (Puppeteer) ── OTLP gRPC ─▶ Alloy:4317 (host.docker.internal)
|
||||||
|
API helper (in-process, covered by backend OTel)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
### Monitoring (`/home/f80ans0/Projects/Monitoring/`)
|
||||||
|
- NEW `alloy-config.alloy` — River config (otel receiver, batch, exporters to tempo/loki/prometheus, self-scrape on 12345)
|
||||||
|
- MOD `docker-compose.yml` — add `alloy` service on `back-tier` (NO front-tier per Q2), bind 4317/4318/8889/12345 to `0.0.0.0` since same host; REMOVE the `otel-collector` service
|
||||||
|
- MOD `prometheus/prometheus.yml` — add `alloy` scrape job for `alloy:12345`
|
||||||
|
- MOD `grafana/provisioning/datasources/datasource.yml` — add Tempo + Loki; fix Prometheus URL from `http://localhost:9090` to `http://prometheus:9090`
|
||||||
|
- NEW `grafana/provisioning/dashboards/faro-web-vitals.json`
|
||||||
|
- NEW `grafana/provisioning/dashboards/faro-js-errors.json`
|
||||||
|
- NEW `grafana/provisioning/dashboards/faro-overview.json`
|
||||||
|
- NEW `README-FARO-ALLOY.md`
|
||||||
|
|
||||||
|
### Frontend (`/home/f80ans0/Projects/Webs/FarmaFinder/frontend/`)
|
||||||
|
- MOD `package.json` — add `@grafana/faro-react`, `faro-web-sdk`, `faro-web-tracing`, `faro-transport-otlp-http`
|
||||||
|
- NEW `src/utils/faro.js`
|
||||||
|
- MOD `src/main.jsx` — call `initFaro()` before render
|
||||||
|
- MOD `Dockerfile` — accept VITE_FARO_* build args
|
||||||
|
- MOD `vite.config.js` — no change needed (Vite reads VITE_* automatically)
|
||||||
|
- NEW `.env.example`
|
||||||
|
|
||||||
|
### Backend Node (`/home/f80ans0/Projects/Webs/FarmaFinder/backend/`)
|
||||||
|
- MOD `package.json` — add `@opentelemetry/api`, `sdk-node`, `auto-instrumentations-node`, `exporter-trace-otlp-grpc`, `exporter-logs-otlp-grpc`, `instrumentation-pino`, `pino`, `pino-pretty`
|
||||||
|
- NEW `src/tracing.js` — NodeSDK bootstrap (ESM)
|
||||||
|
- MOD `server.js` — `import './src/tracing.js';` as line 1; replace some `console.*` with pino
|
||||||
|
- MOD root `docker-compose.yml`:
|
||||||
|
- `backend.environment` add OTEL_* env vars
|
||||||
|
- `frontend.build.args` add `VITE_FARO_ENDPOINT`, `VITE_FARO_APP_NAME`, `VITE_FARO_ENV`
|
||||||
|
|
||||||
|
### pip-platform
|
||||||
|
- MOD `docker-compose.yml` — change `OTEL_EXPORTER_OTLP_ENDPOINT` from `http://otel-collector:4317` to `http://host.docker.internal:4317`; remove the in-stack `otel-collector`, `prometheus`, `grafana` services (or comment out) since they're replaced by the shared stack
|
||||||
|
- (For this phase, leave the pip-platform prometheus+grafana running to avoid disrupting the existing setup — change just the OTel endpoint. Document in README.)
|
||||||
|
|
||||||
|
### scraper
|
||||||
|
- MOD `package.json` — add OTel deps
|
||||||
|
- NEW `tracing.js`
|
||||||
|
- MOD `index.js` — wrap `run()` in a span
|
||||||
|
|
||||||
|
### API helper
|
||||||
|
- No changes (in-process; covered by backend's auto-instrumentation).
|
||||||
|
|
||||||
|
## Phases (8 total)
|
||||||
|
|
||||||
|
### Phase 1: Add Grafana Alloy to monitoring stack
|
||||||
|
1. Write `/home/f80ans0/Projects/Monitoring/alloy-config.alloy`
|
||||||
|
2. Modify `/home/f80ans0/Projects/Monitoring/docker-compose.yml`:
|
||||||
|
- Add `alloy` service (image `grafana/alloy:v1.6.1`)
|
||||||
|
- Remove the `otel-collector` service
|
||||||
|
3. Modify `/home/f80ans0/Projects/Monitoring/prometheus/prometheus.yml` — add `alloy` scrape job
|
||||||
|
4. Verify: `docker compose config -q`
|
||||||
|
|
||||||
|
### Phase 2: Frontend Faro SDK
|
||||||
|
1. Modify `frontend/package.json` (add deps)
|
||||||
|
2. Create `frontend/src/utils/faro.js`
|
||||||
|
3. Modify `frontend/src/main.jsx` (call initFaro)
|
||||||
|
4. Modify `frontend/Dockerfile` (build args)
|
||||||
|
5. Create `frontend/.env.example`
|
||||||
|
6. Verify: `cd frontend && npm ci && npm run build` succeeds
|
||||||
|
|
||||||
|
### Phase 3: Backend Node OTel + pino
|
||||||
|
1. Modify `backend/package.json` (add OTel + pino deps)
|
||||||
|
2. Create `backend/src/tracing.js`
|
||||||
|
3. Modify `backend/server.js` (import tracing first; add pino)
|
||||||
|
4. Modify root `docker-compose.yml` (backend env + frontend build args)
|
||||||
|
5. Verify: `cd backend && npm ci` succeeds; container starts; trace appears in Tempo
|
||||||
|
|
||||||
|
### Phase 4: pip-platform OTel re-route
|
||||||
|
1. Modify `pip-platform/docker-compose.yml` — change `OTEL_EXPORTER_OTLP_ENDPOINT`
|
||||||
|
2. Verify: pip-api trace appears in Tempo
|
||||||
|
|
||||||
|
### Phase 5: Scraper one-shot OTel
|
||||||
|
1. Modify `scraper/package.json` (add OTel deps)
|
||||||
|
2. Create `scraper/tracing.js`
|
||||||
|
3. Modify `scraper/index.js` (wrap run in span)
|
||||||
|
|
||||||
|
### Phase 6: Grafana provisioning
|
||||||
|
1. Modify `datasources/datasource.yml` (add Tempo + Loki, fix Prometheus URL)
|
||||||
|
2. Create 3 dashboard JSONs (web vitals, js errors, overview)
|
||||||
|
3. Verify: dashboards load in Grafana
|
||||||
|
|
||||||
|
### Phase 7: Documentation
|
||||||
|
1. Create `Monitoring/README-FARO-ALLOY.md`
|
||||||
|
|
||||||
|
### Phase 8: Verification
|
||||||
|
1. `docker compose config -q` for both stacks
|
||||||
|
2. `docker compose up -d alloy` from Monitoring dir
|
||||||
|
3. `curl -sf http://localhost:12345/-/ready` returns 200
|
||||||
|
4. `curl -sf -X POST http://localhost:4318/v1/traces` returns 200
|
||||||
|
5. Container logs show OTel init for backend + pip-platform
|
||||||
|
6. Grafana Explore → Tempo shows traces tagged `service.name=farmafinder-backend`
|
||||||
|
7. Grafana Explore → Loki shows `{app="farmafinder-frontend"}` logs
|
||||||
|
8. Grafana Explore → Prometheus shows `farovitals_*` metrics
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- **HIGH** — None remaining (Q1 resolved: same host).
|
||||||
|
- **MEDIUM** — Backend uses ESM; OTel NodeSDK needs ESM-compatible setup via `--import` flag in CMD, not just `import './tracing.js'`. Use the OTel SDK's `register` API in ESM, or use the `--import` Node flag with `instrumentation.mjs` shim.
|
||||||
|
- **MEDIUM** — Alloy config is River syntax, not YAML. Validate with `alloy fmt` and `alloy validate` if available locally.
|
||||||
|
- **LOW** — Dashboard JSON validity.
|
||||||
|
- **INFO (security)** — `alertmanager/config.yml` line 25 has a Telegram bot token in plain text. **Do not fix in this PR**; flag to user.
|
||||||
|
|
||||||
|
## Complexity
|
||||||
|
8-12 hours end-to-end.
|
||||||
|
|
||||||
|
## Verification commands
|
||||||
|
```bash
|
||||||
|
# Syntax
|
||||||
|
docker compose -f /home/f80ans0/Projects/Monitoring/docker-compose.yml config -q && echo "monitoring OK"
|
||||||
|
cd /home/f80ans0/Projects/Webs/FarmaFinder && docker compose config -q && echo "farmafinder OK"
|
||||||
|
|
||||||
|
# Start monitoring stack
|
||||||
|
cd /home/f80ans0/Projects/Monitoring && docker compose up -d alloy
|
||||||
|
curl -sf http://localhost:12345/-/ready && echo "alloy ready"
|
||||||
|
curl -sf -X POST http://localhost:4318/v1/traces -H 'Content-Type: application/x-protobuf' --data-binary @/dev/null -o /dev/null -w '%{http_code}\n'
|
||||||
|
|
||||||
|
# Start FarmaFinder
|
||||||
|
cd /home/f80ans0/Projects/Webs/FarmaFinder && docker compose up -d --build
|
||||||
|
docker logs farmafinder-frontend 2>&1 | grep -i faro
|
||||||
|
docker logs farmafinder-backend 2>&1 | grep -i opentelemetry
|
||||||
|
|
||||||
|
# Grafana Explore (manual):
|
||||||
|
# Tempo → service.name = farmafinder-backend
|
||||||
|
# Loki → {app="farmafinder-frontend"} | json
|
||||||
|
# Prometheus → farovitals_largest_contentful_paint
|
||||||
|
```
|
||||||
+53
-36
@@ -1,3 +1,7 @@
|
|||||||
|
// OpenTelemetry SDK — must be required first to instrument http, dns, etc.
|
||||||
|
require('./tracing');
|
||||||
|
|
||||||
|
const { trace } = require('@opentelemetry/api');
|
||||||
const puppeteer = require('puppeteer-extra');
|
const puppeteer = require('puppeteer-extra');
|
||||||
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
|
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -16,43 +20,56 @@ const QUERIES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
console.log('🚀 Starting Parapharmacy Scraper...');
|
const tracer = trace.getTracer('farmaclic-scraper');
|
||||||
// Launching browser
|
return tracer.startActiveSpan('scraper.run', { attributes: { 'scraper.queries': QUERIES.length } }, async (span) => {
|
||||||
const browser = await puppeteer.launch({
|
try {
|
||||||
headless: 'new',
|
console.log('🚀 Starting Parapharmacy Scraper...');
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
const browser = await puppeteer.launch({
|
||||||
|
headless: 'new',
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||||
|
});
|
||||||
|
|
||||||
|
const allResults = {};
|
||||||
|
|
||||||
|
for (const query of QUERIES) {
|
||||||
|
const querySpan = tracer.startSpan('scraper.query', { attributes: { 'scraper.query': query } });
|
||||||
|
console.log(`\n🔍 Searching for: "${query}"`);
|
||||||
|
|
||||||
|
const promofarmaResults = await scrapePromofarma(query, browser);
|
||||||
|
console.log(` ✅ Promofarma: found ${promofarmaResults.length} items`);
|
||||||
|
|
||||||
|
const amazonResults = await scrapeAmazon(query, browser);
|
||||||
|
console.log(` ✅ Amazon: found ${amazonResults.length} items`);
|
||||||
|
|
||||||
|
const googleShoppingResults = await scrapeGoogleShopping(query, browser);
|
||||||
|
console.log(` ✅ Google Shopping: found ${googleShoppingResults.length} items`);
|
||||||
|
|
||||||
|
const ofFactsResults = await scrapeOpenFoodFacts(query, browser);
|
||||||
|
console.log(` ✅ OpenFoodFacts: found ${ofFactsResults.length} items`);
|
||||||
|
|
||||||
|
allResults[query] = {
|
||||||
|
promofarma: promofarmaResults,
|
||||||
|
amazon: amazonResults,
|
||||||
|
googleshopping: googleShoppingResults,
|
||||||
|
openfoodfacts: ofFactsResults
|
||||||
|
};
|
||||||
|
querySpan.setAttribute('scraper.results.total', promofarmaResults.length + amazonResults.length + googleShoppingResults.length + ofFactsResults.length);
|
||||||
|
querySpan.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
fs.writeFileSync('results.json', JSON.stringify(allResults, null, 2));
|
||||||
|
console.log('\n💾 Scraping finished. Results saved to results.json');
|
||||||
|
span.setStatus({ code: 1 }); // OK
|
||||||
|
} catch (err) {
|
||||||
|
span.recordException(err);
|
||||||
|
span.setStatus({ code: 2, message: err.message }); // ERROR
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
span.end();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const allResults = {};
|
|
||||||
|
|
||||||
for (const query of QUERIES) {
|
|
||||||
console.log(`\n🔍 Searching for: "${query}"`);
|
|
||||||
|
|
||||||
// We run sequentially to be gentle on CPU and anti-bot systems
|
|
||||||
const promofarmaResults = await scrapePromofarma(query, browser);
|
|
||||||
console.log(` ✅ Promofarma: found ${promofarmaResults.length} items`);
|
|
||||||
|
|
||||||
const amazonResults = await scrapeAmazon(query, browser);
|
|
||||||
console.log(` ✅ Amazon: found ${amazonResults.length} items`);
|
|
||||||
|
|
||||||
const googleShoppingResults = await scrapeGoogleShopping(query, browser);
|
|
||||||
console.log(` ✅ Google Shopping: found ${googleShoppingResults.length} items`);
|
|
||||||
|
|
||||||
const ofFactsResults = await scrapeOpenFoodFacts(query, browser);
|
|
||||||
console.log(` ✅ OpenFoodFacts: found ${ofFactsResults.length} items`);
|
|
||||||
|
|
||||||
allResults[query] = {
|
|
||||||
promofarma: promofarmaResults,
|
|
||||||
amazon: amazonResults,
|
|
||||||
googleshopping: googleShoppingResults,
|
|
||||||
openfoodfacts: ofFactsResults
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
|
|
||||||
fs.writeFileSync('results.json', JSON.stringify(allResults, null, 2));
|
|
||||||
console.log('\n💾 Scraping finished. Results saved to results.json');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
run().catch(err => {
|
run().catch(err => {
|
||||||
|
|||||||
Generated
+2417
-3
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,12 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@opentelemetry/auto-instrumentations-node": "^0.52.0",
|
||||||
|
"@opentelemetry/exporter-trace-otlp-grpc": "^0.55.0",
|
||||||
|
"@opentelemetry/resources": "^1.28.0",
|
||||||
|
"@opentelemetry/sdk-node": "^0.55.0",
|
||||||
|
"@opentelemetry/semantic-conventions": "^1.28.0",
|
||||||
"puppeteer": "^24.40.0",
|
"puppeteer": "^24.40.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// OpenTelemetry Node SDK bootstrap for the FarmaClic scraper.
|
||||||
|
// Started as a side-effect require from index.js (CommonJS).
|
||||||
|
//
|
||||||
|
// Env vars:
|
||||||
|
// OTEL_SERVICE_NAME — default: farmaclic-scraper
|
||||||
|
// OTEL_EXPORTER_OTLP_ENDPOINT — OTLP gRPC endpoint (e.g. http://alloy:4317)
|
||||||
|
|
||||||
|
const { NodeSDK } = require('@opentelemetry/sdk-node');
|
||||||
|
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
|
||||||
|
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
|
||||||
|
const { resourceFromAttributes } = require('@opentelemetry/resources');
|
||||||
|
const {
|
||||||
|
ATTR_SERVICE_NAME,
|
||||||
|
ATTR_SERVICE_NAMESPACE,
|
||||||
|
} = require('@opentelemetry/semantic-conventions');
|
||||||
|
|
||||||
|
const serviceName = process.env.OTEL_SERVICE_NAME || 'farmaclic-scraper';
|
||||||
|
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317';
|
||||||
|
|
||||||
|
const sdk = new NodeSDK({
|
||||||
|
resource: resourceFromAttributes({
|
||||||
|
[ATTR_SERVICE_NAME]: serviceName,
|
||||||
|
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
|
||||||
|
}),
|
||||||
|
traceExporter: new OTLPTraceExporter({ url: otlpEndpoint }),
|
||||||
|
instrumentations: [
|
||||||
|
getNodeAutoInstrumentations({
|
||||||
|
'@opentelemetry/instrumentation-fs': { enabled: false },
|
||||||
|
'@opentelemetry/instrumentation-dns': { enabled: false },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.start();
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
try {
|
||||||
|
await sdk.shutdown();
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('OpenTelemetry shutdown failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
@@ -8,7 +8,7 @@ ARCHIVE_PATH="build/App.xcarchive"
|
|||||||
IPA_DIR="build"
|
IPA_DIR="build"
|
||||||
EXPORT_OPTIONS="${EXPORT_OPTIONS:-scripts/export-options.plist}"
|
EXPORT_OPTIONS="${EXPORT_OPTIONS:-scripts/export-options.plist}"
|
||||||
|
|
||||||
echo "=== FarmaFinder iOS Build Script ==="
|
echo "=== FarmaClic iOS Build Script ==="
|
||||||
|
|
||||||
echo "[1/6] Installing frontend dependencies..."
|
echo "[1/6] Installing frontend dependencies..."
|
||||||
(cd frontend && npm ci)
|
(cd frontend && npm ci)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<string>manual</string>
|
<string>manual</string>
|
||||||
<key>provisioningProfiles</key>
|
<key>provisioningProfiles</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>net.hacecalor.farmafinder</key>
|
<key>net.hacecalor.farmaclic</key>
|
||||||
<string>YOUR_PROVISIONING_PROFILE_NAME</string>
|
<string>YOUR_PROVISIONING_PROFILE_NAME</string>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
Reference in New Issue
Block a user