mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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),
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<DbRow>(
|
||||
'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<string, number>();
|
||||
const perProfile = new Map<RouteUsageProfile, number>();
|
||||
const perSurface = new Map<RouteUsageSurface, number>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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: <T>(sql: string, ...p: unknown[]) => db.prepare(sql).get(...p) as T | undefined,
|
||||
all: <T>(sql: string, ...p: unknown[]) => db.prepare(sql).all(...p) as T[],
|
||||
run: (sql: string, ...p: unknown[]) => db.prepare(sql).run(...p),
|
||||
transaction: <T>(fn: () => T) => db.transaction(fn)(),
|
||||
};
|
||||
return new RouteUsageService(bridge as never);
|
||||
}
|
||||
|
||||
const entry = (over: Partial<RouteUsageEntry> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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<typeof routeUsageProfileSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof routeUsageSurfaceSchema>;
|
||||
|
||||
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<typeof routeUsageEntrySchema>;
|
||||
|
||||
export const routeUsageReportRequestSchema = z.object({
|
||||
entries: z.array(routeUsageEntrySchema).min(1).max(40),
|
||||
});
|
||||
export type RouteUsageReportRequest = z.infer<typeof routeUsageReportRequestSchema>;
|
||||
|
||||
/** 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<typeof routeUsageReportResultSchema>;
|
||||
|
||||
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<typeof routeUsageDayRowSchema>;
|
||||
|
||||
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<typeof routeUsageSummaryResultSchema>;
|
||||
Reference in New Issue
Block a user