From 08b28f059893cb9f940b1ae5024ec42b50a9aef2 Mon Sep 17 00:00:00 2001 From: rays Date: Mon, 26 Oct 2026 02:05:31 +0000 Subject: [PATCH] A tap's span ends with its own requests' answers (#379) traceparent keeps one trace for any request within a minute of the last, and the tap span ran to the last answer in that trace, so a live update's refresh 13 s later made a tap answered in 0.9 s read as 15 s (trace a895551d20626fb6dc071d336262b772). Now a request counts towards the tap only when one of the tap's own is in flight or it begins within two seconds of the tap or its last answer; it still joins the trace. Co-Authored-By: Claude Opus 5.5 --- tests/page-unit.js | 13 +++++++++++++ web/CHANGELOG.md | 1 + web/src/util.ts | 18 ++++++++++++------ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/page-unit.js b/tests/page-unit.js index 6a45113..a89622c 100644 --- a/tests/page-unit.js +++ b/tests/page-unit.js @@ -243,6 +243,19 @@ test('a tap is its trace\'s root span, reported with its ids once it has an answ assert.deepEqual([body.ev, body.ms, body.msg, body.span.length, body.trace.length], ['tap', 250, 'keydown', 16, 32]); }); +test('a request begun long after a tap\'s last answer does not stretch the tap (#379)', async () => { + run(`globalThis.sent = []; fetch = (url, o) => { sent.push([url, o]); return Promise.resolve({ ok: true, status: 204 }); }`); + run(`newTrace('pointerdown'); traceAt = Date.now()`); + await run(`api('/api/a')`); + const answered = run('answeredAt'); + assert.ok(answered >= run('rootAt'), 'its own request ends it'); + run('answeredAt -= 5000; rootAt -= 5000'); + await run(`api('/api/later')`); + assert.equal(run('answeredAt'), answered - 5000, 'a refresh five seconds on is not the tap\'s'); + assert.equal(run('tapBusy'), 0); + run(`rootKind = ''`); +}); + test('a tap ended over a minute after its last request reports once, without recursing (#378)', () => { run(`globalThis.sent = []; fetch = (url, o) => { sent.push([url, o]); return Promise.resolve({ ok: true, status: 204 }); }`); run(`newTrace('pointerdown'); answeredAt = rootAt + 100; traceAt = Date.now() - 120_000`); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index af94034..a9c5fb7 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- A tap's time in the traces ends with the last answer to what it set off, not with a refresh made seconds later (#379). - Switching away from the page a minute after using it no longer stops it with "Maximum call stack size exceeded" (#378). - Two items from different feeds that share an id, as a blog's RSS and Atom feeds' do, are told apart: in All Subscriptions the second can be opened and marked read, where the first was marked whichever was clicked (#361). diff --git a/web/src/util.ts b/web/src/util.ts index 7115b21..8158ebe 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -105,8 +105,11 @@ function spanText(secs: number){ /// trace, which then lasted nine hours (#219). A play's position saves, every ten seconds, keep it. /// A tap or a key is the trace's root span (#376): its requests name it as their parent, and once /// the next one begins, or the page is hidden, the page reports it, from the tap to its last -/// answer, for ipx to send on to Tempo with these ids. -let traceId='', traceAt=0, rootId='', rootAt=0, rootKind='', answeredAt=0; +/// answer, for ipx to send on to Tempo with these ids. Only requests begun while the tap is still +/// at work count towards it: one of its own in flight, or within two seconds of the tap or its last +/// answer, as a request an answer sets off is. A live update's refresh thirteen seconds later joined +/// the trace and made a tap answered in 0.9 s read as 15 (#379). +let traceId='', traceAt=0, rootId='', rootAt=0, rootKind='', answeredAt=0, tapBusy=0; const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join(''); function endTap(){ const kind=rootKind; @@ -118,7 +121,7 @@ function endTap(){ } function newTrace(kind: string){ endTap(); - traceId=hex(16); rootId=hex(8); rootAt=Date.now(); rootKind=kind; answeredAt=0; + traceId=hex(16); rootId=hex(8); rootAt=Date.now(); rootKind=kind; answeredAt=0; tapBusy=0; } for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ newTrace(t); traceAt=Date.now(); },{capture:true,passive:true}); window.addEventListener('pagehide',endTap); @@ -149,10 +152,13 @@ window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e async function api(url: string, opts?: RequestInit): Promise{ // The browser's "Failed to fetch" said nothing anyone could act on (#304). const tp=traceparent(), mine=traceId; + const taps=!!rootKind&&(tapBusy>0||Date.now()-Math.max(rootAt,answeredAt)<2000); + if(taps) tapBusy++; const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:tp},...opts}) - .catch(()=>{ throw new Error('Unable to reach iPX. Check your connection and try again.'); }); - // The tap lasts until its last answer. - if(mine===traceId) answeredAt=Date.now(); + .catch(()=>{ throw new Error('Unable to reach iPX. Check your connection and try again.'); }) + .finally(()=>{ if(taps&&mine===traceId) tapBusy--; }); + // The tap lasts until the last answer to what it set off. + if(taps&&mine===traceId) answeredAt=Date.now(); if(r.status===401){ location.href='/login'; throw new Error('signed out'); } if(!r.ok){ // A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a