Aksel Joonas Reedi commited on
Commit
2715896
Β·
unverified Β·
1 Parent(s): c21a9b1

Track Pro conversions + credits top-up; emit user_id + new KPIs (#174)

Browse files

Adds two telemetry events the dashboard needs to answer "how many users
converted to Pro?" and "how many came back from a billing block?":

- pro_conversion: per-user Pro state lives in a new Mongo `pro_users`
collection. mark_pro_seen upserts on every authenticated request and
uses an atomic find_one_and_update to fire at-most-once when a user
first appears as Pro after having been seen as non-Pro. Wired through
session_manager.create_session via the existing `is_pro` signal from
/auth.

- credits_topped_up: fires from jobs_tool when an hf_job submit succeeds
in a session that previously hit a billing-required tool_state_change.
Guarded against re-firing within a session.

build_kpis aggregates both as plain count columns. CLI/local users with
no Mongo silently skip via the NoopSessionStore stub.

agent/core/session_persistence.py CHANGED
@@ -98,6 +98,9 @@ class NoopSessionStore:
98
  async def refund_quota(self, *_: Any, **__: Any) -> None:
99
  return None
100
 
 
 
 
101
 
102
  class MongoSessionStore(NoopSessionStore):
103
  """MongoDB-backed session store."""
@@ -152,6 +155,7 @@ class MongoSessionStore(NoopSessionStore):
152
  [("session_id", 1), ("seq", 1)], unique=True
153
  )
154
  await self.db.session_trace_messages.create_index([("created_at", -1)])
 
155
 
156
  def _ready(self) -> bool:
157
  return bool(self.enabled and self.db is not None)
@@ -410,6 +414,63 @@ class MongoSessionStore(NoopSessionStore):
410
  {"$inc": {"count": -1}, "$set": {"updated_at": _now()}},
411
  )
412
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
  _store: NoopSessionStore | MongoSessionStore | None = None
415
 
 
98
  async def refund_quota(self, *_: Any, **__: Any) -> None:
99
  return None
100
 
101
+ async def mark_pro_seen(self, *_: Any, **__: Any) -> dict[str, Any] | None:
102
+ return None
103
+
104
 
105
  class MongoSessionStore(NoopSessionStore):
106
  """MongoDB-backed session store."""
 
155
  [("session_id", 1), ("seq", 1)], unique=True
156
  )
157
  await self.db.session_trace_messages.create_index([("created_at", -1)])
158
+ await self.db.pro_users.create_index([("first_seen_pro_at", -1)])
159
 
160
  def _ready(self) -> bool:
161
  return bool(self.enabled and self.db is not None)
 
414
  {"$inc": {"count": -1}, "$set": {"updated_at": _now()}},
415
  )
416
 
417
+ async def mark_pro_seen(
418
+ self, user_id: str, *, is_pro: bool
419
+ ) -> dict[str, Any] | None:
420
+ """Track per-user Pro state and detect free→Pro conversions.
421
+
422
+ Returns ``{"converted": True, "first_seen_at": ..."}`` exactly once
423
+ per user β€” the first time we see them as Pro after having recorded
424
+ them as non-Pro at least once. Otherwise returns ``None``.
425
+
426
+ Storing ``ever_non_pro`` lets us distinguish "user joined as Pro"
427
+ (no conversion) from "user upgraded" (conversion). The atomic
428
+ ``find_one_and_update`` on a guarded filter makes the conversion
429
+ emit at-most-once even under concurrent requests.
430
+ """
431
+ if not self._ready() or not user_id:
432
+ return None
433
+ now = _now()
434
+ set_fields: dict[str, Any] = {"last_seen_at": now, "is_pro": bool(is_pro)}
435
+ if not is_pro:
436
+ set_fields["ever_non_pro"] = True
437
+ try:
438
+ await self.db.pro_users.update_one(
439
+ {"_id": user_id},
440
+ {
441
+ "$setOnInsert": {"_id": user_id, "first_seen_at": now},
442
+ "$set": set_fields,
443
+ },
444
+ upsert=True,
445
+ )
446
+ except PyMongoError as e:
447
+ logger.debug("mark_pro_seen upsert failed for %s: %s", user_id, e)
448
+ return None
449
+
450
+ if not is_pro:
451
+ return None
452
+
453
+ try:
454
+ doc = await self.db.pro_users.find_one_and_update(
455
+ {
456
+ "_id": user_id,
457
+ "ever_non_pro": True,
458
+ "first_seen_pro_at": {"$exists": False},
459
+ },
460
+ {"$set": {"first_seen_pro_at": now}},
461
+ return_document=ReturnDocument.AFTER,
462
+ )
463
+ except PyMongoError as e:
464
+ logger.debug("mark_pro_seen conversion check failed for %s: %s", user_id, e)
465
+ return None
466
+
467
+ if not doc:
468
+ return None
469
+ return {
470
+ "converted": True,
471
+ "first_seen_at": (doc.get("first_seen_at") or now).isoformat(),
472
+ }
473
+
474
 
