GET /xrpc/app.bsky.actor.searchActorsTypeahead typeahead.waow.tech
16
fork

Configure Feed

Select the types of activity you want to include in your feed.

add /stats dashboard, fix handle filter in search SQL

- server-rendered stats page: actor counts, sparkline (7d searches/hour),
avg latency, handle/avatar coverage with CSS tooltips
- metrics table + fire-and-forget hourly recording via ctx.waitUntil
- move handle != '' filter into SQL WHERE (before LIMIT) so results
aren't short-changed by empty-handle rows consuming limit slots
- smoke test for /stats endpoint
- stats link in homepage footer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+239 -2
+6
schema.sql
··· 31 31 INSERT INTO actors_fts(rowid, handle, display_name) 32 32 VALUES (new.rowid, new.handle, new.display_name); 33 33 END; 34 + 35 + CREATE TABLE IF NOT EXISTS metrics ( 36 + hour INTEGER PRIMARY KEY, 37 + searches INTEGER NOT NULL DEFAULT 0, 38 + total_ms REAL NOT NULL DEFAULT 0 39 + );
+22
scripts/smoke.py
··· 140 140 check("limit>100 returns 400", data is not None and data.get("_http_error") == 400, f"got {data}") 141 141 142 142 143 + def test_stats_page(base_url: str): 144 + print("\n--- stats page ---") 145 + try: 146 + req = urllib.request.Request( 147 + f"{base_url}/stats", 148 + headers={"User-Agent": "typeahead-smoke/1.0"}, 149 + ) 150 + with urllib.request.urlopen(req, timeout=15) as resp: 151 + ct = resp.headers.get("Content-Type", "") 152 + body = resp.read().decode() 153 + check("stats returns 200", resp.status == 200) 154 + check("stats content-type is html", "text/html" in ct, f"got '{ct}'") 155 + check("stats contains actors indexed", "actors indexed" in body) 156 + check("stats contains sparkline heading", "searches / hour" in body) 157 + check("stats has home link", 'href="/"' in body) 158 + except urllib.error.HTTPError as e: 159 + check("stats returns 200", False, f"got {e.code}") 160 + except Exception as e: 161 + check("stats fetch succeeded", False, str(e)) 162 + 163 + 143 164 def test_comparison(base_url: str, queries: list[str]): 144 165 print("\n--- comparison vs public.api.bsky.app ---") 145 166 ··· 187 208 test_limit_bounds(args.url) 188 209 test_empty_query(args.url) 189 210 test_limit_over_max(args.url) 211 + test_stats_page(args.url) 190 212 191 213 if args.compare: 192 214 test_comparison(args.url, args.queries)
+211 -2
src/index.ts
··· 115 115 116 116 // --- end backfill --- 117 117 118 + /** fire-and-forget: increment hourly search count + accumulate response time */ 119 + async function recordMetric(env: Env, ms: number): Promise<void> { 120 + const hour = Math.floor(Date.now() / 3_600_000); 121 + await env.DB.prepare( 122 + `INSERT INTO metrics (hour, searches, total_ms) 123 + VALUES (?1, 1, ?2) 124 + ON CONFLICT(hour) DO UPDATE SET 125 + searches = searches + 1, 126 + total_ms = total_ms + ?2` 127 + ) 128 + .bind(hour, ms) 129 + .run(); 130 + } 131 + 118 132 async function handleSearch( 119 133 request: Request, 120 134 env: Env, ··· 149 163 return cached; 150 164 } 151 165 166 + const t0 = Date.now(); 167 + 152 168 const ftsQuery = `"${term}"*`; 153 169 const { results } = await env.DB.prepare( 154 170 `SELECT a.did, a.handle, a.display_name, a.avatar_url 155 171 FROM actors_fts 156 172 JOIN actors a ON a.rowid = actors_fts.rowid 157 - WHERE actors_fts MATCH ?1 173 + WHERE actors_fts MATCH ?1 AND a.handle != '' 158 174 ORDER BY rank 159 175 LIMIT ?2` 160 176 ) ··· 162 178 .all<ActorRow>(); 163 179 164 180 const actors = (results || []) 165 - .filter((r) => r.handle) 166 181 .map((r) => ({ 167 182 did: r.did, 168 183 handle: r.handle, ··· 176 191 ctx.waitUntil(throttledBackfill(term, limit, env)); 177 192 } 178 193 // --- end backfill --- 194 + 195 + ctx.waitUntil(recordMetric(env, Date.now() - t0)); 179 196 180 197 const response = json({ actors }); 181 198 ··· 362 379 ); 363 380 } 364 381 382 + async function handleStats(env: Env): Promise<Response> { 383 + const [totalRes, handlesRes, avatarsRes, updatedRes, metricsRes] = 384 + await env.DB.batch([ 385 + env.DB.prepare("SELECT COUNT(*) AS cnt FROM actors"), 386 + env.DB.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE handle != ''"), 387 + env.DB.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE avatar_url != ''"), 388 + env.DB.prepare("SELECT MAX(updated_at) AS ts FROM actors"), 389 + env.DB.prepare( 390 + "SELECT hour, searches, total_ms FROM metrics ORDER BY hour DESC LIMIT 168" 391 + ), 392 + ]); 393 + 394 + const total = (totalRes.results[0] as any)?.cnt ?? 0; 395 + const withHandles = (handlesRes.results[0] as any)?.cnt ?? 0; 396 + const withAvatars = (avatarsRes.results[0] as any)?.cnt ?? 0; 397 + const lastUpdated = (updatedRes.results[0] as any)?.ts ?? null; 398 + const rows = (metricsRes.results ?? []) as { 399 + hour: number; 400 + searches: number; 401 + total_ms: number; 402 + }[]; 403 + 404 + const totalSearches = rows.reduce((s, r) => s + r.searches, 0); 405 + const totalMs = rows.reduce((s, r) => s + r.total_ms, 0); 406 + const avgLatency = totalSearches > 0 ? totalMs / totalSearches : 0; 407 + const handlePct = total > 0 ? ((withHandles / total) * 100).toFixed(1) : "0"; 408 + const avatarPct = total > 0 ? ((withAvatars / total) * 100).toFixed(1) : "0"; 409 + 410 + const lastUpdatedStr = lastUpdated 411 + ? new Date(lastUpdated * 1000).toISOString().replace("T", " ").slice(0, 19) + " UTC" 412 + : "never"; 413 + 414 + return html(statsPage({ total, withHandles, withAvatars, lastUpdatedStr, rows, totalSearches, avgLatency, handlePct, avatarPct })); 415 + } 416 + 417 + interface StatsData { 418 + total: number; 419 + withHandles: number; 420 + withAvatars: number; 421 + lastUpdatedStr: string; 422 + rows: { hour: number; searches: number; total_ms: number }[]; 423 + totalSearches: number; 424 + avgLatency: number; 425 + handlePct: string; 426 + avatarPct: string; 427 + } 428 + 429 + function statsPage(d: StatsData): string { 430 + // build sparkline data (oldest first) 431 + const sorted = [...d.rows].reverse(); 432 + const counts = sorted.map((r) => r.searches); 433 + const max = Math.max(...counts, 1); 434 + const w = 600; 435 + const h = 80; 436 + const step = counts.length > 1 ? w / (counts.length - 1) : 0; 437 + const points = counts 438 + .map((c, i) => `${(i * step).toFixed(1)},${(h - (c / max) * h).toFixed(1)}`) 439 + .join(" "); 440 + const jsonData = JSON.stringify( 441 + sorted.map((r) => ({ 442 + hour: new Date(r.hour * 3_600_000).toISOString().slice(0, 13) + ":00Z", 443 + searches: r.searches, 444 + })) 445 + ); 446 + 447 + return `<!doctype html> 448 + <html> 449 + <head> 450 + <meta charset="utf-8"> 451 + <meta name="viewport" content="width=device-width, initial-scale=1"> 452 + <title>typeahead — stats</title> 453 + <style> 454 + * { margin: 0; padding: 0; box-sizing: border-box; } 455 + body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; 456 + display: flex; justify-content: center; align-items: center; min-height: 100vh; } 457 + .container { max-width: 620px; width: 100%; padding: 2rem; } 458 + .header { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.4rem; } 459 + h1 { font-size: 1.1rem; font-weight: 400; color: #888; } 460 + h1 strong { color: #e0e0e0; } 461 + .subtitle { font-size: 0.8rem; color: #555; margin-bottom: 1.5rem; } 462 + .metrics { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.8rem; margin-bottom: 1.5rem; } 463 + .metric { background: #111; border: 1px solid #222; border-radius: 6px; padding: 0.7rem 0.9rem; } 464 + .metric .label { font-size: 0.7rem; color: #555; margin-bottom: 0.2rem; } 465 + .metric .label[data-tip] { cursor: default; position: relative; border-bottom: 1px dotted #444; display: inline-block; } 466 + .metric .label[data-tip]:hover::after { 467 + content: attr(data-tip); position: absolute; left: 0; top: 1.6em; width: 200px; 468 + padding: 0.4rem 0.5rem; background: #1a1a1a; border: 1px solid #333; border-radius: 4px; 469 + font-size: 0.95em; color: #999; line-height: 1.4; z-index: 20; white-space: normal; 470 + } 471 + .metric .value { font-size: 1.1rem; color: #ccc; } 472 + .sparkline-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 473 + padding: 0.9rem; margin-bottom: 1.5rem; } 474 + .sparkline-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 475 + svg { width: 100%; height: auto; } 476 + polyline { fill: none; stroke: #4a9; stroke-width: 1.5; } 477 + .summary { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.8rem; margin-bottom: 1.5rem; } 478 + .tooltip { display: none; position: fixed; background: #1a1a1a; border: 1px solid #333; 479 + border-radius: 4px; padding: 0.4rem 0.6rem; font-size: 0.75rem; color: #ccc; 480 + pointer-events: none; z-index: 50; } 481 + footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 482 + color: #444; display: flex; justify-content: center; gap: 0.4rem; } 483 + footer a { color: #555; text-decoration: none; } 484 + footer a:hover { color: #888; } 485 + </style> 486 + </head> 487 + <body> 488 + <div class="container"> 489 + <div class="header"> 490 + <h1><strong>typeahead</strong> stats</h1> 491 + </div> 492 + <p class="subtitle">index health and search activity</p> 493 + 494 + <div class="metrics"> 495 + <div class="metric"> 496 + <div class="label">actors indexed</div> 497 + <div class="value">${d.total.toLocaleString()}</div> 498 + </div> 499 + <div class="metric"> 500 + <div class="label">with handles</div> 501 + <div class="value">${d.withHandles.toLocaleString()}</div> 502 + </div> 503 + <div class="metric"> 504 + <div class="label">with avatars</div> 505 + <div class="value">${d.withAvatars.toLocaleString()}</div> 506 + </div> 507 + <div class="metric"> 508 + <div class="label">last indexed</div> 509 + <div class="value" style="font-size:0.8rem">${escHtml(d.lastUpdatedStr)}</div> 510 + </div> 511 + </div> 512 + 513 + <div class="sparkline-wrap"> 514 + <h2>searches / hour (7 days)</h2> 515 + ${counts.length > 1 516 + ? `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" id="spark"> 517 + <polyline points="${points}" /> 518 + </svg>` 519 + : `<div style="color:#444;font-size:0.8rem;padding:1rem 0;text-align:center">no data yet</div>`} 520 + </div> 521 + 522 + <div class="summary"> 523 + <div class="metric"> 524 + <div class="label">total searches (7d)</div> 525 + <div class="value">${d.totalSearches.toLocaleString()}</div> 526 + </div> 527 + <div class="metric"> 528 + <div class="label" data-tip="average response time for uncached searches">avg latency</div> 529 + <div class="value">${d.avgLatency.toFixed(1)} ms</div> 530 + </div> 531 + <div class="metric"> 532 + <div class="label" data-tip="% of indexed actors with a resolved handle (vs bare DID only)">handle coverage</div> 533 + <div class="value">${d.handlePct}%</div> 534 + </div> 535 + <div class="metric"> 536 + <div class="label" data-tip="% of indexed actors with a profile image">avatar coverage</div> 537 + <div class="value">${d.avatarPct}%</div> 538 + </div> 539 + </div> 540 + 541 + <footer> 542 + <a href="/">&larr; home</a> 543 + </footer> 544 + </div> 545 + <div class="tooltip" id="tip"></div> 546 + <script> 547 + const data = ${jsonData}; 548 + const spark = document.getElementById('spark'); 549 + const tip = document.getElementById('tip'); 550 + if (spark) { 551 + spark.addEventListener('mousemove', e => { 552 + const rect = spark.getBoundingClientRect(); 553 + const x = e.clientX - rect.left; 554 + const idx = Math.min(Math.round(x / rect.width * (data.length - 1)), data.length - 1); 555 + if (idx >= 0 && data[idx]) { 556 + tip.textContent = data[idx].hour + ' — ' + data[idx].searches + ' searches'; 557 + tip.style.display = 'block'; 558 + tip.style.left = (e.clientX + 12) + 'px'; 559 + tip.style.top = (e.clientY - 28) + 'px'; 560 + } 561 + }); 562 + spark.addEventListener('mouseleave', () => { tip.style.display = 'none'; }); 563 + } 564 + </script> 565 + </body> 566 + </html>`; 567 + } 568 + 365 569 function indexPage(message?: string): string { 366 570 return `<!doctype html> 367 571 <html> ··· 461 665 462 666 <footer> 463 667 by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener">@zzstoatzz.io</a> 668 + · <a href="/stats">stats</a> 464 669 </footer> 465 670 </div> 466 671 <script> ··· 515 720 516 721 if (pathname === "/" && request.method === "GET") { 517 722 return html(indexPage()); 723 + } 724 + 725 + if (pathname === "/stats" && request.method === "GET") { 726 + return handleStats(env); 518 727 } 519 728 520 729 if (pathname === "/request-indexing" && request.method === "GET") {