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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,3 +17,6 @@ frontend/.openclaude/
# Build artifacts
frontend/next-env.d.ts
CLAUDE 2.md

# Trace logs
*.log
1 change: 1 addition & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,4 @@ OPENAI_API_KEY="YOUR_KEY"
QDRANT_URL=http://qdrant:6333
QDRANT_CLOUD_API_KEY="YOUR_KEY"
DATABASE_CONNECTION_STRING=postgresql://npmatch:npmatch@postgres:5432/npmatch
ANALYTICS_TOKEN="YOUR_TOKEN"
152 changes: 152 additions & 0 deletions backend/app/analytics.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
import hashlib
import logging
import os
from datetime import UTC, datetime, timedelta

from fastapi import Request

from app.db import get_pool

logger = logging.getLogger(__name__)

SEARCH_EVENT_INSERT = """
INSERT INTO search_events (query, framework, priorities, result_count, ip_hash, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
"""


def hash_ip(ip: str) -> str:
"""SHA-256 hash of the client IP, salted so it isn't reversible."""
salt = os.environ.get("ANALYTICS_SALT", "npmatch")
return hashlib.sha256(f"{salt}:{ip}".encode()).hexdigest()


def client_ip(request: Request) -> str:
"""Client IP, honoring X-Forwarded-For (set by the Next.js proxy)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"


async def record_page_view(*, ip_hash: str, user_agent: str | None, referrer: str | None) -> None:
pool = await get_pool()
await pool.execute(
"INSERT INTO page_views (ip_hash, user_agent, referrer) VALUES ($1, $2, $3)",
ip_hash,
user_agent,
referrer,
)


async def record_search(
*,
query: str,
framework: str | None,
priorities: list[str] | None,
result_count: int,
ip_hash: str,
user_agent: str | None,
) -> None:
pool = await get_pool()
await pool.execute(
SEARCH_EVENT_INSERT,
query,
framework,
", ".join(priorities) if priorities else None,
result_count,
ip_hash,
user_agent,
)


async def get_analytics_summary() -> dict:
pool = await get_pool()
cutoff = datetime.now(UTC) - timedelta(hours=24)

row = await pool.fetchrow(
"""
SELECT
(SELECT count(*) FROM page_views) AS total_visits,
(SELECT count(DISTINCT ip_hash) FROM page_views) AS unique_visitors,
(SELECT count(*) FROM search_events) AS total_searches,
(SELECT count(*) FROM page_views WHERE visited_at >= $1) AS visits_last_24h,
(SELECT count(*) FROM search_events WHERE searched_at >= $1) AS searches_last_24h
""",
cutoff,
)

top_queries = await pool.fetch(
"""
SELECT query, count(*) AS count
FROM search_events
GROUP BY query
ORDER BY count DESC, query ASC
LIMIT 10
"""
)
top_frameworks = await pool.fetch(
"""
SELECT COALESCE(NULLIF(framework, ''), 'any') AS framework, count(*) AS count
FROM search_events
GROUP BY COALESCE(NULLIF(framework, ''), 'any')
ORDER BY count DESC, framework ASC
LIMIT 10
"""
)
top_referrers = await pool.fetch(
"""
SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer, count(*) AS count
FROM page_views
GROUP BY COALESCE(NULLIF(referrer, ''), '(direct)')
ORDER BY count DESC, referrer ASC
LIMIT 10
"""
)

return {
"total_visits": row["total_visits"],
"unique_visitors": row["unique_visitors"],
"total_searches": row["total_searches"],
"visits_last_24h": row["visits_last_24h"],
"searches_last_24h": row["searches_last_24h"],
"top_queries": [{"label": r["query"], "count": r["count"]} for r in top_queries],
"top_frameworks": [{"label": r["framework"], "count": r["count"]} for r in top_frameworks],
"top_referrers": [{"label": r["referrer"], "count": r["count"]} for r in top_referrers],
}


async def list_page_views(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT visited_at, ip_hash, user_agent, referrer
FROM page_views
ORDER BY visited_at DESC
LIMIT $1
""",
limit,
)
return [dict(r) for r in rows]