475
  _store: NoopSessionStore | MongoSessionStore | None = None
476
 
agent/core/telemetry.py CHANGED
@@ -277,6 +277,44 @@ async def record_pro_cta_click(
277
  logger.debug("record_pro_cta_click failed (non-fatal): %s", e)
278
 
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  # ── heartbeat ──────────────────────────────────────────────────────────────
281
 
282
  # Module-level reference set for fire-and-forget heartbeat tasks. asyncio only
 
277
  logger.debug("record_pro_cta_click failed (non-fatal): %s", e)
278
 
279
 
280
+ async def record_pro_conversion(
281
+ session: Any,
282
+ *,
283
+ first_seen_at: str | None = None,
284
+ ) -> None:
285
+ """Emit a ``pro_conversion`` event for a user we've previously observed
286
+ as non-Pro and now see as Pro for the first time. Detected upstream in
287
+ ``MongoSessionStore.mark_pro_seen``; fired into the user's first Pro
288
+ session so the rollup picks it up alongside other event-driven KPIs."""
289
+ from agent.core.session import Event
290
+ try:
291
+ await session.send_event(Event(
292
+ event_type="pro_conversion",
293
+ data={"first_seen_at": first_seen_at},
294
+ ))
295
+ except Exception as e:
296
+ logger.debug("record_pro_conversion failed (non-fatal): %s", e)
297
+
298
+
299
+ async def record_credits_topped_up(
300
+ session: Any,
301
+ *,
302
+ namespace: str | None = None,
303
+ ) -> None:
304
+ """Emit a ``credits_topped_up`` event when an hf_job submits successfully
305
+ in a session that previously hit ``jobs_access_blocked`` β€” i.e. the user
306
+ came back from the HF billing top-up flow and unblocked themselves.
307
+ Caller is responsible for firing this at most once per session."""
308
+ from agent.core.session import Event
309
+ try:
310
+ await session.send_event(Event(
311
+ event_type="credits_topped_up",
312
+ data={"namespace": namespace},
313
+ ))
314
+ except Exception as e:
315
+ logger.debug("record_credits_topped_up failed (non-fatal): %s", e)
316
+
317
+
318
  # ── heartbeat ──────────────────────────────────────────────────────────────
319
 
320
  # Module-level reference set for fire-and-forget heartbeat tasks. asyncio only
agent/tools/jobs_tool.py CHANGED
@@ -641,6 +641,23 @@ class HfJobsTool:
641
  {**args, "hardware_flavor": flavor, "timeout": timeout_str, "namespace": self.namespace},
642
  image=image, job_type=job_type,
643
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
 
645
  # Wait for completion and stream logs
646
  logger.info(f"{job_type} job started: {job.url}")
 
641
  {**args, "hardware_flavor": flavor, "timeout": timeout_str, "namespace": self.namespace},
642
  image=image, job_type=job_type,
643
  )
