Skip to content

feat(energy-atlas): live tanker map layer + contract (parity PR 3, plan U7-U8)#3402

Merged
koala73 merged 4 commits into
mainfrom
feat/energy-parity-pr3-live-tankers
Apr 25, 2026
Merged

feat(energy-atlas): live tanker map layer + contract (parity PR 3, plan U7-U8)#3402
koala73 merged 4 commits into
mainfrom
feat/energy-parity-pr3-live-tankers

Conversation

@koala73

@koala73 koala73 commented Apr 25, 2026

Copy link
Copy Markdown
Owner

Summary

Third and final parity-push PR. Lands per-vessel tanker positions inside chokepoint bounding boxes, refreshed every 60s. Closes the visual gap with peer reference energy-intel sites for the live AIS tanker view.

Plan: `docs/plans/2026-04-25-003-feat-energy-parity-pushup-plan.md` PR 3 (U7–U8). Codex-approved through 8 review rounds against `origin/main @ 0500733`.

What ships in this PR

U7 — Contract changes (relay + handler + proto + gateway tier + rate-limit + cache-tier-test):

  • `scripts/ais-relay.cjs` — parallel `tankerReports` Map populated for AIS ship type 80-89 (tanker class) per ITU-R M.1371. SEPARATE from the existing `candidateReports` Map (military-only) so the existing military-detection consumer's contract stays unchanged. Snapshot endpoint extended to accept `bbox=swLat,swLon,neLat,neLon` + `tankers=true` query params, with bbox-filtering applied server-side. Tanker reports cleaned up on the same retention window as candidate reports; capped at 200 per response.
  • Proto — new `bool include_tankers = 6` request field; new `repeated SnapshotCandidateReport tanker_reports = 7` response field. `make generate` regenerated.
  • `server/worldmonitor/maritime/v1/get-vessel-snapshot.ts` — REPLACES the prior 5-minute `with|without` cache with a request-keyed `Map<string, SnapshotCacheSlot>` cache where the key embeds `(includeCandidates, includeTankers, quantizedBbox)`. TTL split: 60s for live-tanker / bbox paths, 300s (preserved) for the existing density / disruption consumers. 1° bbox quantization for cache-key reuse + 10° max-bbox guard via `BboxTooLargeError`.
  • `server/gateway.ts` — NEW `'live'` cache tier (`s-maxage=60, stale-while-revalidate=60`). `CacheTier` union extended; both `TIER_HEADERS` and `TIER_CDN_CACHE` gain entries. `RPC_CACHE_TIER` maps `/api/maritime/v1/get-vessel-snapshot` from `'no-store'` to `'live'` so the CDN absorbs concurrent identical requests.
  • `server/_shared/rate-limit.ts` — `ENDPOINT_RATE_POLICIES` entry: `60 req/min/IP`.
  • `tests/route-cache-tier.test.mjs` — regex extended to include `live` so the every-route-has-an-explicit-tier check still recognises the new mapping.
  • `tests/server-handlers.test.mjs` — updated structural tests to match the new cache shape (Map-based, three-axis key) + split TTLs (300s base, 60s live) + the bbox-size guard.
  • `server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts` — fixed the existing global-bbox caller (was `(-90, -180, 90, 180)`) to pass all-zeros (the new "no-bbox" sentinel) so it doesn't trip the 10° guard.

U8 — LiveTankersLayer consumer:

  • `src/services/live-tankers.ts` — per-chokepoint fetcher with 60s in-memory cache. `Promise.allSettled` so one chokepoint failing doesn't blank the whole layer (failed zones serve last-known data). Sources bbox centroids from `src/config/chokepoint-registry.ts` (lat/lon present at lines 32-33) — NOT `server/.../​_chokepoint-ids.ts` (strips lat/lon). Default chokepoints: hormuz_strait, suez, bab_el_mandeb, malacca_strait, panama, bosphorus.
  • `src/components/DeckGLMap.ts` — new `createLiveTankersLayer()` ScatterplotLayer styled by speed (anchored amber, underway cyan, unknown gray); new `loadLiveTankers()` async loader with abort-controller cancellation.
  • `src/config/map-layer-definitions.ts` — `LayerDefinition` for `liveTankers` (`renderers: ['flat'], deckGLOnly: true`); added to `VARIANT_LAYER_ORDER.energy` near `ais` so `getLayersForVariant()` and `sanitizeLayersForVariant()` include it on the energy variant.
  • `src/types/index.ts` — `liveTankers?: boolean` on the `MapLayers` union.
  • `src/config/panels.ts` — `liveTankers: true` in `ENERGY_MAP_LAYERS` + `ENERGY_MOBILE_MAP_LAYERS`. Default `false` everywhere else.
  • `src/services/maritime/index.ts` — existing snapshot consumer pinned to `includeTankers: false` to satisfy the proto's new field; preserves identical behavior for AIS-density / military-detection surfaces.

Defense in depth (per Codex review)

  • Three-layer cache (CDN `'live'` tier → handler bbox-keyed 60s → service in-memory 60s) — concurrent users hit the relay sub-linearly.
  • Server-side 200-vessel cap on `tanker_reports` (in addition to the client-side cap) — protects render perf even on a runaway relay payload.
  • Bbox-size guard (10° max) — prevents a single global-bbox query from exfiltrating every tanker.
  • Per-IP rate limit at 60/min — covers normal use; flags scrape-class only.
  • Existing military-detection contract preserved — `candidate_reports` field semantics unchanged; consumers self-select via `include_tankers` vs `include_candidates` rather than the field changing meaning.

