fix(telemetry): the human half of the pull ledger recorded one kind in three
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 27s

Opening a snippet in the UI recorded rest_snippet. Opening a note or a task
recorded nothing — so the most direct evidence the product has that anyone
cares about a record existed for one kind out of three, and the other two sat
at zero pulls looking like dead weight beside a kind that merely had a counter.

Not an open question about intent: models/note_usage.py already documented
'rest_note' as a source value. Nothing wrote it. The design named it and the
implementation stopped at snippets.

Adds rest_note and rest_task. Tagged by SURFACE rather than by the record's
kind, matching rest_snippet — the kind is a join away, but which surface asked
is not recoverable after the fact. The mcp_/rest_ split stays load-bearing:
"is this dead weight" is served by any pull, "was that injected line useful" by
agent pulls alone, and a human clicking a link would inflate exactly the number
#1038 and #2085 gate on.

The vocabulary comment in the model was itself the stale-enumeration shape this
survey keeps finding — it named a source nothing wrote while omitting sources
that existed. Replaced with the naming CONVENTION plus a pointer to grep, which
cannot drift, rather than a longer list that would go stale the same way.

Guard extended to the REST surface, same derivation as the MCP half: a route
registered at exactly /<int:x> for GET is a detail view, and one reaching a
note-backed loader must record. Handler source is expanded one level through
module-private helpers, without which get_snippet_route — the route that
already got this right — would drop out of the check by loading via
_load_snippet. Verified the guard fires when a call is removed.

Renamed test_mcp_pull_telemetry.py -> test_pull_telemetry.py; it is no longer
only about MCP.

Closes #2476
This commit is contained in:
2026-08-06 08:51:21 -04:00
parent ac1ce0a7f0
commit c18139622c
4 changed files with 122 additions and 7 deletions
+17 -4
View File
@@ -51,10 +51,23 @@ class NoteUsageEvent(Base):
note_id: Mapped[int] = mapped_column(Integer, nullable=False)
# 'surfaced' | 'pulled'
event: Mapped[str] = mapped_column(Text, nullable=False)
# Which surface produced it: 'auto_inject' | 'write_path_place' |
# 'write_path_semantic' | 'mcp_get_snippet' | 'mcp_get_note' | 'rest_note'.
# Kept granular so the place arm and the semantic arm can be compared —
# that comparison is the whole reason the place arm needed logging at all.
# Which surface produced it. Kept granular so the place arm and the
# semantic arm can be compared — that comparison is the whole reason the
# place arm needed logging at all.
#
# A CONVENTION, not a fixed vocabulary: `mcp_<tool>` for an agent call,
# `rest_<kind>` for a human opening a detail view, and a bare name for a
# hook or background surface ('auto_inject', 'write_path_place',
# 'write_path_semantic'). This comment deliberately no longer lists the
# members — the previous list had gone stale, naming 'rest_note' that
# nothing wrote while omitting sources that existed, and a half-true
# enumeration reads as authoritative in exactly the way that misleads
# (#2476). `grep -rn record_pulled\\\|record_surfaced src/` is the
# authoritative list, and unlike a comment it cannot drift.
#
# The mcp_/rest_ split is load-bearing. "Is this dead weight?" is served by
# any pull; "was that injected line useful?" is served by AGENT pulls only,
# so never aggregate across the prefix without saying why (#1038, #2085).
source: Mapped[str] = mapped_column(Text, nullable=False)
__table_args__ = (
+8
View File
@@ -22,6 +22,7 @@ from scribe.services.notes import (
update_note,
)
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
from scribe.services.note_usage import record_pulled
from scribe.services.note_versions import list_versions, get_version
logger = logging.getLogger(__name__)
@@ -178,6 +179,13 @@ async def get_note_route(note_id: int):
note, permission = result
data = note.to_dict()
data["permission"] = permission
# Opening the detail view IS a pull — the operator chose to look. Tagged by
# SURFACE, not by the record's kind, matching rest_snippet: the kind is a
# join away, but which surface asked is not recoverable after the fact.
# Keeping rest_* apart from mcp_* is load-bearing, not tidiness — "was that
# injected line useful?" is answered by agent pulls alone, and a human
# clicking a link would inflate exactly the number #1038 and #2085 gate on.
record_pulled(user_id=uid, note_id=note_id, source="rest_note")
return jsonify(data)
+4
View File
@@ -13,6 +13,7 @@ from scribe.services.notes import (
list_notes,
update_note,
)
from scribe.services.note_usage import record_pulled
from scribe.services.planning import start_planning as svc_start_planning
from scribe.services.recurrence import calculate_next_due, validate_recurrence_rule
@@ -186,6 +187,9 @@ async def get_task_route(task_id: int):
parent = await get_note_for_user(uid, task.parent_id)
data["parent_title"] = parent[0].title if parent else None
data["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
# Opening the detail view IS a pull — see the note beside rest_note in
# routes/notes.py for why the rest_* and mcp_* prefixes stay separable.
record_pulled(user_id=uid, note_id=task_id, source="rest_task")
return jsonify(data)