644
+ # Top-up signal: this submit succeeded after a prior billing
645
+ # block in the same session, and we haven't fired the event
646
+ # yet β€” the user came back from the HF billing flow.
647
+ events = self.session.logged_events
648
+ already_fired = any(
649
+ e.get("event_type") == "credits_topped_up" for e in events
650
+ )
651
+ if not already_fired:
652
+ blocked = any(
653
+ e.get("event_type") == "tool_state_change"
654
+ and (e.get("data") or {}).get("state") == "billing_required"
655
+ for e in events
656
+ )
657
+ if blocked:
658
+ await telemetry.record_credits_topped_up(
659
+ self.session, namespace=self.namespace,
660
+ )
661
 
662
  # Wait for completion and stream logs
663
  logger.info(f"{job_type} job started: {job.url}")
backend/routes/agent.py CHANGED
@@ -334,7 +334,10 @@ async def create_session(
334
 
335
  try:
336
  session_id = await session_manager.create_session(
337
- user_id=user["user_id"], hf_token=hf_token, model=model
 
 
 
338
  )
339
  except SessionCapacityError as e:
340
  raise HTTPException(status_code=503, detail=str(e))
@@ -370,7 +373,10 @@ async def restore_session_summary(
370
 
371
  try:
372
  session_id = await session_manager.create_session(
373
- user_id=user["user_id"], hf_token=hf_token, model=model
 
 
 
374
  )
375
  except SessionCapacityError as e:
376
  raise HTTPException(status_code=503, detail=str(e))
 
334
 
335
  try:
336
  session_id = await session_manager.create_session(
337
+ user_id=user["user_id"],
338
+ hf_token=hf_token,
339
+ model=model,
340
+ is_pro=user.get("plan") == "pro",
341
  )
342
  except SessionCapacityError as e:
343
  raise HTTPException(status_code=503, detail=str(e))
 
373
 
374
  try:
375
  session_id = await session_manager.create_session(
376
+ user_id=user["user_id"],
377
+ hf_token=hf_token,
378
+ model=model,
379
+ is_pro=user.get("plan") == "pro",
380
  )
381
  except SessionCapacityError as e:
382
  raise HTTPException(status_code=503, detail=str(e))
backend/session_manager.py CHANGED
@@ -465,6 +465,7 @@ class SessionManager:
465
  user_id: str = "dev",
466
  hf_token: str | None = None,
467
  model: str | None = None,
 
468
  ) -> str:
469
  """Create a new agent session and return its ID.
470
 
@@ -534,9 +535,36 @@ class SessionManager:
534
  )
535
  await self.persist_session_snapshot(agent_session, runtime_state="idle")
536
 
 
 
 
537
  logger.info(f"Created session {session_id} for user {user_id}")
538
  return session_id
539
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  async def seed_from_summary(self, session_id: str, messages: list[dict]) -> int:
541
  """Rehydrate a session from cached prior messages via summarization.
542
 
 
465
  user_id: str = "dev",
466
  hf_token: str | None = None,
467
  model: str | None = None,
468
+ is_pro: bool | None = None,
469
  ) -> str:
470
  """Create a new agent session and return its ID.
471
 
 
535
  )
536
  await self.persist_session_snapshot(agent_session, runtime_state="idle")
537
 
538
+ if is_pro is not None and user_id and user_id != "dev":
539
+ await self._track_pro_status(agent_session, is_pro=is_pro)
540
+
541
  logger.info(f"Created session {session_id} for user {user_id}")
542
  return session_id
543
 
544
+ async def _track_pro_status(self, agent_session: AgentSession, *, is_pro: bool) -> None:
545
+ """Update Mongo per-user Pro state and emit a one-shot conversion
546
+ event if the store reports a free→Pro transition. Best-effort: any
547
+ Mongo failure is swallowed so we never fail session creation on
548
+ telemetry."""
549
+ store = self._store()
550
+ if not getattr(store, "enabled", False):
551
+ return
552
+ try:
553
+ result = await store.mark_pro_seen(agent_session.user_id, is_pro=is_pro)
554
+ except Exception as e:
555
+ logger.debug("mark_pro_seen failed: %s", e)
556
+ return
557
+ if not result or not result.get("converted"):
558
+ return
559
+ try:
560
+ from agent.core import telemetry
561
+ await telemetry.record_pro_conversion(
562
+ agent_session.session,
563
+ first_seen_at=result.get("first_seen_at"),
564
+ )
565
+ except Exception as e:
566
+ logger.debug("record_pro_conversion failed: %s", e)
567
+
568
  async def seed_from_summary(self, session_id: str, messages: list[dict]) -> int:
569
  """Rehydrate a session from cached prior messages via summarization.
