feat(energy-atlas): live tanker map layer + contract (parity PR 3, plan U7-U8)#3402
Conversation
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.
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR lands the live AIS tanker map layer for Energy Atlas (plan U7–U8): a new
Confidence Score: 3/5Not safe to merge as-is — the bbox validation error surfaces as 500 to clients and must be fixed before deploying. One clear P1:
Important Files Changed
Sequence DiagramsequenceDiagram
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
Reviews (1): Last reviewed commit: "feat(energy-atlas): live tanker map laye..." | Re-trigger Greptile |
|
|
||
| // Bbox-size guard: reject requests where either dimension exceeds 10°. This | ||
| // prevents a malicious or buggy client from requesting a global box and |
There was a problem hiding this comment.
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';
}
}| * 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) => ({ |
There was a problem hiding this comment.
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();| 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. |
There was a problem hiding this comment.
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.
- 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]>
… 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]>
…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.
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):
U8 — LiveTankersLayer consumer:
Defense in depth (per Codex review)
Test plan
Sequence