nikeshn commited on
Commit
f99f29c
Β·
verified Β·
1 Parent(s): 0acad7c

Create usage.py

Browse files
Files changed (1) hide show
  1. usage.py +275 -0
usage.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ usage.py β€” ResearchBee usage counting
3
+
4
+ Counts only. There is no query-text path through this module and no
5
+ column to put one in: the manuscript tab receives unpublished abstracts,
6
+ and the cheapest guarantee that those are never persisted is to have
7
+ nowhere to persist them.
8
+
9
+ Design notes
10
+ ------------
11
+ 1. Counting happens in memory and costs a dict update. Nothing touches
12
+ the network on the request path.
13
+
14
+ 2. Deltas are flushed to the Cloudflare Worker every FLUSH_SECONDS.
15
+ A failed flush is NOT lost: the counts are folded back into the
16
+ pending dict and go out with the next attempt. Because we send
17
+ accumulated deltas rather than one row per request, an outage costs
18
+ a delay in the dashboard, not data. This is the main reason the
19
+ aggregate design is worth having β€” LibBee's per-row logging had to
20
+ grow a 200-item retry buffer to get the same property, and still
21
+ drops rows past that bound.
22
+
23
+ 3. HF Spaces egress to workers.dev is known to fail intermittently
24
+ (LibBee v3.9.2 moved its logging into the browser for exactly this
25
+ reason). Retry-with-accumulation handles that without needing a
26
+ client-side path, so nothing has to change in the LibGuides fragment.
27
+
28
+ 4. Counters do NOT survive a container restart if a flush has not
29
+ happened yet β€” at most FLUSH_SECONDS of activity is lost on a hard
30
+ restart. The 6-hour canary keeps the Space warm, so restarts should
31
+ be rare.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import logging
38
+ import os
39
+ from collections import deque
40
+ from datetime import datetime
41
+ from typing import Dict, List, Tuple
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ WORKER_URL = (os.environ.get("CLOUDFLARE_WORKER_URL") or "").strip().strip('"').strip("'").rstrip("/")
46
+ WORKER_TOKEN = (os.environ.get("CLOUDFLARE_WORKER_TOKEN") or "").strip()
47
+
48
+ FLUSH_SECONDS = 60
49
+ MAX_ATTEMPTS_LOG = 15
50
+
51
+ # Endpoint β†’ surface. Adding an endpoint here is the only step needed to
52
+ # start counting it; the Worker validates the name and rejects unknowns,
53
+ # so a typo surfaces as a visible 400 rather than a phantom column.
54
+ SURFACE_BY_PATH: Dict[str, str] = {
55
+ "/api/browse-subject": "journal_subject",
56
+ "/api/analyze-journal": "journal_manuscript",
57
+ "/api/check-license": "licence_deposit",
58
+ "/api/find-repository": "data_repository",
59
+ "/api/cover-letter": "cover_letter",
60
+ "/api/chat": "chat",
61
+ }
62
+
63
+ # Key: (surface, outcome, gap_code)
64
+ _Key = Tuple[str, str, str]
65
+
66
+
67
+ class UsageCounters:
68
+ def __init__(self) -> None:
69
+ self._pending: Dict[_Key, int] = {}
70
+ self._lifetime: Dict[_Key, int] = {}
71
+ self._lock = asyncio.Lock()
72
+ self._attempts: deque = deque(maxlen=MAX_ATTEMPTS_LOG)
73
+ self._task: asyncio.Task | None = None
74
+ self._started_at = datetime.utcnow()
75
+
76
+ # ── Recording (sync, no I/O) ──────────────────────────────────────
77
+
78
+ def record(self, surface: str, outcome: str = "ok", gap_code: str = "") -> None:
79
+ """Count one event. Never raises β€” instrumentation must not be
80
+ able to fail a user request."""
81
+ try:
82
+ if surface not in SURFACE_BY_PATH.values():
83
+ return
84
+ if outcome not in ("ok", "miss", "error"):
85
+ outcome = "error"
86
+ key = (surface, outcome, (gap_code or "")[:40])
87
+ self._pending[key] = self._pending.get(key, 0) + 1
88
+ self._lifetime[key] = self._lifetime.get(key, 0) + 1
89
+ except Exception:
90
+ pass
91
+
92
+ def record_gap(self, surface: str, gap_code: str) -> None:
93
+ """A request that completed but could not be answered from the
94
+ available sources β€” a SCImago subject with no matches, an
95
+ OA.Works 404, a DOI that resolves to nothing. Recorded as its own
96
+ outcome so 'served a page' and 'answered the question' stay
97
+ distinguishable in the dashboard."""
98
+ self.record(surface, outcome="miss", gap_code=gap_code)
99
+
100
+ # ── Local snapshot (works with no Worker configured) ──────────────
101
+
102
+ def snapshot(self) -> dict:
103
+ by_surface: Dict[str, Dict[str, int]] = {}
104
+ gaps: Dict[str, int] = {}
105
+ for (surface, outcome, gap), n in self._lifetime.items():
106
+ s = by_surface.setdefault(
107
+ surface, {"total": 0, "ok": 0, "miss": 0, "error": 0}
108
+ )
109
+ s["total"] += n
110
+ s[outcome] = s.get(outcome, 0) + n
111
+ if gap:
112
+ gaps[gap] = gaps.get(gap, 0) + n
113
+ return {
114
+ "since": self._started_at.strftime("%Y-%m-%d %H:%M:%S") + " UTC",
115
+ "note": "Counts since this container last started. "
116
+ "Durable totals come from the Worker.",
117
+ "by_surface": by_surface,
118
+ "gaps": dict(sorted(gaps.items(), key=lambda kv: -kv[1])),
119
+ "pending_deltas": len(self._pending),
120
+ "worker_configured": bool(WORKER_URL),
121
+ "recent_flushes": list(self._attempts)[::-1],
122
+ }
123
+
124
+ # ── Flushing ──────────────────────────────────────────────────────
125
+
126
+ def _record_attempt(self, outcome: str, detail: str = "") -> None:
127
+ self._attempts.append({
128
+ "at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
129
+ "outcome": outcome,
130
+ "detail": detail[:200],
131
+ })
132
+
133
+ async def flush(self) -> bool:
134
+ """Send accumulated deltas. On failure the deltas are folded back
135
+ in and retried on the next pass, so nothing is dropped."""
136
+ if not WORKER_URL or not self._pending:
137
+ return True
138
+
139
+ async with self._lock:
140
+ batch = self._pending
141
+ self._pending = {}
142
+
143
+ deltas: List[dict] = [
144
+ {"surface": s, "outcome": o, "gap_code": g, "n": n}
145
+ for (s, o, g), n in batch.items()
146
+ ]
147
+
148
+ try:
149
+ import httpx
150
+ headers = {"Authorization": f"Bearer {WORKER_TOKEN}"} if WORKER_TOKEN else {}
151
+ async with httpx.AsyncClient(timeout=10) as client:
152
+ r = await client.post(
153
+ f"{WORKER_URL}/rb/log",
154
+ json={"deltas": deltas},
155
+ headers=headers,
156
+ )
157
+ body = {}
158
+ try:
159
+ body = r.json()
160
+ except Exception:
161
+ pass
162
+
163
+ # The Worker answers 200 with logged=false when the D1 write
164
+ # itself failed, so the status code alone cannot tell a stored
165
+ # batch from a dropped one. Check the verdict, not the code.
166
+ if r.status_code < 400 and body.get("logged"):
167
+ self._record_attempt(
168
+ "ok", f"{body.get('accepted', len(deltas))} delta(s) stored"
169
+ )
170
+ return True
171
+
172
+ self._requeue(batch)
173
+ if r.status_code == 401:
174
+ self._record_attempt(
175
+ "rejected_401",
176
+ "CLOUDFLARE_WORKER_TOKEN does not match the Worker's ANALYTICS_TOKEN",
177
+ )
178
+ else:
179
+ self._record_attempt(
180
+ "refused",
181
+ f"HTTP {r.status_code} {body.get('warning', '')}",
182
+ )
183
+ return False
184
+
185
+ except Exception as exc:
186
+ self._requeue(batch)
187
+ self._record_attempt("unreachable", f"{type(exc).__name__}: {exc}")
188
+ return False
189
+
190
+ def _requeue(self, batch: Dict[_Key, int]) -> None:
191
+ for key, n in batch.items():
192
+ self._pending[key] = self._pending.get(key, 0) + n
193
+
194
+ async def _loop(self) -> None:
195
+ while True:
196
+ await asyncio.sleep(FLUSH_SECONDS)
197
+ try:
198
+ await self.flush()
199
+ except Exception as exc:
200
+ logger.warning("usage flush loop error: %s", exc)
201
+
202
+ def start(self) -> None:
203
+ """Start the background flusher. Called once from app startup."""
204
+ if self._task is not None:
205
+ return
206
+ try:
207
+ loop = asyncio.get_running_loop()
208
+ except RuntimeError:
209
+ return
210
+ # A strong reference is held on the instance: asyncio keeps only
211
+ # weak references to tasks, and a dropped task can be collected
212
+ # mid-flight, silently cancelling the flush.
213
+ self._task = loop.create_task(self._loop())
214
+ logger.info("Usage flusher started (%ss interval, worker=%s)",
215
+ FLUSH_SECONDS, bool(WORKER_URL))
216
+
217
+ async def stop(self) -> None:
218
+ """Final flush on shutdown so the last minute of activity lands."""
219
+ if self._task:
220
+ self._task.cancel()
221
+ self._task = None
222
+ await self.flush()
223
+
224
+
225
+ usage = UsageCounters()
226
+
227
+
228
+ # ── FastAPI middleware ────────────────────────────────────────────────
229
+
230
+ def install(app) -> None:
231
+ """Wire counting into the app. No frontend change is required: the
232
+ tab a person used is implied by the endpoint their browser called,
233
+ which is why this survives the move to LibGuides and any future
234
+ frontend rewrite."""
235
+ from starlette.requests import Request
236
+
237
+ @app.middleware("http")
238
+ async def _count(request: Request, call_next):
239
+ surface = SURFACE_BY_PATH.get(request.url.path)
240
+ # Canary traffic exists to keep the Space awake and must never
241
+ # appear in usage figures β€” the same exclusion LibBee applies.
242
+ counted = surface and request.headers.get("X-ResearchBee-Canary") != "1"
243
+
244
+ try:
245
+ response = await call_next(request)
246
+ except Exception:
247
+ # An unhandled exception propagates out of call_next, so
248
+ # anything written after the await would never run. Without
249
+ # this branch the most important failures β€” the ones that
250
+ # crash a handler β€” would be the only ones missing from the
251
+ # dashboard.
252
+ if counted:
253
+ usage.record(surface, "error", "unhandled_exception")
254
+ raise
255
+
256
+ try:
257
+ if counted:
258
+ if response.status_code < 400:
259
+ # A handler that recorded its own miss has already
260
+ # counted this request; the flag stops it counting twice.
261
+ if not getattr(request.state, "usage_recorded", False):
262
+ usage.record(surface, "ok")
263
+ else:
264
+ usage.record(surface, "error")
265
+ except Exception:
266
+ pass
267
+ return response
268
+
269
+ @app.on_event("startup")
270
+ async def _start_usage():
271
+ usage.start()
272
+
273
+ @app.on_event("shutdown")
274
+ async def _stop_usage():
275
+ await usage.stop()