570
 
scripts/build_kpis.py CHANGED
@@ -224,7 +224,7 @@ def _session_metrics(session: dict) -> dict:
224
  "failures": 0, "regenerate_sessions": 0,
225
  "thumbs_up": 0, "thumbs_down": 0,
226
  "hf_jobs_submitted": 0, "hf_jobs_succeeded": 0, "hf_jobs_blocked": 0,
227
- "pro_cta_clicks": 0,
228
  "sandboxes_created": 0, "sandboxes_cpu": 0, "sandboxes_gpu": 0,
229
  "first_tool_s": -1,
230
  }
@@ -251,6 +251,8 @@ def _session_metrics(session: dict) -> dict:
251
  sandboxes_gpu = 0
252
  jobs_blocked = 0
253
  pro_cta_clicks = 0
 
 
254
  pro_cta_by_source: dict[str, int] = defaultdict(int)
255
  # Per-tool counters from tool_call events. Counted off tool_call (which
256
  # carries data["tool"]) rather than tool_output (which only carries
@@ -321,6 +323,12 @@ def _session_metrics(session: dict) -> dict:
321
  source = str(data.get("source") or "unknown")
322
  pro_cta_by_source[source] += 1
323
 
 
 
 
 
 
 
324
  elif et == "sandbox_create":
325
  sandboxes_created += 1
326
  hardware = (data.get("hardware") or "").lower()
@@ -347,6 +355,8 @@ def _session_metrics(session: dict) -> dict:
347
  out["sandboxes_gpu"] = sandboxes_gpu
348
  out["hf_jobs_blocked"] = jobs_blocked
349
  out["pro_cta_clicks"] = pro_cta_clicks
 
 
350
  out["first_tool_s"] = first_tool_ts if first_tool_ts is not None else -1
351
  out["_gpu_hours_by_flavor"] = dict(gpu_hours_by_flavor)
352
  out["_pro_cta_by_source"] = dict(pro_cta_by_source)
@@ -462,6 +472,8 @@ def _aggregate(per_session: list[dict]) -> dict:
462
  "sandboxes_gpu": int(sum(s.get("sandboxes_gpu", 0) for s in per_session)),
463
  "hf_jobs_blocked": int(sum(s.get("hf_jobs_blocked", 0) for s in per_session)),
464
  "pro_cta_clicks": int(sum(s.get("pro_cta_clicks", 0) for s in per_session)),
 
 
465
  "gpu_hours_by_flavor_json": json.dumps(dict(gpu_hours), sort_keys=True),
466
  # Research KPIs β€” answer "is the agent reaching for research?".
467
  "research_calls": research_calls_total,
 
224
  "failures": 0, "regenerate_sessions": 0,
225
  "thumbs_up": 0, "thumbs_down": 0,
226
  "hf_jobs_submitted": 0, "hf_jobs_succeeded": 0, "hf_jobs_blocked": 0,
227
+ "pro_cta_clicks": 0, "pro_conversions": 0, "credits_topped_up": 0,
228
  "sandboxes_created": 0, "sandboxes_cpu": 0, "sandboxes_gpu": 0,
229
  "first_tool_s": -1,
230
  }
 
251
  sandboxes_gpu = 0
252
  jobs_blocked = 0
253
  pro_cta_clicks = 0
254
+ pro_conversions = 0
255
+ credits_topped_up = 0
256
  pro_cta_by_source: dict[str, int] = defaultdict(int)
257
  # Per-tool counters from tool_call events. Counted off tool_call (which
