Remove Livewire docs, add Vue SPA frontend docs, document log-masking runbook
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

- Delete `.claude/rules/livewire.md` (Livewire is vestigial – only starter-kit auth screens remain)
- Add `.claude/rules/frontend.md` documenting Vue 3 SPA stack, router, composables, search-URL mirroring, and Leaflet integration
- Add `docs/ops/nginx-log-masking.md` runbook for redacting lat/lng from Nginx access/error logs and PHP-FPM on IONOS VPS
- Update `CLAUDE.md` and `architecture.md` to reflect Vue SPA as primary frontend, REST API as backend contract, and Livewire's reduced role
- Note stale service names in legacy domain docs (`scoring.md`, `prediction.md`, `notifications.md`) now refactored into `Services/Forecasting/`, `Services/StationSearch/`, and `PlanFeatures`
This commit is contained in:
Ovidiu U
2026-06-10 10:55:34 +01:00
parent 7a8bd5c86a
commit df4ebdb7c6
5 changed files with 413 additions and 102 deletions

View File

@@ -1,57 +1,118 @@
# Architecture # Architecture
## Shape: Vue SPA ⇄ REST API ⇄ fat Laravel Services
The frontend is a **Vue 3 single-page app** (`resources/js/`). It talks to the
backend exclusively over a **REST API** (`routes/api.php`
`app/Http/Controllers/Api/`). Controllers stay thin; all business logic lives in
**Service classes** (`app/Services/`). Blade is reduced to a one-line SPA shell,
server-rendered legal pages, transactional emails, and the Filament admin panel.
```
Browser
└── Vue SPA (resources/js, mounted on #app via app.blade.php)
└── axios (resources/js/axios.js — baseURL '/api', cookie + XSRF auth)
└── REST API (routes/api.php → Http/Controllers/Api/*)
└── Services (app/Services/*) ← all business logic
└── Models / Jobs / Events / Notifications
```
## Core principle: fat Services, thin everything else ## Core principle: fat Services, thin everything else
All business logic lives in Service classes. Controllers, Livewire components, All business logic lives in Service classes. API controllers, console commands,
and console commands are thin orchestrators — they call Services and return results. jobs, listeners, and Filament resources are thin orchestrators — they call
This keeps the app API-extractable later without a rewrite. Services and return results. Never put domain logic in a controller or a Vue
component.
## Directory structure ## Directory structure (actual)
``` ```
app/ app/
├── Console/Commands/ # Scheduler commands (PollFuelPrices, PredictOilPrices, RunScoringEngine) ├── Console/Commands/ # Scheduler: PollFuelPrices, FetchOilPrices, BackfillOilPrices,
├── Http/Controllers/ # Minimal — auth + Stripe webhook only │ # RunLlmOverlay, EvaluateVolatilityRegime, ResolveForecastOutcomes,
├── Livewire/ # Classic two-file Livewire components # ArchiveOldPricesCommand, ImportBeisFuelPrices, ImportPostcodes
├── Http/
│ ├── Controllers/Api/ # AuthController, StationController, StatsController, UserController
│ ├── Controllers/ # BillingController (Stripe Checkout/Portal redirects)
│ ├── Middleware/ # VerifyApiKey (API-key gate), RequiresFeature (tier gate)
│ ├── Requests/ # Form requests
│ └── Resources/Api/ # StationResource (API output shaping)
├── Actions/Fortify/ # Fortify auth actions (CreateNewUser, …)
├── Services/ # ALL business logic — see below
├── Models/ # Eloquent models ├── Models/ # Eloquent models
├── Notifications/ # Laravel Notification classes (multi-channel) ├── Notifications/ # FuelPriceAlert + custom Channels (OneSignal, Vonage WA/SMS)
├── Services/ # ALL business logic lives here ├── Jobs/ # DispatchUserNotificationJob, PollFuelPricesJob,
├── FuelPriceService.php # Fuel Finder API polling + storage # SendScheduledWhatsAppJob, SendPaymentFailedReminderJob
│ ├── AlertScoringService.php # Fill-up timing recommendation engine ├── Events/ # PricesUpdatedEvent
│ ├── StationTaggingService.php # Supermarket brand detection ├── Listeners/ # HandleStripeWebhook (Cashier WebhookReceived)
│ ├── NotificationDispatchService.php # Tier-aware notification routing ├── Filament/ # Admin panel (Resources, Widgets) + Providers/Filament
│ ├── SubscriptionService.php # Cashier/tier helpers ├── Enums/ # FuelType, PlanTier, …
│ ├── PostcodeService.php # Resolves postcodes/outcodes/place names → lat/lng └── Livewire/Actions/ # Logout only — Livewire is otherwise vestigial
│ ├── OilPriceService.php # FRED fetch + EWMA/LLM Brent crude prediction
│ ├── LocationResult.php # DTO returned by PostcodeService
│ └── ApiLogger.php # Wraps all outbound HTTP calls, logs to api_logs
└── Jobs/ # Queued jobs (dispatch notifications per user)
resources/views/ app/Services/
├── livewire/ # Livewire Blade templates ├── FuelPriceService.php # Fuel Finder API polling + storage
── emails/ # Mailable templates ── StationTaggingService.php # Supermarket brand detection
├── PostcodeService.php # postcode/outcode/place → lat/lng (+ LocationResult DTO)
├── PlanFeatures.php # Single source of tier-entitlement truth (see tiers.md)
├── HaversineQuery.php # Distance SQL helper
├── ApiLogger.php # Wraps outbound HTTP, logs to api_logs
├── StationSearch/ # StationSearchService (+ SearchCriteria, SearchResult DTOs)
├── BrentPriceSources/ # FRED + EIA Brent sources (+ BrentPriceFetcher)
└── Forecasting/ # Weekly forecast subsystem — WeeklyForecastService,
# LlmOverlayService, BacktestRunner, OutcomeResolver,
# VolatilityRegimeService, feature/leak tooling, …
resources/
├── js/ # Vue 3 SPA — see .claude/rules/frontend.md
└── views/
├── app.blade.php # SPA shell (mounts #app)
├── legal/ # Server-rendered legal pages
├── emails/ # Mailable templates
└── livewire/auth/ # Starter-kit Volt auth screens (left untouched)
routes/ routes/
├── web.php # All web routes (Livewire pages) ├── web.php # SPA shell + catch-all, legal pages, billing redirects, server logout
└── api.php # Empty for now — API added later if needed └── api.php # REST API consumed by the SPA (see below)
``` ```
> **Stale service names elsewhere.** Domain rule files (`scoring.md`,
> `prediction.md`, `notifications.md`) still name services that have since been
> refactored away — e.g. `AlertScoringService`, `OilPriceService`,
> `NationalFuelPredictionService`, `NotificationDispatchService`,
> `SubscriptionService`. Those concerns now live under `Services/Forecasting/`,
> `Services/StationSearch/`, and `PlanFeatures`. Always verify a service name
> against `app/Services/` before trusting it from those files.
## Service class conventions ## Service class conventions
- Constructor injection only — no facade usage inside Services - Constructor injection only — no facade usage inside Services
- Services are bound in AppServiceProvider if they need interfaces
- Each Service has one responsibility — do not merge concerns - Each Service has one responsibility — do not merge concerns
- Return typed DTOs or Eloquent collections — never raw arrays from Services - Return typed DTOs or Eloquent collections — never raw arrays from Services
- Services never dispatch jobs directly — that's the controller/command's job - Services never dispatch jobs directly — that's the controller/command/listener's job
## No API yet ## The REST API
`routes/api.php` stays empty for v1. Do not create API controllers or Sanctum `routes/api.php` is the SPA's backend. Three access tiers:
token auth. The app is Livewire-only until subscriber count justifies a native app.
When the API is added later, it will reuse the same Service classes.
## Livewire components (classic only) - **Public** (no auth): `POST /api/auth/register`, `POST /api/auth/login`,
`GET /api/auth/me`, `GET /api/fuel-types`, `GET /api/stats/live`
- **API-key** (`VerifyApiKey` + `throttle:60,1`): `GET /api/stations`,
`GET /api/stats/searches`
- **Sanctum** (`auth:sanctum`): `POST /api/auth/logout`, `/api/user/*`
(preferences, saved stations, profile, password, delete account)
Use two-file classic Livewire components. Do NOT use Volt single-file syntax. Conventions:
Volt files from the starter kit (auth screens) are left as-is — do not convert them.
New components go in `app/Livewire/` with corresponding Blade in `resources/views/livewire/`. - Shape responses with API Resources (`app/Http/Resources/Api/`), never raw arrays.
- The fuel **prediction is embedded in the `/api/stations` response** under the
`prediction` key — there is no standalone prediction endpoint (see `prediction.md`).
- Tier-gate routes with the `feature` middleware (`RequiresFeature`); never
inline entitlement checks (see `tiers.md`).
- Auth is cookie/session via Sanctum SPA mode (axios sends XSRF + credentials);
the API-key gate protects the expensive station search from scraping.
## Frontend
The Vue SPA is documented in `.claude/rules/frontend.md`. Do **not** build new
Livewire components — Livewire/Flux remain only for the starter-kit auth screens
and the `Logout` action.

