Skip to content

Commit befb49d

Browse files
authored
fix(tracing): buffer bridge events to eliminate 50-60s ghost traces (#116) (#116)
Root cause: Runtime.addBinding is async — on page navigation, the bridge script (via addScriptToEvaluateOnNewDocument) fires BEFORE Chrome registers __rrwebPush in the new execution context. For fast-solving managed Turnstile widgets (Ahrefs), the token is available within ms of page load. emit() silently dropped the event, tokenReported=true prevented retries, and awaitResolutionRace waited the full 60s timeout for a signal that was permanently lost. Fix: Buffer events in emit() and flush via 50ms poll when the binding appears. Also includes trace improvements from production analysis: - Add click timing attributes to cf.resolutionRace span - Fix empty cf.domain for malformed URLs - Update tab.url span attribute on navigation - Add screencast.fps/duration_s attributes - Add solved_set_size warning log (>50) - Add BridgeEvent timing type to union - Add vitest JSON reporter for test output capture
1 parent 3d32d03 commit befb49d

8 files changed

Lines changed: 72 additions & 9 deletions

File tree

src/browser/cf-bridge.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,41 @@ import { setupTurnstileHooks } from './turnstile-hooks';
1414
import { setupErrorMonitor } from './turnstile-error';
1515
import type { BridgeEvent } from './types';
1616

17+
// ── Buffered emit ─────────────────────────────────────────────────────
18+
// Runtime.addBinding is async — on navigation, the bridge script fires BEFORE
19+
// Chrome registers __rrwebPush in the new execution context. Without buffering,
20+
// solved events are silently dropped (tokenReported=true prevents retry),
21+
// causing 50-60s ghost traces while awaitResolutionRace waits for a signal
22+
// that was lost. Buffer events and flush when the binding appears.
23+
const pendingEvents: string[] = [];
24+
let flushTimer: ReturnType<typeof setInterval> | null = null;
25+
26+
function flushPending(): void {
27+
while (pendingEvents.length > 0) {
28+
try { window.__rrwebPush!(pendingEvents.shift()!); } catch (_) {}
29+
}
30+
if (flushTimer) {
31+
clearInterval(flushTimer);
32+
flushTimer = null;
33+
}
34+
}
35+
1736
function emit(event: BridgeEvent): void {
18-
// Multiplex through rrweb binding — avoids adding a new detectable binding.
19-
// Server distinguishes bridge events (object with type) from rrweb batches (array).
37+
const payload = JSON.stringify(event);
2038
if (typeof window.__rrwebPush === 'function') {
21-
try {
22-
window.__rrwebPush(JSON.stringify(event));
23-
} catch (_) {}
39+
if (pendingEvents.length > 0) flushPending();
40+
try { window.__rrwebPush(payload); } catch (_) {}
41+
} else {
42+
pendingEvents.push(payload);
43+
if (!flushTimer) {
44+
flushTimer = setInterval(() => {
45+
if (typeof window.__rrwebPush === 'function') flushPending();
46+
}, 50);
47+
// Stop after 10s — binding should appear within milliseconds
48+
setTimeout(() => {
49+
if (flushTimer) { clearInterval(flushTimer); flushTimer = null; }
50+
}, 10000);
51+
}
2452
}
2553
}
2654

src/browser/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ export type BridgeEvent =
88
| { type: 'detected'; method: string; cType?: string; cRay?: string }
99
| { type: 'solved'; token: string; tokenLength: number }
1010
| { type: 'error'; errorType: string; hasToken: boolean }
11-
| { type: 'still_detected'; detected: boolean };
11+
| { type: 'still_detected'; detected: boolean }
12+
| { type: 'timing'; event: string; ts: number };
1213

1314
/** Emit function — pushes a BridgeEvent to the server. */
1415
export type Emit = (event: BridgeEvent) => void;

src/session/cdp-session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,12 @@ export class CdpSession {
15351535
'cdp.url': targetInfo.url?.substring(0, 200) ?? '',
15361536
});
15371537
if (targetInfo.type === 'page') {
1538+
// Update tab span URL attribute — shows final URL, not initial about:blank
1539+
const tab = session.tabs.get(changedTargetId);
1540+
if (tab && targetInfo.url) {
1541+
tab.span.attribute('tab.url', targetInfo.url.substring(0, 200));
1542+
}
1543+
15381544
const target = session.targets.getByTarget(changedTargetId);
15391545
if (target) {
15401546
// Re-enable CDP domains that Chrome resets on same-target navigation

src/session/cf/cf-detection-registry.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ export class DetectionRegistry {
5151
// Extract domain from page URL for Tempo filtering
5252
let domain = 'unknown';
5353
try {
54-
if (active.info.url) domain = new URL(active.info.url).hostname;
54+
if (active.info.url) {
55+
const hostname = new URL(active.info.url).hostname;
56+
domain = hostname || 'unknown';
57+
}
5558
} catch { /* malformed URL */ }
5659

5760
// Assign detectionId — groups all solve spans for this challenge

src/session/cf/cloudflare-detector.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,15 @@ export class CloudflareDetector {
647647

648648
const maybeResolved = yield* active.resolution.awaitBounded;
649649

650+
// Tasks 2+4: Click timing attributes — distinguish pre-click vs post-click timeout
651+
yield* Effect.annotateCurrentSpan({
652+
'cf.click_delivered': !!active.clickDelivered,
653+
...(active.clickDeliveredAt ? {
654+
'cf.click_delivered_at_ms': active.clickDeliveredAt,
655+
'cf.click_to_resolve_ms': Date.now() - active.clickDeliveredAt,
656+
} : {}),
657+
});
658+
650659
if (maybeResolved._tag === 'Some') {
651660
const resolved = maybeResolved.value;
652661
if (resolved._tag === 'solved') {

src/session/cf/cloudflare-solve-strategies.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,12 +788,19 @@ export class CloudflareSolveStrategies {
788788
(t: { targetId?: string }) => t.targetId && solvedCFTargetIds?.has(t.targetId),
789789
);
790790

791+
const solvedSetSize = solvedCFTargetIds?.size ?? 0;
792+
if (solvedSetSize > 50) {
793+
yield* Effect.logWarning('cf.detect.solved_set_size exceeds 50').pipe(
794+
Effect.annotateLogs({ solved_set_size: solvedSetSize }),
795+
);
796+
}
797+
791798
yield* Effect.annotateCurrentSpan({
792799
'cf.detect.total_targets': targetInfos.length,
793800
'cf.detect.cf_targets': cfTargets.length,
794801
'cf.detect.filtered_stale': filteredOut.length,
795802
'cf.detect.fresh': matched.length,
796-
'cf.detect.solved_set_size': solvedCFTargetIds?.size ?? 0,
803+
'cf.detect.solved_set_size': solvedSetSize,
797804
'cf.detect.matched_urls': matched.map((t: { url?: string; targetId?: string }) => `${t.targetId?.slice(0, 8)}=${t.url}`).join(' | '),
798805
'cf.detect.filtered_ids': filteredOut.map((t: { targetId?: string }) => t.targetId?.slice(0, 8)).join(','),
799806
});

src/session/screencast-capture.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const targetConsumer = (
7575
): Effect.Effect<number> =>
7676
Effect.fn('screencast.target')(function*() {
7777
yield* Effect.annotateCurrentSpan({ 'screencast.target_id': cdpSessionId });
78+
const startTime = Date.now();
7879
let frameCount = 0;
7980

8081
// Ref holds the current fallback fiber so we can interrupt+restart it
@@ -137,7 +138,13 @@ const targetConsumer = (
137138
const fb = yield* Ref.get(fallbackRef);
138139
if (fb) yield* Fiber.interrupt(fb).pipe(Effect.ignore);
139140

140-
yield* Effect.annotateCurrentSpan({ 'screencast.frame_count': frameCount });
141+
const durationSec = (Date.now() - startTime) / 1000;
142+
const fps = durationSec > 0 ? frameCount / durationSec : 0;
143+
yield* Effect.annotateCurrentSpan({
144+
'screencast.frame_count': frameCount,
145+
'screencast.fps': Math.round(fps * 100) / 100,
146+
'screencast.duration_s': Math.round(durationSec * 100) / 100,
147+
});
141148
return frameCount;
142149
})();
143150

vitest.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const resolveConfig = {
1515
export default defineConfig({
1616
test: {
1717
bail: 1,
18+
reporters: ['default', 'json'],
19+
outputFile: { json: '/tmp/vitest-results.json' },
1820
projects: [
1921
{
2022
resolve: resolveConfig,

0 commit comments

Comments
 (0)