258
  # carries data["tool"]) rather than tool_output (which only carries
 
323
  source = str(data.get("source") or "unknown")
324
  pro_cta_by_source[source] += 1
325
 
326
+ elif et == "pro_conversion":
327
+ pro_conversions += 1
328
+
329
+ elif et == "credits_topped_up":
330
+ credits_topped_up += 1
331
+
332
  elif et == "sandbox_create":
333
  sandboxes_created += 1
334
  hardware = (data.get("hardware") or "").lower()
 
355
  out["sandboxes_gpu"] = sandboxes_gpu
356
  out["hf_jobs_blocked"] = jobs_blocked
357
  out["pro_cta_clicks"] = pro_cta_clicks
358
+ out["pro_conversions"] = pro_conversions
359
+ out["credits_topped_up"] = credits_topped_up
360
  out["first_tool_s"] = first_tool_ts if first_tool_ts is not None else -1
361
  out["_gpu_hours_by_flavor"] = dict(gpu_hours_by_flavor)
362
  out["_pro_cta_by_source"] = dict(pro_cta_by_source)
 
472
  "sandboxes_gpu": int(sum(s.get("sandboxes_gpu", 0) for s in per_session)),
473
  "hf_jobs_blocked": int(sum(s.get("hf_jobs_blocked", 0) for s in per_session)),
474
  "pro_cta_clicks": int(sum(s.get("pro_cta_clicks", 0) for s in per_session)),
475
+ "pro_conversions": int(sum(s.get("pro_conversions", 0) for s in per_session)),
476
+ "credits_topped_up": int(sum(s.get("credits_topped_up", 0) for s in per_session)),
477
  "gpu_hours_by_flavor_json": json.dumps(dict(gpu_hours), sort_keys=True),
478
  # Research KPIs β€” answer "is the agent reaching for research?".
479
  "research_calls": research_calls_total,
tests/unit/test_build_kpis.py CHANGED
@@ -104,6 +104,32 @@ def test_hf_job_blocked_and_pro_clicks_are_counted():
104
  }
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def test_feedback_counts():
108
  mod = _load()
109
  events = [
 
104
  }
105
 
106
 
107
+ def test_pro_conversions_and_credits_topped_up_per_session():
108
+ mod = _load()
109
+ events = [
110
+ _ev("pro_conversion", {"first_seen_at": "2026-04-20T10:00:00"}),
111
+ _ev("credits_topped_up", {"namespace": "smolagents"}),
112
+ _ev("credits_topped_up", {"namespace": "smolagents"}),
113
+ ]
114
+ m = mod._session_metrics(_session(events))
115
+ assert m["pro_conversions"] == 1
116
+ assert m["credits_topped_up"] == 2
117
+
118
+
119
+ def test_aggregate_sums_pro_conversions_and_credits_topped_up():
120
+ mod = _load()
121
+ s1 = mod._session_metrics(_session([
122
+ _ev("pro_conversion", {}),
123
+ ], user_id="u1"))
124
+ s2 = mod._session_metrics(_session([
125
+ _ev("credits_topped_up", {"namespace": "ns"}),
126
+ ], user_id="u2"))
127
+ s3 = mod._session_metrics(_session([], user_id="u3"))
128
+ row = mod._aggregate([s1, s2, s3])
129
+ assert row["pro_conversions"] == 1
130
+ assert row["credits_topped_up"] == 1
131
+
132
+
133
  def test_feedback_counts():
134
  mod = _load()