74
.claude/rules/frontend.md Normal file
View File

@@ -0,0 +1,74 @@
# Frontend — Vue 3 SPA
The entire user-facing app (landing, station search, dashboard, settings) is a
**Vue 3 single-page application** under `resources/js/`. It is served from the
Blade shell `resources/views/app.blade.php` via the SPA catch-all in
`routes/web.php` and consumes the REST API (`routes/api.php`). There is **no
Livewire in the product UI** — do not add Livewire / Volt / Alpine components for
new features. Build Vue.
## Stack
- Vue 3.5 with `<script setup>` (Composition API), Vue Router 4 (`createWebHistory`)
- Vite 8 (`npm run dev` / `npm run build`), `@vitejs/plugin-vue`
- Tailwind CSS v4 (`@tailwindcss/vite`)
- Leaflet for maps, iconify-icon + Lucide for icons, axios for HTTP
## Layout
```
resources/js/
├── app.js # Entry — createApp(App).use(router).mount('#app')
├── App.vue # Root — <RouterView/>, fetches the user on mount
├── axios.js # Configured axios instance (baseURL '/api', XSRF + credentials)
├── router/index.js # Routes + requiresAuth guard (redirects to /login)
├── views/
│ ├── Home.vue # Landing + station search (mirrors state to the URL query)
│ └── dashboard/ # Overview, SavedStations, Preferences, settings/{Profile,Security,Appearance}
├── components/ # LeafletMap, StationCard, StationList, PredictionCard/Full,
│ # PostSearchFilters, UpsellBanner, landing/*
├── composables/ # useAuth, useStations, useSavedStations
└── constants/ # fuelTypes.js — shared fuel-type source of truth (front + back)
```
## Conventions
- **Composition API + `<script setup>` only.** No Options API.
- **All server state goes through composables** that call the configured `api`
axios instance (`axios.js`) — never `fetch()` or a bare `axios` call inside a
component.
- **Auth is cookie/session (Sanctum SPA mode).** axios is configured with
`withCredentials` + `withXSRFToken`; don't add bearer-token handling.
- Route guards live in `router/index.js` (`meta.requiresAuth`). Hard navigations
(`/login`, `/logout`) use `window.location.href`, not the router.
- Keep business logic in the API / Services. Vue components render and orchestrate.
## Search & the shareable URL
`Home.vue` is the search surface. Search params (postcode **or** lat/lng,
`fuel_type`, `radius`, `sort`) are mirrored into the **URL query** via
`router.push` (`queryFromParams`) so searches are shareable and bookmarkable, and
restored on load (`paramsFromQuery`). Because the URL is shareable, **any
coordinates written to it must be coarsened** — a precise GPS pair from "Use my
location" (`HeroSearch.vue` → browser Geolocation) would otherwise broadcast the
sharer's exact position to everyone they send the link to.
## Maps (Leaflet)
`components/LeafletMap.vue` wraps Leaflet directly (Leaflet is a plain JS library,
not a Vue plugin). Station and user markers and the Google-Maps directions links
are built from `station.lat` / `station.lng`; the user marker comes from the
browser Geolocation API.
## Prediction
The fuel prediction ships **inside** the `/api/stations` response under the
`prediction` key and is rendered by `PredictionCard.vue` / `PredictionFull.vue`
(`useStations` reads `response.data.prediction`). See `prediction.md` for the
payload shape and the tier gate.
---
paths:
- "resources/js/**/*.vue"
- "resources/js/**/*.js"
---

