feat(maps): count how much routing an instance actually does

Every route is calculated in the browser against the public FOSSGIS hosts, so the
server never sees a routing request and nobody has a number: not how many a real trip
costs, not how big they are, not how much already goes to a self-hosted engine.
Deciding whether TREK could run its own router without that number is guessing at the
bill, and the counters have to be collecting before the question comes up.

Counters, not a log. A row is a day, a profile, a request kind and an engine kind with
totals on it: how many requests, how many waypoints, roughly how far, how many were
refused. No query, no coordinate, no route, no user, no trip. Nothing leaves the
instance: its own database holds them and its own admin reads them at
GET /api/route-usage/summary.

The client tallies in memory and flushes in batches — on a timer, past a threshold and
when the page is hidden — because a counter that cost a request of its own would
measure the load by adding to it. Routing all funnels through one fetch in
RouteCalculator, so that is the only place instrumented.

On by default and switchable off with route_usage_enabled, unlike the shadow log next
door: that one stores what people typed, this one stores four integers about a day.
This commit is contained in:
Maurice
2026-09-09 14:44:32 +02:00
committed by Maurice
parent 88129e4546
commit d30b916f9c
14 changed files with 830 additions and 6 deletions
+24
View File
@@ -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<void> =>
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<RouteUsageSummaryResult> =>
apiClient.get('/route-usage/summary').then(r => r.data),
}
+51 -5
View File
@@ -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<Response> {
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<RouteAlternative[]> {
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()
@@ -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)
})
})
@@ -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<RouteUsageEntry, 'profile' | 'surface' | 'selfHosted'> & {
profile: RouteUsageProfile
surface: RouteUsageSurface
selfHosted: boolean
}
const buckets = new Map<string, Bucket>()
let pending = 0
let timer: ReturnType<typeof setTimeout> | 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()
}