fix(tech-diag): warm-up fetch + no-redirect host so ping matches reality

Techs reported cloudflare.com showing 300+ms on the diagnostic page
while OS-level ICMP ping returned 5ms. The gap is entirely protocol
overhead:

- fetch() ≠ ICMP. Every call pays DNS + TCP + TLS + HTTP on top of
  the real RTT, which is easily 150–300ms cold on mobile LTE when the
  radio has to wake the RRC connection.
- Bare cloudflare.com redirects 301 → www.cloudflare.com, forcing a
  second DNS + TCP + TLS handshake for every "ping" and doubling
  the measured latency.
- TechDiagnosticPage.vue was also labeling the full 10MB download
  time as "Latence", so the number on the speed-test card was never
  a latency measurement at all.

Fixes, applied to both surfaces (Ops /j/diagnostic + Field /diagnostic):

- Swap cloudflare.com → 1.1.1.1/cdn-cgi/trace. 88-byte response, no
  redirect, no keepalive games — canonical "internet is up" endpoint.
- Warm-up fetch before every measurement. First call absorbs DNS +
  TCP + TLS + LTE wake; second call reports steady-state RTT. This
  applies to checkHosts() (ops) and resolveHost() (field composable).
- Split runSpeed() into separate ping + throughput measurements. Ping
  hits speed.cloudflare.com/cdn-cgi/trace (88 bytes on a warm
  connection); throughput hits /__down on the same origin so the TLS
  session is reused.

Deployed to production; smoke-verified:
- ops bundle TechDiagnosticPage.b925e02c.js contains
  '1.1.1.1/cdn-cgi/trace'
- field bundle DiagnosticPage.38a45f65.js contains the same
- zero bare 'cloudflare.com' hostname in either hosts array

Files:
- apps/ops/src/modules/tech/pages/TechDiagnosticPage.vue
- apps/field/src/composables/useSpeedTest.js
- apps/field/src/pages/DiagnosticPage.vue

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-22 16:08:24 -04:00
parent 30a867a326
commit 07365d3b71
3 changed files with 50 additions and 9 deletions

View File

@ -55,11 +55,21 @@ export function useSpeedTest () {
}
// HTTP resolve — check if a host is reachable via HTTP
//
// Why two fetches: browser fetch() includes DNS + TCP + TLS + HTTP on top
// of real RTT. First call pays the cold-connection tax (easily 200400ms
// on mobile LTE when the radio is idle); second call on the now-warm
// connection reports something close to actual RTT. Before this change
// techs saw "cloudflare.com a répondu en 350ms" on what was actually a
// 5ms link and opened false-positive tickets.
async function resolveHost (host) {
resolveResult.value = null
const url = host.startsWith('http') ? host : 'https://' + host
try {
// Warm-up — result discarded. Pays DNS + TCP + TLS + LTE wake-up.
await fetch(url, { mode: 'no-cors', cache: 'no-store' })
// Steady-state measurement on the warm connection.
const t0 = performance.now()
const res = await fetch(url, { mode: 'no-cors', cache: 'no-store' })
const t1 = performance.now()

View File

@ -91,7 +91,10 @@ const resolveResults = ref([])
const batchRunning = ref(false)
const batchResults = ref([])
const quickHosts = ['google.ca', 'erp.gigafibre.ca', 'cloudflare.com', '8.8.8.8']
// 1.1.1.1/cdn-cgi/trace instead of bare cloudflare.com the bare host
// redirects 301 www.cloudflare.com, doubling the handshake cost. The
// trace endpoint is 88 bytes, no redirect, canonical "internet is up" probe.
const quickHosts = ['google.ca', 'erp.gigafibre.ca', '1.1.1.1/cdn-cgi/trace', '8.8.8.8']
const batchHosts = [
'erp.gigafibre.ca',
'dispatch.gigafibre.ca',

View File

@ -40,32 +40,60 @@ const speed = ref(null)
const latency = ref(null)
const checking = ref(false)
// Hosts to check
// We deliberately use 1.1.1.1 (not cloudflare.com) because the bare
// cloudflare.com redirects 301 www.cloudflare.com, forcing a second DNS +
// TCP + TLS handshake on every "ping" and making the browser report
// 300500ms on a 5ms link. 1.1.1.1/cdn-cgi/trace is a single no-redirect
// 88-byte response the canonical "is the internet up" endpoint.
const hosts = ref([
{ host: 'google.ca', status: null, ms: null },
{ host: 'erp.gigafibre.ca', status: null, ms: null },
{ host: 'cloudflare.com', status: null, ms: null },
{ host: '1.1.1.1/cdn-cgi/trace', status: null, ms: null },
])
// Speed test
// Browser fetch() ICMP ping. A cold HTTPS request pays DNS + TCP + TLS +
// HTTP on top of the real RTT, which on mobile LTE is ~150300ms before
// the first byte moves. We split this into two separate measurements:
// 1. Ping: warm-up + measured call against /cdn-cgi/trace (88 bytes),
// so the second call runs on a warm connection and reports close to
// real RTT.
// 2. Throughput: 10MB download on the SAME warm connection (same origin
// connection reuse, no second handshake tax).
async function runSpeed () {
testing.value = true; speed.value = null; latency.value = null
try {
const url = 'https://speed.cloudflare.com/__down?bytes=10000000'
const pingUrl = 'https://speed.cloudflare.com/cdn-cgi/trace'
// Warm-up pays DNS + TCP + TLS so the next call is steady-state.
await fetch(pingUrl, { mode: 'no-cors', cache: 'no-store' })
// Measured ping on a warm connection.
const p0 = performance.now()
await fetch(pingUrl, { mode: 'no-cors', cache: 'no-store' })
latency.value = Math.round(performance.now() - p0)
// Throughput 10MB download, same origin so TLS session is reused.
const dlUrl = 'https://speed.cloudflare.com/__down?bytes=10000000'
const t0 = performance.now()
const res = await fetch(url, { mode: 'no-cors' })
const t1 = performance.now()
const ms = t1 - t0
latency.value = Math.round(ms)
// Approximate: 10MB / time = speed
speed.value = Math.round((10 * 8) / (ms / 1000))
await fetch(dlUrl, { mode: 'no-cors' })
const dlMs = performance.now() - t0
speed.value = Math.round((10 * 8) / (dlMs / 1000))
} catch {
speed.value = 0
} finally { testing.value = false }
}
// Warm-up + measure pattern so "cloudflare.com a répondu en 300ms" stops
// showing up for what is actually a 5ms link. First call absorbs the
// cold-connection penalty (DNS + TCP + TLS + LTE RRC wake); second call
// reports real RTT + HTTP round trip.
async function checkHosts () {
checking.value = true
for (const h of hosts.value) {
try {
// Warm-up result discarded.
await fetch('https://' + h.host, { mode: 'no-cors', signal: AbortSignal.timeout(5000) })
// Measurement.
const t0 = performance.now()
await fetch('https://' + h.host, { mode: 'no-cors', signal: AbortSignal.timeout(5000) })
h.ms = Math.round(performance.now() - t0)