View File

@@ -1,61 +0,0 @@
# Livewire Components
## Classic two-file components only
Do NOT use Volt single-file syntax for new components.
Volt files created by the Livewire starter kit (auth screens) are left untouched.
## Component locations
```
app/Livewire/
├── Dashboard/
│ ├── FuelRecommendation.php # Main fill-up/wait card
│ ├── NearbyStations.php # Map + station list
│ └── PriceHistory.php # 14-day trend chart
├── Account/
│ ├── NotificationSettings.php # Channel prefs + WhatsApp OTP
│ ├── SubscriptionManager.php # Upgrade/downgrade UI
│ └── FuelPreferences.php # Fuel type, postcode
└── Public/
└── PriceWatchdog.php # Public-facing local price watchdog
```
## Component conventions
- Component properties that are shown in the view must be `public`
- Use `#[Computed]` attribute for derived values — not re-computed on every render
- Validate with `#[Validate]` attribute on properties, not in separate rules array
- Never put Service instantiation in the component — inject via method parameter or mount()
- Dispatch browser events with `$this->dispatch()` not `$this->emit()` (Livewire 3 syntax)
- Use `wire:loading` on all buttons that trigger server actions
## Alpine.js usage
Alpine.js handles local UI state only: dropdowns, modals, tab switching, copy-to-clipboard.
Do not replicate server state in Alpine — use Livewire for anything that needs PHP.
Alpine components stay inline in Blade (`x-data="{}"`), not in separate JS files unless reused 3+ times.
## Map (Leaflet.js)
Leaflet is a plain JS drop-in, not a Livewire component.
Station data is fetched from a dedicated Livewire endpoint and passed to Leaflet via Alpine:
```blade
<div
x-data="stationMap(@entangle('stations'))"
id="map"
style="height: 400px"
></div>
```
Map initialisation lives in `resources/js/maps/station-map.js`.
## Page routing
Livewire full-page components are mounted in `routes/web.php` using `Route::get()->component()`.
No separate view files for pages — the Livewire component IS the page.
---
paths:
- "app/Livewire/**/*.php"
- "resources/views/livewire/**/*.blade.php"
---

