Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions backend/agents/document.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@
from agents.concept_extraction import concept_extraction_agent, ConceptList
from agents.syllabus_extraction import syllabus_extraction_agent, SyllabusAssignments
from agents.tools.graph import apply_concepts_to_graph
from agents.usage import record_agent_usage
from services.durable import workflow as durable_workflow, step as durable_step


Expand DownExpand Up@@ -86,25 +87,37 @@ class _WorkerResults:

@durable_step
async def _step_classify(text: str, deps: SaplingDeps) -> DocumentClassification:
result = await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="classifier", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_summary(text: str, deps: SaplingDeps) -> Summary:
result = await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="summary", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_concepts(text: str, deps: SaplingDeps) -> ConceptList:
result = await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="concepts", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_syllabus(text: str, deps: SaplingDeps) -> SyllabusAssignments:
result = await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="syllabus", user_id=deps.user_id,
)
return result.output


Expand Down
82 changes: 82 additions & 0 deletions backend/agents/usage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-line usage capture for Pydantic AI agent runs (issue #118).

Every agent call site wraps its result in ``record_agent_usage(result,
feature=..., task=...)``. The helper reads ``result.usage()`` and the model
actually used, then hands them to ``events_service.log_llm_usage`` (which
normalizes tokens, computes cost, and enqueues off the request thread).

Two properties matter:

* **One line per call site.** Because it returns ``result`` unchanged, a call
site can wrap inline (``result = record_agent_usage(await agent.run(...),
feature=..., task=...)``) or add it as a trailing statement.
* **Never raises.** Instrumentation must not break an agent run, so every
failure — a result shape we don't recognize, a usage extraction slip — is
swallowed and logged at debug level.

The ``model`` is read from the result's final ``ModelResponse`` (the model the
provider actually served); if that's unavailable it falls back to the task's
configured model via ``_providers.model_for``.
"""

from __future__ import annotations

import logging
from typing import Any

from agents._providers import AgentTask, model_for
from services import events_service

logger = logging.getLogger("sapling.agents.usage")


def _model_name(result: Any, task: AgentTask | None) -> str:
"""Best-effort model id for the run, resilient to Pydantic AI churn."""
# Preferred: the final ModelResponse carries the served model name.
try:
name = getattr(result.response, "model_name", None)
if name:
return name
except Exception:
pass
# Fallback: scan the message history for the last response with a model.
try:
for msg in reversed(result.all_messages()):
name = getattr(msg, "model_name", None)
if name:
return name
except Exception:
pass
# Last resort: the task's configured default model.
if task is not None:
try:
return model_for(task).model_name
except Exception:
pass
return "unknown"


def record_agent_usage(
result: Any,
*,
feature: str,
task: AgentTask | None = None,
user_id: str | None = None,
) -> Any:
"""Record token usage for an agent run and return ``result`` unchanged.

``user_id`` is optional: pass it where the actor is in scope (routes with a
``deps.user_id`` / request body) for per-user rollups; omit it and the
request_id from the contextvar still attributes the row.
"""
try:
events_service.log_llm_usage(
feature=feature,
task=task,
model=_model_name(result, task),
usage=result.usage(),
user_id=user_id,
)
except Exception:
logger.debug("record_agent_usage: could not capture usage", exc_info=True)
return result
57 changes: 57 additions & 0 deletions backend/db/migrations/0035_observability.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
-- 0035: observability tables (issue #115) — the two tables backing the site
-- logging + usage-tracking system.
--
-- events — flexible, high-volume analytics / audit / error events.
-- llm_usage — structured per-call LLM token + cost rows, queried with
-- SUM/GROUP BY for billing rollups.
--
-- Notes / deviations from the original issue spec:
-- * Ids and user_id are TEXT, not uuid, to match the rest of this schema
-- (users.id is TEXT, e.g. 'user_andres'); a uuid user_id column could not
-- hold the existing text ids. Same gen_random_uuid()::text PK style as
-- 0026_ops.sql.
-- * No FK on user_id. These are append-only, high-write analytics tables and
-- user_id is intentionally nullable (system/anonymous actors); we don't
-- want a per-row FK check on the write path or cascade coupling to the
-- users lifecycle. Kept as a plain nullable column.
-- * NO raw-content columns. Sensitive content is represented only by
-- content_fp (a 16-hex sha256 fingerprint). Never store message text,
-- document text, or names here.
-- * admin_audit_log is deliberately untouched — events complements it.
--
-- Idempotent (IF NOT EXISTS throughout) so it is safe to re-run.

-- ── events ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
event_type TEXT NOT NULL, -- dotted taxonomy: document.upload, auth.login, error.5xx
category TEXT NOT NULL, -- usage | audit | error
user_id TEXT, -- actor; NULL for anonymous/system
request_id TEXT, -- correlates to RequestIDMiddleware + Logfire
payload JSONB NOT NULL DEFAULT '{}', -- type-specific metadata (counts, ids, status_code, duration_ms…)
content_fp TEXT, -- sha256 fingerprint (16 hex) — never raw content
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_events_user_created ON events (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_type_created ON events (event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_cat_created ON events (category, created_at DESC);

-- ── llm_usage ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS llm_usage (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT, -- per-user rollups; NULL for system
request_id TEXT,
feature TEXT NOT NULL, -- quiz | chat_tutor | document | notes …
task TEXT, -- matches agents/_providers.py task slots
model TEXT NOT NULL, -- e.g. gemini-2.5-flash
provider TEXT NOT NULL DEFAULT 'gemini',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(12,6), -- NULL when the model isn't priced
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_llm_usage_user_created ON llm_usage (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_llm_usage_feature_created ON llm_usage (feature, created_at DESC);
18 changes: 15 additions & 3 deletions backend/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
from routes import graph, learn, quiz, calendar, social, extract, auth, documents, flashcards, study_guide, feedback, careers, onboarding, gradebook, gradescope, notes, academics
from routes.profile import router as profile_router
from routes.admin import router as admin_router
from routes.admin_analytics import router as admin_analytics_router
from routes.newsletter import router as newsletter_router
from services.logfire_scrubber import EXTRA_PATTERNS, scrub_value
from services.request_context import RequestIDMiddleware, current_request_id
Expand DownExpand Up@@ -80,8 +81,14 @@ async def _lifespan(_app: FastAPI):
file_size_limit=MAX_AVATAR_SIZE,
allowed_mime_types=sorted(ALLOWED_CONTENT_TYPES),
)
# #116/#118: start the fire-and-forget observability drain thread so LLM
# usage + event rows flush off the request path.
from services import events_service
events_service.start_worker()
yield
# No shutdown hooks today.
# Stop the drain thread and flush anything still queued so the last batch
# of usage rows isn't lost on shutdown.
events_service.shutdown()


def _drop_request_arguments(_request, _attributes):
Expand DownExpand Up@@ -199,6 +206,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
app.include_router(onboarding.router, prefix="/api/onboarding")
app.include_router(profile_router, prefix="/api/profile")
app.include_router(admin_router, prefix="/api/admin")
app.include_router(admin_analytics_router, prefix="/api/admin/analytics")
app.include_router(newsletter_router, prefix="/api/newsletter")
app.include_router(gradebook.router, prefix="/api/gradebook")
app.include_router(gradescope.router, prefix="/api/gradescope")
Expand DownExpand Up@@ -267,9 +275,13 @@ def gemini_test(request: Request):
require_admin(request) # 403 unless the session belongs to an admin; 401 if unauthenticated
from agents._run import run_agent_sync
from agents.health import health_probe_agent
from agents.usage import record_agent_usage
try:
result = run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
result = record_agent_usage(
run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
),
feature="health",
)
return {"ok": True, "reply": result.output.strip()}
except Exception as e:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions backend/agents/document.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@
from agents.concept_extraction import concept_extraction_agent, ConceptList
from agents.syllabus_extraction import syllabus_extraction_agent, SyllabusAssignments
from agents.tools.graph import apply_concepts_to_graph
from agents.usage import record_agent_usage
from services.durable import workflow as durable_workflow, step as durable_step


Expand DownExpand Up@@ -86,25 +87,37 @@ class _WorkerResults:

@durable_step
async def _step_classify(text: str, deps: SaplingDeps) -> DocumentClassification:
result = await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="classifier", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_summary(text: str, deps: SaplingDeps) -> Summary:
result = await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="summary", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_concepts(text: str, deps: SaplingDeps) -> ConceptList:
result = await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="concepts", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_syllabus(text: str, deps: SaplingDeps) -> SyllabusAssignments:
result = await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="syllabus", user_id=deps.user_id,
)
return result.output


Expand Down
82 changes: 82 additions & 0 deletions backend/agents/usage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-line usage capture for Pydantic AI agent runs (issue #118).

Every agent call site wraps its result in ``record_agent_usage(result,
feature=..., task=...)``. The helper reads ``result.usage()`` and the model
actually used, then hands them to ``events_service.log_llm_usage`` (which
normalizes tokens, computes cost, and enqueues off the request thread).

Two properties matter:

* **One line per call site.** Because it returns ``result`` unchanged, a call
site can wrap inline (``result = record_agent_usage(await agent.run(...),
feature=..., task=...)``) or add it as a trailing statement.
* **Never raises.** Instrumentation must not break an agent run, so every
failure — a result shape we don't recognize, a usage extraction slip — is
swallowed and logged at debug level.

The ``model`` is read from the result's final ``ModelResponse`` (the model the
provider actually served); if that's unavailable it falls back to the task's
configured model via ``_providers.model_for``.
"""