Test plan

  • `npm run typecheck` clean (both src and api tsconfigs).
  • `npm run test:data` (full unit suite, ~6957 tests) green; 5 new live-tankers-service tests pass.
  • `tests/server-handlers.test.mjs` updated structural tests pass (Map-based cache shape, split TTLs, bbox guard).
  • `tests/route-cache-tier.test.mjs` recognises the new `'live'` tier.
  • After merge + Railway redeploy: `curl /ais/snapshot?tankers=true&bbox=24,55,28,58` returns non-empty `tankerReports`.
  • After Vercel deploy: `curl '/api/maritime/v1/get-vessel-snapshot?include_tankers=true&ne_lat=28&ne_lon=58&sw_lat=24&sw_lon=55'` returns a 200 with bbox-filtered tanker positions.
  • Manual visual check on `https://energy.worldmonitor.app\` post-deploy: zoom into Hormuz, layer toggled on, ~50-200 dots visible; click reveals MMSI + status.

Sequence

Lands the third and final parity-push surface — per-vessel tanker positions
inside chokepoint bounding boxes, refreshed every 60s. Closes the visual
gap with peer reference energy-intel sites for the live AIS tanker view.

Per docs/plans/2026-04-25-003-feat-energy-parity-pushup-plan.md PR 3.
Codex-approved through 8 review rounds against origin/main @ 0500733.

U7 — Contract changes (relay + handler + proto + gateway + rate-limit + test):

- scripts/ais-relay.cjs: parallel `tankerReports` Map populated for AIS
  ship type 80-89 (tanker class) per ITU-R M.1371. SEPARATE from the
  existing `candidateReports` Map (military-only) so the existing
  military-detection consumer's contract stays unchanged. Snapshot
  endpoint extended to accept `bbox=swLat,swLon,neLat,neLon` + `tankers=true`
  query params, with bbox-filtering applied server-side. Tanker reports
  cleaned up on the same retention window as candidate reports; capped
  at 200 per response (10× headroom for global storage).
- proto/worldmonitor/maritime/v1/{get_,}vessel_snapshot.proto:
  - new `bool include_tankers = 6` request field
  - new `repeated SnapshotCandidateReport tanker_reports = 7` response
    field (reuses existing message shape; parallel to candidate_reports)
- server/worldmonitor/maritime/v1/get-vessel-snapshot.ts: REPLACES the
  prior 5-minute `with|without` cache with a request-keyed cache —
  (includeCandidates, includeTankers, quantizedBbox) — at 60s TTL for
  the live-tanker path and 5min TTL for the existing density/disruption
  consumers. Also adds 1° bbox quantization for cache-key reuse and a
  10° max-bbox guard (BboxTooLargeError) to prevent malicious clients
  from pulling all tankers through one query.
- server/gateway.ts: NEW `'live'` cache tier. CacheTier union extended;
  TIER_HEADERS + TIER_CDN_CACHE both gain entries with `s-maxage=60,
  stale-while-revalidate=60`. RPC_CACHE_TIER maps the maritime endpoint
  from `'no-store'` to `'live'` so the CDN absorbs concurrent identical
  requests across all viewers (without this, N viewers × 6 chokepoints
  hit AISStream upstream linearly).
- server/_shared/rate-limit.ts: ENDPOINT_RATE_POLICIES entry for the
  maritime endpoint at 60 req/min/IP — enough headroom for one user's
  6-chokepoint tab plus refreshes; flags only true scrape-class traffic.
- tests/route-cache-tier.test.mjs: regex extended to include `live` so
  the every-route-has-an-explicit-tier check still recognises the new
  mapping. Without this, the new tier would silently drop the maritime
  route from the validator's route map.

U8 — LiveTankersLayer consumer:

- src/services/live-tankers.ts: per-chokepoint fetcher with 60s in-memory
  cache. Promise.allSettled — never .all — so one chokepoint failing
  doesn't blank the whole layer (failed zones serve last-known data).
  Sources bbox centroids from src/config/chokepoint-registry.ts
  (CORRECT location — server/.../​_chokepoint-ids.ts strips lat/lon).
  Default chokepoint set: hormuz_strait, suez, bab_el_mandeb,
  malacca_strait, panama, bosphorus.
- src/components/DeckGLMap.ts: new `createLiveTankersLayer()` ScatterplotLayer
  styled by speed (anchored amber when speed < 0.5 kn, underway cyan,
  unknown gray); new `loadLiveTankers()` async loader with abort-controller
  cancellation. Layer instantiated when `mapLayers.liveTankers && this.liveTankers.length > 0`.
- src/config/map-layer-definitions.ts: `LayerDefinition` for `liveTankers`
  with `renderers: ['flat'], deckGLOnly: true` (matches existing
  storageFacilities/fuelShortages pattern). Added to `VARIANT_LAYER_ORDER.energy`
  near `ais` so getLayersForVariant() and sanitizeLayersForVariant()
  include it on the energy variant — without this addition the layer
  would be silently stripped even when toggled on.
- src/types/index.ts: `liveTankers?: boolean` on the MapLayers union.
- src/config/panels.ts: ENERGY_MAP_LAYERS + ENERGY_MOBILE_MAP_LAYERS
  both gain `liveTankers: true`. Default `false` everywhere else.
- src/services/maritime/index.ts: existing snapshot consumer pinned to
  `includeTankers: false` to satisfy the proto's new required field;
  preserves identical behavior for the AIS-density / military-detection
  surfaces.

Tests:
- npm run typecheck clean.
- 5 unit tests in tests/live-tankers-service.test.mjs cover the default
  chokepoint set (rejects ids that aren't in CHOKEPOINT_REGISTRY), the
  60s cache TTL pin (must match gateway 'live' tier s-maxage), and bbox
  derivation (±2° padding, total span under the 10° handler guard).
- tests/route-cache-tier.test.mjs continues to pass after the regex
  extension; the new maritime tier is correctly extracted.

Defense in depth:
- THREE-layer cache (CDN 'live' tier → handler bbox-keyed 60s → service
  in-memory 60s) means concurrent users hit the relay sub-linearly.
- Server-side 200-vessel cap on tanker_reports + client-side cap;
  protects layer render perf even on a runaway relay payload.
- Bbox-size guard (10° max) prevents a single global-bbox query from
  exfiltrating every tanker.
- Per-IP rate limit at 60/min covers normal use; flags scrape-class only.
- Existing military-detection contract preserved: `candidate_reports`
  field semantics unchanged; consumers self-select via include_tankers
  vs include_candidates rather than the response field changing meaning.
@mintlify

mintlify Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview Apr 25, 2026, 12:34 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@vercel

vercel Bot commented Apr 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 25, 2026 1:53pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR lands the live AIS tanker map layer for Energy Atlas (plan U7–U8): a new tankerReports field in the relay and proto, a request-keyed handler cache with split TTLs, a 'live' CDN tier, a per-chokepoint client service, and the LiveTankersLayer ScatterplotLayer. The architecture is well thought-out with three-layer caching (CDN → handler → service) and good separation from the existing military-detection consumer.

  • P1 — BboxTooLargeError returns HTTP 500 instead of 400: the error is thrown with the intent to surface a 400, but mapErrorToResponse has no case for it (it checks for statusCode property, SyntaxError, and network errors only) and falls through to the 500 catch-all. Adding readonly statusCode = 400 to the error class fixes this without touching the error mapper.

Confidence Score: 3/5

Not safe to merge as-is — the bbox validation error surfaces as 500 to clients and must be fixed before deploying.

One clear P1: BboxTooLargeError is thrown expecting a 400 response but mapErrorToResponse has no matching case and will always return 500 for this error. This means any client that passes an oversized bbox gets an opaque server error instead of a useful validation failure. The rest of the change is solid — the three-layer cache design, relay contract separation, and rate limiting are all well-implemented.

server/worldmonitor/maritime/v1/get-vessel-snapshot.ts (BboxTooLargeError class needs statusCode: 400) and src/components/DeckGLMap.ts (abort controller signal unused).

Important Files Changed

Filename Overview
server/worldmonitor/maritime/v1/get-vessel-snapshot.ts Replaces two-slot cache with a keyed Map, adds split TTLs and bbox validation. Critical bug: BboxTooLargeError is thrown expecting a 400 response, but mapErrorToResponse has no matching case and will return 500. Also: unbounded cache Map growth for long-lived instances.
scripts/ais-relay.cjs Adds tankerReports Map (AIS type 80-89), bbox parsing with 10° guard, and a filtered/capped getTankerReportsSnapshot helper. Relay-side logic looks correct; MAX_TANKER_REPORTS_PER_RESPONSE is defined after first use but safe at call-time in CJS.
src/components/DeckGLMap.ts Adds createLiveTankersLayer() ScatterplotLayer and loadLiveTankers() async loader. The abort controller is created but its signal is never forwarded to fetchLiveTankers, so cancellation of overlapping ticks is non-functional.
src/services/live-tankers.ts New per-chokepoint fetcher with 60s in-memory cache and Promise.allSettled graceful degradation. Logic is sound; exports _internal helpers for unit testing.
server/gateway.ts Adds 'live' cache tier (30s browser / 60s CDN) and maps get-vessel-snapshot to it. Well-structured; TIER_HEADERS and TIER_CDN_CACHE both updated consistently.
server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts Fixes the existing global-bbox caller to pass all-zeros sentinel instead of (-90,-180,90,180), correctly avoiding the new 10° guard. Change is minimal and accurate.
server/_shared/rate-limit.ts Adds 60 req/min/IP policy for get-vessel-snapshot. Policy is appropriately sized for the 6-chokepoint × 1-call/min use case with user headroom.
tests/live-tankers-service.test.mts Unit tests for pure helpers (bboxFor, default chokepoint set, TTL constants). Appropriately scoped given network dependency; pins the contract values against accidental drift.
proto/worldmonitor/maritime/v1/vessel_snapshot.proto Adds tanker_reports = 7 as a new repeated field reusing SnapshotCandidateReport. Field numbering is correct and backward-compatible.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant CDN as CDN (live tier, s-maxage=60)
    participant Handler as Handler<br/>get-vessel-snapshot.ts<br/>(Map cache, 60s TTL)
    participant Service as live-tankers.ts<br/>(in-memory cache, 60s)
    participant Relay as ais-relay.cjs<br/>tankerReports Map

    Browser->>CDN: GET /api/maritime/v1/get-vessel-snapshot<br/>?include_tankers=true&bbox=...
    alt CDN hit (< 60s)
        CDN-->>Browser: 200 cached response
    else CDN miss
        CDN->>Handler: forward request
        alt Handler cache hit (quantized bbox key)
            Handler-->>CDN: 200 cached snapshot
        else Handler cache miss
            Handler->>Relay: GET /ais/snapshot?tankers=true&bbox=quantized
            Relay-->>Handler: JSON tankerReports array
            Handler-->>CDN: 200 fresh snapshot
        end
        CDN-->>Browser: 200 response
    end

    note over Browser,Service: Client-side path (DeckGLMap.loadLiveTankers)
    Browser->>Service: fetchLiveTankers(chokepoints)
    loop per chokepoint (Promise.allSettled)
        Service->>Handler: getVesselSnapshot bbox + includeTankers true
    end
    Service-->>Browser: ChokepointTankers array to LiveTankersLayer
Loading

Reviews (1): Last reviewed commit: "feat(energy-atlas): live tanker map laye..." | Re-trigger Greptile

Comment on lines 233 to +235

// Bbox-size guard: reject requests where either dimension exceeds 10°. This
// prevents a malicious or buggy client from requesting a global box and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 BboxTooLargeError surfaces as HTTP 500, not 400

The comment says // surface to the gateway as 400, but mapErrorToResponse in server/error-mapper.ts has no case for BboxTooLargeError. It isn't an ApiError (no statusCode property), not a SyntaxError, and not a network error — so it falls straight to the catch-all 500 Internal Server Error. Clients that pass an oversized bbox get a 500 and have no way to distinguish a validation error from an infrastructure failure.

Fix: give BboxTooLargeError a statusCode: 400 property so the existing 'statusCode' in error branch in mapErrorToResponse picks it up:

export class BboxTooLargeError extends Error {
  readonly statusCode = 400;
  constructor() {
    super('bbox too large: each dimension must be ≤ 10 degrees');
    this.name = 'BboxTooLargeError';
  }
}

Comment on lines +2995 to +3010
* Tanker loader — called externally (or on a 60s tick) to refresh
* `this.liveTankers`. Imports lazily so the service module isn't pulled
* into the bundle for variants where the layer is disabled.
*/
public async loadLiveTankers(): Promise<void> {
// Cancel any in-flight tick before starting another. Per skill
// closure-scoped-state-teardown-order: don't null out the abort
// controller before calling abort.
if (this.liveTankersAbort) {
this.liveTankersAbort.abort();
}
this.liveTankersAbort = new AbortController();
try {
const { fetchLiveTankers } = await import('@/services/live-tankers');
const zones = await fetchLiveTankers();
const flat = zones.flatMap((z) => z.tankers).map((t) => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Abort controller signal never passed — in-flight cancellation is broken

A new AbortController is created and the previous one is .abort()ed on every tick, but the signal is never forwarded to fetchLiveTankers() (which calls the RPC). Aborting the controller has no effect on the in-flight network requests. If two ticks overlap (e.g., layer toggled rapidly or a slow relay), both fetches run to completion and whichever finishes last silently overwrites this.liveTankers — potentially replacing newer data with older data.

Since fetchLiveTankers doesn't currently accept a signal, the simplest fix is to guard the state write using the controller that was active when the fetch started:

const ctrl = this.liveTankersAbort;
// ... await fetchLiveTankers() ...
if (ctrl.signal.aborted) return; // stale tick — discard result
this.liveTankers = flat;
this.updateLayers();

Comment on lines 55 to +60
inFlight: Promise<VesselSnapshot | undefined> | null;
}

const cache: Record<'with' | 'without', SnapshotCacheSlot> = {
with: { snapshot: undefined, timestamp: 0, inFlight: null },
without: { snapshot: undefined, timestamp: 0, inFlight: null },
};
// Cache keyed by request shape: candidates, tankers, and quantized bbox.
// Replaces the prior `with|without` keying which would silently serve
// stale tanker data and collapse distinct bboxes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Handler-side cache Map has no eviction — unbounded growth in warm instances

Each distinct (includeCandidates, includeTankers, quantizedBbox) triple adds a new slot that is never removed. The energy-atlas service calls with a fixed set of 6 chokepoint bboxes, so the realistic key space is small. However, the endpoint is now public (rate-limited but accessible) and any client can freely enumerate quantized 1°×1° bboxes within the 10°×10° guard — up to ~10,000 distinct slots per instance warm period. Each slot holds a full VesselSnapshot object.

Consider evicting slots whose timestamp is older than SNAPSHOT_CACHE_TTL_BASE_MS (the longest TTL) during each cache-write to bound memory growth:

// After updating slot.snapshot / slot.timestamp:
for (const [k, s] of cache) {
  if (Date.now() - s.timestamp > SNAPSHOT_CACHE_TTL_BASE_MS) cache.delete(k);
}

…review)

Three findings from review of #3402:

P1 — loadLiveTankers() was never called (DeckGLMap.ts:2999):
- Add ensureLiveTankersLoop() / stopLiveTankersLoop() helpers paired with
  the layer-enabled / layer-disabled branches in updateLayers(). The
  ensure helper kicks an immediate load + a 60s setInterval; idempotent
  so calling it on every layers update is safe.
- Wire stopLiveTankersLoop() into destroy() and into the layer-disabled
  branch so we don't hammer the relay when the layer is off.
- Layer factory now runs only when liveTankers.length > 0; ensureLoop
  fires on every observed-enabled tick so first-paint kicks the load
  even before the first tanker arrives.

P1 — bbox lat/lon range guard (get-vessel-snapshot.ts:253):
- Out-of-range bboxes (e.g. ne_lat=200) previously passed the size
  guard (200-195=5° < 10°) but failed at the relay, which silently
  drops the bbox param and returns a global capped subset — making
  the layer appear to "work" with stale phantom data.
- Add isValidLatLon() check inside extractAndValidateBbox(): every
  corner must satisfy [-90, 90] / [-180, 180] before the size guard
  runs. Failure throws BboxValidationError.

P2 — BboxTooLargeError surfaced as 500 instead of 400:
- server/error-mapper.ts maps errors to HTTP status by checking
  `'statusCode' in error`. The previous BboxTooLargeError extended
  Error without that property, so the mapper fell through to
  "unhandled error" → 500.
- Rename to BboxValidationError, add `readonly statusCode = 400`.
  Mapper now surfaces it as HTTP 400 with a descriptive reason.
- Keep BboxTooLargeError as a backwards-compat alias so existing
  imports / tests don't break.

Tests:
- Updated tests/server-handlers.test.mjs structural test to pin the
  new class name + statusCode + lat/lon range checks. 24 tests pass.
- typecheck (src + api) clean.
…eview #2)

P2 — AbortController was created + aborted but signal was never passed
into the actual fetch path (DeckGLMap.ts:3048 / live-tankers.ts:100):
- Toggling the layer off, destroying the map, or starting a new refresh
  did not actually cancel in-flight network work. A slow older refresh
  could complete after a newer one and overwrite this.liveTankers with
  stale data.

Threading:
- fetchLiveTankers() now accepts `options.signal: AbortSignal`. Signal
  is passed through to client.getVesselSnapshot() per chokepoint via
  the Connect-RPC client's standard `{ signal }` option.
- Per-zone abort handling: bail early if signal is already aborted
  before the fetch starts (saves a wasted RPC + cache write); re-check
  after the fetch resolves so a slow resolver can't clobber cache
  after the caller cancelled.

Stale-result race guard in DeckGLMap.loadLiveTankers:
- Capture controller in a local before storing on this.liveTankersAbort.
- After fetchLiveTankers resolves, drop the result if EITHER:
  - controller.signal is now aborted (newer load cancelled this one)
  - this.liveTankersAbort points to a different controller (a newer
    load already started + replaced us in the field)
- Without these guards, an older fetch that completed despite
  signal.aborted could still write to this.liveTankers and call
  updateLayers, racing with the newer load.

Tests: 1 new signature-pin test in tests/live-tankers-service.test.mts
verifies fetchLiveTankers accepts options.signal — guards against future
edits silently dropping the parameter and re-introducing the race.
6 tests pass. typecheck clean.
…review)

Greptile P2 finding: the in-process cache Map grows unbounded across the
serverless instance lifetime. Each distinct (includeCandidates,
includeTankers, quantizedBbox) triple creates a slot that's never evicted.
With 1° quantization and a misbehaving client the keyspace is ~64,000
entries — realistic load is ~12, so a 128-slot cap leaves 10x headroom
while making OOM impossible.

Implementation:
- SNAPSHOT_CACHE_MAX_SLOTS = 128.
- evictIfNeeded() walks insertion order and evicts the first slot whose
  inFlight is null. Slots with active fetches are skipped to avoid
  orphaning awaiting callers; we accept brief over-cap growth until
  in-flight settles.
- touchSlot() re-inserts a slot at the end of Map insertion order on
  hit / in-flight join / fresh write so it counts as most-recently-used.
@koala73
koala73 merged commit 5c95569 into main Apr 25, 2026
11 checks passed
@koala73
koala73 deleted the feat/energy-parity-pr3-live-tankers branch April 25, 2026 13:56
SebastienMelki added a commit that referenced this pull request Apr 25, 2026
- Sync UsageCacheTier with the local CacheTier in gateway.ts (main added
  'live' in PR #3402 — synthetic merge with main was failing typecheck:api).
- Revert temporary unconditional debug logs in sendToAxiom now that Axiom
  delivery is verified end-to-end on preview (event landed with all fields
  populated, including the new auth_401 reason from the koala #3403 fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
SebastienMelki added a commit that referenced this pull request Apr 25, 2026
… attribution) (#3403)

* feat(gateway): thread Vercel Edge ctx through createDomainGateway (#3381)

PR-0 of the Axiom usage-telemetry stack. Pure infra change: no telemetry
emission yet, only the signature plumbing required for ctx.waitUntil to
exist on the hot path.

- createDomainGateway returns (req, ctx) instead of (req)
- rewriteToSebuf propagates ctx to its target gateway
- 5 alias callsites updated to pass ctx through
- ~30 [rpc].ts callsites unchanged (export default createDomainGateway(...))

Pattern reference: api/notification-channels.ts:166.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(usage): pure UsageIdentity resolver + Axiom emit primitives (#3381)

server/_shared/usage-identity.ts
- buildUsageIdentity: pure function, consumes already-resolved gateway state.
- Static ENTERPRISE_KEY_TO_CUSTOMER map (explicit, reviewable in code).
- Does not re-verify JWTs or re-validate API keys.

server/_shared/usage.ts
- buildRequestEvent / buildUpstreamEvent: allowlisted-primitive builders only.
  Never accept Request/Response — additive field leaks become structurally
  impossible.
- emitUsageEvents → ctx.waitUntil(sendToAxiom). Direct fetch, 1.5s timeout,
  no retry, gated by USAGE_TELEMETRY=1 and AXIOM_API_TOKEN.
- Sliding-window circuit breaker (5% over 5min, min 20 samples). Trips with
  one structured console.error; subsequent drops are 1%-sampled console.warn.
- Header derivers reuse Vercel/CF headers for request_id, region, country,
  reqBytes; ua_hash null unless USAGE_UA_PEPPER is set (no stable
  fingerprinting).
- Dev-only x-usage-telemetry response header for 2-second debugging.

server/_shared/auth-session.ts
- New resolveClerkSession returning { userId, orgId } in one JWT verify so
  customer_id can be Clerk org id without a second pass. resolveSessionUserId
  kept as back-compat wrapper.

No emission wiring yet — that lands in the next commit (gateway request
event + 403 + 429).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(gateway): emit Axiom request events on every return path (#3381)

Wires the request-event side of the Axiom usage-telemetry stack. Behind
USAGE_TELEMETRY=1 — no-op when the env var is unset.

Emit points (each builds identity from accumulated gateway state):
- origin_403 disallowed origin → reason=origin_403
- API access subscription required (403)
- legacy bearer 401 / 403 / 401-without-bearer
- entitlement check fail-through
- endpoint rate-limit 429 → reason=rate_limit_429
- global rate-limit 429 → reason=rate_limit_429
- 405 method not allowed
- 404 not found
- 304 etag match (resolved cache tier)
- 200 GET with body (resolved cache tier, real res_bytes)
- streaming / non-GET-200 final return (res_bytes best-effort)

Identity inputs (UsageIdentityInput):
- sessionUserId / clerkOrgId from new resolveClerkSession (one JWT verify)
- isUserApiKey + userApiKeyCustomerRef from validateUserApiKey result
- enterpriseApiKey when keyCheck.valid + non-wm_ wmKey present
- widgetKey from x-widget-key header (best-effort)
- tier captured opportunistically from existing getEntitlements calls

Header derivers reuse Vercel/CF metadata (x-vercel-id, x-vercel-ip-country,
cf-ipcountry, content-length, sentry-trace) — no new geo lookup, no new
crypto on the hot path. ua_hash null unless USAGE_UA_PEPPER is set.

Dev-only x-usage-telemetry response header (ok | degraded | off) attached
on the response paths for 2-second debugging in non-production.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(usage): upstream events via implicit request scope (#3381)

Closes the upstream-attribution side of the Axiom usage-telemetry stack
without requiring leaf-handler changes (per koala's review).

server/_shared/usage.ts
- AsyncLocalStorage-backed UsageScope: gateway sets it once per request,
  fetch helpers read from it lazily. Defensive import — if the runtime
  rejects node:async_hooks, scope helpers degrade to no-ops and the
  request event is unaffected.
- runWithUsageScope(scope, fn) / getUsageScope() exports.

server/gateway.ts
- Wraps matchedHandler in runWithUsageScope({ ctx, requestId, customerId,
  route, tier }) so deep fetchers can attribute upstream calls without
  threading state through every handler signature.

server/_shared/redis.ts
- cachedFetchJsonWithMeta accepts opts.usage = { provider, operation? }.
  Only the provider label is required to opt in — request_id / customer_id
  / route / tier flow implicitly from UsageScope.
- Emits on the fresh path only (cache hits don't emit; the inbound
  request event already records cache_status).
- cache_status correctly distinguishes 'miss' vs 'neg-sentinel' by
  construction, matching NEG_SENTINEL handling.
- Telemetry never throws — failures are swallowed in the lazy-import
  catch, sink itself short-circuits on USAGE_TELEMETRY=0.

server/_shared/fetch-json.ts
- New optional { provider, operation } in FetchJsonOptions. Same
  opt-in-by-provider model as cachedFetchJsonWithMeta. Auto-derives host
  from URL. Reads body via .text() so response_bytes is recorded
  (best-effort; chunked responses still report 0).

Net result: any handler that uses fetchJson or cachedFetchJsonWithMeta
gets full per-customer upstream attribution by adding two fields to the
options bag. No signature changes anywhere else.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(gateway): address round-1 codex feedback on usage telemetry

- ctx is now optional on the createDomainGateway handler signature so
  direct callers (tests, non-Vercel paths) no longer crash on emit
- legacy premium bearer-token routes (resilience, shipping-v2) propagate
  session.userId into the usage accumulator so successful requests are
  attributed instead of emitting as anon
- after checkEntitlement allows a tier-gated route, re-read entitlements
  (Redis-cached + in-flight coalesced) to populate usage.tier so
  analyze-stock & co. emit the correct tier rather than 0
- domain extraction now skips a leading vN segment, so /api/v2/shipping/*
  records domain="shipping" instead of "v2"

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(usage): assert telemetry payload + identity resolver + operator guide

- tests/usage-telemetry-emission.test.mts stubs globalThis.fetch to
  capture the Axiom ingest POST body and asserts the four review-flagged
  fields end-to-end through the gateway: domain on /api/v2/<svc>/* (was
  "v2"), customer_id on legacy premium bearer success (was null/anon),
  tier on entitlement-gated success via the Convex fallback path (was 0),
  plus a ctx-optional regression guard
- server/__tests__/usage-identity.test.ts unit-tests the pure
  buildUsageIdentity() resolver across every auth_kind branch, tier
  coercion, and the secret-handling invariant (raw enterprise key never
  lands in any output field)
- docs/architecture/usage-telemetry.md is the operator + dev guide:
  field reference, architecture, configuration, failure modes, local
  workflow, eight Axiom APL recipes, and runbooks for adding fields /
  new gateway return paths

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(usage): make recorder.settled robust to nested waitUntil

Promise.all(pending) snapshotted the array at call time, missing the
inner ctx.waitUntil(sendToAxiom(...)) that emitUsageEvents pushes after
the outer drain begins. Tests passed only because the fetch spy resolved
in an earlier microtask tick. Replace with a quiescence loop so the
helper survives any future async in the emit path.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore: trigger preview

* fix(usage): address koala #3403 review — collapse nested waitUntil, widget-key validation, neg-sentinel status, auth_* reasons

P1
- Collapse nested ctx.waitUntil at all 3 emit sites (gateway.ts emitRequest,
  fetch-json.ts, redis.ts emitUpstreamFromHook). Export sendToAxiom and call
  it directly inside the outer waitUntil so Edge runtimes don't drop the
  delivery promise after the response phase.
- Validate X-Widget-Key against WIDGET_AGENT_KEY before populating usage.widgetKey
  so unauthenticated callers can't spoof per-customer attribution.

P2
- Emit on OPTIONS preflight (new 'preflight' RequestReason).
- Gate cachedFetchJsonWithMeta upstreamStatus=200 on result != null so the
  neg-sentinel branch no longer reports as a successful upstream call.
- Extend RequestReason with auth_401/auth_403/tier_403 and replace
  reason:'ok' on every auth/tier-rejection emit path.
- Replace 32-bit FNV-1a with a two-round XOR-folded 64-bit variant in
  hashKeySync (collision space matters once widget-key adoption grows).

Verification
- tests/usage-telemetry-emission.test.mts — 6/6
- tests/premium-stock-gateway.test.mts + tests/gateway-cdn-origin-policy.test.mts — 15/15
- npx vitest run server/__tests__/usage-identity.test.ts — 13/13
- npx tsc --noEmit clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore: trigger preview rebuild for AXIOM_API_TOKEN

* chore(usage): note Axiom region in ingest URL comment

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* debug(usage): unconditional logs in sendToAxiom for preview troubleshooting

Temporary — to be reverted once Axiom delivery is confirmed working in preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(usage): add 'live' cache tier + revert preview debug logs

- Sync UsageCacheTier with the local CacheTier in gateway.ts (main added
  'live' in PR #3402 — synthetic merge with main was failing typecheck:api).
- Revert temporary unconditional debug logs in sendToAxiom now that Axiom
  delivery is verified end-to-end on preview (event landed with all fields
  populated, including the new auth_401 reason from the koala #3403 fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
koala73 added a commit that referenced this pull request Apr 25, 2026
…populates (#3410)

* fix(ais-relay): subscribe to ShipStaticData so tanker layer actually populates

User-reported on energy.worldmonitor.app: Live Tanker Positions layer
renders zero vessels despite PR #3402 wiring being correct end-to-end.

Root cause:
  AISStream's PositionReport.MetaData does NOT carry `ShipType` per their
  schema — that field only arrives in ShipStaticData (Type 5) frames.
  PR #3402 shipped tanker capture predicated on `meta.ShipType`:

      const shipType = Number(meta.ShipType);  // always NaN
      if (Number.isFinite(shipType) && shipType >= 80 && shipType <= 89) {
        tankerReports.set(mmsi, ...);  // never executes
      }

  The relay subscribed to AISStream with `FilterMessageTypes:
  ['PositionReport']` only — ShipStaticData never reached the relay, so
  the predicate always failed (NaN), `tankerReports` stayed permanently
  empty, and getVesselSnapshot returned `tankerReports: []` to every
  caller. The frontend layer correctly rendered zero vessels.

  Military detection (isLikelyMilitaryCandidate) survived because it has
  fallback paths (NAVAL_PREFIX_RE on ShipName + MMSI-suffix 00/99). Tanker
  detection had zero fallback because tanker MMSIs and names don't follow
  a single regex pattern.

Fix:
  - Add 'ShipStaticData' to AISStream's FilterMessageTypes — Type 5
    frames are broadcast every ~6 min per vessel (vs every 2-10s for
    PositionReport while underway), so the volume add is small.
  - Dispatch ShipStaticData → new processShipStaticDataForMeta handler
    that caches { shipType, shipName, lastSeen } by MMSI in a vesselMeta
    Map.
  - Tanker capture in processPositionReportForSnapshot now falls back to
    vesselMeta.get(mmsi).shipType when meta.ShipType is missing.
  - vesselMeta gets TTL eviction (24h) in cleanupAggregates so a long-
    running relay doesn't accumulate metadata for vessels that have left
    all tracked regions. 24h covers vessels with intermittent visibility
    while bounding memory growth.

Tests:
  - tests/relay-tanker-shipstatic.test.mjs (new, 5/5 pass) — pins the
    fix shape so a regression can't flip FilterMessageTypes back to
    PositionReport-only.
  - `node -c` clean.

Deploy:
  Requires a Railway redeploy of the AIS-relay service to pick up the
  new subscription filter. After redeploy, vesselMeta needs ~5-10 min
  to populate from ShipStaticData broadcasts before tanker classification
  reaches steady state for vessels in the 6 chokepoint bboxes.

* fix(ais-relay): tighten ShipStaticData parsing — UserID fallback + field-name pinning (PR3410 review)

Three review findings on PR #3410:

P2 — Static-analysis tests don't pin field names (correctness/testing):
  Pre-fix tests asserted `vesselMeta.set(... { shipType` which only checks
  the property name, not the source field. A typo regression like
  Number(sd.Typ) or Number(sd.shipType) (lowercase) would still pass —
  silently re-emptying the tanker layer.
  Added two new tests:
    - "reads ShipType from sd.Type (NOT meta.ShipType)" — pins the exact
      Number(sd.Type) substring; also pins sd.Name for shipName fallback.
    - "accepts MMSI from meta.MMSI OR sd.UserID" — pins the new fallback
      shape (see #2 below).

P2 — MMSI-extraction lacks UserID fallback (correctness):
  AISStream's ShipStaticData payload sample mirrors MMSI as `UserID` on
  the message body, while PositionReport puts it under MetaData.MMSI.
  If a wrapper-schema variant ever ships a Type 5 frame without
  MetaData.MMSI, processShipStaticDataForMeta early-returned and
  vesselMeta stayed empty — silent re-empty of the tanker layer.
  Defensive: `String(meta.MMSI || sd.UserID || '')`.

P3 — Test #4 order check used file-wide indexOf (testing):
  RELAY.indexOf('vesselMeta.get(mmsi)') finds the FIRST occurrence in
  the file. A future earlier vesselMeta.get(...) elsewhere would let
  the in-tanker-path lookup be removed without the test failing.
  Scoped the substring search to the body of
  processPositionReportForSnapshot (slice from fn declaration to the
  next top-level `function ` line).

Tests: 7/7 pass (was 5). `node -c` clean.

* fix(ais-relay): broaden shipType fix — chokepoint transit + military candidate paths also use vesselMeta

User-reported review finding on PR #3410: the previous commit fixed only
the tanker snapshot path. Two more sites in processPositionReportForSnapshot
read the same broken meta.ShipType field:

  Site 1 (line ~6651, vessels record):
    vessels.set(mmsi, { ..., shipType: meta.ShipType, ... });
  Consumer: classifyVesselType(vessel?.shipType) at the chokepoint
  transit logging site (line ~6545). With shipType permanently undefined,
  classifyVesselType always returned 'other' — silently breaking per-type
  transit counts in /seedChokepointTransits and any downstream consumer
  using transit-by-type breakdowns.

  Site 2 (line ~6692, military candidate record):
    candidateReports.set(mmsi, { ..., shipType: meta.ShipType, ... });
    isLikelyMilitaryCandidate(meta) — read meta.ShipType for the
    35/55/50-59 type-based arms.
  Consequence: the military classifier survived in production only via
  the NAVAL_PREFIX_RE + MMSI-suffix fallbacks. The type-based arm never
  fired. candidateReports also exposed shipType: undefined to callers.

Fix:
  - Compute `effectiveShipType` ONCE at the top of processPositionReportForSnapshot,
    preferring meta.ShipType when present (defense for any future schema
    enrichment) and falling back to vesselMeta cache from ShipStaticData.
  - Pass effectiveShipType to vessels.set, isLikelyMilitaryCandidate, and
    the tanker-capture predicate. All three sites now classify correctly.
  - Refactor isLikelyMilitaryCandidate(meta) → isLikelyMilitaryCandidate(meta, resolvedShipType)
    with backward-compat fallback to meta.ShipType when no override is passed.

Same root cause, same vesselMeta cache solves all three sites.

Tests: 10/10 (was 7) — added 3 new regression tests pinning:
  - vessels.set shipType field comes from `effectiveShipType`
  - isLikelyMilitaryCandidate signature accepts resolvedShipType param
  - candidateReports.set shipType field comes from `effectiveShipType`
node -c clean.

* fix(ais-relay): hard size cap + Type=0 guard on vesselMeta (PR3410 Greptile review)

Two findings on PR #3410:

P1 — vesselMeta has TTL but no hard size cap:
  Every peer Map in cleanupAggregates (tankerReports, candidateReports,
  densityGrid, vesselHistory) follows its TTL loop with evictMapByTimestamp.
  vesselMeta did not — leaving memory growth unbounded against a hostile
  or buggy upstream flooding unique MMSIs faster than TTL drains them.
  Added MAX_VESSEL_META=50000 (covers ~50-70k active global AIS fleet
  with headroom) + evictMapByTimestamp call after the TTL loop.

P2 — Number(null) === 0 could overwrite valid cache entries:
  processShipStaticDataForMeta's `if (!Number.isFinite(shipType)) return`
  guard accepts shipType=0 (AIS code 0 = "Not available" per ITU-R M.1371).
  A vessel that broadcasts {Type: 85} then later {Type: null} would have
  its cached tanker classification overwritten with shipType=0, downgrading
  it to non-tanker on the next PositionReport.
  Tightened to `|| shipType <= 0`.

Tests: 11/11 (was 10) — added 2 new pins:
  - vesselMeta has TTL AND hard size cap (asserts MAX_VESSEL_META +
    evictMapByTimestamp(vesselMeta, ...))
  - processShipStaticDataForMeta rejects shipType <= 0 (asserts the
    `<= 0` guard so a refactor can't silently drop it)
node -c clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant