Cleanup modified & misisng files
Run Tests on Branches / Backend Tests (push) Successful in 3m29s
Run Tests on Branches / Frontend Tests (push) Successful in 3m25s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-02 20:41:50 +02:00
parent 3f5f88bbf5
commit 5114c0bc57
15 changed files with 385 additions and 3737 deletions
+358 -271
View File
@@ -1,276 +1,363 @@
# Monitoring Integration Plan for Pharmacy Integration Platform (PIP) # FarmaFinder — Feature Plan
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. ## Overview
## Prerequisites Three features in recommended implementation order. Each is self-contained and can be shipped independently.
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.
## 1. Metrics Endpoint
The PIP application exposes Prometheus-formatted metrics at:
```
http://<pip-host>:8000/metrics
```
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.
### Optional: Add Basic Auth to Metrics Endpoint
If your Prometheus server requires authentication, you can wrap the metrics endpoint with middleware. Example:
```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
curl -X POST http://<prometheus-host>:9090/-/reload
```
## 3. Grafana Dashboards
You can import a pre-built dashboard JSON (provided below) or create your own panels using the exposed metrics.
### Example Dashboard JSON
Save this as `pip-dashboard.json` and import via Grafana UI (`+ Import` → Paste JSON).
```json
{
"dashboard": {
"id": null,
"title": "Pharmacy Integration Platform",
"timezone": "browser",
"schemaVersion": 38,
"version": 1,
"refresh": "10s",
"panels": [
{
"type": "graph",
"title": "HTTP Requests Rate",
"datasource": "Prometheus",
"targets": [
{
"expr": "sum by (method, endpoint, http_status) (rate(pip_http_requests_total[5m]))",
"legendFormat": "{{method}} {{endpoint}} {{http_status}}",
"refId": "A"
}
],
"yAxes": [
{
"format": "ops",
"label": "req/s"
}
],
"gridPos": { "x": 0, "y": 0, "w": 12, "h": 8 }
},
{
"type": "graph",
"title": "Request Duration (seconds)",
"datasource": "Prometheus",
"targets": [
{
"expr": "histogram_quantile(0.95, sum by (le, method, endpoint) (rate(pip_http_request_duration_seconds_bucket[5m])))",
"legendFormat": "{{method}} {{endpoint}} 95th percentile",
"refId": "A"
}
],
"yAxes": [
{
"format": "seconds",
"label": "seconds"
}
],
"gridPos": { "x": 12, "y": 0, "w": 12, "h": 8 }
},
{
"type": "graph",
"title": "Database Query Duration",
"datasource": "Prometheus",
"targets": [
{
"expr": "histogram_quantile(0.95, sum by (le, operation) (rate(pip_db_query_duration_seconds_bucket[5m])))",
"legendFormat": "{{operation}} 95th percentile",
"refId": "A"
}
],
"yAxes": [
{
"format": "seconds",
"label": "seconds"
}
],
"gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 }
},
{
"type": "graph",
"title": "Redis Operations",
"datasource": "Prometheus",
"targets": [
{
"expr": "sum by (operation) (rate(pip_redis_operations_total[5m]))",
"legendFormat": "{{operation}}",
"refId": "A"
}
],
"yAxes": [
{
"format": "ops",
"label": "ops/s"
}
],
"gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 }
},
{
"type": "graph",
"title": "RabbitMQ Messages Published",
"datasource": "Prometheus",
"targets": [
{
"expr": "sum by (exchange, routing_key) (rate(pip_rabbitmq_messages_published_total[5m]))",
"legendFormat": "{{exchange}} -> {{routing_key}}",
"refId": "A"
}
],
"yAxes": [
{
"format": "ops",
"label": "msgs/s"
}
],
"gridPos": { "x": 0, "y": 16, "w": 24, "h": 8 }
}
],
"templating": {
"list": []
},
"annotations": {
"list": []
}
},
"overwrite": true
}
```
### Import Steps
1. In Grafana, click the **+** icon → **Import**.
2. Paste the JSON above or upload the file.
3. Select your Prometheus data source.
4. Click **Import**.
## 4. Alerting Rules (Optional)
Create a rule file for Alertmanager (e.g., `pip_alerts.yml`) and add it to your Prometheus server.
```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."
```
Add the file to your Prometheus server's rule files configuration and reload.
## 5. Verification Steps
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 / Rollneeded) 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:
```
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).
--- ---
*This plan is intended to be a onetime setup guide. Adjust hostnames, ports, authentication, and resource limits to match your production environment.* ## Step 1 — "Near Me" Sorting (~2 hrs)
### Goal
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`.
### 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`**
- 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`**
- Accept optional `userPosition` prop
- When present and pharmacy has lat/lng, show distance badge: `1.2 km` next to pharmacy name
### Verification
```bash
# Start app, search a medicine, click "Sort by distance", allow geolocation
# Pharmacies reorder, distance badges appear
```
---
## Step 2 — Pharmacy Hours (~3 hrs)
### Goal
Store structured opening hours per pharmacy. Show "Open now" / "Closed" / "Opens at HH:MM" badge on pharmacy cards.
### Data model
Store hours as JSON in a single column — structured enough to parse, simple enough to edit manually in admin:
```json
{
"mon": ["09:00", "21:00"],
"tue": ["09:00", "21:00"],
"wed": ["09:00", "21:00"],
"thu": ["09:00", "21:00"],
"fri": ["09:00", "21:00"],
"sat": ["09:00", "14:00"],
"sun": null
}
```
`null` = closed that day. 24h format strings. Only one time range per day (no split shifts) — extend later if needed.
### Backend changes
**`backend/server.js`** — `initDatabase()`
```sql
ALTER TABLE pharmacies ADD COLUMN opening_hours TEXT;
```
Wrap in try/catch — `ALTER TABLE` throws if column already exists.
**`GET /api/medicines/:nregistro/pharmacies`** — already returns `SELECT *`, no change needed.
**`POST /api/admin/pharmacies`** and **`PUT /api/admin/pharmacies/:id`**
- Accept `opening_hours` in request body (JSON string or object)
- Serialize to string before INSERT/UPDATE: `JSON.stringify(opening_hours) || null`
### Frontend changes
**`frontend/src/utils/hours.js`** (new)
```js
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
export function getOpenStatus(openingHoursJson) {
if (!openingHoursJson) return null;
let hours;
try { hours = typeof openingHoursJson === 'string' ? JSON.parse(openingHoursJson) : openingHoursJson; }
catch { return null; }
const now = new Date();
const day = DAYS[now.getDay()];
const range = hours[day];
if (!range) return { status: 'closed', label: 'Closed today' };
const [openStr, closeStr] = range;
const [oh, om] = openStr.split(':').map(Number);
const [ch, cm] = closeStr.split(':').map(Number);
const nowMins = now.getHours() * 60 + now.getMinutes();
const openMins = oh * 60 + om;
const closeMins = ch * 60 + cm;
if (nowMins < openMins) return { status: 'closed', label: `Opens at ${openStr}` };
if (nowMins >= closeMins) return { status: 'closed', label: `Closed · Opens ${getNextOpen(hours, now)}` };
return { status: 'open', label: `Open · Closes at ${closeStr}` };
}
```
**`frontend/src/components/PharmacyList.jsx`**
- Import `getOpenStatus`, render status badge per pharmacy card
- CSS: `.badge-open { color: green }` / `.badge-closed { color: #999 }`
**`frontend/src/components/admin/PharmacyManagement.jsx`**
- Add hours editor: 7 rows (MonSun), each with two time inputs (open/close) + "Closed" checkbox
- Serialize to JSON on save
### Verification
```bash
# Add pharmacy with hours via admin panel
# Search medicine linked to that pharmacy
# Card shows "Open · Closes at 21:00" or "Closed · Opens at 09:00"
# Change system clock or test at different times
```
---
## Step 3 — PWA + Push Notifications / Medicine Alerts (~1 day)
### 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).
### Architecture
```
User visits app
→ service worker registered (PWA)
→ user searches medicine, clicks "Notify me"
→ browser prompts for notification permission
→ browser generates push subscription (endpoint + keys)
→ POST /api/notifications/subscribe { medicine_nregistro, subscription }
→ stored in DB
Admin links medicine to pharmacy (POST /api/admin/pharmacy-medicines)
→ backend queries notifications table for medicine_nregistro
→ fires web-push to each subscription endpoint
→ user receives notification: "Ibuprofeno 400mg now available at Farmacia Sol"
```
### Backend changes
**Dependencies**
```bash
npm install web-push
```
Generate VAPID keys (one-time, store in `.env`):
```bash
node -e "const wp = require('web-push'); const k = wp.generateVAPIDKeys(); console.log(k)"
```
Add to `.env.example`:
```
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: {
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`**
- Add "Notify me" bell button per medicine result
- On click: call `subscribeToPush(medicine.id, medicine.name)`
- Toggle state: subscribed / not subscribed (persist in `localStorage`)
**Service worker push handler** — `vite-plugin-pwa` injects the SW; add a custom handler:
```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 => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
```
### React Native path (future)
- Replace `web-push` with Firebase Admin SDK (`firebase-admin`)
- Replace `push_subscriptions.endpoint/p256dh/auth` with `fcm_token`
- Frontend subscription logic moves to Expo `registerForPushNotificationsAsync()`
- Backend notification dispatch abstracted into `notify(medicineNregistro, message)` function — same interface, different transport
### 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"
```
---
## Recommended Order
| 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.
-115
View File
@@ -1,115 +0,0 @@
# 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
+2 -2
View File
@@ -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.farmaclic; PRODUCT_BUNDLE_IDENTIFIER = net.hacecalor.farmafinder;
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.farmaclic; PRODUCT_BUNDLE_IDENTIFIER = net.hacecalor.farmafinder;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
+4 -4
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>FarmaClic</string> <string>FarmaFinder</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>FarmaClic uses your location to sort nearby pharmacies by distance.</string> <string>FarmaFinder uses your location to sort nearby pharmacies by distance.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>FarmaClic uses your location to sort nearby pharmacies by distance.</string> <string>FarmaFinder uses your location to sort nearby pharmacies by distance.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>FarmaClic uses the camera to scan your TSI health card barcode and QR code.</string> <string>FarmaFinder uses the camera to scan your TSI health card barcode and QR code.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
-639
View File
@@ -1,639 +0,0 @@
{
"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": ""
}
]
}
@@ -1,27 +0,0 @@
{
"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"
}
-21
View File
@@ -1,21 +0,0 @@
<?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
View File
@@ -1,154 +0,0 @@
# 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
```
+36 -53
View File
@@ -1,7 +1,3 @@
// 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');
@@ -20,56 +16,43 @@ const QUERIES = [
]; ];
async function run() { async function run() {
const tracer = trace.getTracer('farmaclic-scraper'); console.log('🚀 Starting Parapharmacy Scraper...');
return tracer.startActiveSpan('scraper.run', { attributes: { 'scraper.queries': QUERIES.length } }, async (span) => { // Launching browser
try { const browser = await puppeteer.launch({
console.log('🚀 Starting Parapharmacy Scraper...'); headless: 'new',
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox']
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 => {
+3 -2417
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -11,12 +11,6 @@
"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"
-46
View File
@@ -1,46 +0,0 @@
// 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);
+1 -1
View File
@@ -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 "=== FarmaClic iOS Build Script ===" echo "=== FarmaFinder iOS Build Script ==="
echo "[1/6] Installing frontend dependencies..." echo "[1/6] Installing frontend dependencies..."
(cd frontend && npm ci) (cd frontend && npm ci)
+1 -1
View File
@@ -12,7 +12,7 @@
<string>manual</string> <string>manual</string>
<key>provisioningProfiles</key> <key>provisioningProfiles</key>
<dict> <dict>
<key>net.hacecalor.farmaclic</key> <key>net.hacecalor.farmafinder</key>
<string>YOUR_PROVISIONING_PROFILE_NAME</string> <string>YOUR_PROVISIONING_PROFILE_NAME</string>
</dict> </dict>
</dict> </dict>