from __future__ import annotations

import logging
from typing import Any

from agents._providers import AgentTask, model_for
from services import events_service

logger = logging.getLogger("sapling.agents.usage")


def _model_name(result: Any, task: AgentTask | None) -> str:
"""Best-effort model id for the run, resilient to Pydantic AI churn."""
# Preferred: the final ModelResponse carries the served model name.
try:
name = getattr(result.response, "model_name", None)
if name:
return name
except Exception:
pass
# Fallback: scan the message history for the last response with a model.
try:
for msg in reversed(result.all_messages()):
name = getattr(msg, "model_name", None)
if name:
return name
except Exception:
pass
# Last resort: the task's configured default model.
if task is not None:
try:
return model_for(task).model_name
except Exception:
pass
return "unknown"


def record_agent_usage(
result: Any,
*,
feature: str,
task: AgentTask | None = None,
user_id: str | None = None,
) -> Any:
"""Record token usage for an agent run and return ``result`` unchanged.

``user_id`` is optional: pass it where the actor is in scope (routes with a
``deps.user_id`` / request body) for per-user rollups; omit it and the
request_id from the contextvar still attributes the row.
"""
try:
events_service.log_llm_usage(
feature=feature,
task=task,
model=_model_name(result, task),
usage=result.usage(),
user_id=user_id,
)
except Exception:
logger.debug("record_agent_usage: could not capture usage", exc_info=True)
return result
57 changes: 57 additions & 0 deletions backend/db/migrations/0035_observability.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
-- 0035: observability tables (issue #115) — the two tables backing the site
-- logging + usage-tracking system.
--
-- events — flexible, high-volume analytics / audit / error events.
-- llm_usage — structured per-call LLM token + cost rows, queried with
-- SUM/GROUP BY for billing rollups.
--
-- Notes / deviations from the original issue spec:
-- * Ids and user_id are TEXT, not uuid, to match the rest of this schema
-- (users.id is TEXT, e.g. 'user_andres'); a uuid user_id column could not
-- hold the existing text ids. Same gen_random_uuid()::text PK style as
-- 0026_ops.sql.
-- * No FK on user_id. These are append-only, high-write analytics tables and
-- user_id is intentionally nullable (system/anonymous actors); we don't
-- want a per-row FK check on the write path or cascade coupling to the
-- users lifecycle. Kept as a plain nullable column.
-- * NO raw-content columns. Sensitive content is represented only by
-- content_fp (a 16-hex sha256 fingerprint). Never store message text,
-- document text, or names here.
-- * admin_audit_log is deliberately untouched — events complements it.
--
-- Idempotent (IF NOT EXISTS throughout) so it is safe to re-run.

-- ── events ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
event_type TEXT NOT NULL, -- dotted taxonomy: document.upload, auth.login, error.5xx
category TEXT NOT NULL, -- usage | audit | error
user_id TEXT, -- actor; NULL for anonymous/system
request_id TEXT, -- correlates to RequestIDMiddleware + Logfire
payload JSONB NOT NULL DEFAULT '{}', -- type-specific metadata (counts, ids, status_code, duration_ms…)
content_fp TEXT, -- sha256 fingerprint (16 hex) — never raw content
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_events_user_created ON events (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_type_created ON events (event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_cat_created ON events (category, created_at DESC);

-- ── llm_usage ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS llm_usage (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT, -- per-user rollups; NULL for system
request_id TEXT,
feature TEXT NOT NULL, -- quiz | chat_tutor | document | notes …
task TEXT, -- matches agents/_providers.py task slots
model TEXT NOT NULL, -- e.g. gemini-2.5-flash
provider TEXT NOT NULL DEFAULT 'gemini',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(12,6), -- NULL when the model isn't priced
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_llm_usage_user_created ON llm_usage (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_llm_usage_feature_created ON llm_usage (feature, created_at DESC);
18 changes: 15 additions & 3 deletions backend/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
from routes import graph, learn, quiz, calendar, social, extract, auth, documents, flashcards, study_guide, feedback, careers, onboarding, gradebook, gradescope, notes, academics
from routes.profile import router as profile_router
from routes.admin import router as admin_router
from routes.admin_analytics import router as admin_analytics_router
from routes.newsletter import router as newsletter_router
from services.logfire_scrubber import EXTRA_PATTERNS, scrub_value
from services.request_context import RequestIDMiddleware, current_request_id
Expand DownExpand Up@@ -80,8 +81,14 @@ async def _lifespan(_app: FastAPI):
file_size_limit=MAX_AVATAR_SIZE,
allowed_mime_types=sorted(ALLOWED_CONTENT_TYPES),
)
# #116/#118: start the fire-and-forget observability drain thread so LLM
# usage + event rows flush off the request path.
from services import events_service
events_service.start_worker()
yield
# No shutdown hooks today.
# Stop the drain thread and flush anything still queued so the last batch
# of usage rows isn't lost on shutdown.
events_service.shutdown()


def _drop_request_arguments(_request, _attributes):
Expand DownExpand Up@@ -199,6 +206,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
app.include_router(onboarding.router, prefix="/api/onboarding")
app.include_router(profile_router, prefix="/api/profile")
app.include_router(admin_router, prefix="/api/admin")
app.include_router(admin_analytics_router, prefix="/api/admin/analytics")
app.include_router(newsletter_router, prefix="/api/newsletter")
app.include_router(gradebook.router, prefix="/api/gradebook")
app.include_router(gradescope.router, prefix="/api/gradescope")
Expand DownExpand Up@@ -267,9 +275,13 @@ def gemini_test(request: Request):
require_admin(request) # 403 unless the session belongs to an admin; 401 if unauthenticated
from agents._run import run_agent_sync
from agents.health import health_probe_agent
from agents.usage import record_agent_usage
try:
result = run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
result = record_agent_usage(
run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
),
feature="health",
)
return {"ok": True, "reply": result.output.strip()}
except Exception as e:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions backend/agents/document.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@
from agents.concept_extraction import concept_extraction_agent, ConceptList
from agents.syllabus_extraction import syllabus_extraction_agent, SyllabusAssignments
from agents.tools.graph import apply_concepts_to_graph
from agents.usage import record_agent_usage
from services.durable import workflow as durable_workflow, step as durable_step


Expand DownExpand Up@@ -86,25 +87,37 @@ class _WorkerResults:

@durable_step
async def _step_classify(text: str, deps: SaplingDeps) -> DocumentClassification:
result = await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="classifier", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_summary(text: str, deps: SaplingDeps) -> Summary:
result = await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="summary", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_concepts(text: str, deps: SaplingDeps) -> ConceptList:
result = await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="concepts", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_syllabus(text: str, deps: SaplingDeps) -> SyllabusAssignments:
result = await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="syllabus", user_id=deps.user_id,
)
return result.output


Expand Down
82 changes: 82 additions & 0 deletions backend/agents/usage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-line usage capture for Pydantic AI agent runs (issue #118).

Every agent call site wraps its result in ``record_agent_usage(result,
feature=..., task=...)``. The helper reads ``result.usage()`` and the model
actually used, then hands them to ``events_service.log_llm_usage`` (which
normalizes tokens, computes cost, and enqueues off the request thread).

Two properties matter:

* **One line per call site.** Because it returns ``result`` unchanged, a call
site can wrap inline (``result = record_agent_usage(await agent.run(...),
feature=..., task=...)``) or add it as a trailing statement.
* **Never raises.** Instrumentation must not break an agent run, so every
failure — a result shape we don't recognize, a usage extraction slip — is
swallowed and logged at debug level.

The ``model`` is read from the result's final ``ModelResponse`` (the model the
provider actually served); if that's unavailable it falls back to the task's
configured model via ``_providers.model_for``.
"""

from __future__ import annotations

import logging
from typing import Any

from agents._providers import AgentTask, model_for
from services import events_service

logger = logging.getLogger("sapling.agents.usage")


def _model_name(result: Any, task: AgentTask | None) -> str:
"""Best-effort model id for the run, resilient to Pydantic AI churn."""
# Preferred: the final ModelResponse carries the served model name.
try:
name = getattr(result.response, "model_name", None)
if name:
return name
except Exception:
pass
# Fallback: scan the message history for the last response with a model.
try:
for msg in reversed(result.all_messages()):
name = getattr(msg, "model_name", None)
if name:
return name
except Exception:
pass
# Last resort: the task's configured default model.
if task is not None:
try:
return model_for(task).model_name
except Exception:
pass
return "unknown"


def record_agent_usage(
result: Any,
*,
feature: str,
task: AgentTask | None = None,
user_id: str | None = None,
) -> Any:
"""Record token usage for an agent run and return ``result`` unchanged.

``user_id`` is optional: pass it where the actor is in scope (routes with a
``deps.user_id`` / request body) for per-user rollups; omit it and the
request_id from the contextvar still attributes the row.
"""
try:
events_service.log_llm_usage(
feature=feature,
task=task,
model=_model_name(result, task),
usage=result.usage(),
user_id=user_id,
)
except Exception:
logger.debug("record_agent_usage: could not capture usage", exc_info=True)
return result
57 changes: 57 additions & 0 deletions backend/db/migrations/0035_observability.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
-- 0035: observability tables (issue #115) — the two tables backing the site
-- logging + usage-tracking system.
--
-- events — flexible, high-volume analytics / audit / error events.
-- llm_usage — structured per-call LLM token + cost rows, queried with
-- SUM/GROUP BY for billing rollups.
--
-- Notes / deviations from the original issue spec:
-- * Ids and user_id are TEXT, not uuid, to match the rest of this schema
-- (users.id is TEXT, e.g. 'user_andres'); a uuid user_id column could not
-- hold the existing text ids. Same gen_random_uuid()::text PK style as
-- 0026_ops.sql.
-- * No FK on user_id. These are append-only, high-write analytics tables and
-- user_id is intentionally nullable (system/anonymous actors); we don't
-- want a per-row FK check on the write path or cascade coupling to the
-- users lifecycle. Kept as a plain nullable column.
-- * NO raw-content columns. Sensitive content is represented only by
-- content_fp (a 16-hex sha256 fingerprint). Never store message text,
-- document text, or names here.
-- * admin_audit_log is deliberately untouched — events complements it.
--
-- Idempotent (IF NOT EXISTS throughout) so it is safe to re-run.

-- ── events ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
event_type TEXT NOT NULL, -- dotted taxonomy: document.upload, auth.login, error.5xx
category TEXT NOT NULL, -- usage | audit | error
user_id TEXT, -- actor; NULL for anonymous/system
request_id TEXT, -- correlates to RequestIDMiddleware + Logfire
payload JSONB NOT NULL DEFAULT '{}', -- type-specific metadata (counts, ids, status_code, duration_ms…)
content_fp TEXT, -- sha256 fingerprint (16 hex) — never raw content
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_events_user_created ON events (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_type_created ON events (event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_cat_created ON events (category, created_at DESC);

-- ── llm_usage ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS llm_usage (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT, -- per-user rollups; NULL for system
request_id TEXT,
feature TEXT NOT NULL, -- quiz | chat_tutor | document | notes …
task TEXT, -- matches agents/_providers.py task slots
model TEXT NOT NULL, -- e.g. gemini-2.5-flash
provider TEXT NOT NULL DEFAULT 'gemini',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(12,6), -- NULL when the model isn't priced
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_llm_usage_user_created ON llm_usage (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_llm_usage_feature_created ON llm_usage (feature, created_at DESC);
18 changes: 15 additions & 3 deletions backend/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
from routes import graph, learn, quiz, calendar, social, extract, auth, documents, flashcards, study_guide, feedback, careers, onboarding, gradebook, gradescope, notes, academics
from routes.profile import router as profile_router
from routes.admin import router as admin_router
from routes.admin_analytics import router as admin_analytics_router
from routes.newsletter import router as newsletter_router
from services.logfire_scrubber import EXTRA_PATTERNS, scrub_value
from services.request_context import RequestIDMiddleware, current_request_id
Expand DownExpand Up@@ -80,8 +81,14 @@ async def _lifespan(_app: FastAPI):
file_size_limit=MAX_AVATAR_SIZE,
allowed_mime_types=sorted(ALLOWED_CONTENT_TYPES),
)
# #116/#118: start the fire-and-forget observability drain thread so LLM
# usage + event rows flush off the request path.
from services import events_service
events_service.start_worker()
yield
# No shutdown hooks today.
# Stop the drain thread and flush anything still queued so the last batch
# of usage rows isn't lost on shutdown.
events_service.shutdown()


def _drop_request_arguments(_request, _attributes):
Expand DownExpand Up@@ -199,6 +206,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
app.include_router(onboarding.router, prefix="/api/onboarding")
app.include_router(profile_router, prefix="/api/profile")
app.include_router(admin_router, prefix="/api/admin")
app.include_router(admin_analytics_router, prefix="/api/admin/analytics")
app.include_router(newsletter_router, prefix="/api/newsletter")
app.include_router(gradebook.router, prefix="/api/gradebook")
app.include_router(gradescope.router, prefix="/api/gradescope")
Expand DownExpand Up@@ -267,9 +275,13 @@ def gemini_test(request: Request):
require_admin(request) # 403 unless the session belongs to an admin; 401 if unauthenticated
from agents._run import run_agent_sync
from agents.health import health_probe_agent
from agents.usage import record_agent_usage
try:
result = run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
result = record_agent_usage(
run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
),
feature="health",
)
return {"ok": True, "reply": result.output.strip()}
except Exception as e:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions backend/agents/document.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@
from agents.concept_extraction import concept_extraction_agent, ConceptList
from agents.syllabus_extraction import syllabus_extraction_agent, SyllabusAssignments
from agents.tools.graph import apply_concepts_to_graph
from agents.usage import record_agent_usage
from services.durable import workflow as durable_workflow, step as durable_step


Expand DownExpand Up@@ -86,25 +87,37 @@ class _WorkerResults:

@durable_step
async def _step_classify(text: str, deps: SaplingDeps) -> DocumentClassification:
result = await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="classifier", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_summary(text: str, deps: SaplingDeps) -> Summary:
result = await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="summary", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_concepts(text: str, deps: SaplingDeps) -> ConceptList:
result = await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="concepts", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_syllabus(text: str, deps: SaplingDeps) -> SyllabusAssignments:
result = await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="syllabus", user_id=deps.user_id,
)
return result.output


Expand Down
82 changes: 82 additions & 0 deletions backend/agents/usage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-line usage capture for Pydantic AI agent runs (issue #118).

Every agent call site wraps its result in ``record_agent_usage(result,
feature=..., task=...)``. The helper reads ``result.usage()`` and the model
actually used, then hands them to ``events_service.log_llm_usage`` (which
normalizes tokens, computes cost, and enqueues off the request thread).

Two properties matter:

* **One line per call site.** Because it returns ``result`` unchanged, a call
site can wrap inline (``result = record_agent_usage(await agent.run(...),
feature=..., task=...)``) or add it as a trailing statement.
* **Never raises.** Instrumentation must not break an agent run, so every
failure — a result shape we don't recognize, a usage extraction slip — is
swallowed and logged at debug level.

The ``model`` is read from the result's final ``ModelResponse`` (the model the
provider actually served); if that's unavailable it falls back to the task's
configured model via ``_providers.model_for``.
"""

from __future__ import annotations

import logging
from typing import Any

from agents._providers import AgentTask, model_for
from services import events_service

logger = logging.getLogger("sapling.agents.usage")


def _model_name(result: Any, task: AgentTask | None) -> str:
"""Best-effort model id for the run, resilient to Pydantic AI churn."""
# Preferred: the final ModelResponse carries the served model name.
try:
name = getattr(result.response, "model_name", None)
if name:
return name
except Exception:
pass
# Fallback: scan the message history for the last response with a model.
try:
for msg in reversed(result.all_messages()):
name = getattr(msg, "model_name", None)
if name:
return name
except Exception:
pass
# Last resort: the task's configured default model.
if task is not None:
try:
return model_for(task).model_name
except Exception:
pass
return "unknown"


def record_agent_usage(
result: Any,
*,
feature: str,
task: AgentTask | None = None,
user_id: str | None = None,
) -> Any:
"""Record token usage for an agent run and return ``result`` unchanged.

``user_id`` is optional: pass it where the actor is in scope (routes with a
``deps.user_id`` / request body) for per-user rollups; omit it and the
request_id from the contextvar still attributes the row.
"""
try:
events_service.log_llm_usage(
feature=feature,
task=task,
model=_model_name(result, task),
usage=result.usage(),
user_id=user_id,
)
except Exception:
logger.debug("record_agent_usage: could not capture usage", exc_info=True)
return result
57 changes: 57 additions & 0 deletions backend/db/migrations/0035_observability.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
-- 0035: observability tables (issue #115) — the two tables backing the site
-- logging + usage-tracking system.
--
-- events — flexible, high-volume analytics / audit / error events.
-- llm_usage — structured per-call LLM token + cost rows, queried with
-- SUM/GROUP BY for billing rollups.
--
-- Notes / deviations from the original issue spec:
-- * Ids and user_id are TEXT, not uuid, to match the rest of this schema
-- (users.id is TEXT, e.g. 'user_andres'); a uuid user_id column could not
-- hold the existing text ids. Same gen_random_uuid()::text PK style as
-- 0026_ops.sql.
-- * No FK on user_id. These are append-only, high-write analytics tables and
-- user_id is intentionally nullable (system/anonymous actors); we don't
-- want a per-row FK check on the write path or cascade coupling to the
-- users lifecycle. Kept as a plain nullable column.
-- * NO raw-content columns. Sensitive content is represented only by
-- content_fp (a 16-hex sha256 fingerprint). Never store message text,
-- document text, or names here.
-- * admin_audit_log is deliberately untouched — events complements it.
--
-- Idempotent (IF NOT EXISTS throughout) so it is safe to re-run.

-- ── events ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
event_type TEXT NOT NULL, -- dotted taxonomy: document.upload, auth.login, error.5xx
category TEXT NOT NULL, -- usage | audit | error
user_id TEXT, -- actor; NULL for anonymous/system
request_id TEXT, -- correlates to RequestIDMiddleware + Logfire
payload JSONB NOT NULL DEFAULT '{}', -- type-specific metadata (counts, ids, status_code, duration_ms…)
content_fp TEXT, -- sha256 fingerprint (16 hex) — never raw content
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_events_user_created ON events (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_type_created ON events (event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_cat_created ON events (category, created_at DESC);

-- ── llm_usage ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS llm_usage (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT, -- per-user rollups; NULL for system
request_id TEXT,
feature TEXT NOT NULL, -- quiz | chat_tutor | document | notes …
task TEXT, -- matches agents/_providers.py task slots
model TEXT NOT NULL, -- e.g. gemini-2.5-flash
provider TEXT NOT NULL DEFAULT 'gemini',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(12,6), -- NULL when the model isn't priced
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_llm_usage_user_created ON llm_usage (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_llm_usage_feature_created ON llm_usage (feature, created_at DESC);
18 changes: 15 additions & 3 deletions backend/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
from routes import graph, learn, quiz, calendar, social, extract, auth, documents, flashcards, study_guide, feedback, careers, onboarding, gradebook, gradescope, notes, academics
from routes.profile import router as profile_router
from routes.admin import router as admin_router
from routes.admin_analytics import router as admin_analytics_router
from routes.newsletter import router as newsletter_router
from services.logfire_scrubber import EXTRA_PATTERNS, scrub_value
from services.request_context import RequestIDMiddleware, current_request_id
Expand DownExpand Up@@ -80,8 +81,14 @@ async def _lifespan(_app: FastAPI):
file_size_limit=MAX_AVATAR_SIZE,
allowed_mime_types=sorted(ALLOWED_CONTENT_TYPES),
)
# #116/#118: start the fire-and-forget observability drain thread so LLM
# usage + event rows flush off the request path.
from services import events_service
events_service.start_worker()
yield
# No shutdown hooks today.
# Stop the drain thread and flush anything still queued so the last batch
# of usage rows isn't lost on shutdown.
events_service.shutdown()


def _drop_request_arguments(_request, _attributes):
Expand DownExpand Up@@ -199,6 +206,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
app.include_router(onboarding.router, prefix="/api/onboarding")
app.include_router(profile_router, prefix="/api/profile")
app.include_router(admin_router, prefix="/api/admin")
app.include_router(admin_analytics_router, prefix="/api/admin/analytics")
app.include_router(newsletter_router, prefix="/api/newsletter")
app.include_router(gradebook.router, prefix="/api/gradebook")
app.include_router(gradescope.router, prefix="/api/gradescope")
Expand DownExpand Up@@ -267,9 +275,13 @@ def gemini_test(request: Request):
require_admin(request) # 403 unless the session belongs to an admin; 401 if unauthenticated
from agents._run import run_agent_sync
from agents.health import health_probe_agent
from agents.usage import record_agent_usage
try:
result = run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
result = record_agent_usage(
run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
),
feature="health",
)
return {"ok": True, "reply": result.output.strip()}
except Exception as e:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions backend/agents/document.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@
from agents.concept_extraction import concept_extraction_agent, ConceptList
from agents.syllabus_extraction import syllabus_extraction_agent, SyllabusAssignments
from agents.tools.graph import apply_concepts_to_graph
from agents.usage import record_agent_usage
from services.durable import workflow as durable_workflow, step as durable_step


Expand DownExpand Up@@ -86,25 +87,37 @@ class _WorkerResults:

@durable_step
async def _step_classify(text: str, deps: SaplingDeps) -> DocumentClassification:
result = await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="classifier", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_summary(text: str, deps: SaplingDeps) -> Summary:
result = await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="summary", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_concepts(text: str, deps: SaplingDeps) -> ConceptList:
result = await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="concepts", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_syllabus(text: str, deps: SaplingDeps) -> SyllabusAssignments:
result = await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="syllabus", user_id=deps.user_id,
)
return result.output


Expand Down
82 changes: 82 additions & 0 deletions backend/agents/usage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-line usage capture for Pydantic AI agent runs (issue #118).

Every agent call site wraps its result in ``record_agent_usage(result,
feature=..., task=...)``. The helper reads ``result.usage()`` and the model
actually used, then hands them to ``events_service.log_llm_usage`` (which
normalizes tokens, computes cost, and enqueues off the request thread).

Two properties matter:

* **One line per call site.** Because it returns ``result`` unchanged, a call
site can wrap inline (``result = record_agent_usage(await agent.run(...),
feature=..., task=...)``) or add it as a trailing statement.
* **Never raises.** Instrumentation must not break an agent run, so every
failure — a result shape we don't recognize, a usage extraction slip — is
swallowed and logged at debug level.

The ``model`` is read from the result's final ``ModelResponse`` (the model the
provider actually served); if that's unavailable it falls back to the task's
configured model via ``_providers.model_for``.
"""

from __future__ import annotations

import logging
from typing import Any

from agents._providers import AgentTask, model_for
from services import events_service

logger = logging.getLogger("sapling.agents.usage")


def _model_name(result: Any, task: AgentTask | None) -> str:
"""Best-effort model id for the run, resilient to Pydantic AI churn."""
# Preferred: the final ModelResponse carries the served model name.
try:
name = getattr(result.response, "model_name", None)
if name:
return name
except Exception:
pass
# Fallback: scan the message history for the last response with a model.
try:
for msg in reversed(result.all_messages()):
name = getattr(msg, "model_name", None)
if name:
return name
except Exception:
pass
# Last resort: the task's configured default model.
if task is not None:
try:
return model_for(task).model_name
except Exception:
pass
return "unknown"


def record_agent_usage(
result: Any,
*,
feature: str,
task: AgentTask | None = None,
user_id: str | None = None,
) -> Any:
"""Record token usage for an agent run and return ``result`` unchanged.

``user_id`` is optional: pass it where the actor is in scope (routes with a
``deps.user_id`` / request body) for per-user rollups; omit it and the
request_id from the contextvar still attributes the row.
"""
try:
events_service.log_llm_usage(
feature=feature,
task=task,
model=_model_name(result, task),
usage=result.usage(),
user_id=user_id,
)
except Exception:
logger.debug("record_agent_usage: could not capture usage", exc_info=True)
return result
57 changes: 57 additions & 0 deletions backend/db/migrations/0035_observability.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
-- 0035: observability tables (issue #115) — the two tables backing the site
-- logging + usage-tracking system.
--
-- events — flexible, high-volume analytics / audit / error events.
-- llm_usage — structured per-call LLM token + cost rows, queried with
-- SUM/GROUP BY for billing rollups.
--
-- Notes / deviations from the original issue spec:
-- * Ids and user_id are TEXT, not uuid, to match the rest of this schema
-- (users.id is TEXT, e.g. 'user_andres'); a uuid user_id column could not
-- hold the existing text ids. Same gen_random_uuid()::text PK style as
-- 0026_ops.sql.
-- * No FK on user_id. These are append-only, high-write analytics tables and
-- user_id is intentionally nullable (system/anonymous actors); we don't
-- want a per-row FK check on the write path or cascade coupling to the
-- users lifecycle. Kept as a plain nullable column.
-- * NO raw-content columns. Sensitive content is represented only by
-- content_fp (a 16-hex sha256 fingerprint). Never store message text,
-- document text, or names here.
-- * admin_audit_log is deliberately untouched — events complements it.
--
-- Idempotent (IF NOT EXISTS throughout) so it is safe to re-run.

-- ── events ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
event_type TEXT NOT NULL, -- dotted taxonomy: document.upload, auth.login, error.5xx
category TEXT NOT NULL, -- usage | audit | error
user_id TEXT, -- actor; NULL for anonymous/system
request_id TEXT, -- correlates to RequestIDMiddleware + Logfire
payload JSONB NOT NULL DEFAULT '{}', -- type-specific metadata (counts, ids, status_code, duration_ms…)
content_fp TEXT, -- sha256 fingerprint (16 hex) — never raw content
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_events_user_created ON events (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_type_created ON events (event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_cat_created ON events (category, created_at DESC);

-- ── llm_usage ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS llm_usage (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT, -- per-user rollups; NULL for system
request_id TEXT,
feature TEXT NOT NULL, -- quiz | chat_tutor | document | notes …
task TEXT, -- matches agents/_providers.py task slots
model TEXT NOT NULL, -- e.g. gemini-2.5-flash
provider TEXT NOT NULL DEFAULT 'gemini',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(12,6), -- NULL when the model isn't priced
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_llm_usage_user_created ON llm_usage (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_llm_usage_feature_created ON llm_usage (feature, created_at DESC);
18 changes: 15 additions & 3 deletions backend/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
from routes import graph, learn, quiz, calendar, social, extract, auth, documents, flashcards, study_guide, feedback, careers, onboarding, gradebook, gradescope, notes, academics
from routes.profile import router as profile_router
from routes.admin import router as admin_router
from routes.admin_analytics import router as admin_analytics_router
from routes.newsletter import router as newsletter_router
from services.logfire_scrubber import EXTRA_PATTERNS, scrub_value
from services.request_context import RequestIDMiddleware, current_request_id
Expand DownExpand Up@@ -80,8 +81,14 @@ async def _lifespan(_app: FastAPI):
file_size_limit=MAX_AVATAR_SIZE,
allowed_mime_types=sorted(ALLOWED_CONTENT_TYPES),
)
# #116/#118: start the fire-and-forget observability drain thread so LLM
# usage + event rows flush off the request path.
from services import events_service
events_service.start_worker()
yield
# No shutdown hooks today.
# Stop the drain thread and flush anything still queued so the last batch
# of usage rows isn't lost on shutdown.
events_service.shutdown()


def _drop_request_arguments(_request, _attributes):
Expand DownExpand Up@@ -199,6 +206,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
app.include_router(onboarding.router, prefix="/api/onboarding")
app.include_router(profile_router, prefix="/api/profile")
app.include_router(admin_router, prefix="/api/admin")
app.include_router(admin_analytics_router, prefix="/api/admin/analytics")
app.include_router(newsletter_router, prefix="/api/newsletter")
app.include_router(gradebook.router, prefix="/api/gradebook")
app.include_router(gradescope.router, prefix="/api/gradescope")
Expand DownExpand Up@@ -267,9 +275,13 @@ def gemini_test(request: Request):
require_admin(request) # 403 unless the session belongs to an admin; 401 if unauthenticated
from agents._run import run_agent_sync
from agents.health import health_probe_agent
from agents.usage import record_agent_usage
try:
result = run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
result = record_agent_usage(
run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
),
feature="health",
)
return {"ok": True, "reply": result.output.strip()}
except Exception as e:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions backend/agents/document.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@
from agents.concept_extraction import concept_extraction_agent, ConceptList
from agents.syllabus_extraction import syllabus_extraction_agent, SyllabusAssignments
from agents.tools.graph import apply_concepts_to_graph
from agents.usage import record_agent_usage
from services.durable import workflow as durable_workflow, step as durable_step


Expand DownExpand Up@@ -86,25 +87,37 @@ class _WorkerResults:

@durable_step
async def _step_classify(text: str, deps: SaplingDeps) -> DocumentClassification:
result = await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="classifier", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_summary(text: str, deps: SaplingDeps) -> Summary:
result = await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="summary", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_concepts(text: str, deps: SaplingDeps) -> ConceptList:
result = await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="concepts", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_syllabus(text: str, deps: SaplingDeps) -> SyllabusAssignments:
result = await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="syllabus", user_id=deps.user_id,
)
return result.output


Expand Down
82 changes: 82 additions & 0 deletions backend/agents/usage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-line usage capture for Pydantic AI agent runs (issue #118).

Every agent call site wraps its result in ``record_agent_usage(result,
feature=..., task=...)``. The helper reads ``result.usage()`` and the model
actually used, then hands them to ``events_service.log_llm_usage`` (which
normalizes tokens, computes cost, and enqueues off the request thread).

Two properties matter:

* **One line per call site.** Because it returns ``result`` unchanged, a call
site can wrap inline (``result = record_agent_usage(await agent.run(...),
feature=..., task=...)``) or add it as a trailing statement.
* **Never raises.** Instrumentation must not break an agent run, so every
failure — a result shape we don't recognize, a usage extraction slip — is
swallowed and logged at debug level.

The ``model`` is read from the result's final ``ModelResponse`` (the model the
provider actually served); if that's unavailable it falls back to the task's
configured model via ``_providers.model_for``.
"""

from __future__ import annotations

import logging
from typing import Any

from agents._providers import AgentTask, model_for
from services import events_service

logger = logging.getLogger("sapling.agents.usage")


def _model_name(result: Any, task: AgentTask | None) -> str:
"""Best-effort model id for the run, resilient to Pydantic AI churn."""
# Preferred: the final ModelResponse carries the served model name.
try:
name = getattr(result.response, "model_name", None)
if name:
return name
except Exception:
pass
# Fallback: scan the message history for the last response with a model.
try:
for msg in reversed(result.all_messages()):
name = getattr(msg, "model_name", None)
if name:
return name
except Exception:
pass
# Last resort: the task's configured default model.
if task is not None:
try:
return model_for(task).model_name
except Exception:
pass
return "unknown"


def record_agent_usage(
result: Any,
*,
feature: str,
task: AgentTask | None = None,
user_id: str | None = None,
) -> Any:
"""Record token usage for an agent run and return ``result`` unchanged.

``user_id`` is optional: pass it where the actor is in scope (routes with a
``deps.user_id`` / request body) for per-user rollups; omit it and the
request_id from the contextvar still attributes the row.
"""
try:
events_service.log_llm_usage(
feature=feature,
task=task,
model=_model_name(result, task),
usage=result.usage(),
user_id=user_id,
)
except Exception:
logger.debug("record_agent_usage: could not capture usage", exc_info=True)
return result
57 changes: 57 additions & 0 deletions backend/db/migrations/0035_observability.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
-- 0035: observability tables (issue #115) — the two tables backing the site
-- logging + usage-tracking system.
--
-- events — flexible, high-volume analytics / audit / error events.
-- llm_usage — structured per-call LLM token + cost rows, queried with
-- SUM/GROUP BY for billing rollups.
--
-- Notes / deviations from the original issue spec:
-- * Ids and user_id are TEXT, not uuid, to match the rest of this schema
-- (users.id is TEXT, e.g. 'user_andres'); a uuid user_id column could not
-- hold the existing text ids. Same gen_random_uuid()::text PK style as
-- 0026_ops.sql.
-- * No FK on user_id. These are append-only, high-write analytics tables and
-- user_id is intentionally nullable (system/anonymous actors); we don't
-- want a per-row FK check on the write path or cascade coupling to the
-- users lifecycle. Kept as a plain nullable column.
-- * NO raw-content columns. Sensitive content is represented only by
-- content_fp (a 16-hex sha256 fingerprint). Never store message text,
-- document text, or names here.
-- * admin_audit_log is deliberately untouched — events complements it.
--
-- Idempotent (IF NOT EXISTS throughout) so it is safe to re-run.

-- ── events ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
event_type TEXT NOT NULL, -- dotted taxonomy: document.upload, auth.login, error.5xx
category TEXT NOT NULL, -- usage | audit | error
user_id TEXT, -- actor; NULL for anonymous/system
request_id TEXT, -- correlates to RequestIDMiddleware + Logfire
payload JSONB NOT NULL DEFAULT '{}', -- type-specific metadata (counts, ids, status_code, duration_ms…)
content_fp TEXT, -- sha256 fingerprint (16 hex) — never raw content
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_events_user_created ON events (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_type_created ON events (event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_cat_created ON events (category, created_at DESC);

-- ── llm_usage ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS llm_usage (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT, -- per-user rollups; NULL for system
request_id TEXT,
feature TEXT NOT NULL, -- quiz | chat_tutor | document | notes …
task TEXT, -- matches agents/_providers.py task slots
model TEXT NOT NULL, -- e.g. gemini-2.5-flash
provider TEXT NOT NULL DEFAULT 'gemini',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(12,6), -- NULL when the model isn't priced
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_llm_usage_user_created ON llm_usage (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_llm_usage_feature_created ON llm_usage (feature, created_at DESC);
18 changes: 15 additions & 3 deletions backend/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
from routes import graph, learn, quiz, calendar, social, extract, auth, documents, flashcards, study_guide, feedback, careers, onboarding, gradebook, gradescope, notes, academics
from routes.profile import router as profile_router
from routes.admin import router as admin_router
from routes.admin_analytics import router as admin_analytics_router
from routes.newsletter import router as newsletter_router
from services.logfire_scrubber import EXTRA_PATTERNS, scrub_value
from services.request_context import RequestIDMiddleware, current_request_id
Expand DownExpand Up@@ -80,8 +81,14 @@ async def _lifespan(_app: FastAPI):
file_size_limit=MAX_AVATAR_SIZE,
allowed_mime_types=sorted(ALLOWED_CONTENT_TYPES),
)
# #116/#118: start the fire-and-forget observability drain thread so LLM
# usage + event rows flush off the request path.
from services import events_service
events_service.start_worker()
yield
# No shutdown hooks today.
# Stop the drain thread and flush anything still queued so the last batch
# of usage rows isn't lost on shutdown.
events_service.shutdown()


def _drop_request_arguments(_request, _attributes):
Expand DownExpand Up@@ -199,6 +206,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
app.include_router(onboarding.router, prefix="/api/onboarding")
app.include_router(profile_router, prefix="/api/profile")
app.include_router(admin_router, prefix="/api/admin")
app.include_router(admin_analytics_router, prefix="/api/admin/analytics")
app.include_router(newsletter_router, prefix="/api/newsletter")
app.include_router(gradebook.router, prefix="/api/gradebook")
app.include_router(gradescope.router, prefix="/api/gradescope")
Expand DownExpand Up@@ -267,9 +275,13 @@ def gemini_test(request: Request):
require_admin(request) # 403 unless the session belongs to an admin; 401 if unauthenticated
from agents._run import run_agent_sync
from agents.health import health_probe_agent
from agents.usage import record_agent_usage
try:
result = run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
result = record_agent_usage(
run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
),
feature="health",
)
return {"ok": True, "reply": result.output.strip()}
except Exception as e:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions backend/agents/document.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@
from agents.concept_extraction import concept_extraction_agent, ConceptList
from agents.syllabus_extraction import syllabus_extraction_agent, SyllabusAssignments
from agents.tools.graph import apply_concepts_to_graph
from agents.usage import record_agent_usage
from services.durable import workflow as durable_workflow, step as durable_step


Expand DownExpand Up@@ -86,25 +87,37 @@ class _WorkerResults:

@durable_step
async def _step_classify(text: str, deps: SaplingDeps) -> DocumentClassification:
result = await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="classifier", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_summary(text: str, deps: SaplingDeps) -> Summary:
result = await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="summary", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_concepts(text: str, deps: SaplingDeps) -> ConceptList:
result = await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="concepts", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_syllabus(text: str, deps: SaplingDeps) -> SyllabusAssignments:
result = await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="syllabus", user_id=deps.user_id,
)
return result.output


Expand Down
82 changes: 82 additions & 0 deletions backend/agents/usage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-line usage capture for Pydantic AI agent runs (issue #118).

Every agent call site wraps its result in ``record_agent_usage(result,
feature=..., task=...)``. The helper reads ``result.usage()`` and the model
actually used, then hands them to ``events_service.log_llm_usage`` (which
normalizes tokens, computes cost, and enqueues off the request thread).

Two properties matter:

* **One line per call site.** Because it returns ``result`` unchanged, a call
site can wrap inline (``result = record_agent_usage(await agent.run(...),
feature=..., task=...)``) or add it as a trailing statement.
* **Never raises.** Instrumentation must not break an agent run, so every
failure — a result shape we don't recognize, a usage extraction slip — is
swallowed and logged at debug level.

The ``model`` is read from the result's final ``ModelResponse`` (the model the
provider actually served); if that's unavailable it falls back to the task's
configured model via ``_providers.model_for``.
"""

from __future__ import annotations

import logging
from typing import Any

from agents._providers import AgentTask, model_for
from services import events_service

logger = logging.getLogger("sapling.agents.usage")


def _model_name(result: Any, task: AgentTask | None) -> str:
"""Best-effort model id for the run, resilient to Pydantic AI churn."""
# Preferred: the final ModelResponse carries the served model name.
try:
name = getattr(result.response, "model_name", None)
if name:
return name
except Exception:
pass
# Fallback: scan the message history for the last response with a model.
try:
for msg in reversed(result.all_messages()):
name = getattr(msg, "model_name", None)
if name:
return name
except Exception:
pass
# Last resort: the task's configured default model.
if task is not None:
try:
return model_for(task).model_name
except Exception:
pass
return "unknown"


def record_agent_usage(
result: Any,
*,
feature: str,
task: AgentTask | None = None,
user_id: str | None = None,
) -> Any:
"""Record token usage for an agent run and return ``result`` unchanged.

``user_id`` is optional: pass it where the actor is in scope (routes with a
``deps.user_id`` / request body) for per-user rollups; omit it and the
request_id from the contextvar still attributes the row.
"""
try:
events_service.log_llm_usage(
feature=feature,
task=task,
model=_model_name(result, task),
usage=result.usage(),
user_id=user_id,
)
except Exception:
logger.debug("record_agent_usage: could not capture usage", exc_info=True)
return result
57 changes: 57 additions & 0 deletions backend/db/migrations/0035_observability.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
-- 0035: observability tables (issue #115) — the two tables backing the site
-- logging + usage-tracking system.
--
-- events — flexible, high-volume analytics / audit / error events.
-- llm_usage — structured per-call LLM token + cost rows, queried with
-- SUM/GROUP BY for billing rollups.
--
-- Notes / deviations from the original issue spec:
-- * Ids and user_id are TEXT, not uuid, to match the rest of this schema
-- (users.id is TEXT, e.g. 'user_andres'); a uuid user_id column could not
-- hold the existing text ids. Same gen_random_uuid()::text PK style as
-- 0026_ops.sql.
-- * No FK on user_id. These are append-only, high-write analytics tables and
-- user_id is intentionally nullable (system/anonymous actors); we don't
-- want a per-row FK check on the write path or cascade coupling to the
-- users lifecycle. Kept as a plain nullable column.
-- * NO raw-content columns. Sensitive content is represented only by
-- content_fp (a 16-hex sha256 fingerprint). Never store message text,
-- document text, or names here.
-- * admin_audit_log is deliberately untouched — events complements it.
--
-- Idempotent (IF NOT EXISTS throughout) so it is safe to re-run.

-- ── events ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
event_type TEXT NOT NULL, -- dotted taxonomy: document.upload, auth.login, error.5xx
category TEXT NOT NULL, -- usage | audit | error
user_id TEXT, -- actor; NULL for anonymous/system
request_id TEXT, -- correlates to RequestIDMiddleware + Logfire
payload JSONB NOT NULL DEFAULT '{}', -- type-specific metadata (counts, ids, status_code, duration_ms…)
content_fp TEXT, -- sha256 fingerprint (16 hex) — never raw content
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_events_user_created ON events (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_type_created ON events (event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_cat_created ON events (category, created_at DESC);

-- ── llm_usage ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS llm_usage (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT, -- per-user rollups; NULL for system
request_id TEXT,
feature TEXT NOT NULL, -- quiz | chat_tutor | document | notes …
task TEXT, -- matches agents/_providers.py task slots
model TEXT NOT NULL, -- e.g. gemini-2.5-flash
provider TEXT NOT NULL DEFAULT 'gemini',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(12,6), -- NULL when the model isn't priced
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_llm_usage_user_created ON llm_usage (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_llm_usage_feature_created ON llm_usage (feature, created_at DESC);
18 changes: 15 additions & 3 deletions backend/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
from routes import graph, learn, quiz, calendar, social, extract, auth, documents, flashcards, study_guide, feedback, careers, onboarding, gradebook, gradescope, notes, academics
from routes.profile import router as profile_router
from routes.admin import router as admin_router
from routes.admin_analytics import router as admin_analytics_router
from routes.newsletter import router as newsletter_router
from services.logfire_scrubber import EXTRA_PATTERNS, scrub_value
from services.request_context import RequestIDMiddleware, current_request_id
Expand DownExpand Up@@ -80,8 +81,14 @@ async def _lifespan(_app: FastAPI):
file_size_limit=MAX_AVATAR_SIZE,
allowed_mime_types=sorted(ALLOWED_CONTENT_TYPES),
)
# #116/#118: start the fire-and-forget observability drain thread so LLM
# usage + event rows flush off the request path.
from services import events_service
events_service.start_worker()
yield
# No shutdown hooks today.
# Stop the drain thread and flush anything still queued so the last batch
# of usage rows isn't lost on shutdown.
events_service.shutdown()


def _drop_request_arguments(_request, _attributes):
Expand DownExpand Up@@ -199,6 +206,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
app.include_router(onboarding.router, prefix="/api/onboarding")
app.include_router(profile_router, prefix="/api/profile")
app.include_router(admin_router, prefix="/api/admin")
app.include_router(admin_analytics_router, prefix="/api/admin/analytics")
app.include_router(newsletter_router, prefix="/api/newsletter")
app.include_router(gradebook.router, prefix="/api/gradebook")
app.include_router(gradescope.router, prefix="/api/gradescope")
Expand DownExpand Up@@ -267,9 +275,13 @@ def gemini_test(request: Request):
require_admin(request) # 403 unless the session belongs to an admin; 401 if unauthenticated
from agents._run import run_agent_sync
from agents.health import health_probe_agent
from agents.usage import record_agent_usage
try:
result = run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
result = record_agent_usage(
run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
),
feature="health",
)
return {"ok": True, "reply": result.output.strip()}
except Exception as e:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions backend/agents/document.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@
from agents.concept_extraction import concept_extraction_agent, ConceptList
from agents.syllabus_extraction import syllabus_extraction_agent, SyllabusAssignments
from agents.tools.graph import apply_concepts_to_graph
from agents.usage import record_agent_usage
from services.durable import workflow as durable_workflow, step as durable_step


Expand DownExpand Up@@ -86,25 +87,37 @@ class _WorkerResults:

@durable_step
async def _step_classify(text: str, deps: SaplingDeps) -> DocumentClassification:
result = await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await classifier_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="classifier", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_summary(text: str, deps: SaplingDeps) -> Summary:
result = await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await summary_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="summary", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_concepts(text: str, deps: SaplingDeps) -> ConceptList:
result = await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await concept_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="concepts", user_id=deps.user_id,
)
return result.output


@durable_step
async def _step_syllabus(text: str, deps: SaplingDeps) -> SyllabusAssignments:
result = await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS)
result = record_agent_usage(
await syllabus_extraction_agent.run(text, deps=deps, usage_limits=WORKER_LIMITS),
feature="document", task="syllabus", user_id=deps.user_id,
)
return result.output


Expand Down
82 changes: 82 additions & 0 deletions backend/agents/usage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-line usage capture for Pydantic AI agent runs (issue #118).

Every agent call site wraps its result in ``record_agent_usage(result,
feature=..., task=...)``. The helper reads ``result.usage()`` and the model
actually used, then hands them to ``events_service.log_llm_usage`` (which
normalizes tokens, computes cost, and enqueues off the request thread).

Two properties matter:

* **One line per call site.** Because it returns ``result`` unchanged, a call
site can wrap inline (``result = record_agent_usage(await agent.run(...),
feature=..., task=...)``) or add it as a trailing statement.
* **Never raises.** Instrumentation must not break an agent run, so every
failure — a result shape we don't recognize, a usage extraction slip — is
swallowed and logged at debug level.

The ``model`` is read from the result's final ``ModelResponse`` (the model the
provider actually served); if that's unavailable it falls back to the task's
configured model via ``_providers.model_for``.
"""

from __future__ import annotations

import logging
from typing import Any

from agents._providers import AgentTask, model_for
from services import events_service

logger = logging.getLogger("sapling.agents.usage")


def _model_name(result: Any, task: AgentTask | None) -> str:
"""Best-effort model id for the run, resilient to Pydantic AI churn."""
# Preferred: the final ModelResponse carries the served model name.
try:
name = getattr(result.response, "model_name", None)
if name:
return name
except Exception:
pass
# Fallback: scan the message history for the last response with a model.
try:
for msg in reversed(result.all_messages()):
name = getattr(msg, "model_name", None)
if name:
return name
except Exception:
pass
# Last resort: the task's configured default model.
if task is not None:
try:
return model_for(task).model_name
except Exception:
pass
return "unknown"


def record_agent_usage(
result: Any,
*,
feature: str,
task: AgentTask | None = None,
user_id: str | None = None,
) -> Any:
"""Record token usage for an agent run and return ``result`` unchanged.

``user_id`` is optional: pass it where the actor is in scope (routes with a
``deps.user_id`` / request body) for per-user rollups; omit it and the
request_id from the contextvar still attributes the row.
"""
try:
events_service.log_llm_usage(
feature=feature,
task=task,
model=_model_name(result, task),
usage=result.usage(),
user_id=user_id,
)
except Exception:
logger.debug("record_agent_usage: could not capture usage", exc_info=True)
return result
57 changes: 57 additions & 0 deletions backend/db/migrations/0035_observability.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
-- 0035: observability tables (issue #115) — the two tables backing the site
-- logging + usage-tracking system.
--
-- events — flexible, high-volume analytics / audit / error events.
-- llm_usage — structured per-call LLM token + cost rows, queried with
-- SUM/GROUP BY for billing rollups.
--
-- Notes / deviations from the original issue spec:
-- * Ids and user_id are TEXT, not uuid, to match the rest of this schema
-- (users.id is TEXT, e.g. 'user_andres'); a uuid user_id column could not
-- hold the existing text ids. Same gen_random_uuid()::text PK style as
-- 0026_ops.sql.
-- * No FK on user_id. These are append-only, high-write analytics tables and
-- user_id is intentionally nullable (system/anonymous actors); we don't
-- want a per-row FK check on the write path or cascade coupling to the
-- users lifecycle. Kept as a plain nullable column.
-- * NO raw-content columns. Sensitive content is represented only by
-- content_fp (a 16-hex sha256 fingerprint). Never store message text,
-- document text, or names here.
-- * admin_audit_log is deliberately untouched — events complements it.
--
-- Idempotent (IF NOT EXISTS throughout) so it is safe to re-run.

-- ── events ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
event_type TEXT NOT NULL, -- dotted taxonomy: document.upload, auth.login, error.5xx
category TEXT NOT NULL, -- usage | audit | error
user_id TEXT, -- actor; NULL for anonymous/system
request_id TEXT, -- correlates to RequestIDMiddleware + Logfire
payload JSONB NOT NULL DEFAULT '{}', -- type-specific metadata (counts, ids, status_code, duration_ms…)
content_fp TEXT, -- sha256 fingerprint (16 hex) — never raw content
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_events_user_created ON events (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_type_created ON events (event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_cat_created ON events (category, created_at DESC);

-- ── llm_usage ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS llm_usage (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT, -- per-user rollups; NULL for system
request_id TEXT,
feature TEXT NOT NULL, -- quiz | chat_tutor | document | notes …
task TEXT, -- matches agents/_providers.py task slots
model TEXT NOT NULL, -- e.g. gemini-2.5-flash
provider TEXT NOT NULL DEFAULT 'gemini',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(12,6), -- NULL when the model isn't priced
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_llm_usage_user_created ON llm_usage (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_llm_usage_feature_created ON llm_usage (feature, created_at DESC);
18 changes: 15 additions & 3 deletions backend/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
from routes import graph, learn, quiz, calendar, social, extract, auth, documents, flashcards, study_guide, feedback, careers, onboarding, gradebook, gradescope, notes, academics
from routes.profile import router as profile_router
from routes.admin import router as admin_router
from routes.admin_analytics import router as admin_analytics_router
from routes.newsletter import router as newsletter_router
from services.logfire_scrubber import EXTRA_PATTERNS, scrub_value
from services.request_context import RequestIDMiddleware, current_request_id
Expand DownExpand Up@@ -80,8 +81,14 @@ async def _lifespan(_app: FastAPI):
file_size_limit=MAX_AVATAR_SIZE,
allowed_mime_types=sorted(ALLOWED_CONTENT_TYPES),
)
# #116/#118: start the fire-and-forget observability drain thread so LLM
# usage + event rows flush off the request path.
from services import events_service
events_service.start_worker()
yield
# No shutdown hooks today.
# Stop the drain thread and flush anything still queued so the last batch
# of usage rows isn't lost on shutdown.
events_service.shutdown()


def _drop_request_arguments(_request, _attributes):
Expand DownExpand Up@@ -199,6 +206,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
app.include_router(onboarding.router, prefix="/api/onboarding")
app.include_router(profile_router, prefix="/api/profile")
app.include_router(admin_router, prefix="/api/admin")
app.include_router(admin_analytics_router, prefix="/api/admin/analytics")
app.include_router(newsletter_router, prefix="/api/newsletter")
app.include_router(gradebook.router, prefix="/api/gradebook")
app.include_router(gradescope.router, prefix="/api/gradescope")
Expand DownExpand Up@@ -267,9 +275,13 @@ def gemini_test(request: Request):
require_admin(request) # 403 unless the session belongs to an admin; 401 if unauthenticated
from agents._run import run_agent_sync
from agents.health import health_probe_agent
from agents.usage import record_agent_usage
try:
result = run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
result = record_agent_usage(
run_agent_sync(
health_probe_agent.run('Reply with exactly the text: Gemini OK')
),
feature="health",
)
return {"ok": True, "reply": result.output.strip()}
except Exception as e:
Expand Down
Loading
Loading