async def list_searches(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT searched_at, query, framework, priorities, result_count
FROM search_events
ORDER BY searched_at DESC
LIMIT $1
""",
limit,
)
out = []
for row in rows:
item = dict(row)
priorities = item["priorities"]
item["priorities"] = [p.strip() for p in priorities.split(",")] if priorities else None
out.append(item)
return out
19 changes: 19 additions & 0 deletions backend/app/db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import os

import asyncpg

_pg_pool: asyncpg.Pool | None = None


async def get_pool() -> asyncpg.Pool:
"""Lazy initialization of the shared asyncpg pool."""
global _pg_pool

if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)

return _pg_pool
85 changes: 83 additions & 2 deletions backend/app/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,15 @@
from slowapi.util import get_remote_address

import app.env
from app.analytics import (
client_ip,
get_analytics_summary,
hash_ip,
list_page_views,
list_searches,
record_page_view,
record_search,
)
from app.llm import stream_response
from app.models import SearchRequest
from app.search import package_search
Expand DownExpand Up@@ -50,6 +59,66 @@ async def health():
return {"status": "ok"}


def _require_analytics_token(request: Request) -> None:
expected = os.environ.get("ANALYTICS_TOKEN")
if not expected:
raise HTTPException(status_code=503, detail="Analytics token not configured")
if request.headers.get("x-analytics-token") != expected:
raise HTTPException(status_code=401, detail="Unauthorized")


@app.post("/api/track/pageview")
@limiter.limit("120/minute")
async def track_pageview(request: Request):
referrer = None
try:
body = await request.json()
referrer = body.get("referrer")
except Exception:
pass

try:
await record_page_view(
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
referrer=referrer or request.headers.get("referer"),
)
except Exception as e:
logger.warning(f"Failed to record page view: {e}")

return {"ok": True}


@app.get("/api/analytics/summary")
async def analytics_summary(request: Request):
_require_analytics_token(request)
try:
return await get_analytics_summary()
except Exception:
logger.exception("Analytics summary failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/visits")
async def analytics_visits(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_page_views(limit=min(limit, 200))
except Exception:
logger.exception("Analytics visits failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/searches")
async def analytics_searches(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_searches(limit=min(limit, 200))
except Exception:
logger.exception("Analytics searches failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.post("/api/search")
@limiter.limit("2/minute")
async def search(request: Request, body: SearchRequest):
Expand All@@ -60,9 +129,21 @@ async def search(request: Request, body: SearchRequest):

try:
packages = await package_search(body.query)
except Exception:
logger.exception("Package search failed")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from None

try:
await record_search(
query=body.query,
framework=body.framework,
priorities=body.priorities,
result_count=len(packages),
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
)
except Exception as e:
logger.error(f"Package search failed: {e}")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from e
logger.warning(f"Failed to record search event: {e}")

if not packages:
logger.info("No packages found for query, returning empty response")
Expand Down
33 changes: 33 additions & 0 deletions backend/app/models.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
from datetime import datetime

from pydantic import BaseModel, Field


Expand All@@ -18,3 +20,34 @@ class Package(BaseModel):

class SearchResponse(BaseModel):
packages: list[Package]


class PageView(BaseModel):
visited_at: datetime
ip_hash: str
user_agent: str | None = None
referrer: str | None = None


class SearchEvent(BaseModel):
searched_at: datetime
query: str
framework: str | None = None
priorities: list[str] | None = None
result_count: int


class TopItem(BaseModel):
label: str
count: int


class AnalyticsSummary(BaseModel):
total_visits: int
unique_visitors: int
total_searches: int
visits_last_24h: int
searches_last_24h: int
top_queries: list[TopItem]
top_frameworks: list[TopItem]
top_referrers: list[TopItem]
20 changes: 4 additions & 16 deletions backend/app/search.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,11 @@
import logging
import os

import asyncpg
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient

from app.db import get_pool

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -40,19 +41,6 @@ def get_qdrant_client() -> AsyncQdrantClient:
COLLECTION_NAME = "npmatch"
CANDIDATE_LIMIT = 20

_pg_pool: asyncpg.Pool | None = None


async def _get_pool() -> asyncpg.Pool:
global _pg_pool
if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)
return _pg_pool


def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
scores: dict[str, float] = {}
Expand DownExpand Up@@ -89,7 +77,7 @@ async def _vector_search(embedding: list[float]) -> list[str]:


async def _fts_search(query: str) -> list[str]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT name,
Expand All@@ -115,7 +103,7 @@ def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:


async def _fetch_metadata(names: list[str]) -> list[dict]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"SELECT name, description, keywords, version FROM packages WHERE name = ANY($1)",
names,
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,3 +17,6 @@ frontend/.openclaude/
# Build artifacts
frontend/next-env.d.ts
CLAUDE 2.md

# Trace logs
*.log
1 change: 1 addition & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,4 @@ OPENAI_API_KEY="YOUR_KEY"
QDRANT_URL=http://qdrant:6333
QDRANT_CLOUD_API_KEY="YOUR_KEY"
DATABASE_CONNECTION_STRING=postgresql://npmatch:npmatch@postgres:5432/npmatch
ANALYTICS_TOKEN="YOUR_TOKEN"
152 changes: 152 additions & 0 deletions backend/app/analytics.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
import hashlib
import logging
import os
from datetime import UTC, datetime, timedelta

from fastapi import Request

from app.db import get_pool

logger = logging.getLogger(__name__)

SEARCH_EVENT_INSERT = """
INSERT INTO search_events (query, framework, priorities, result_count, ip_hash, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
"""


def hash_ip(ip: str) -> str:
"""SHA-256 hash of the client IP, salted so it isn't reversible."""
salt = os.environ.get("ANALYTICS_SALT", "npmatch")
return hashlib.sha256(f"{salt}:{ip}".encode()).hexdigest()


def client_ip(request: Request) -> str:
"""Client IP, honoring X-Forwarded-For (set by the Next.js proxy)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"


async def record_page_view(*, ip_hash: str, user_agent: str | None, referrer: str | None) -> None:
pool = await get_pool()
await pool.execute(
"INSERT INTO page_views (ip_hash, user_agent, referrer) VALUES ($1, $2, $3)",
ip_hash,
user_agent,
referrer,
)


async def record_search(
*,
query: str,
framework: str | None,
priorities: list[str] | None,
result_count: int,
ip_hash: str,
user_agent: str | None,
) -> None:
pool = await get_pool()
await pool.execute(
SEARCH_EVENT_INSERT,
query,
framework,
", ".join(priorities) if priorities else None,
result_count,
ip_hash,
user_agent,
)


async def get_analytics_summary() -> dict:
pool = await get_pool()
cutoff = datetime.now(UTC) - timedelta(hours=24)

row = await pool.fetchrow(
"""
SELECT
(SELECT count(*) FROM page_views) AS total_visits,
(SELECT count(DISTINCT ip_hash) FROM page_views) AS unique_visitors,
(SELECT count(*) FROM search_events) AS total_searches,
(SELECT count(*) FROM page_views WHERE visited_at >= $1) AS visits_last_24h,
(SELECT count(*) FROM search_events WHERE searched_at >= $1) AS searches_last_24h
""",
cutoff,
)

top_queries = await pool.fetch(
"""
SELECT query, count(*) AS count
FROM search_events
GROUP BY query
ORDER BY count DESC, query ASC
LIMIT 10
"""
)
top_frameworks = await pool.fetch(
"""
SELECT COALESCE(NULLIF(framework, ''), 'any') AS framework, count(*) AS count
FROM search_events
GROUP BY COALESCE(NULLIF(framework, ''), 'any')
ORDER BY count DESC, framework ASC
LIMIT 10
"""
)
top_referrers = await pool.fetch(
"""
SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer, count(*) AS count
FROM page_views
GROUP BY COALESCE(NULLIF(referrer, ''), '(direct)')
ORDER BY count DESC, referrer ASC
LIMIT 10
"""
)

return {
"total_visits": row["total_visits"],
"unique_visitors": row["unique_visitors"],
"total_searches": row["total_searches"],
"visits_last_24h": row["visits_last_24h"],
"searches_last_24h": row["searches_last_24h"],
"top_queries": [{"label": r["query"], "count": r["count"]} for r in top_queries],
"top_frameworks": [{"label": r["framework"], "count": r["count"]} for r in top_frameworks],
"top_referrers": [{"label": r["referrer"], "count": r["count"]} for r in top_referrers],
}


async def list_page_views(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT visited_at, ip_hash, user_agent, referrer
FROM page_views
ORDER BY visited_at DESC
LIMIT $1
""",
limit,
)
return [dict(r) for r in rows]


async def list_searches(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT searched_at, query, framework, priorities, result_count
FROM search_events
ORDER BY searched_at DESC
LIMIT $1
""",
limit,
)
out = []
for row in rows:
item = dict(row)
priorities = item["priorities"]
item["priorities"] = [p.strip() for p in priorities.split(",")] if priorities else None
out.append(item)
return out
19 changes: 19 additions & 0 deletions backend/app/db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import os

import asyncpg

_pg_pool: asyncpg.Pool | None = None


async def get_pool() -> asyncpg.Pool:
"""Lazy initialization of the shared asyncpg pool."""
global _pg_pool

if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)

return _pg_pool
85 changes: 83 additions & 2 deletions backend/app/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,15 @@
from slowapi.util import get_remote_address

import app.env
from app.analytics import (
client_ip,
get_analytics_summary,
hash_ip,
list_page_views,
list_searches,
record_page_view,
record_search,
)
from app.llm import stream_response
from app.models import SearchRequest
from app.search import package_search
Expand DownExpand Up@@ -50,6 +59,66 @@ async def health():
return {"status": "ok"}


def _require_analytics_token(request: Request) -> None:
expected = os.environ.get("ANALYTICS_TOKEN")
if not expected:
raise HTTPException(status_code=503, detail="Analytics token not configured")
if request.headers.get("x-analytics-token") != expected:
raise HTTPException(status_code=401, detail="Unauthorized")


@app.post("/api/track/pageview")
@limiter.limit("120/minute")
async def track_pageview(request: Request):
referrer = None
try:
body = await request.json()
referrer = body.get("referrer")
except Exception:
pass

try:
await record_page_view(
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
referrer=referrer or request.headers.get("referer"),
)
except Exception as e:
logger.warning(f"Failed to record page view: {e}")

return {"ok": True}


@app.get("/api/analytics/summary")
async def analytics_summary(request: Request):
_require_analytics_token(request)
try:
return await get_analytics_summary()
except Exception:
logger.exception("Analytics summary failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/visits")
async def analytics_visits(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_page_views(limit=min(limit, 200))
except Exception:
logger.exception("Analytics visits failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/searches")
async def analytics_searches(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_searches(limit=min(limit, 200))
except Exception:
logger.exception("Analytics searches failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.post("/api/search")
@limiter.limit("2/minute")
async def search(request: Request, body: SearchRequest):
Expand All@@ -60,9 +129,21 @@ async def search(request: Request, body: SearchRequest):

try:
packages = await package_search(body.query)
except Exception:
logger.exception("Package search failed")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from None

try:
await record_search(
query=body.query,
framework=body.framework,
priorities=body.priorities,
result_count=len(packages),
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
)
except Exception as e:
logger.error(f"Package search failed: {e}")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from e
logger.warning(f"Failed to record search event: {e}")

if not packages:
logger.info("No packages found for query, returning empty response")
Expand Down
33 changes: 33 additions & 0 deletions backend/app/models.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
from datetime import datetime

from pydantic import BaseModel, Field


Expand All@@ -18,3 +20,34 @@ class Package(BaseModel):

class SearchResponse(BaseModel):
packages: list[Package]


class PageView(BaseModel):
visited_at: datetime
ip_hash: str
user_agent: str | None = None
referrer: str | None = None


class SearchEvent(BaseModel):
searched_at: datetime
query: str
framework: str | None = None
priorities: list[str] | None = None
result_count: int


class TopItem(BaseModel):
label: str
count: int


class AnalyticsSummary(BaseModel):
total_visits: int
unique_visitors: int
total_searches: int
visits_last_24h: int
searches_last_24h: int
top_queries: list[TopItem]
top_frameworks: list[TopItem]
top_referrers: list[TopItem]
20 changes: 4 additions & 16 deletions backend/app/search.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,11 @@
import logging
import os

import asyncpg
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient

from app.db import get_pool

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -40,19 +41,6 @@ def get_qdrant_client() -> AsyncQdrantClient:
COLLECTION_NAME = "npmatch"
CANDIDATE_LIMIT = 20

_pg_pool: asyncpg.Pool | None = None


async def _get_pool() -> asyncpg.Pool:
global _pg_pool
if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)
return _pg_pool


def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
scores: dict[str, float] = {}
Expand DownExpand Up@@ -89,7 +77,7 @@ async def _vector_search(embedding: list[float]) -> list[str]:


async def _fts_search(query: str) -> list[str]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT name,
Expand All@@ -115,7 +103,7 @@ def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:


async def _fetch_metadata(names: list[str]) -> list[dict]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"SELECT name, description, keywords, version FROM packages WHERE name = ANY($1)",
names,
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,3 +17,6 @@ frontend/.openclaude/
# Build artifacts
frontend/next-env.d.ts
CLAUDE 2.md

# Trace logs
*.log
1 change: 1 addition & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,4 @@ OPENAI_API_KEY="YOUR_KEY"
QDRANT_URL=http://qdrant:6333
QDRANT_CLOUD_API_KEY="YOUR_KEY"
DATABASE_CONNECTION_STRING=postgresql://npmatch:npmatch@postgres:5432/npmatch
ANALYTICS_TOKEN="YOUR_TOKEN"
152 changes: 152 additions & 0 deletions backend/app/analytics.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
import hashlib
import logging
import os
from datetime import UTC, datetime, timedelta

from fastapi import Request

from app.db import get_pool

logger = logging.getLogger(__name__)

SEARCH_EVENT_INSERT = """
INSERT INTO search_events (query, framework, priorities, result_count, ip_hash, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
"""


def hash_ip(ip: str) -> str:
"""SHA-256 hash of the client IP, salted so it isn't reversible."""
salt = os.environ.get("ANALYTICS_SALT", "npmatch")
return hashlib.sha256(f"{salt}:{ip}".encode()).hexdigest()


def client_ip(request: Request) -> str:
"""Client IP, honoring X-Forwarded-For (set by the Next.js proxy)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"


async def record_page_view(*, ip_hash: str, user_agent: str | None, referrer: str | None) -> None:
pool = await get_pool()
await pool.execute(
"INSERT INTO page_views (ip_hash, user_agent, referrer) VALUES ($1, $2, $3)",
ip_hash,
user_agent,
referrer,
)


async def record_search(
*,
query: str,
framework: str | None,
priorities: list[str] | None,
result_count: int,
ip_hash: str,
user_agent: str | None,
) -> None:
pool = await get_pool()
await pool.execute(
SEARCH_EVENT_INSERT,
query,
framework,
", ".join(priorities) if priorities else None,
result_count,
ip_hash,
user_agent,
)


async def get_analytics_summary() -> dict:
pool = await get_pool()
cutoff = datetime.now(UTC) - timedelta(hours=24)

row = await pool.fetchrow(
"""
SELECT
(SELECT count(*) FROM page_views) AS total_visits,
(SELECT count(DISTINCT ip_hash) FROM page_views) AS unique_visitors,
(SELECT count(*) FROM search_events) AS total_searches,
(SELECT count(*) FROM page_views WHERE visited_at >= $1) AS visits_last_24h,
(SELECT count(*) FROM search_events WHERE searched_at >= $1) AS searches_last_24h
""",
cutoff,
)

top_queries = await pool.fetch(
"""
SELECT query, count(*) AS count
FROM search_events
GROUP BY query
ORDER BY count DESC, query ASC
LIMIT 10
"""
)
top_frameworks = await pool.fetch(
"""
SELECT COALESCE(NULLIF(framework, ''), 'any') AS framework, count(*) AS count
FROM search_events
GROUP BY COALESCE(NULLIF(framework, ''), 'any')
ORDER BY count DESC, framework ASC
LIMIT 10
"""
)
top_referrers = await pool.fetch(
"""
SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer, count(*) AS count
FROM page_views
GROUP BY COALESCE(NULLIF(referrer, ''), '(direct)')
ORDER BY count DESC, referrer ASC
LIMIT 10
"""
)

return {
"total_visits": row["total_visits"],
"unique_visitors": row["unique_visitors"],
"total_searches": row["total_searches"],
"visits_last_24h": row["visits_last_24h"],
"searches_last_24h": row["searches_last_24h"],
"top_queries": [{"label": r["query"], "count": r["count"]} for r in top_queries],
"top_frameworks": [{"label": r["framework"], "count": r["count"]} for r in top_frameworks],
"top_referrers": [{"label": r["referrer"], "count": r["count"]} for r in top_referrers],
}


async def list_page_views(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT visited_at, ip_hash, user_agent, referrer
FROM page_views
ORDER BY visited_at DESC
LIMIT $1
""",
limit,
)
return [dict(r) for r in rows]


async def list_searches(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT searched_at, query, framework, priorities, result_count
FROM search_events
ORDER BY searched_at DESC
LIMIT $1
""",
limit,
)
out = []
for row in rows:
item = dict(row)
priorities = item["priorities"]
item["priorities"] = [p.strip() for p in priorities.split(",")] if priorities else None
out.append(item)
return out
19 changes: 19 additions & 0 deletions backend/app/db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import os

import asyncpg

_pg_pool: asyncpg.Pool | None = None


async def get_pool() -> asyncpg.Pool:
"""Lazy initialization of the shared asyncpg pool."""
global _pg_pool

if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)

return _pg_pool
85 changes: 83 additions & 2 deletions backend/app/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,15 @@
from slowapi.util import get_remote_address

import app.env
from app.analytics import (
client_ip,
get_analytics_summary,
hash_ip,
list_page_views,
list_searches,
record_page_view,
record_search,
)
from app.llm import stream_response
from app.models import SearchRequest
from app.search import package_search
Expand DownExpand Up@@ -50,6 +59,66 @@ async def health():
return {"status": "ok"}


def _require_analytics_token(request: Request) -> None:
expected = os.environ.get("ANALYTICS_TOKEN")
if not expected:
raise HTTPException(status_code=503, detail="Analytics token not configured")
if request.headers.get("x-analytics-token") != expected:
raise HTTPException(status_code=401, detail="Unauthorized")


@app.post("/api/track/pageview")
@limiter.limit("120/minute")
async def track_pageview(request: Request):
referrer = None
try:
body = await request.json()
referrer = body.get("referrer")
except Exception:
pass

try:
await record_page_view(
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
referrer=referrer or request.headers.get("referer"),
)
except Exception as e:
logger.warning(f"Failed to record page view: {e}")

return {"ok": True}


@app.get("/api/analytics/summary")
async def analytics_summary(request: Request):
_require_analytics_token(request)
try:
return await get_analytics_summary()
except Exception:
logger.exception("Analytics summary failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/visits")
async def analytics_visits(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_page_views(limit=min(limit, 200))
except Exception:
logger.exception("Analytics visits failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/searches")
async def analytics_searches(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_searches(limit=min(limit, 200))
except Exception:
logger.exception("Analytics searches failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.post("/api/search")
@limiter.limit("2/minute")
async def search(request: Request, body: SearchRequest):
Expand All@@ -60,9 +129,21 @@ async def search(request: Request, body: SearchRequest):

try:
packages = await package_search(body.query)
except Exception:
logger.exception("Package search failed")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from None

try:
await record_search(
query=body.query,
framework=body.framework,
priorities=body.priorities,
result_count=len(packages),
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
)
except Exception as e:
logger.error(f"Package search failed: {e}")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from e
logger.warning(f"Failed to record search event: {e}")

if not packages:
logger.info("No packages found for query, returning empty response")
Expand Down
33 changes: 33 additions & 0 deletions backend/app/models.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
from datetime import datetime

from pydantic import BaseModel, Field


Expand All@@ -18,3 +20,34 @@ class Package(BaseModel):

class SearchResponse(BaseModel):
packages: list[Package]


class PageView(BaseModel):
visited_at: datetime
ip_hash: str
user_agent: str | None = None
referrer: str | None = None


class SearchEvent(BaseModel):
searched_at: datetime
query: str
framework: str | None = None
priorities: list[str] | None = None
result_count: int


class TopItem(BaseModel):
label: str
count: int


class AnalyticsSummary(BaseModel):
total_visits: int
unique_visitors: int
total_searches: int
visits_last_24h: int
searches_last_24h: int
top_queries: list[TopItem]
top_frameworks: list[TopItem]
top_referrers: list[TopItem]
20 changes: 4 additions & 16 deletions backend/app/search.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,11 @@
import logging
import os

import asyncpg
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient

from app.db import get_pool

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -40,19 +41,6 @@ def get_qdrant_client() -> AsyncQdrantClient:
COLLECTION_NAME = "npmatch"
CANDIDATE_LIMIT = 20

_pg_pool: asyncpg.Pool | None = None


async def _get_pool() -> asyncpg.Pool:
global _pg_pool
if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)
return _pg_pool


def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
scores: dict[str, float] = {}
Expand DownExpand Up@@ -89,7 +77,7 @@ async def _vector_search(embedding: list[float]) -> list[str]:


async def _fts_search(query: str) -> list[str]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT name,
Expand All@@ -115,7 +103,7 @@ def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:


async def _fetch_metadata(names: list[str]) -> list[dict]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"SELECT name, description, keywords, version FROM packages WHERE name = ANY($1)",
names,
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,3 +17,6 @@ frontend/.openclaude/
# Build artifacts
frontend/next-env.d.ts
CLAUDE 2.md

# Trace logs
*.log
1 change: 1 addition & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,4 @@ OPENAI_API_KEY="YOUR_KEY"
QDRANT_URL=http://qdrant:6333
QDRANT_CLOUD_API_KEY="YOUR_KEY"
DATABASE_CONNECTION_STRING=postgresql://npmatch:npmatch@postgres:5432/npmatch
ANALYTICS_TOKEN="YOUR_TOKEN"
152 changes: 152 additions & 0 deletions backend/app/analytics.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
import hashlib
import logging
import os
from datetime import UTC, datetime, timedelta

from fastapi import Request

from app.db import get_pool

logger = logging.getLogger(__name__)

SEARCH_EVENT_INSERT = """
INSERT INTO search_events (query, framework, priorities, result_count, ip_hash, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
"""


def hash_ip(ip: str) -> str:
"""SHA-256 hash of the client IP, salted so it isn't reversible."""
salt = os.environ.get("ANALYTICS_SALT", "npmatch")
return hashlib.sha256(f"{salt}:{ip}".encode()).hexdigest()


def client_ip(request: Request) -> str:
"""Client IP, honoring X-Forwarded-For (set by the Next.js proxy)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"


async def record_page_view(*, ip_hash: str, user_agent: str | None, referrer: str | None) -> None:
pool = await get_pool()
await pool.execute(
"INSERT INTO page_views (ip_hash, user_agent, referrer) VALUES ($1, $2, $3)",
ip_hash,
user_agent,
referrer,
)


async def record_search(
*,
query: str,
framework: str | None,
priorities: list[str] | None,
result_count: int,
ip_hash: str,
user_agent: str | None,
) -> None:
pool = await get_pool()
await pool.execute(
SEARCH_EVENT_INSERT,
query,
framework,
", ".join(priorities) if priorities else None,
result_count,
ip_hash,
user_agent,
)


async def get_analytics_summary() -> dict:
pool = await get_pool()
cutoff = datetime.now(UTC) - timedelta(hours=24)

row = await pool.fetchrow(
"""
SELECT
(SELECT count(*) FROM page_views) AS total_visits,
(SELECT count(DISTINCT ip_hash) FROM page_views) AS unique_visitors,
(SELECT count(*) FROM search_events) AS total_searches,
(SELECT count(*) FROM page_views WHERE visited_at >= $1) AS visits_last_24h,
(SELECT count(*) FROM search_events WHERE searched_at >= $1) AS searches_last_24h
""",
cutoff,
)

top_queries = await pool.fetch(
"""
SELECT query, count(*) AS count
FROM search_events
GROUP BY query
ORDER BY count DESC, query ASC
LIMIT 10
"""
)
top_frameworks = await pool.fetch(
"""
SELECT COALESCE(NULLIF(framework, ''), 'any') AS framework, count(*) AS count
FROM search_events
GROUP BY COALESCE(NULLIF(framework, ''), 'any')
ORDER BY count DESC, framework ASC
LIMIT 10
"""
)
top_referrers = await pool.fetch(
"""
SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer, count(*) AS count
FROM page_views
GROUP BY COALESCE(NULLIF(referrer, ''), '(direct)')
ORDER BY count DESC, referrer ASC
LIMIT 10
"""
)

return {
"total_visits": row["total_visits"],
"unique_visitors": row["unique_visitors"],
"total_searches": row["total_searches"],
"visits_last_24h": row["visits_last_24h"],
"searches_last_24h": row["searches_last_24h"],
"top_queries": [{"label": r["query"], "count": r["count"]} for r in top_queries],
"top_frameworks": [{"label": r["framework"], "count": r["count"]} for r in top_frameworks],
"top_referrers": [{"label": r["referrer"], "count": r["count"]} for r in top_referrers],
}


async def list_page_views(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT visited_at, ip_hash, user_agent, referrer
FROM page_views
ORDER BY visited_at DESC
LIMIT $1
""",
limit,
)
return [dict(r) for r in rows]


async def list_searches(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT searched_at, query, framework, priorities, result_count
FROM search_events
ORDER BY searched_at DESC
LIMIT $1
""",
limit,
)
out = []
for row in rows:
item = dict(row)
priorities = item["priorities"]
item["priorities"] = [p.strip() for p in priorities.split(",")] if priorities else None
out.append(item)
return out
19 changes: 19 additions & 0 deletions backend/app/db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import os

import asyncpg

_pg_pool: asyncpg.Pool | None = None


async def get_pool() -> asyncpg.Pool:
"""Lazy initialization of the shared asyncpg pool."""
global _pg_pool

if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)

return _pg_pool
85 changes: 83 additions & 2 deletions backend/app/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,15 @@
from slowapi.util import get_remote_address

import app.env
from app.analytics import (
client_ip,
get_analytics_summary,
hash_ip,
list_page_views,
list_searches,
record_page_view,
record_search,
)
from app.llm import stream_response
from app.models import SearchRequest
from app.search import package_search
Expand DownExpand Up@@ -50,6 +59,66 @@ async def health():
return {"status": "ok"}


def _require_analytics_token(request: Request) -> None:
expected = os.environ.get("ANALYTICS_TOKEN")
if not expected:
raise HTTPException(status_code=503, detail="Analytics token not configured")
if request.headers.get("x-analytics-token") != expected:
raise HTTPException(status_code=401, detail="Unauthorized")


@app.post("/api/track/pageview")
@limiter.limit("120/minute")
async def track_pageview(request: Request):
referrer = None
try:
body = await request.json()
referrer = body.get("referrer")
except Exception:
pass

try:
await record_page_view(
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
referrer=referrer or request.headers.get("referer"),
)
except Exception as e:
logger.warning(f"Failed to record page view: {e}")

return {"ok": True}


@app.get("/api/analytics/summary")
async def analytics_summary(request: Request):
_require_analytics_token(request)
try:
return await get_analytics_summary()
except Exception:
logger.exception("Analytics summary failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/visits")
async def analytics_visits(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_page_views(limit=min(limit, 200))
except Exception:
logger.exception("Analytics visits failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/searches")
async def analytics_searches(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_searches(limit=min(limit, 200))
except Exception:
logger.exception("Analytics searches failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.post("/api/search")
@limiter.limit("2/minute")
async def search(request: Request, body: SearchRequest):
Expand All@@ -60,9 +129,21 @@ async def search(request: Request, body: SearchRequest):

try:
packages = await package_search(body.query)
except Exception:
logger.exception("Package search failed")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from None

try:
await record_search(
query=body.query,
framework=body.framework,
priorities=body.priorities,
result_count=len(packages),
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
)
except Exception as e:
logger.error(f"Package search failed: {e}")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from e
logger.warning(f"Failed to record search event: {e}")

if not packages:
logger.info("No packages found for query, returning empty response")
Expand Down
33 changes: 33 additions & 0 deletions backend/app/models.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
from datetime import datetime

from pydantic import BaseModel, Field


Expand All@@ -18,3 +20,34 @@ class Package(BaseModel):

class SearchResponse(BaseModel):
packages: list[Package]


class PageView(BaseModel):
visited_at: datetime
ip_hash: str
user_agent: str | None = None
referrer: str | None = None


class SearchEvent(BaseModel):
searched_at: datetime
query: str
framework: str | None = None
priorities: list[str] | None = None
result_count: int


class TopItem(BaseModel):
label: str
count: int


class AnalyticsSummary(BaseModel):
total_visits: int
unique_visitors: int
total_searches: int
visits_last_24h: int
searches_last_24h: int
top_queries: list[TopItem]
top_frameworks: list[TopItem]
top_referrers: list[TopItem]
20 changes: 4 additions & 16 deletions backend/app/search.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,11 @@
import logging
import os

import asyncpg
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient

from app.db import get_pool

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -40,19 +41,6 @@ def get_qdrant_client() -> AsyncQdrantClient:
COLLECTION_NAME = "npmatch"
CANDIDATE_LIMIT = 20

_pg_pool: asyncpg.Pool | None = None


async def _get_pool() -> asyncpg.Pool:
global _pg_pool
if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)
return _pg_pool


def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
scores: dict[str, float] = {}
Expand DownExpand Up@@ -89,7 +77,7 @@ async def _vector_search(embedding: list[float]) -> list[str]:


async def _fts_search(query: str) -> list[str]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT name,
Expand All@@ -115,7 +103,7 @@ def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:


async def _fetch_metadata(names: list[str]) -> list[dict]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"SELECT name, description, keywords, version FROM packages WHERE name = ANY($1)",
names,
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,3 +17,6 @@ frontend/.openclaude/
# Build artifacts
frontend/next-env.d.ts
CLAUDE 2.md

# Trace logs
*.log
1 change: 1 addition & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,4 @@ OPENAI_API_KEY="YOUR_KEY"
QDRANT_URL=http://qdrant:6333
QDRANT_CLOUD_API_KEY="YOUR_KEY"
DATABASE_CONNECTION_STRING=postgresql://npmatch:npmatch@postgres:5432/npmatch
ANALYTICS_TOKEN="YOUR_TOKEN"
152 changes: 152 additions & 0 deletions backend/app/analytics.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
import hashlib
import logging
import os
from datetime import UTC, datetime, timedelta

from fastapi import Request

from app.db import get_pool

logger = logging.getLogger(__name__)

SEARCH_EVENT_INSERT = """
INSERT INTO search_events (query, framework, priorities, result_count, ip_hash, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
"""


def hash_ip(ip: str) -> str:
"""SHA-256 hash of the client IP, salted so it isn't reversible."""
salt = os.environ.get("ANALYTICS_SALT", "npmatch")
return hashlib.sha256(f"{salt}:{ip}".encode()).hexdigest()


def client_ip(request: Request) -> str:
"""Client IP, honoring X-Forwarded-For (set by the Next.js proxy)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"


async def record_page_view(*, ip_hash: str, user_agent: str | None, referrer: str | None) -> None:
pool = await get_pool()
await pool.execute(
"INSERT INTO page_views (ip_hash, user_agent, referrer) VALUES ($1, $2, $3)",
ip_hash,
user_agent,
referrer,
)


async def record_search(
*,
query: str,
framework: str | None,
priorities: list[str] | None,
result_count: int,
ip_hash: str,
user_agent: str | None,
) -> None:
pool = await get_pool()
await pool.execute(
SEARCH_EVENT_INSERT,
query,
framework,
", ".join(priorities) if priorities else None,
result_count,
ip_hash,
user_agent,
)


async def get_analytics_summary() -> dict:
pool = await get_pool()
cutoff = datetime.now(UTC) - timedelta(hours=24)

row = await pool.fetchrow(
"""
SELECT
(SELECT count(*) FROM page_views) AS total_visits,
(SELECT count(DISTINCT ip_hash) FROM page_views) AS unique_visitors,
(SELECT count(*) FROM search_events) AS total_searches,
(SELECT count(*) FROM page_views WHERE visited_at >= $1) AS visits_last_24h,
(SELECT count(*) FROM search_events WHERE searched_at >= $1) AS searches_last_24h
""",
cutoff,
)

top_queries = await pool.fetch(
"""
SELECT query, count(*) AS count
FROM search_events
GROUP BY query
ORDER BY count DESC, query ASC
LIMIT 10
"""
)
top_frameworks = await pool.fetch(
"""
SELECT COALESCE(NULLIF(framework, ''), 'any') AS framework, count(*) AS count
FROM search_events
GROUP BY COALESCE(NULLIF(framework, ''), 'any')
ORDER BY count DESC, framework ASC
LIMIT 10
"""
)
top_referrers = await pool.fetch(
"""
SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer, count(*) AS count
FROM page_views
GROUP BY COALESCE(NULLIF(referrer, ''), '(direct)')
ORDER BY count DESC, referrer ASC
LIMIT 10
"""
)

return {
"total_visits": row["total_visits"],
"unique_visitors": row["unique_visitors"],
"total_searches": row["total_searches"],
"visits_last_24h": row["visits_last_24h"],
"searches_last_24h": row["searches_last_24h"],
"top_queries": [{"label": r["query"], "count": r["count"]} for r in top_queries],
"top_frameworks": [{"label": r["framework"], "count": r["count"]} for r in top_frameworks],
"top_referrers": [{"label": r["referrer"], "count": r["count"]} for r in top_referrers],
}


async def list_page_views(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT visited_at, ip_hash, user_agent, referrer
FROM page_views
ORDER BY visited_at DESC
LIMIT $1
""",
limit,
)
return [dict(r) for r in rows]


async def list_searches(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT searched_at, query, framework, priorities, result_count
FROM search_events
ORDER BY searched_at DESC
LIMIT $1
""",
limit,
)
out = []
for row in rows:
item = dict(row)
priorities = item["priorities"]
item["priorities"] = [p.strip() for p in priorities.split(",")] if priorities else None
out.append(item)
return out
19 changes: 19 additions & 0 deletions backend/app/db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import os

import asyncpg

_pg_pool: asyncpg.Pool | None = None


async def get_pool() -> asyncpg.Pool:
"""Lazy initialization of the shared asyncpg pool."""
global _pg_pool

if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)

return _pg_pool
85 changes: 83 additions & 2 deletions backend/app/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,15 @@
from slowapi.util import get_remote_address

import app.env
from app.analytics import (
client_ip,
get_analytics_summary,
hash_ip,
list_page_views,
list_searches,
record_page_view,
record_search,
)
from app.llm import stream_response
from app.models import SearchRequest
from app.search import package_search
Expand DownExpand Up@@ -50,6 +59,66 @@ async def health():
return {"status": "ok"}


def _require_analytics_token(request: Request) -> None:
expected = os.environ.get("ANALYTICS_TOKEN")
if not expected:
raise HTTPException(status_code=503, detail="Analytics token not configured")
if request.headers.get("x-analytics-token") != expected:
raise HTTPException(status_code=401, detail="Unauthorized")


@app.post("/api/track/pageview")
@limiter.limit("120/minute")
async def track_pageview(request: Request):
referrer = None
try:
body = await request.json()
referrer = body.get("referrer")
except Exception:
pass

try:
await record_page_view(
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
referrer=referrer or request.headers.get("referer"),
)
except Exception as e:
logger.warning(f"Failed to record page view: {e}")

return {"ok": True}


@app.get("/api/analytics/summary")
async def analytics_summary(request: Request):
_require_analytics_token(request)
try:
return await get_analytics_summary()
except Exception:
logger.exception("Analytics summary failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/visits")
async def analytics_visits(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_page_views(limit=min(limit, 200))
except Exception:
logger.exception("Analytics visits failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/searches")
async def analytics_searches(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_searches(limit=min(limit, 200))
except Exception:
logger.exception("Analytics searches failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.post("/api/search")
@limiter.limit("2/minute")
async def search(request: Request, body: SearchRequest):
Expand All@@ -60,9 +129,21 @@ async def search(request: Request, body: SearchRequest):

try:
packages = await package_search(body.query)
except Exception:
logger.exception("Package search failed")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from None

try:
await record_search(
query=body.query,
framework=body.framework,
priorities=body.priorities,
result_count=len(packages),
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
)
except Exception as e:
logger.error(f"Package search failed: {e}")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from e
logger.warning(f"Failed to record search event: {e}")

if not packages:
logger.info("No packages found for query, returning empty response")
Expand Down
33 changes: 33 additions & 0 deletions backend/app/models.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
from datetime import datetime

from pydantic import BaseModel, Field


Expand All@@ -18,3 +20,34 @@ class Package(BaseModel):

class SearchResponse(BaseModel):
packages: list[Package]


class PageView(BaseModel):
visited_at: datetime
ip_hash: str
user_agent: str | None = None
referrer: str | None = None


class SearchEvent(BaseModel):
searched_at: datetime
query: str
framework: str | None = None
priorities: list[str] | None = None
result_count: int


class TopItem(BaseModel):
label: str
count: int


class AnalyticsSummary(BaseModel):
total_visits: int
unique_visitors: int
total_searches: int
visits_last_24h: int
searches_last_24h: int
top_queries: list[TopItem]
top_frameworks: list[TopItem]
top_referrers: list[TopItem]
20 changes: 4 additions & 16 deletions backend/app/search.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,11 @@
import logging
import os

import asyncpg
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient

from app.db import get_pool

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -40,19 +41,6 @@ def get_qdrant_client() -> AsyncQdrantClient:
COLLECTION_NAME = "npmatch"
CANDIDATE_LIMIT = 20

_pg_pool: asyncpg.Pool | None = None


async def _get_pool() -> asyncpg.Pool:
global _pg_pool
if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)
return _pg_pool


def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
scores: dict[str, float] = {}
Expand DownExpand Up@@ -89,7 +77,7 @@ async def _vector_search(embedding: list[float]) -> list[str]:


async def _fts_search(query: str) -> list[str]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT name,
Expand All@@ -115,7 +103,7 @@ def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:


async def _fetch_metadata(names: list[str]) -> list[dict]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"SELECT name, description, keywords, version FROM packages WHERE name = ANY($1)",
names,
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,3 +17,6 @@ frontend/.openclaude/
# Build artifacts
frontend/next-env.d.ts
CLAUDE 2.md

# Trace logs
*.log
1 change: 1 addition & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,4 @@ OPENAI_API_KEY="YOUR_KEY"
QDRANT_URL=http://qdrant:6333
QDRANT_CLOUD_API_KEY="YOUR_KEY"
DATABASE_CONNECTION_STRING=postgresql://npmatch:npmatch@postgres:5432/npmatch
ANALYTICS_TOKEN="YOUR_TOKEN"
152 changes: 152 additions & 0 deletions backend/app/analytics.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
import hashlib
import logging
import os
from datetime import UTC, datetime, timedelta

from fastapi import Request

from app.db import get_pool

logger = logging.getLogger(__name__)

SEARCH_EVENT_INSERT = """
INSERT INTO search_events (query, framework, priorities, result_count, ip_hash, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
"""


def hash_ip(ip: str) -> str:
"""SHA-256 hash of the client IP, salted so it isn't reversible."""
salt = os.environ.get("ANALYTICS_SALT", "npmatch")
return hashlib.sha256(f"{salt}:{ip}".encode()).hexdigest()


def client_ip(request: Request) -> str:
"""Client IP, honoring X-Forwarded-For (set by the Next.js proxy)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"


async def record_page_view(*, ip_hash: str, user_agent: str | None, referrer: str | None) -> None:
pool = await get_pool()
await pool.execute(
"INSERT INTO page_views (ip_hash, user_agent, referrer) VALUES ($1, $2, $3)",
ip_hash,
user_agent,
referrer,
)


async def record_search(
*,
query: str,
framework: str | None,
priorities: list[str] | None,
result_count: int,
ip_hash: str,
user_agent: str | None,
) -> None:
pool = await get_pool()
await pool.execute(
SEARCH_EVENT_INSERT,
query,
framework,
", ".join(priorities) if priorities else None,
result_count,
ip_hash,
user_agent,
)


async def get_analytics_summary() -> dict:
pool = await get_pool()
cutoff = datetime.now(UTC) - timedelta(hours=24)

row = await pool.fetchrow(
"""
SELECT
(SELECT count(*) FROM page_views) AS total_visits,
(SELECT count(DISTINCT ip_hash) FROM page_views) AS unique_visitors,
(SELECT count(*) FROM search_events) AS total_searches,
(SELECT count(*) FROM page_views WHERE visited_at >= $1) AS visits_last_24h,
(SELECT count(*) FROM search_events WHERE searched_at >= $1) AS searches_last_24h
""",
cutoff,
)

top_queries = await pool.fetch(
"""
SELECT query, count(*) AS count
FROM search_events
GROUP BY query
ORDER BY count DESC, query ASC
LIMIT 10
"""
)
top_frameworks = await pool.fetch(
"""
SELECT COALESCE(NULLIF(framework, ''), 'any') AS framework, count(*) AS count
FROM search_events
GROUP BY COALESCE(NULLIF(framework, ''), 'any')
ORDER BY count DESC, framework ASC
LIMIT 10
"""
)
top_referrers = await pool.fetch(
"""
SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer, count(*) AS count
FROM page_views
GROUP BY COALESCE(NULLIF(referrer, ''), '(direct)')
ORDER BY count DESC, referrer ASC
LIMIT 10
"""
)

return {
"total_visits": row["total_visits"],
"unique_visitors": row["unique_visitors"],
"total_searches": row["total_searches"],
"visits_last_24h": row["visits_last_24h"],
"searches_last_24h": row["searches_last_24h"],
"top_queries": [{"label": r["query"], "count": r["count"]} for r in top_queries],
"top_frameworks": [{"label": r["framework"], "count": r["count"]} for r in top_frameworks],
"top_referrers": [{"label": r["referrer"], "count": r["count"]} for r in top_referrers],
}


async def list_page_views(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT visited_at, ip_hash, user_agent, referrer
FROM page_views
ORDER BY visited_at DESC
LIMIT $1
""",
limit,
)
return [dict(r) for r in rows]


async def list_searches(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT searched_at, query, framework, priorities, result_count
FROM search_events
ORDER BY searched_at DESC
LIMIT $1
""",
limit,
)
out = []
for row in rows:
item = dict(row)
priorities = item["priorities"]
item["priorities"] = [p.strip() for p in priorities.split(",")] if priorities else None
out.append(item)
return out
19 changes: 19 additions & 0 deletions backend/app/db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import os

import asyncpg

_pg_pool: asyncpg.Pool | None = None


async def get_pool() -> asyncpg.Pool:
"""Lazy initialization of the shared asyncpg pool."""
global _pg_pool

if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)

return _pg_pool
85 changes: 83 additions & 2 deletions backend/app/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,15 @@
from slowapi.util import get_remote_address

import app.env
from app.analytics import (
client_ip,
get_analytics_summary,
hash_ip,
list_page_views,
list_searches,
record_page_view,
record_search,
)
from app.llm import stream_response
from app.models import SearchRequest
from app.search import package_search
Expand DownExpand Up@@ -50,6 +59,66 @@ async def health():
return {"status": "ok"}


def _require_analytics_token(request: Request) -> None:
expected = os.environ.get("ANALYTICS_TOKEN")
if not expected:
raise HTTPException(status_code=503, detail="Analytics token not configured")
if request.headers.get("x-analytics-token") != expected:
raise HTTPException(status_code=401, detail="Unauthorized")


@app.post("/api/track/pageview")
@limiter.limit("120/minute")
async def track_pageview(request: Request):
referrer = None
try:
body = await request.json()
referrer = body.get("referrer")
except Exception:
pass

try:
await record_page_view(
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
referrer=referrer or request.headers.get("referer"),
)
except Exception as e:
logger.warning(f"Failed to record page view: {e}")

return {"ok": True}


@app.get("/api/analytics/summary")
async def analytics_summary(request: Request):
_require_analytics_token(request)
try:
return await get_analytics_summary()
except Exception:
logger.exception("Analytics summary failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/visits")
async def analytics_visits(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_page_views(limit=min(limit, 200))
except Exception:
logger.exception("Analytics visits failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/searches")
async def analytics_searches(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_searches(limit=min(limit, 200))
except Exception:
logger.exception("Analytics searches failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.post("/api/search")
@limiter.limit("2/minute")
async def search(request: Request, body: SearchRequest):
Expand All@@ -60,9 +129,21 @@ async def search(request: Request, body: SearchRequest):

try:
packages = await package_search(body.query)
except Exception:
logger.exception("Package search failed")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from None

try:
await record_search(
query=body.query,
framework=body.framework,
priorities=body.priorities,
result_count=len(packages),
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
)
except Exception as e:
logger.error(f"Package search failed: {e}")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from e
logger.warning(f"Failed to record search event: {e}")

if not packages:
logger.info("No packages found for query, returning empty response")
Expand Down
33 changes: 33 additions & 0 deletions backend/app/models.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
from datetime import datetime

from pydantic import BaseModel, Field


Expand All@@ -18,3 +20,34 @@ class Package(BaseModel):

class SearchResponse(BaseModel):
packages: list[Package]


class PageView(BaseModel):
visited_at: datetime
ip_hash: str
user_agent: str | None = None
referrer: str | None = None


class SearchEvent(BaseModel):
searched_at: datetime
query: str
framework: str | None = None
priorities: list[str] | None = None
result_count: int


class TopItem(BaseModel):
label: str
count: int


class AnalyticsSummary(BaseModel):
total_visits: int
unique_visitors: int
total_searches: int
visits_last_24h: int
searches_last_24h: int
top_queries: list[TopItem]
top_frameworks: list[TopItem]
top_referrers: list[TopItem]
20 changes: 4 additions & 16 deletions backend/app/search.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,11 @@
import logging
import os

import asyncpg
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient

from app.db import get_pool

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -40,19 +41,6 @@ def get_qdrant_client() -> AsyncQdrantClient:
COLLECTION_NAME = "npmatch"
CANDIDATE_LIMIT = 20

_pg_pool: asyncpg.Pool | None = None


async def _get_pool() -> asyncpg.Pool:
global _pg_pool
if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)
return _pg_pool


def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
scores: dict[str, float] = {}
Expand DownExpand Up@@ -89,7 +77,7 @@ async def _vector_search(embedding: list[float]) -> list[str]:


async def _fts_search(query: str) -> list[str]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT name,
Expand All@@ -115,7 +103,7 @@ def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:


async def _fetch_metadata(names: list[str]) -> list[dict]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"SELECT name, description, keywords, version FROM packages WHERE name = ANY($1)",
names,
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,3 +17,6 @@ frontend/.openclaude/
# Build artifacts
frontend/next-env.d.ts
CLAUDE 2.md

# Trace logs
*.log
1 change: 1 addition & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,4 @@ OPENAI_API_KEY="YOUR_KEY"
QDRANT_URL=http://qdrant:6333
QDRANT_CLOUD_API_KEY="YOUR_KEY"
DATABASE_CONNECTION_STRING=postgresql://npmatch:npmatch@postgres:5432/npmatch
ANALYTICS_TOKEN="YOUR_TOKEN"
152 changes: 152 additions & 0 deletions backend/app/analytics.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
import hashlib
import logging
import os
from datetime import UTC, datetime, timedelta

from fastapi import Request

from app.db import get_pool

logger = logging.getLogger(__name__)

SEARCH_EVENT_INSERT = """
INSERT INTO search_events (query, framework, priorities, result_count, ip_hash, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
"""


def hash_ip(ip: str) -> str:
"""SHA-256 hash of the client IP, salted so it isn't reversible."""
salt = os.environ.get("ANALYTICS_SALT", "npmatch")
return hashlib.sha256(f"{salt}:{ip}".encode()).hexdigest()


def client_ip(request: Request) -> str:
"""Client IP, honoring X-Forwarded-For (set by the Next.js proxy)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"


async def record_page_view(*, ip_hash: str, user_agent: str | None, referrer: str | None) -> None:
pool = await get_pool()
await pool.execute(
"INSERT INTO page_views (ip_hash, user_agent, referrer) VALUES ($1, $2, $3)",
ip_hash,
user_agent,
referrer,
)


async def record_search(
*,
query: str,
framework: str | None,
priorities: list[str] | None,
result_count: int,
ip_hash: str,
user_agent: str | None,
) -> None:
pool = await get_pool()
await pool.execute(
SEARCH_EVENT_INSERT,
query,
framework,
", ".join(priorities) if priorities else None,
result_count,
ip_hash,
user_agent,
)


async def get_analytics_summary() -> dict:
pool = await get_pool()
cutoff = datetime.now(UTC) - timedelta(hours=24)

row = await pool.fetchrow(
"""
SELECT
(SELECT count(*) FROM page_views) AS total_visits,
(SELECT count(DISTINCT ip_hash) FROM page_views) AS unique_visitors,
(SELECT count(*) FROM search_events) AS total_searches,
(SELECT count(*) FROM page_views WHERE visited_at >= $1) AS visits_last_24h,
(SELECT count(*) FROM search_events WHERE searched_at >= $1) AS searches_last_24h
""",
cutoff,
)

top_queries = await pool.fetch(
"""
SELECT query, count(*) AS count
FROM search_events
GROUP BY query
ORDER BY count DESC, query ASC
LIMIT 10
"""
)
top_frameworks = await pool.fetch(
"""
SELECT COALESCE(NULLIF(framework, ''), 'any') AS framework, count(*) AS count
FROM search_events
GROUP BY COALESCE(NULLIF(framework, ''), 'any')
ORDER BY count DESC, framework ASC
LIMIT 10
"""
)
top_referrers = await pool.fetch(
"""
SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer, count(*) AS count
FROM page_views
GROUP BY COALESCE(NULLIF(referrer, ''), '(direct)')
ORDER BY count DESC, referrer ASC
LIMIT 10
"""
)

return {
"total_visits": row["total_visits"],
"unique_visitors": row["unique_visitors"],
"total_searches": row["total_searches"],
"visits_last_24h": row["visits_last_24h"],
"searches_last_24h": row["searches_last_24h"],
"top_queries": [{"label": r["query"], "count": r["count"]} for r in top_queries],
"top_frameworks": [{"label": r["framework"], "count": r["count"]} for r in top_frameworks],
"top_referrers": [{"label": r["referrer"], "count": r["count"]} for r in top_referrers],
}


async def list_page_views(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT visited_at, ip_hash, user_agent, referrer
FROM page_views
ORDER BY visited_at DESC
LIMIT $1
""",
limit,
)
return [dict(r) for r in rows]


async def list_searches(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT searched_at, query, framework, priorities, result_count
FROM search_events
ORDER BY searched_at DESC
LIMIT $1
""",
limit,
)
out = []
for row in rows:
item = dict(row)
priorities = item["priorities"]
item["priorities"] = [p.strip() for p in priorities.split(",")] if priorities else None
out.append(item)
return out
19 changes: 19 additions & 0 deletions backend/app/db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import os

import asyncpg

_pg_pool: asyncpg.Pool | None = None


async def get_pool() -> asyncpg.Pool:
"""Lazy initialization of the shared asyncpg pool."""
global _pg_pool

if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)

return _pg_pool
85 changes: 83 additions & 2 deletions backend/app/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,15 @@
from slowapi.util import get_remote_address

import app.env
from app.analytics import (
client_ip,
get_analytics_summary,
hash_ip,
list_page_views,
list_searches,
record_page_view,
record_search,
)
from app.llm import stream_response
from app.models import SearchRequest
from app.search import package_search
Expand DownExpand Up@@ -50,6 +59,66 @@ async def health():
return {"status": "ok"}


def _require_analytics_token(request: Request) -> None:
expected = os.environ.get("ANALYTICS_TOKEN")
if not expected:
raise HTTPException(status_code=503, detail="Analytics token not configured")
if request.headers.get("x-analytics-token") != expected:
raise HTTPException(status_code=401, detail="Unauthorized")


@app.post("/api/track/pageview")
@limiter.limit("120/minute")
async def track_pageview(request: Request):
referrer = None
try:
body = await request.json()
referrer = body.get("referrer")
except Exception:
pass

try:
await record_page_view(
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
referrer=referrer or request.headers.get("referer"),
)
except Exception as e:
logger.warning(f"Failed to record page view: {e}")

return {"ok": True}


@app.get("/api/analytics/summary")
async def analytics_summary(request: Request):
_require_analytics_token(request)
try:
return await get_analytics_summary()
except Exception:
logger.exception("Analytics summary failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/visits")
async def analytics_visits(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_page_views(limit=min(limit, 200))
except Exception:
logger.exception("Analytics visits failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/searches")
async def analytics_searches(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_searches(limit=min(limit, 200))
except Exception:
logger.exception("Analytics searches failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.post("/api/search")
@limiter.limit("2/minute")
async def search(request: Request, body: SearchRequest):
Expand All@@ -60,9 +129,21 @@ async def search(request: Request, body: SearchRequest):

try:
packages = await package_search(body.query)
except Exception:
logger.exception("Package search failed")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from None

try:
await record_search(
query=body.query,
framework=body.framework,
priorities=body.priorities,
result_count=len(packages),
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
)
except Exception as e:
logger.error(f"Package search failed: {e}")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from e
logger.warning(f"Failed to record search event: {e}")

if not packages:
logger.info("No packages found for query, returning empty response")
Expand Down
33 changes: 33 additions & 0 deletions backend/app/models.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
from datetime import datetime

from pydantic import BaseModel, Field


Expand All@@ -18,3 +20,34 @@ class Package(BaseModel):

class SearchResponse(BaseModel):
packages: list[Package]


class PageView(BaseModel):
visited_at: datetime
ip_hash: str
user_agent: str | None = None
referrer: str | None = None


class SearchEvent(BaseModel):
searched_at: datetime
query: str
framework: str | None = None
priorities: list[str] | None = None
result_count: int


class TopItem(BaseModel):
label: str
count: int


class AnalyticsSummary(BaseModel):
total_visits: int
unique_visitors: int
total_searches: int
visits_last_24h: int
searches_last_24h: int
top_queries: list[TopItem]
top_frameworks: list[TopItem]
top_referrers: list[TopItem]
20 changes: 4 additions & 16 deletions backend/app/search.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,11 @@
import logging
import os

import asyncpg
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient

from app.db import get_pool

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -40,19 +41,6 @@ def get_qdrant_client() -> AsyncQdrantClient:
COLLECTION_NAME = "npmatch"
CANDIDATE_LIMIT = 20

_pg_pool: asyncpg.Pool | None = None


async def _get_pool() -> asyncpg.Pool:
global _pg_pool
if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)
return _pg_pool


def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
scores: dict[str, float] = {}
Expand DownExpand Up@@ -89,7 +77,7 @@ async def _vector_search(embedding: list[float]) -> list[str]:


async def _fts_search(query: str) -> list[str]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT name,
Expand All@@ -115,7 +103,7 @@ def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:


async def _fetch_metadata(names: list[str]) -> list[dict]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"SELECT name, description, keywords, version FROM packages WHERE name = ANY($1)",
names,
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,3 +17,6 @@ frontend/.openclaude/
# Build artifacts
frontend/next-env.d.ts
CLAUDE 2.md

# Trace logs
*.log
1 change: 1 addition & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,4 @@ OPENAI_API_KEY="YOUR_KEY"
QDRANT_URL=http://qdrant:6333
QDRANT_CLOUD_API_KEY="YOUR_KEY"
DATABASE_CONNECTION_STRING=postgresql://npmatch:npmatch@postgres:5432/npmatch
ANALYTICS_TOKEN="YOUR_TOKEN"
152 changes: 152 additions & 0 deletions backend/app/analytics.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
import hashlib
import logging
import os
from datetime import UTC, datetime, timedelta

from fastapi import Request

from app.db import get_pool

logger = logging.getLogger(__name__)

SEARCH_EVENT_INSERT = """
INSERT INTO search_events (query, framework, priorities, result_count, ip_hash, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
"""


def hash_ip(ip: str) -> str:
"""SHA-256 hash of the client IP, salted so it isn't reversible."""
salt = os.environ.get("ANALYTICS_SALT", "npmatch")
return hashlib.sha256(f"{salt}:{ip}".encode()).hexdigest()


def client_ip(request: Request) -> str:
"""Client IP, honoring X-Forwarded-For (set by the Next.js proxy)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"


async def record_page_view(*, ip_hash: str, user_agent: str | None, referrer: str | None) -> None:
pool = await get_pool()
await pool.execute(
"INSERT INTO page_views (ip_hash, user_agent, referrer) VALUES ($1, $2, $3)",
ip_hash,
user_agent,
referrer,
)


async def record_search(
*,
query: str,
framework: str | None,
priorities: list[str] | None,
result_count: int,
ip_hash: str,
user_agent: str | None,
) -> None:
pool = await get_pool()
await pool.execute(
SEARCH_EVENT_INSERT,
query,
framework,
", ".join(priorities) if priorities else None,
result_count,
ip_hash,
user_agent,
)


async def get_analytics_summary() -> dict:
pool = await get_pool()
cutoff = datetime.now(UTC) - timedelta(hours=24)

row = await pool.fetchrow(
"""
SELECT
(SELECT count(*) FROM page_views) AS total_visits,
(SELECT count(DISTINCT ip_hash) FROM page_views) AS unique_visitors,
(SELECT count(*) FROM search_events) AS total_searches,
(SELECT count(*) FROM page_views WHERE visited_at >= $1) AS visits_last_24h,
(SELECT count(*) FROM search_events WHERE searched_at >= $1) AS searches_last_24h
""",
cutoff,
)

top_queries = await pool.fetch(
"""
SELECT query, count(*) AS count
FROM search_events
GROUP BY query
ORDER BY count DESC, query ASC
LIMIT 10
"""
)
top_frameworks = await pool.fetch(
"""
SELECT COALESCE(NULLIF(framework, ''), 'any') AS framework, count(*) AS count
FROM search_events
GROUP BY COALESCE(NULLIF(framework, ''), 'any')
ORDER BY count DESC, framework ASC
LIMIT 10
"""
)
top_referrers = await pool.fetch(
"""
SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer, count(*) AS count
FROM page_views
GROUP BY COALESCE(NULLIF(referrer, ''), '(direct)')
ORDER BY count DESC, referrer ASC
LIMIT 10
"""
)

return {
"total_visits": row["total_visits"],
"unique_visitors": row["unique_visitors"],
"total_searches": row["total_searches"],
"visits_last_24h": row["visits_last_24h"],
"searches_last_24h": row["searches_last_24h"],
"top_queries": [{"label": r["query"], "count": r["count"]} for r in top_queries],
"top_frameworks": [{"label": r["framework"], "count": r["count"]} for r in top_frameworks],
"top_referrers": [{"label": r["referrer"], "count": r["count"]} for r in top_referrers],
}


async def list_page_views(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT visited_at, ip_hash, user_agent, referrer
FROM page_views
ORDER BY visited_at DESC
LIMIT $1
""",
limit,
)
return [dict(r) for r in rows]


async def list_searches(limit: int = 50) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT searched_at, query, framework, priorities, result_count
FROM search_events
ORDER BY searched_at DESC
LIMIT $1
""",
limit,
)
out = []
for row in rows:
item = dict(row)
priorities = item["priorities"]
item["priorities"] = [p.strip() for p in priorities.split(",")] if priorities else None
out.append(item)
return out
19 changes: 19 additions & 0 deletions backend/app/db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import os

import asyncpg

_pg_pool: asyncpg.Pool | None = None


async def get_pool() -> asyncpg.Pool:
"""Lazy initialization of the shared asyncpg pool."""
global _pg_pool

if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)

return _pg_pool
85 changes: 83 additions & 2 deletions backend/app/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,15 @@
from slowapi.util import get_remote_address

import app.env
from app.analytics import (
client_ip,
get_analytics_summary,
hash_ip,
list_page_views,
list_searches,
record_page_view,
record_search,
)
from app.llm import stream_response
from app.models import SearchRequest
from app.search import package_search
Expand DownExpand Up@@ -50,6 +59,66 @@ async def health():
return {"status": "ok"}


def _require_analytics_token(request: Request) -> None:
expected = os.environ.get("ANALYTICS_TOKEN")
if not expected:
raise HTTPException(status_code=503, detail="Analytics token not configured")
if request.headers.get("x-analytics-token") != expected:
raise HTTPException(status_code=401, detail="Unauthorized")


@app.post("/api/track/pageview")
@limiter.limit("120/minute")
async def track_pageview(request: Request):
referrer = None
try:
body = await request.json()
referrer = body.get("referrer")
except Exception:
pass

try:
await record_page_view(
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
referrer=referrer or request.headers.get("referer"),
)
except Exception as e:
logger.warning(f"Failed to record page view: {e}")

return {"ok": True}


@app.get("/api/analytics/summary")
async def analytics_summary(request: Request):
_require_analytics_token(request)
try:
return await get_analytics_summary()
except Exception:
logger.exception("Analytics summary failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/visits")
async def analytics_visits(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_page_views(limit=min(limit, 200))
except Exception:
logger.exception("Analytics visits failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.get("/api/analytics/searches")
async def analytics_searches(request: Request, limit: int = 50):
_require_analytics_token(request)
try:
return await list_searches(limit=min(limit, 200))
except Exception:
logger.exception("Analytics searches failed")
raise HTTPException(status_code=502, detail="Analytics query failed") from None


@app.post("/api/search")
@limiter.limit("2/minute")
async def search(request: Request, body: SearchRequest):
Expand All@@ -60,9 +129,21 @@ async def search(request: Request, body: SearchRequest):

try:
packages = await package_search(body.query)
except Exception:
logger.exception("Package search failed")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from None

try:
await record_search(
query=body.query,
framework=body.framework,
priorities=body.priorities,
result_count=len(packages),
ip_hash=hash_ip(client_ip(request)),
user_agent=request.headers.get("user-agent"),
)
except Exception as e:
logger.error(f"Package search failed: {e}")
raise HTTPException(status_code=502, detail="Failed in hybrid search") from e
logger.warning(f"Failed to record search event: {e}")

if not packages:
logger.info("No packages found for query, returning empty response")
Expand Down
33 changes: 33 additions & 0 deletions backend/app/models.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
from datetime import datetime

from pydantic import BaseModel, Field


Expand All@@ -18,3 +20,34 @@ class Package(BaseModel):

class SearchResponse(BaseModel):
packages: list[Package]


class PageView(BaseModel):
visited_at: datetime
ip_hash: str
user_agent: str | None = None
referrer: str | None = None


class SearchEvent(BaseModel):
searched_at: datetime
query: str
framework: str | None = None
priorities: list[str] | None = None
result_count: int


class TopItem(BaseModel):
label: str
count: int


class AnalyticsSummary(BaseModel):
total_visits: int
unique_visitors: int
total_searches: int
visits_last_24h: int
searches_last_24h: int
top_queries: list[TopItem]
top_frameworks: list[TopItem]
top_referrers: list[TopItem]
20 changes: 4 additions & 16 deletions backend/app/search.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,11 @@
import logging
import os

import asyncpg
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient

from app.db import get_pool

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -40,19 +41,6 @@ def get_qdrant_client() -> AsyncQdrantClient:
COLLECTION_NAME = "npmatch"
CANDIDATE_LIMIT = 20

_pg_pool: asyncpg.Pool | None = None


async def _get_pool() -> asyncpg.Pool:
global _pg_pool
if _pg_pool is None:
_pg_pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_CONNECTION_STRING"],
min_size=1,
max_size=5,
)
return _pg_pool


def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
scores: dict[str, float] = {}
Expand DownExpand Up@@ -89,7 +77,7 @@ async def _vector_search(embedding: list[float]) -> list[str]:


async def _fts_search(query: str) -> list[str]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT name,
Expand All@@ -115,7 +103,7 @@ def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:


async def _fetch_metadata(names: list[str]) -> list[dict]:
pool = await _get_pool()
pool = await get_pool()
rows = await pool.fetch(
"SELECT name, description, keywords, version FROM packages WHERE name = ANY($1)",
names,
Expand Down
Loading
Loading