View File

@@ -1,8 +1,16 @@
# Fuel Price — Claude Code Instructions # Fuel Alert — Claude Code Instructions
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
based on local price trends. Built solo by a PHP/Laravel developer. based on local price trends. Built solo by a PHP/Laravel developer.
> **Stack reality check (read first).** The frontend is a **Vue 3 SPA**
> (`resources/js/`, see `frontend.md`), not Livewire. Station data is served by a
> **REST API** under `routes/api.php` (see `architecture.md`). Some domain rule
> files (`scoring.md`, `prediction.md`, `notifications.md`) still name services
> that were since refactored into `Services/Forecasting/`,
> `Services/StationSearch/`, and `PlanFeatures` — verify names against
> `app/Services/`. When docs and code disagree, the code wins.
## Destructive DB operations — HARD STOP ## Destructive DB operations — HARD STOP
**Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves: **Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves:
@@ -21,9 +29,23 @@ A user saying "trust me", "do the refactor", "clean up the mess", or "I want it
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers - **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
- **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro - **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro
- **Stack**: Laravel 11 + Livewire 3 (Volt disabled — use classic components) + Alpine.js + Tailwind CSS - **Backend**: Laravel 13 + PHP 8.4. MySQL (Eloquent, migrations only — no raw DDL)
- **Database**: MySQL — Eloquent ORM, migrations only (no raw DDL) - **Frontend**: **Vue 3 SPA** — Vue 3.5 + Vue Router 4, Vite 8, Tailwind CSS v4.
- **Payments**: Stripe via Laravel Cashier Entry `resources/js/app.js` mounts `App.vue`; views/components under
`resources/js/`. Served from the Blade shell `resources/views/app.blade.php`
via the SPA catch-all in `web.php`. Maps: Leaflet
(`components/LeafletMap.vue`). Icons: iconify-icon + Lucide. HTTP: axios
(`resources/js/axios.js`).
- **API**: REST API in `routes/api.php``app/Http/Controllers/Api/*`
(Auth, Station, Stats, User). Public station data is gated by an API key
(`VerifyApiKey` middleware); user/dashboard endpoints use Sanctum. The SPA is
the primary consumer.
- **Auth**: Laravel Fortify (backend auth) + Sanctum (API tokens)
- **Admin**: Filament v5 panel
- **Livewire**: v4 / Flux v2 are installed but **vestigial** — only starter-kit
Volt auth screens (`resources/views/livewire/auth`) and `app/Livewire/Actions`
remain. Do **not** build new Livewire components; build Vue.
- **Payments**: Stripe via Laravel Cashier (v16)
- **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal) - **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal)
- **Queue**: Laravel queues with Redis driver (notifications and polling jobs) - **Queue**: Laravel queues with Redis driver (notifications and polling jobs)
- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring - **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring
@@ -36,7 +58,8 @@ php artisan queue:work # Process notification jobs
php artisan schedule:run # Run scheduled commands (cron every minute) php artisan schedule:run # Run scheduled commands (cron every minute)
php artisan migrate # Run migrations php artisan migrate # Run migrations
php artisan test # Run Pest test suite php artisan test # Run Pest test suite
npm run dev # Vite asset watcher npm run dev # Vite dev server (Vue SPA + HMR)
npm run build # Production build — run if SPA changes don't show up
``` ```
## Imports ## Imports
@@ -48,7 +71,7 @@ npm run dev # Vite asset watcher
@.claude/rules/prediction.md @.claude/rules/prediction.md
@.claude/rules/payments.md @.claude/rules/payments.md
@.claude/rules/tiers.md @.claude/rules/tiers.md
@.claude/rules/livewire.md @.claude/rules/frontend.md
@.claude/rules/api-data.md @.claude/rules/api-data.md
@.claude/rules/testing.md @.claude/rules/testing.md
@.claude/rules/code-style.md @.claude/rules/code-style.md

