Skip to content

24 feat visitor stats#24

Merged
simonabler merged 12 commits intomasterfrom
24-feat-visitor-stats
Mar 14, 2026
Merged

24 feat visitor stats#24
simonabler merged 12 commits intomasterfrom
24-feat-visitor-stats

Conversation

@simonabler
Copy link
Copy Markdown
Owner

No description provided.

Simon Abler added 12 commits March 12, 2026 05:52
…ash + GeoIP per day

Schema: visitor_daily (id, day, ip_hash, country_code, route_group, tier,
api_key_prefix, request_count, error_count, updated_at)

Privacy model:
- ip_hash = SHA-256(rawIp + YYYY-MM-DD + IP_HASH_SECRET)
- Daily-rotating salt means same IP on different days → different hash
- No long-term user tracking possible by design
- Raw IP is never persisted

Indexes: IDX_VD_DATE_HASH (day, ip_hash) for upsert lookups
         IDX_VD_DATE (day) for daily aggregation queries
…+ sweep

Core logic:
  record(): called per-request, increments in-memory bucket keyed by
    (day | ip_hash | route_group | tier | api_key_prefix).
    IP → SHA-256(ip|YYYY-MM-DD|IP_HASH_SECRET) — raw IP never leaves this method.
    Country resolved via geoip-lite.lookup() → ISO 3166-1 alpha-2 or 'XX'.

  flush(): runs every 60s + on module destroy. Upserts dirty buckets into
    visitor_daily via orUpdate so concurrent instances don't race.

  sweep(): runs every 10min. Evicts in-memory entries older than 25h to
    prevent unbounded heap growth (mirrors UsageService.sweepStale pattern).

Stats queries (called by upcoming admin endpoints):
  getSummary()    — today / 7d / 30d unique IPs + request counts + tier breakdown
  getDailyUnique()— per-day unique IPs + requests for sparkline charts
  getByApi()      — route_group breakdown with error rate for today
  getByCountry()  — country breakdown with unique IPs for today

resolveRouteGroup() exported helper maps /api/<prefix> → logical group name.
…tricsModule

MetricsInterceptor (metrics.interceptor.ts):
  - Injects VisitorService alongside existing MetricsService + AnomalyDetector
  - After each non-skipped request: calls visitor.record() with rawIp
    (resolved from X-Forwarded-For → req.ip → socket.remoteAddress),
    route path, HTTP status, tier and first-8-chars API key prefix
  - Admin routes are still skipped via SKIP_METRICS decorator as before

MetricsModule (metrics.module.ts):
  - VisitorDailyEntity added to TypeOrmModule.forFeature
    (TypeORM synchronize=true will auto-create the table on startup)
  - VisitorService added to providers and exports
All endpoints: @RequiresAdminKey() + @SkipMetrics() + @SkipAnomalyGuard()

GET /admin/stats/visitors/summary
  → today unique IPs, requests, errors, tier breakdown + 7d + 30d totals

GET /admin/stats/visitors/daily?days=30
  → per-day array { day, uniqueIps, totalRequests } — sparkline source
  days param: 1–90, default 30

GET /admin/stats/visitors/by-api?day=YYYY-MM-DD
  → route group breakdown { routeGroup, uniqueIps, totalRequests, errorRate }
  day param: defaults to today

GET /admin/stats/visitors/by-country?day=YYYY-MM-DD
  → country breakdown { countryCode, uniqueIps, totalRequests }
  day param: defaults to today

286/286 tests pass.
…se comment

.env.example: added IP_HASH_SECRET section explaining the daily-salt
  mechanism and how to generate a secure value (openssl rand -hex 32).
  Also serves as a reminder that changing the secret invalidates existing
  ip_hash rows (old data becomes un-relatable — rows are kept as aggregate
  counts only).

docker-compose.yml: added inline comment on the .env volume mount listing
  DATA_DIR and IP_HASH_SECRET as required env vars that must be present
  in the server-side .env file.
…line, API/country breakdown

stats.service.ts:
  New interfaces: VisitorSummary, VisitorDailyPoint, VisitorByApi,
  VisitorByCountry + 4 new methods: getVisitorSummary(), getVisitorDaily(),
  getVisitorByApi(), getVisitorByCountry().

visitors.component.ts / .html:
  Standalone component with signals — loads all 4 endpoints on init.
  Layout:
    Row 1: 4 summary cards (unique IPs today/7d/30d, requests today)
    Row 2: SVG sparkline (last 14 days) + tier breakdown bar chart
    Row 3: By-API table (uniqueIps, requests, error%) + By-Country table
             with flag emoji + ISO code (top 10)
  Refresh button + privacy note (daily-rotating hash disclaimer).
  flagEmoji() converts ISO 3166-1 alpha-2 to regional indicator emoji.
  sparklinePoints builds a polyline from daily unique-IP counts.

app.routes.ts: /admin/visitors lazy-loads VisitorsComponent.
stats-dashboard.component.html: 👥 Visitors nav tab added.
styles.scss: .vstat-card, .vstat-panel, .sparkline, .tier-bar, .vstat-table,
  .route-badge — all scoped with vstat- prefix, consistent with design tokens.