135
  events = [
tests/unit/test_session_persistence.py CHANGED
@@ -2,7 +2,11 @@
2
 
3
  import pytest
4
 
5
- from agent.core.session_persistence import NoopSessionStore, _safe_message_doc
 
 
 
 
6
 
7
 
8
  @pytest.mark.asyncio
@@ -29,3 +33,97 @@ def test_unsafe_message_payload_is_replaced_with_marker():
29
 
30
  assert marker["role"] == "tool"
31
  assert marker["ml_intern_persistence_error"] == "message_too_large_or_invalid"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import pytest
4
 
5
+ from agent.core.session_persistence import (
6
+ MongoSessionStore,
7
+ NoopSessionStore,
8
+ _safe_message_doc,
9
+ )
10
 
11
 
12
  @pytest.mark.asyncio
 
33
 
34
  assert marker["role"] == "tool"
35
  assert marker["ml_intern_persistence_error"] == "message_too_large_or_invalid"
36
+
37
+
38
+ # ── mark_pro_seen ─────────────────────────────────────────────────────────
39
+
40
+
41
+ class _FakeProUsers:
42
+ """In-memory stand-in for the ``pro_users`` collection.
43
+
44
+ Supports just enough of the Motor API to exercise ``mark_pro_seen``:
45
+ ``update_one`` with ``$setOnInsert`` + ``$set`` + ``upsert=True``, and
46
+ ``find_one_and_update`` with the guarded filter the conversion check uses.
47
+ """
48
+
49
+ def __init__(self) -> None:
50
+ self.docs: dict[str, dict] = {}
51
+
52
+ async def update_one(self, filt, update, upsert=False):
53
+ _id = filt["_id"]
54
+ doc = self.docs.get(_id)
55
+ if doc is None and upsert:
56
+ doc = dict(update.get("$setOnInsert") or {})
57
+ self.docs[_id] = doc
58
+ if doc is None:
59
+ return
60
+ for k, v in (update.get("$set") or {}).items():
61
+ doc[k] = v
62
+
63
+ async def find_one_and_update(self, filt, update, return_document=None):
64
+ _id = filt["_id"]
65
+ doc = self.docs.get(_id)
66
+ if doc is None:
67
+ return None
68
+ # Guard checks the conversion test uses: ever_non_pro=True AND
69
+ # first_seen_pro_at missing.
70
+ for k, v in filt.items():
71
+ if k == "_id":
72
+ continue
73
+ if isinstance(v, dict) and "$exists" in v:
74
+ if v["$exists"] and k not in doc:
75
+ return None
76
+ if not v["$exists"] and k in doc:
77
+ return None
78
+ elif doc.get(k) != v:
79
+ return None
80
+ for k, v in (update.get("$set") or {}).items():
81
+ doc[k] = v
82
+ return dict(doc)
83
+
84
+
85
+ class _FakeDB:
86
+ def __init__(self) -> None:
87
+ self.pro_users = _FakeProUsers()
88
+
89
+
90
+ def _store_with_fake_db() -> MongoSessionStore:
91
+ s = MongoSessionStore.__new__(MongoSessionStore)
92
+ s.enabled = True
93
+ s.db = _FakeDB()
94
+ return s
95
+
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_mark_pro_seen_returns_none_when_unknown_user_starts_pro():
99
+ """Joining as Pro shouldn't count as a conversion."""
100
+ store = _store_with_fake_db()
101
+ assert await store.mark_pro_seen("u-new-pro", is_pro=True) is None
102
+
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_mark_pro_seen_emits_conversion_after_seeing_user_as_free():
106
+ store = _store_with_fake_db()
107
+ assert await store.mark_pro_seen("u1", is_pro=False) is None
108
+ result = await store.mark_pro_seen("u1", is_pro=True)
109
+ assert result is not None
110
+ assert result["converted"] is True
111
+ assert isinstance(result["first_seen_at"], str)
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_mark_pro_seen_only_fires_conversion_once():
116
+ """Re-checking a converted user must not re-emit the event."""
117
+ store = _store_with_fake_db()
118
+ await store.mark_pro_seen("u1", is_pro=False)
119
+ first = await store.mark_pro_seen("u1", is_pro=True)
120
+ assert first is not None and first["converted"] is True
121
+ second = await store.mark_pro_seen("u1", is_pro=True)
122
+ assert second is None
123
+
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_noop_store_mark_pro_seen_returns_none():
127
+ store = NoopSessionStore()
128
+ assert await store.mark_pro_seen("u1", is_pro=True) is None
129
+ assert await store.mark_pro_seen("u1", is_pro=False) is None