View File

@@ -0,0 +1,214 @@
# Runbook — Mask lat/lng coordinates in server logs (IONOS VPS)
**Goal:** stop precise search coordinates (`lat`/`lng`) from being written to server
logs, while keeping GET-based shareable search URLs and full debugging value (IP,
status, path, fuel_type all stay readable).
**Applies to:** the production Nginx + PHP-FPM box on IONOS. None of this lives in the
app repo — it is server config you apply over SSH. The app already stores only a
~1km-rounded location bucket + a SHA-256 IP hash in the `searches` table; this runbook
covers the *raw* coordinates that transit the server in the URL.
## Scope — what this does and does NOT cover
| Sink | Covered here? |
|---|---|
| Nginx **access** log | ✅ Step 1 (request line + referer) |
| Nginx **error** log | ✅ Step 2 (scrub, since `log_format` can't touch it) |
| **PHP-FPM** access log | ✅ Step 3 (check / disable) |
| Laravel app logs | ✅ Nothing to do — stock config logs no URL, no error tracker installed |
| **IONOS edge** (CDN / WAF / managed LB) | ❌ Cannot be redacted from your side — see "Honest limit" |
> If you ever add Sentry/Flare/Bugsnag later, they capture the full request URL by
> default — you'd need a `before_send` scrubber there too.
---
## Step 1 — Mask the Nginx access log
### 1a. Create `/etc/nginx/conf.d/fuel-alert-log-masking.conf`
```nginx
# Redact lat/lng from the access log — request line AND referer.
# Every other field and query param is left intact.
# --- request line (e.g. GET /api/stations?lat=..&lng=.. HTTP/1.1) ---
map $request $fa_req_1 {
default $request;
"~^(?<rqa>.*[?&])lat=[^& ]*(?<rqb>.*)$" "${rqa}lat=***${rqb}";
}
map $fa_req_1 $fa_request_masked {
default $fa_req_1;
"~^(?<rqc>.*[?&])lng=[^& ]*(?<rqd>.*)$" "${rqc}lng=***${rqd}";
}
# --- referer header (e.g. https://fuel-alert.co.uk/?lat=..&lng=..) ---
map $http_referer $fa_ref_1 {
default $http_referer;
"~^(?<rfa>.*[?&])lat=[^&]*(?<rfb>.*)$" "${rfa}lat=***${rfb}";
}
map $fa_ref_1 $fa_referer_masked {
default $fa_ref_1;
"~^(?<rfc>.*[?&])lng=[^&]*(?<rfd>.*)$" "${rfc}lng=***${rfd}";
}
# A copy of the standard "combined" format, but using the masked values.
log_format fuelalert_masked
'$remote_addr - $remote_user [$time_local] '
'"$fa_request_masked" $status $body_bytes_sent '
'"$fa_referer_masked" "$http_user_agent"';
```
The `map` blocks must sit in the `http {}` context. Files in `/etc/nginx/conf.d/` are
included there by default — if your setup doesn't include `conf.d/*.conf`, paste the
block inside `http {}` in `/etc/nginx/nginx.conf` instead.
### 1b. Point the site at the masked format
Find your site's server block and its current `access_log` line:
```bash
sudo nginx -T | grep -nE "server_name|access_log|root" | grep -i fuel
ls /etc/nginx/sites-enabled/ # the site file is usually here
```
Inside that `server { … }` block, set:
```nginx
access_log /var/log/nginx/fuel-alert.access.log fuelalert_masked;
```
(Keep the path the same as whatever it currently is; only the trailing format name
`fuelalert_masked` is the change.)
---
## Step 2 — Scrub the Nginx error log
Error-log lines are generated internally by Nginx and **do not pass through
`log_format`**, so they can't be masked at write time. They include `request:` and
`referrer:` fields that carry the coordinates. Two parts:
### 2a. Reduce how often request lines are written
In the same `server { … }` block (or globally), lower the level so routine
warn/error/info entries that embed the request line are dropped:
```nginx
error_log /var/log/nginx/fuel-alert.error.log crit;
```
### 2b. Scrub whatever still gets written
Create `/usr/local/bin/scrub-fuel-logs.sh`:
```bash
#!/bin/sh
# Redact lat/lng values in the Nginx error log. GNU sed (Linux). Verified portable expr.
sed -E -i 's/([?&])(lat|lng)=[^ &"]*/\1\2=***/g' /var/log/nginx/fuel-alert.error.log
```
```bash
sudo chmod +x /usr/local/bin/scrub-fuel-logs.sh
```
Run it every minute via a systemd timer.
`/etc/systemd/system/scrub-fuel-logs.service`:
```ini
[Unit]
Description=Scrub lat/lng from Nginx error log
[Service]
Type=oneshot
ExecStart=/usr/local/bin/scrub-fuel-logs.sh
```
`/etc/systemd/system/scrub-fuel-logs.timer`:
```ini
[Unit]
Description=Run lat/lng scrub every minute
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
AccuracySec=10s
[Install]
WantedBy=timers.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now scrub-fuel-logs.timer
```
> **Trade-off:** a coordinate from an *errored* request can sit on disk for up to ~60s
> before the scrub runs. Error entries are low-volume, so the surface is small but not
> zero. For a zero-window setup (nothing un-redacted ever hits disk) you'd route
> `error_log` to syslog and scrub at the syslog layer, or tail a RAM-backed raw file
> with Vector and write only the redacted copy — more setup; ask if you want it.
---
## Step 3 — Check PHP-FPM
```bash
grep -nE '^\s*(access\.log|access\.format)' /etc/php/*/fpm/pool.d/*.conf
```
- **No output / commented out** → FPM isn't logging requests. Nothing to do.
- **`access.log` is enabled** → either comment it out, or remove the `%r`, `%q`, `%Q`
tokens from `access.format`, then `sudo systemctl reload php*-fpm`.
(FPM slowlog and error log record the PHP script/backtrace, not the query string —
no action needed there.)
---
## Step 4 — Apply, test, verify
```bash
# 1. Validate config BEFORE reloading — catches typos safely.
sudo nginx -t
# If this FAILS, do NOT reload. Fix the error first.
# 2. Reload Nginx (zero downtime).
sudo systemctl reload nginx
# 3. Trigger a real search in the app, then inspect the freshest lines:
sudo tail -n 5 /var/log/nginx/fuel-alert.access.log
sudo tail -n 5 /var/log/nginx/fuel-alert.error.log
# Expect: lat=***&lng=*** — IP, status, path, fuel_type all still present.
```
---
## Honest limit — IONOS edge
Masking only reaches logs **you** control (your Nginx, FPM, app). It cannot touch
anything IONOS runs in front of the box:
- **Plain IONOS VPS / Cloud Server (root server):** there is no HTTP-layer edge — your
Nginx is the first HTTP hop, and IONOS network logging is L3/L4 (IPs/ports/bytes, no
URLs). In this case this runbook covers everything that exists. ✅
- **Managed hosting / Deploy Now / CDN / managed WAF or LB:** those terminate HTTPS and
log full URLs with coordinates, on their retention, and you **cannot** redact them.
Levers: ask IONOS what they log + for how long, or stop putting coords in the URL.
**The only way coords never reach *any* HTTP log (yours or theirs) is to keep them out
of the URL.** To preserve shareable links without coords in the URL, use **opaque share
tokens**: store the search server-side, share `/s/<token>`, resolve token → coords on
the server. Coords then appear in zero logs and live in one DB row you control
(set its precision + expiry). This is an app change, tracked separately if wanted.
---
## Rollback
1. In the site `server {}` block, restore the original `access_log` (drop the
`fuelalert_masked` name) and `error_log` lines.
2. `sudo rm /etc/nginx/conf.d/fuel-alert-log-masking.conf`
3. `sudo nginx -t && sudo systemctl reload nginx`
4. `sudo systemctl disable --now scrub-fuel-logs.timer` (optional).