diff --git a/client/src/api/routeUsage.ts b/client/src/api/routeUsage.ts new file mode 100644 index 000000000..12916f553 --- /dev/null +++ b/client/src/api/routeUsage.ts @@ -0,0 +1,24 @@ +import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared' +import { apiClient } from './client' + +/** + * Reports the routing counters this browser has tallied up. + * + * Fire and forget: it never throws and never blocks the route it is describing. A + * failed report is a lost count, which is a far smaller problem than a map that waits + * on a measurement. + * + * No client-side switch to check, unlike the shadow log. Counting is on unless an + * operator turned it off, and that is a server setting the server enforces — a report + * to a switched-off instance is answered with `recorded: false` and costs one request. + */ +export const routeUsageApi = { + report: (entries: RouteUsageEntry[]): Promise => + entries.length + ? apiClient.post('/route-usage/report', { entries }).then(() => undefined) + : Promise.resolve(), + + /** Admin only. The figures behind "could we host a router ourselves". */ + summary: (): Promise => + apiClient.get('/route-usage/summary').then(r => r.data), +} diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index 788b20e5a..ce243bf40 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -3,6 +3,8 @@ import { pluginsApi } from '../../api/client' import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types' import { haversineKm } from '../../utils/geo' import { formatDistance } from '../../utils/units' +import { countRoute } from './routeUsageCounter' +import type { RouteUsageSurface } from '@trek/shared' // FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the // project-osrm.org demo is car-only (it ignores the profile in the URL). Use @@ -50,6 +52,44 @@ function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string { return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}` } +/** + * `fetch`, with the request counted. + * + * Every route TREK draws goes out from here, and nowhere else, which makes this the one + * place that can answer how much routing an instance really does — the number behind + * "could we host an engine ourselves". Counted: how many requests, of what kind, how + * many waypoints, roughly how far, and whether the host refused. Not counted, because it + * is never sent: where any of it was. + * + * Distance is the straight line along the waypoint chain rather than the routed length, + * which is only in the answer and differs per response shape. It is a floor on the real + * figure, and a floor is enough to tell a 1500 km per-request limit from a 150 km one. + * + * An aborted request is not a failure: the map cancels constantly while someone drags. + */ +async function routedFetch( + url: string, + signal: AbortSignal | undefined, + kind: RouteUsageSurface, + profile: 'driving' | 'walking' | 'cycling', + waypoints: readonly Waypoint[], +): Promise { + const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim() + let km = 0 + for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i]) + const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km } + try { + const response = await fetch(url, { signal }) + countRoute({ ...sample, failed: !response.ok }) + return response + } catch (err) { + if (!(err instanceof DOMException && err.name === 'AbortError')) { + countRoute({ ...sample, failed: true }) + } + throw err + } +} + /** * A routing request the host refused, as opposed to one it answered with "no route". * @@ -114,7 +154,7 @@ export async function calculateRoute( const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false` - const response = await fetch(url, { signal }) + const response = await routedFetch(url, signal, 'route', profile, waypoints) if (!response.ok) { throw new RoutingRefusedError(response.status, retryAfterMs(response)) } @@ -321,7 +361,7 @@ export async function calculateSegments( const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration` - const response = await fetch(url, { signal }) + const response = await routedFetch(url, signal, 'segments', 'driving', waypoints) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() @@ -416,7 +456,7 @@ export async function calculateRouteWithLegs( const osrmProfile: 'driving' | 'walking' | 'cycling' = profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving' const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration` - const response = await fetch(url, { signal }) + const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() @@ -572,7 +612,13 @@ async function routeExcluding( if (excludeUnsupported.has(base)) return null try { const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson` - const response = await fetch(url, { signal }) + // This one is handed its points already encoded, so they are read back out for the + // counter rather than threaded through a second parameter nobody else needs. + const points = coords.split(';').map(pair => { + const [lng, lat] = pair.split(',').map(Number) + return { lat, lng } + }) + const response = await routedFetch(url, signal, 'alternatives', profile, points) if (!response.ok) { // 400 is the router saying the parameter itself is not available here, which is // true of every leg from now on. Anything else is about this request alone. @@ -614,7 +660,7 @@ export async function calculateAlternatives( ): Promise { const coords = `${from.lng},${from.lat};${to.lng},${to.lat}` const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson` - const response = await fetch(url, { signal }) + const response = await routedFetch(url, signal, 'alternatives', profile, [from, to]) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() diff --git a/client/src/components/Map/routeUsageCounter.test.ts b/client/src/components/Map/routeUsageCounter.test.ts new file mode 100644 index 000000000..ac5802cef --- /dev/null +++ b/client/src/components/Map/routeUsageCounter.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { countRoute, flushRouteUsage } from './routeUsageCounter' +import { routeUsageApi } from '../../api/routeUsage' + +/** + * FE-MAP-ROUTECOUNT-001..006 — the tally behind "how much routing does an instance do". + * + * Two things are worth pinning: that it batches rather than posting per route, because a + * counter that costs a request of its own measures the load by adding to it, and that it + * never throws — it sits on the hot path of every route the map draws. + */ + +const sample = { + profile: 'driving' as const, + surface: 'legs' as const, + selfHosted: false, + waypoints: 3, + km: 100, + failed: false, +} + +describe('routeUsageCounter', () => { + beforeEach(() => { + vi.spyOn(routeUsageApi, 'report').mockResolvedValue(undefined) + vi.useFakeTimers() + }) + + afterEach(() => { + flushRouteUsage() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('FE-MAP-ROUTECOUNT-001: a single route posts nothing on its own', () => { + countRoute(sample) + expect(routeUsageApi.report).not.toHaveBeenCalled() + }) + + it('FE-MAP-ROUTECOUNT-002: the tally goes out once the timer runs out', () => { + countRoute(sample) + vi.advanceTimersByTime(60_000) + + expect(routeUsageApi.report).toHaveBeenCalledTimes(1) + const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0] + expect(entries).toEqual([{ ...sample, requests: 1, failed: 0 }]) + }) + + it('FE-MAP-ROUTECOUNT-003: routes of one kind add onto one entry', () => { + countRoute(sample) + countRoute({ ...sample, waypoints: 5, km: 200 }) + countRoute({ ...sample, failed: true }) + flushRouteUsage() + + const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0] + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ requests: 3, waypoints: 11, km: 400, failed: 1 }) + }) + + it('FE-MAP-ROUTECOUNT-004: a different kind or engine is its own entry', () => { + countRoute(sample) + countRoute({ ...sample, surface: 'alternatives' }) + countRoute({ ...sample, selfHosted: true }) + countRoute({ ...sample, profile: 'walking' }) + flushRouteUsage() + + const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0] + expect(entries).toHaveLength(4) + }) + + it('FE-MAP-ROUTECOUNT-005: a busy stretch flushes on its own, without waiting', () => { + for (let i = 0; i < 25; i++) countRoute(sample) + expect(routeUsageApi.report).toHaveBeenCalledTimes(1) + const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0] + expect(entries[0].requests).toBe(25) + }) + + it('FE-MAP-ROUTECOUNT-006: a refused report is swallowed, and the next tally starts clean', async () => { + vi.mocked(routeUsageApi.report).mockRejectedValueOnce(new Error('offline')) + countRoute(sample) + expect(() => flushRouteUsage()).not.toThrow() + await Promise.resolve() + + countRoute(sample) + flushRouteUsage() + const [entries] = vi.mocked(routeUsageApi.report).mock.calls[1] + expect(entries[0].requests).toBe(1) + }) +}) diff --git a/client/src/components/Map/routeUsageCounter.ts b/client/src/components/Map/routeUsageCounter.ts new file mode 100644 index 000000000..0dfe05361 --- /dev/null +++ b/client/src/components/Map/routeUsageCounter.ts @@ -0,0 +1,102 @@ +import type { RouteUsageEntry, RouteUsageProfile, RouteUsageSurface } from '@trek/shared' +import { routeUsageApi } from '../../api/routeUsage' + +/** + * Counts the routing this browser asks for, and posts the totals in batches. + * + * All routing happens client-side against an external engine, so the server never + * sees a route request and cannot count one. Without a count nobody knows what a + * self-hosted router would have to carry — how many requests a real trip costs, how + * big they are, how much already goes to an operator's own engine. + * + * Counters only. What goes over the wire is four integers per bucket: how many + * requests, how many waypoints, how many kilometres, how many failed. No coordinate, + * no route, no trip, nothing that says where anybody went. + * + * Batched on purpose. A counter that cost a request of its own would measure the load + * by adding to it, so the tally is held in memory and flushed on a timer, when it + * grows past a threshold, and when the page goes away. + */ + +/** Flush when the tally has this many requests in it, whichever comes first. */ +const FLUSH_AFTER_REQUESTS = 25 +/** …or after this long, so a quiet session still reports what it did. */ +const FLUSH_AFTER_MS = 60_000 +/** Guard against a pathological session: drop the tally rather than grow forever. */ +const MAX_BUCKETS = 40 + +type Bucket = Omit & { + profile: RouteUsageProfile + surface: RouteUsageSurface + selfHosted: boolean +} + +const buckets = new Map() +let pending = 0 +let timer: ReturnType | null = null +let unloadWired = false + +function flush(): void { + if (timer !== null) { clearTimeout(timer); timer = null } + if (!buckets.size) return + const entries = [...buckets.values()] + buckets.clear() + pending = 0 + // Fire and forget: a failed report is a lost count, not a problem worth telling + // anybody about. The next flush carries on from an empty tally either way. + void routeUsageApi.report(entries).catch(() => {}) +} + +function wireUnload(): void { + if (unloadWired || typeof document === 'undefined') return + unloadWired = true + // `visibilitychange`, not `unload`: the latter no longer fires reliably on mobile, + // where a session ends by switching apps rather than by closing a tab. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flush() + }) +} + +/** + * Records one routing request. Never throws and never awaits: it sits on the hot path + * of every route the map draws. + */ +export function countRoute(sample: { + profile: RouteUsageProfile + surface: RouteUsageSurface + selfHosted: boolean + waypoints: number + km: number + failed: boolean +}): void { + try { + wireUnload() + const key = `${sample.profile}|${sample.surface}|${sample.selfHosted}` + const bucket = buckets.get(key) + if (bucket) { + bucket.requests += 1 + bucket.waypoints += sample.waypoints + bucket.km += sample.km + if (sample.failed) bucket.failed += 1 + } else { + if (buckets.size >= MAX_BUCKETS) return + buckets.set(key, { + profile: sample.profile, + surface: sample.surface, + selfHosted: sample.selfHosted, + requests: 1, + waypoints: sample.waypoints, + km: sample.km, + failed: sample.failed ? 1 : 0, + }) + } + pending += 1 + if (pending >= FLUSH_AFTER_REQUESTS) { flush(); return } + if (timer === null) timer = setTimeout(flush, FLUSH_AFTER_MS) + } catch { /* counting must never break routing */ } +} + +/** Flushes now. Exported for tests and for a deliberate "report before you go". */ +export function flushRouteUsage(): void { + flush() +} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 52e79a542..f2e9c6408 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4483,6 +4483,37 @@ function runMigrations(db: Database.Database): void { )`); db.exec('CREATE INDEX IF NOT EXISTS idx_collab_links_trip ON collab_links(trip_id)'); }, + /** + * Route usage counters — how much routing this instance really does. + * + * Daily aggregates, not a log: one row per day, profile, surface and engine + * kind, carrying totals. No query, no coordinate, no route, no user, no trip. + * The question they answer is whether TREK could host a router itself, and + * that needs volume, not itineraries. + * + * The table is created regardless of the switch, like the shadow log above: + * the switch decides whether rows are written, and a schema that appears only + * when a feature is on is a schema that differs between installs. + * + * Appended LAST: the array is index-addressed against schema_version. + */ + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS route_usage_daily ( + day TEXT NOT NULL, + profile TEXT NOT NULL, + surface TEXT NOT NULL, + self_hosted INTEGER NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + waypoints INTEGER NOT NULL DEFAULT 0, + km REAL NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, profile, surface, self_hosted) + ) + `); + // Retention deletes by day, and the summary reads the newest days first. + db.exec('CREATE INDEX IF NOT EXISTS idx_route_usage_day ON route_usage_daily(day)'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 8faacb667..e567b7b96 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -22,6 +22,7 @@ import { MapsModule } from './maps/maps.module'; import { GeoModule } from './geo/geo.module'; import { PlaceEnrichmentModule } from './place-enrichment/place-enrichment.module'; import { PlaceShadowModule } from './place-shadow/place-shadow.module'; +import { RouteUsageModule } from './route-usage/route-usage.module'; import { CategoriesModule } from './categories/categories.module'; import { TagsModule } from './tags/tags.module'; import { NotificationsModule } from './notifications/notifications.module'; @@ -78,7 +79,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], providers: [ // Default-deny: a route is authenticated unless it carries @Public() or // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to diff --git a/server/src/nest/route-usage/route-usage.controller.ts b/server/src/nest/route-usage/route-usage.controller.ts new file mode 100644 index 000000000..b026c901a --- /dev/null +++ b/server/src/nest/route-usage/route-usage.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Delete, Get, HttpCode, Post, UseGuards } from '@nestjs/common'; +import type { RouteUsageReportResult, RouteUsageSummaryResult } from '@trek/shared'; +import { AdminGuard } from '../auth/admin.guard'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RouteUsageReportDto } from './route-usage.dto'; +import { RouteUsageService } from './route-usage.service'; + +/** + * /api/route-usage — how much routing this instance does. + * + * Reporting is open to any signed-in user, because every signed-in user is the one + * generating the load; reading is admin only, because the totals are everybody's + * routing and the person entitled to see that is the person running the instance. + * Nothing here leaves the instance on its own. + */ +@Controller('api/route-usage') +@UseGuards(JwtAuthGuard) +export class RouteUsageController { + constructor(private readonly usage: RouteUsageService) {} + + /** + * 200 with `{ recorded: false }` when counting is off, rather than a 403. The + * client flushes and moves on; a rejection would be a permanent error in the + * console of every install that switched the counters off. + */ + @Post('report') + @HttpCode(200) + report(@Body() body: RouteUsageReportDto): RouteUsageReportResult { + return { recorded: this.usage.record(body) }; + } + + @Get('summary') + @UseGuards(JwtAuthGuard, AdminGuard) + summary(): RouteUsageSummaryResult { + return this.usage.summary(); + } + + @Delete() + @UseGuards(JwtAuthGuard, AdminGuard) + clear(): { removed: number } { + return { removed: this.usage.clear() }; + } +} diff --git a/server/src/nest/route-usage/route-usage.dto.ts b/server/src/nest/route-usage/route-usage.dto.ts new file mode 100644 index 000000000..aa04b525b --- /dev/null +++ b/server/src/nest/route-usage/route-usage.dto.ts @@ -0,0 +1,9 @@ +import { createZodDto } from 'nestjs-zod'; +import { routeUsageReportRequestSchema } from '@trek/shared'; + +/** + * Zod-pipe wrapper for a batch of counters. Same pattern as every other write + * endpoint: the contract lives in @trek/shared, the class exists so a controller + * parameter can be typed with it. + */ +export class RouteUsageReportDto extends createZodDto(routeUsageReportRequestSchema) {} diff --git a/server/src/nest/route-usage/route-usage.job.ts b/server/src/nest/route-usage/route-usage.job.ts new file mode 100644 index 000000000..7019482a9 --- /dev/null +++ b/server/src/nest/route-usage/route-usage.job.ts @@ -0,0 +1,38 @@ +import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'; +import { logError, logInfo } from '../audit/audit-log.logger'; +import { CronRegistrarService } from '../scheduling/cron-registrar.service'; +import { RouteUsageService } from './route-usage.service'; + +/** + * Nightly retention for the routing counters. + * + * Runs even when counting is switched off, on purpose: switching it off should also + * let what it already counted age out, rather than freezing the rows in place until + * somebody remembers to press the wipe button. + * + * 3:45 AM, just after the shadow log's own sweep and clear of the 3:00 idempotency + * purge and the backup window. + */ +@Injectable() +export class RouteUsageRetentionJob implements OnApplicationBootstrap { + constructor( + private readonly usage: RouteUsageService, + private readonly registrar: CronRegistrarService, + ) {} + + onApplicationBootstrap(): void { + if (!this.registrar.isEnabled()) return; + this.registrar.register('route-usage-retention', '45 3 * * *', () => this.tick()); + } + + tick(): void { + try { + const removed = this.usage.purgeExpired(); + if (removed > 0) { + logInfo(`Route usage retention: removed ${removed} expired row(s)`); + } + } catch (err: unknown) { + logError(`Route usage retention: ${err instanceof Error ? err.message : err}`); + } + } +} diff --git a/server/src/nest/route-usage/route-usage.module.ts b/server/src/nest/route-usage/route-usage.module.ts new file mode 100644 index 000000000..09e818536 --- /dev/null +++ b/server/src/nest/route-usage/route-usage.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { SchedulingModule } from '../scheduling/scheduling.module'; +import { RouteUsageController } from './route-usage.controller'; +import { RouteUsageRetentionJob } from './route-usage.job'; +import { RouteUsageService } from './route-usage.service'; + +/** + * Routing usage counters. Registered in AppModule; exports the service so an admin + * surface can read the summary without going through HTTP. + */ +@Module({ + imports: [SchedulingModule], + controllers: [RouteUsageController], + providers: [RouteUsageService, RouteUsageRetentionJob], + exports: [RouteUsageService], +}) +export class RouteUsageModule {} diff --git a/server/src/nest/route-usage/route-usage.service.ts b/server/src/nest/route-usage/route-usage.service.ts new file mode 100644 index 000000000..79897c805 --- /dev/null +++ b/server/src/nest/route-usage/route-usage.service.ts @@ -0,0 +1,185 @@ +import { Injectable } from '@nestjs/common'; +import type { + RouteUsageDayRow, + RouteUsageProfile, + RouteUsageReportRequest, + RouteUsageSummaryResult, + RouteUsageSurface, +} from '@trek/shared'; +import { DatabaseService } from '../database/database.service'; + +/** Days a counted day survives. Aggregates are tiny, so this is a year and a bit. */ +export const RETENTION_DAYS = 400; + +/** Days returned in the summary's own series, newest first. */ +export const SERIES_DAYS = 90; + +interface DbRow { + day: string; + profile: string; + surface: string; + self_hosted: number; + requests: number; + waypoints: number; + km: number; + failed: number; +} + +/** + * Route usage counters. + * + * All routing happens in the browser, so the server never sees a route request and + * cannot count one. The client keeps a small tally and posts it in batches; this + * adds those batches onto the day's row. + * + * On by default, unlike the shadow log next door. That log stores what people typed, + * so silence is the safe default; this stores four integers about a day with nothing + * in them that could belong to anybody, and it is worthless unless it is already + * running by the time the question comes up. An operator who wants none of it sets + * `route_usage_enabled` to `false`. + */ +@Injectable() +export class RouteUsageService { + constructor(private readonly db: DatabaseService) {} + + enabled(): boolean { + const row = this.db.get<{ value: string }>( + "SELECT value FROM app_settings WHERE key = 'route_usage_enabled'", + ); + // Absent means on: the counters have to be collecting before anyone thinks to + // look for them, and there is nothing here to protect. + return row?.value !== 'false'; + } + + /** + * Adds a batch onto today's rows. Returns whether anything was written, so a + * switched-off instance answers 200 rather than an error the client would log on + * every flush. + */ + record(report: RouteUsageReportRequest): boolean { + if (!this.enabled()) return false; + // One transaction for the batch: a flush is a handful of rows, and a partial + // one would leave a day counted twice on the client's next retry. + this.db.transaction(() => { + for (const entry of report.entries) { + this.db.run( + `INSERT INTO route_usage_daily (day, profile, surface, self_hosted, requests, waypoints, km, failed) + VALUES (date('now'), ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(day, profile, surface, self_hosted) DO UPDATE SET + requests = requests + excluded.requests, + waypoints = waypoints + excluded.waypoints, + km = km + excluded.km, + failed = failed + excluded.failed`, + entry.profile, + entry.surface, + entry.selfHosted ? 1 : 0, + entry.requests, + entry.waypoints, + Math.round(entry.km), + entry.failed, + ); + } + }); + return true; + } + + rows(): RouteUsageDayRow[] { + const rows = this.db.all( + 'SELECT * FROM route_usage_daily ORDER BY day DESC, profile, surface', + ); + return rows.map((r) => ({ + day: r.day, + profile: r.profile as RouteUsageProfile, + surface: r.surface as RouteUsageSurface, + selfHosted: r.self_hosted === 1, + requests: r.requests, + waypoints: r.waypoints, + km: r.km, + failed: r.failed, + })); + } + + summary(): RouteUsageSummaryResult { + const rows = this.rows(); + const enabled = this.enabled(); + const empty: RouteUsageSummaryResult = { + enabled, + retentionDays: RETENTION_DAYS, + daysCovered: 0, + firstDay: null, + lastDay: null, + totalRequests: 0, + totalFailed: 0, + requestsPerDay: 0, + busiestDay: null, + busiestDayRequests: 0, + waypointsPerRequest: 0, + kmPerRequest: 0, + byProfile: [], + bySurface: [], + selfHostedShare: 0, + days: [], + }; + if (!rows.length) return empty; + + const perDay = new Map(); + const perProfile = new Map(); + const perSurface = new Map(); + let requests = 0; + let failed = 0; + let waypoints = 0; + let km = 0; + let selfHosted = 0; + + for (const row of rows) { + requests += row.requests; + failed += row.failed; + waypoints += row.waypoints; + km += row.km; + if (row.selfHosted) selfHosted += row.requests; + perDay.set(row.day, (perDay.get(row.day) ?? 0) + row.requests); + perProfile.set(row.profile, (perProfile.get(row.profile) ?? 0) + row.requests); + perSurface.set(row.surface, (perSurface.get(row.surface) ?? 0) + row.requests); + } + + // Days come back newest first, so the last one is the earliest counted day. + const days = [...perDay.entries()].map(([day, count]) => ({ day, requests: count })); + const busiest = days.reduce((a, b) => (b.requests > a.requests ? b : a), days[0]); + const ratio = (part: number) => (requests > 0 ? Math.round((part / requests) * 1000) / 1000 : 0); + const per = (total: number) => (requests > 0 ? Math.round((total / requests) * 100) / 100 : 0); + + return { + enabled, + retentionDays: RETENTION_DAYS, + daysCovered: perDay.size, + firstDay: rows[rows.length - 1].day, + lastDay: rows[0].day, + totalRequests: requests, + totalFailed: failed, + requestsPerDay: perDay.size > 0 ? Math.round((requests / perDay.size) * 10) / 10 : 0, + busiestDay: busiest.day, + busiestDayRequests: busiest.requests, + waypointsPerRequest: per(waypoints), + kmPerRequest: per(km), + byProfile: [...perProfile.entries()].map(([profile, count]) => ({ profile, requests: count })), + bySurface: [...perSurface.entries()].map(([surface, count]) => ({ surface, requests: count })), + selfHostedShare: ratio(selfHosted), + days: days.slice(0, SERIES_DAYS), + }; + } + + /** Removes days past the retention window. Returns how many rows went. */ + purgeExpired(): number { + const result = this.db.run( + `DELETE FROM route_usage_daily WHERE day < date('now', ?)`, + `-${RETENTION_DAYS} days`, + ); + return result.changes ?? 0; + } + + /** Wipes every counter. The admin's own "start over". */ + clear(): number { + const result = this.db.run('DELETE FROM route_usage_daily'); + return result.changes ?? 0; + } +} diff --git a/server/tests/unit/nest/route-usage.service.test.ts b/server/tests/unit/nest/route-usage.service.test.ts new file mode 100644 index 000000000..01899bc4d --- /dev/null +++ b/server/tests/unit/nest/route-usage.service.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { RouteUsageService, RETENTION_DAYS } from '../../../src/nest/route-usage/route-usage.service'; +import type { RouteUsageEntry } from '@trek/shared'; + +/** + * SRV-ROUTEUSAGE-001..010 — the counters behind "could TREK host a router". + * + * Aggregates, not a log: the same day and kind adds onto one row, and the summary + * turns those rows into the two figures the decision needs, requests on an average + * day and requests on the busiest one. + */ + +function makeDb(): Database.Database { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE route_usage_daily ( + day TEXT NOT NULL, profile TEXT NOT NULL, surface TEXT NOT NULL, + self_hosted INTEGER NOT NULL, requests INTEGER NOT NULL DEFAULT 0, + waypoints INTEGER NOT NULL DEFAULT 0, km REAL NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, profile, surface, self_hosted) + ); + `); + return db; +} + +/** The slice of DatabaseService this domain uses. */ +function serviceOver(db: Database.Database): RouteUsageService { + const bridge = { + get: (sql: string, ...p: unknown[]) => db.prepare(sql).get(...p) as T | undefined, + all: (sql: string, ...p: unknown[]) => db.prepare(sql).all(...p) as T[], + run: (sql: string, ...p: unknown[]) => db.prepare(sql).run(...p), + transaction: (fn: () => T) => db.transaction(fn)(), + }; + return new RouteUsageService(bridge as never); +} + +const entry = (over: Partial = {}): RouteUsageEntry => ({ + profile: 'driving', surface: 'legs', selfHosted: false, + requests: 1, waypoints: 3, km: 100, failed: 0, ...over, +}); + +describe('RouteUsageService', () => { + let db: Database.Database; + let svc: RouteUsageService; + + beforeEach(() => { + db = makeDb(); + svc = serviceOver(db); + }); + + it('SRV-ROUTEUSAGE-001: counting is on unless an operator turned it off', () => { + expect(svc.enabled()).toBe(true); + db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run(); + expect(svc.enabled()).toBe(false); + }); + + it('SRV-ROUTEUSAGE-002: a switched-off instance records nothing and says so', () => { + db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run(); + expect(svc.record({ entries: [entry()] })).toBe(false); + expect(db.prepare('SELECT COUNT(*) c FROM route_usage_daily').get()).toEqual({ c: 0 }); + }); + + it('SRV-ROUTEUSAGE-003: a batch adds onto one row per day, profile, kind and engine', () => { + svc.record({ entries: [entry({ requests: 5, waypoints: 12, km: 300, failed: 1 })] }); + svc.record({ entries: [entry({ requests: 3, waypoints: 6, km: 150, failed: 0 })] }); + + const rows = svc.rows(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ requests: 8, waypoints: 18, km: 450, failed: 1 }); + }); + + it('SRV-ROUTEUSAGE-004: a different kind or engine is its own row', () => { + svc.record({ entries: [ + entry({ surface: 'legs' }), + entry({ surface: 'alternatives' }), + entry({ surface: 'legs', selfHosted: true }), + entry({ surface: 'legs', profile: 'walking' }), + ] }); + expect(svc.rows()).toHaveLength(4); + }); + + it('SRV-ROUTEUSAGE-005: an empty corpus answers with zeroes rather than nothing', () => { + const s = svc.summary(); + expect(s.totalRequests).toBe(0); + expect(s.daysCovered).toBe(0); + expect(s.busiestDay).toBeNull(); + expect(s.enabled).toBe(true); + expect(s.retentionDays).toBe(RETENTION_DAYS); + }); + + it('SRV-ROUTEUSAGE-006: the summary carries the per-day average and the busiest day', () => { + // Two days by hand: the service always writes "today", so the spread is set here. + db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,10,30,900,0)`).run(); + db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-02','driving','legs',0,30,60,1800,2)`).run(); + + const s = svc.summary(); + expect(s.totalRequests).toBe(40); + expect(s.totalFailed).toBe(2); + expect(s.daysCovered).toBe(2); + expect(s.requestsPerDay).toBe(20); + expect(s.busiestDay).toBe('2026-09-02'); + expect(s.busiestDayRequests).toBe(30); + expect(s.firstDay).toBe('2026-09-01'); + expect(s.lastDay).toBe('2026-09-02'); + }); + + it('SRV-ROUTEUSAGE-007: mean waypoints and kilometres are per request, not per row', () => { + db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,4,20,400,0)`).run(); + const s = svc.summary(); + expect(s.waypointsPerRequest).toBe(5); + expect(s.kmPerRequest).toBe(100); + }); + + it('SRV-ROUTEUSAGE-008: the self-hosted share is the part already off the public hosts', () => { + db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,30,0,0,0)`).run(); + db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',1,10,0,0,0)`).run(); + expect(svc.summary().selfHostedShare).toBe(0.25); + }); + + it('SRV-ROUTEUSAGE-009: retention drops days past the window and keeps the rest', () => { + db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-1 day'),'driving','legs',0,1,0,0,0)`).run(); + db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-${RETENTION_DAYS + 5} days'),'driving','legs',0,1,0,0,0)`).run(); + + expect(svc.purgeExpired()).toBe(1); + expect(svc.rows()).toHaveLength(1); + }); + + it('SRV-ROUTEUSAGE-010: clearing wipes every counter', () => { + svc.record({ entries: [entry(), entry({ surface: 'route' })] }); + expect(svc.clear()).toBe(2); + expect(svc.rows()).toHaveLength(0); + }); +}); diff --git a/shared/src/index.ts b/shared/src/index.ts index f7c580386..32f7b5712 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -19,6 +19,7 @@ export * from './config/config.schema'; export * from './system-notice/system-notice.schema'; export * from './maps/maps.schema'; export * from './maps/place-shadow.schema'; +export * from './maps/route-usage.schema'; export * from './category/category.schema'; export * from './tag/tag.schema'; export * from './notification/notification.schema'; diff --git a/shared/src/maps/route-usage.schema.ts b/shared/src/maps/route-usage.schema.ts new file mode 100644 index 000000000..17842e0da --- /dev/null +++ b/shared/src/maps/route-usage.schema.ts @@ -0,0 +1,103 @@ +import { z } from 'zod'; + +/** + * Route usage counters — how much routing this instance actually does. + * + * The question behind them is whether TREK could host its own router. Today every + * route is calculated in the browser against the public FOSSGIS hosts, so nobody + * has a number: not how many requests a real trip costs, not how they split + * between the day plan and the road trip mode, not how many already go to a + * self-hosted engine. Deciding on a hosted router without that number would be + * guessing at the bill. + * + * Counters, not a log. A row is a day, a profile and a surface with totals on it — + * there is no query, no coordinate, no route, no user, no trip and no session. The + * only thing recorded about a request is that it happened, what kind it was, and + * how big it was. Nothing here leaves the instance on its own: the totals sit in + * that instance's own database and its own admin reads them. + * + * Reported in batches rather than per request. A counter that costs a network call + * of its own would measure load by adding it. + */ + +/** The three OSRM profiles TREK asks for, plus a bucket for a routing plugin. */ +export const routeUsageProfileSchema = z.enum(['driving', 'walking', 'cycling', 'other']); +export type RouteUsageProfile = z.infer; + +/** + * What kind of request it was, named after the call that made it rather than after + * the screen it came from: the day plan and road trip mode share `legs`, so a screen + * label would have to be threaded through every caller to be true. The split still + * carries the hosting question, because the kinds cost very different amounts — + * `legs` is one request for a whole chain, `alternatives` asks two or three times + * over for a single leg. + */ +export const routeUsageSurfaceSchema = z.enum(['route', 'segments', 'legs', 'alternatives', 'other']); +export type RouteUsageSurface = z.infer; + +export const routeUsageEntrySchema = z.object({ + profile: routeUsageProfileSchema, + surface: routeUsageSurfaceSchema, + /** True when the instance points at its own engine, so its load is already local. */ + selfHosted: z.boolean(), + /** Requests in this batch. Capped well above any plausible batch. */ + requests: z.number().int().min(1).max(10_000), + /** Waypoints handed over in total, which is what a per-request limit binds on. */ + waypoints: z.number().int().min(0).max(1_000_000), + /** Routed distance in kilometres, summed. Rounded to whole kilometres. */ + km: z.number().min(0).max(10_000_000), + /** How many of them came back with no route at all. */ + failed: z.number().int().min(0).max(10_000), +}); +export type RouteUsageEntry = z.infer; + +export const routeUsageReportRequestSchema = z.object({ + entries: z.array(routeUsageEntrySchema).min(1).max(40), +}); +export type RouteUsageReportRequest = z.infer; + +/** Answered even when counting is switched off, so the client never has to care. */ +export const routeUsageReportResultSchema = z.object({ + recorded: z.boolean(), +}); +export type RouteUsageReportResult = z.infer; + +export const routeUsageDayRowSchema = z.object({ + day: z.string(), + profile: routeUsageProfileSchema, + surface: routeUsageSurfaceSchema, + selfHosted: z.boolean(), + requests: z.number().int(), + waypoints: z.number().int(), + km: z.number(), + failed: z.number().int(), +}); +export type RouteUsageDayRow = z.infer; + +export const routeUsageSummaryResultSchema = z.object({ + enabled: z.boolean(), + /** Days a row survives. Aggregates are tiny, so this is far longer than a log's. */ + retentionDays: z.number().int(), + /** Days that have any counter at all, which is what the averages divide by. */ + daysCovered: z.number().int(), + firstDay: z.string().nullable(), + lastDay: z.string().nullable(), + totalRequests: z.number().int(), + totalFailed: z.number().int(), + /** The headline for the hosting question: requests on an average counted day. */ + requestsPerDay: z.number(), + /** Busiest single day, because a router has to survive the peak, not the mean. */ + busiestDay: z.string().nullable(), + busiestDayRequests: z.number().int(), + /** Mean waypoints per request: the figure a 10-waypoint API limit binds on. */ + waypointsPerRequest: z.number(), + /** Mean routed kilometres per request, against a 1500 km limit. */ + kmPerRequest: z.number(), + byProfile: z.array(z.object({ profile: routeUsageProfileSchema, requests: z.number().int() })), + bySurface: z.array(z.object({ surface: routeUsageSurfaceSchema, requests: z.number().int() })), + /** How much of the load already goes to an engine the operator runs. */ + selfHostedShare: z.number(), + /** The counted days themselves, newest first, for a chart or an export. */ + days: z.array(z.object({ day: z.string(), requests: z.number().int() })), +}); +export type RouteUsageSummaryResult = z.infer;