geoip-lite ships its own MaxMind GeoLite2 database as part of the npm
package and requires a postinstall script to update/verify the DB files.
Since the Dockerfile uses --ignore-scripts for npm ci, geoip-lite must be
installed explicitly via npm install (no --ignore-scripts) in the runtime
stage alongside pg and sharp.
Security issues fixed:
  - Stats, API Keys and Visitors were all rendering content (or firing API
    requests) before an admin key was entered. Now all three pages show a
    lock screen until statsService.apiKey() is truthy.
  - api-keys.component.ts: ngOnInit no longer calls load() unless a key is
    already stored in sessionStorage.
  - visitors.component.ts: ngOnInit no longer calls load() without a key.

AdminNavComponent (admin-nav.component.ts) — new shared component:
  - Single source of truth for the key input + all three nav tabs
    (📊 Stats / 🔑 API Keys / 👥 Visitors).
  - Replaces the duplicated nav+key-input markup that existed independently
    in stats-dashboard and api-keys templates.
  - Calls svc.setApiKey() directly on input — no per-page handler needed.
  - Imported by all three admin pages.

Result: entering/clearing the key on any tab immediately reflects across
all tabs via the shared StatsService signal.

286/286 backend tests pass. TypeScript clean.
1. AnomalyDetectorService — 5 Maps now bounded (anomaly-detector.service.ts)
   Added OnModuleInit/OnModuleDestroy + setInterval sweep every 10 min.
   New lastSeen Map tracks last-observed timestamp per IP.
   sweepStale() evicts all 5 counter-Maps for IPs not seen in 20 min.
   Returns evicted count (testable). Mirrors the UsageService.sweepStale()
   pattern already in use. Prevents unbounded heap growth under sustained
   traffic from many distinct IPs.

2. ValidationPipe — forbidNonWhitelisted: true (main.ts)
   Was false: unknown query/body params silently passed through.
   Now returns 400 Bad Request if a caller sends e.g. ?scalee=2 (typo).
   286/286 tests still pass — no existing test sent unknown params.

3. DATA_DIR env var (docker-compose.yml)
   Volume mounts /home/simon/simonapi/data → /data but DATA_DIR was never
   set, so signpack files were written to /app/data/signpacks (inside the
   container layer, lost on rebuild). Fixed by adding:
     environment:
       - DATA_DIR=/data/signpacks
   Signpack files now persist correctly on the host volume.
…ignpack oracle

BUG 1 — AdminKey not enforced (CRITICAL):
  @RequiresAdminKey() / @RequiresTier() decorators only set metadata.
  Without a guard that reads that metadata, they were complete no-ops.
  ApiKeyGuard was never registered as APP_GUARD — only via @UseGuards()
  on barcode routes. MetricsController and ApiKeyAdminController were
  therefore fully open to anyone.

  Fix: register ApiKeyGuard as APP_GUARD in AppModule providers.
  The guard reads decorator metadata via Reflector. Routes with no
  decorator pass through (public). @RequiresAdminKey() now actually
  enforces constant-time ADMIN_KEY comparison. 286/286 tests still pass.

BUG 2 — VisitorService flush crash (QueryFailedError):
  orUpdate() tried to update 'updated_at' column, but @UpdateDateColumn
  is not included in the INSERT VALUES clause — so excluded.updated_at
  does not exist in Postgres ON CONFLICT DO UPDATE.

  Fix: remove 'updated_at' from orUpdate column list. TypeORM's
  @UpdateDateColumn is handled automatically by the ORM on save(),
  not via raw QueryBuilder upserts. The flush now only updates
  request_count and error_count on conflict.

BUG 3 — Signpack timing oracle:
  assertToken() threw ForbiddenException (403) when token was wrong,
  but NotFoundException (404) when pack didn't exist. An attacker could
  distinguish the two responses to determine whether a pack ID exists.

  Fix: assertToken() now always throws NotFoundException('Signpack not
  found') regardless of reason — 404 in both cases.
…rget

Root cause:
  ON CONFLICT DO UPDATE requires a UNIQUE or EXCLUSION constraint on the
  conflict columns. @Index alone creates an ordinary index — not a unique
  constraint — so Postgres rejected the upsert with:
    'there is no unique or exclusion constraint matching the ON CONFLICT spec'

Entity (visitor-daily.entity.ts):
  Replaced @Index('IDX_VD_DATE_HASH', [...]) with
  @unique('UQ_VD_BUCKET', ['day','ipHash','routeGroup','tier','apiKeyPrefix'])
  This creates a real UNIQUE constraint that Postgres can use for upserts.

Service (visitor.service.ts):
  orUpdate() conflict target changed from column-list to constraint name
  'UQ_VD_BUCKET'. TypeORM maps this to:
    ON CONFLICT ON CONSTRAINT "UQ_VD_BUCKET" DO UPDATE ...
  This is more explicit and avoids column-name vs DB-name mismatches.
  Also removed stale .setParameter('now', ...) leftover.

⚠️  DEPLOY NOTE:
  TypeORM synchronize=true cannot add a UNIQUE constraint to an existing
  table. The visitor_daily table must be dropped once on the server so
  TypeORM recreates it with the correct schema:
    docker exec simonapi-backend psql $TYPEORM_URL -c 'DROP TABLE IF EXISTS visitor_daily;'
  Data loss is acceptable — the table only holds rolling 30-day stats.
@simonabler simonabler merged commit f4f7223 into master Mar 14, 2026
1 of 2 checks passed
@simonabler simonabler deleted the 24-feat-visitor-stats branch March 15, 2026 08:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant