Skip to content

Commit 0eeff6c

Browse files
committed
chore: bump version to 1.3.35
1 parent c580805 commit 0eeff6c

6 files changed

Lines changed: 333 additions & 3 deletions

File tree

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ def _isolate_data(tmp_path, monkeypatch):
6767
monkeypatch.setattr(app_mod, "ARKFORGE_PUBLIC_KEY", test_pubkey)
6868
monkeypatch.setattr(app_mod, "PROOF_ACCESS_LOG", tmp_path / "data" / "proof_access_log.jsonl")
6969
monkeypatch.setattr(app_mod, "WEBHOOK_IDEMPOTENCY_FILE", tmp_path / "data" / "webhook_idempotency.jsonl")
70+
monkeypatch.setattr(app_mod, "FUNNEL_EVENTS_LOG", tmp_path / "data" / "funnel_events.jsonl")
71+
monkeypatch.setattr(app_mod, "CONVERSION_EVENTS_LOG", tmp_path / "data" / "conversion_events.jsonl")
7072

7173
# DNS rebinding mock — prevent real DNS resolution in proxy tests
7274
monkeypatch.setattr(proxy_mod, "_check_no_private_dns", AsyncMock(return_value=None))
@@ -98,6 +100,10 @@ def _isolate_data(tmp_path, monkeypatch):
98100
monkeypatch.setattr(assess_mod, "MCP_BASELINES_DIR", tmp_path / "data" / "mcp_baselines")
99101
monkeypatch.setattr(assess_mod, "ASSESSMENTS_DIR", tmp_path / "data" / "assessments")
100102

103+
import trust_layer.routers.assess as assess_router_mod
104+
monkeypatch.setattr(assess_router_mod, "SCAN_EVENTS_LOG", tmp_path / "data" / "scan_events.jsonl")
105+
monkeypatch.setattr(assess_router_mod, "FUNNEL_EVENTS_LOG", tmp_path / "data" / "funnel_events.jsonl")
106+
101107
import trust_layer.proof_index as pidx_mod
102108
monkeypatch.setattr(pidx_mod, "PROOF_INDEX_FILE", tmp_path / "data" / "proof_index.jsonl")
103109
# Reset proof_index singleton so each test gets a fresh File backend pointing to tmp_path

tests/test_funnel_metrics.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Tests for funnel metrics instrumentation (cta_impression, register_page_visit, register_completion)."""
2+
3+
import json
4+
from pathlib import Path
5+
from unittest.mock import patch, AsyncMock
6+
7+
import pytest
8+
from fastapi.testclient import TestClient
9+
10+
11+
@pytest.fixture
12+
def client():
13+
from trust_layer.app import app
14+
return TestClient(app)
15+
16+
17+
@pytest.fixture
18+
def internal_key(tmp_path):
19+
from trust_layer.keys import create_api_key
20+
key = create_api_key("", "internal_test", "internal@arkforge.tech", test_mode=False, plan="internal")
21+
return key
22+
23+
24+
@pytest.fixture
25+
def free_key(tmp_path):
26+
from trust_layer.keys import create_api_key
27+
key = create_api_key("", "free_test", "free@test.com", test_mode=False, plan="free")
28+
return key
29+
30+
31+
def _read_funnel_events(tmp_path) -> list:
32+
log = tmp_path / "data" / "funnel_events.jsonl"
33+
if not log.exists():
34+
return []
35+
events = []
36+
for line in log.read_text().splitlines():
37+
if line.strip():
38+
events.append(json.loads(line))
39+
return events
40+
41+
42+
class TestCtaImpression:
43+
def test_assess_logs_cta_impression(self, client, free_key, tmp_path):
44+
resp = client.post(
45+
"/v1/assess",
46+
json={"server_id": "test-mcp", "manifest": {"tools": [{"name": "t1"}]}},
47+
headers={"X-Api-Key": free_key},
48+
)
49+
assert resp.status_code == 200
50+
events = _read_funnel_events(tmp_path)
51+
cta_events = [e for e in events if e["event"] == "cta_impression"]
52+
assert len(cta_events) == 1
53+
assert cta_events[0]["server_id"] == "test-mcp"
54+
assert cta_events[0]["assess_id"].startswith("asr_")
55+
56+
57+
class TestRegisterPageVisit:
58+
def test_register_get_logs_visit(self, client, tmp_path):
59+
resp = client.get("/register?scan_id=asr_20260416_120000_abc123")
60+
assert resp.status_code == 200
61+
events = _read_funnel_events(tmp_path)
62+
visit_events = [e for e in events if e["event"] == "register_page_visit"]
63+
assert len(visit_events) == 1
64+
assert visit_events[0]["scan_id"] == "asr_20260416_120000_abc123"[:32]
65+
66+
def test_register_get_without_scan_id(self, client, tmp_path):
67+
resp = client.get("/register")
68+
assert resp.status_code == 200
69+
events = _read_funnel_events(tmp_path)
70+
visit_events = [e for e in events if e["event"] == "register_page_visit"]
71+
assert len(visit_events) == 1
72+
assert visit_events[0]["scan_id"] == ""
73+
74+
75+
class TestRegisterCompletion:
76+
def test_register_post_logs_completion(self, client, tmp_path):
77+
resp = client.post(
78+
"/api/register",
79+
json={"email": "newuser@example.com", "source": "web_register", "scan_id": "asr_test_123"},
80+
)
81+
assert resp.status_code == 200
82+
data = resp.json()
83+
assert "api_key" in data
84+
85+
events = _read_funnel_events(tmp_path)
86+
completion_events = [e for e in events if e["event"] == "register_completion"]
87+
assert len(completion_events) == 1
88+
assert completion_events[0]["source"] == "web_register"
89+
assert completion_events[0]["scan_id"] == "asr_test_123"
90+
91+
def test_existing_key_no_completion_event(self, client, tmp_path):
92+
client.post("/api/register", json={"email": "dup@example.com", "source": "web_register"})
93+
events_before = len(_read_funnel_events(tmp_path))
94+
95+
resp = client.post("/api/register", json={"email": "dup@example.com", "source": "web_register"})
96+
assert resp.status_code == 200
97+
assert resp.json().get("already_existed") is True
98+
99+
events_after = _read_funnel_events(tmp_path)
100+
completions_after = [e for e in events_after if e["event"] == "register_completion"]
101+
assert len(completions_after) == 1
102+
103+
104+
class TestFunnelMetricsEndpoint:
105+
def test_requires_internal_key(self, client, free_key):
106+
resp = client.get("/v1/funnel-metrics", headers={"X-Api-Key": free_key})
107+
assert resp.status_code == 403
108+
109+
def test_returns_aggregated_counts(self, client, internal_key, free_key, tmp_path):
110+
client.post(
111+
"/v1/assess",
112+
json={"server_id": "s1", "manifest": {"tools": [{"name": "t1"}]}},
113+
headers={"X-Api-Key": free_key},
114+
)
115+
client.get("/register?scan_id=asr_test")
116+
client.post("/api/register", json={"email": "agg@example.com", "source": "web_register"})
117+
118+
resp = client.get("/v1/funnel-metrics", headers={"X-Api-Key": internal_key})
119+
assert resp.status_code == 200
120+
data = resp.json()
121+
assert data["cta_impressions"] >= 1
122+
assert data["register_page_visits"] >= 1
123+
assert data["register_completions"] >= 1
124+
assert data["cta_to_visit_rate"] is not None
125+
assert data["visit_to_completion_rate"] is not None
126+
assert "by_day" in data

trust_layer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""ArkForge Trust Layer — Certifying proxy for agent-to-agent payments."""
22

3-
__version__ = "1.3.34"
3+
__version__ = "1.3.35"

trust_layer/app.py

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ async def dispatch(self, request: Request, call_next):
123123
ARKFORGE_PUBLIC_KEY,
124124
WEBHOOK_IDEMPOTENCY_FILE,
125125
CONVERSION_EVENTS_LOG,
126+
FUNNEL_EVENTS_LOG,
126127
CORS_ALLOWED_ORIGINS,
127128
PRO_OVERAGE_PRICE,
128129
ENTERPRISE_OVERAGE_PRICE,
@@ -1009,6 +1010,113 @@ async def free_signup(request: Request):
10091010
}
10101011

10111012

1013+
# --- GET /register (inline CTA from MCP scan results) ---
1014+
1015+
@app.get("/register")
1016+
async def register_form(request: Request, scan_id: str = ""):
1017+
"""Minimal registration form linked from MCP scan output."""
1018+
scan_id_safe = scan_id[:32] if scan_id else ""
1019+
1020+
# --- Funnel event: register page visit ---
1021+
try:
1022+
client_ip = (
1023+
request.headers.get("x-real-ip")
1024+
or (request.client.host if request.client else "unknown")
1025+
)
1026+
with open(FUNNEL_EVENTS_LOG, "a") as _f:
1027+
_f.write(json.dumps({
1028+
"ts": datetime.now(timezone.utc).isoformat(),
1029+
"event": "register_page_visit",
1030+
"scan_id": scan_id_safe,
1031+
"client_ip_hash": hashlib.sha256(client_ip.encode()).hexdigest()[:12],
1032+
"referrer": (request.headers.get("referer") or "")[:200],
1033+
}) + "\n")
1034+
except Exception:
1035+
pass
1036+
1037+
html = f"""<!DOCTYPE html>
1038+
<html lang="en">
1039+
<head>
1040+
<meta charset="utf-8">
1041+
<meta name="viewport" content="width=device-width, initial-scale=1">
1042+
<title>Save Your Scan Results - ArkForge</title>
1043+
<style>
1044+
*{{margin:0;padding:0;box-sizing:border-box}}
1045+
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0a0a0f;color:#e0e0e0;min-height:100vh;display:flex;align-items:center;justify-content:center}}
1046+
.card{{background:#14141f;border:1px solid #2a2a3a;border-radius:12px;padding:2.5rem;max-width:420px;width:90%}}
1047+
h1{{font-size:1.4rem;margin-bottom:.5rem;color:#fff}}
1048+
p{{font-size:.9rem;color:#a0a0b0;margin-bottom:1.5rem;line-height:1.5}}
1049+
label{{font-size:.85rem;color:#c0c0d0;display:block;margin-bottom:.4rem}}
1050+
input[type=email]{{width:100%;padding:.75rem 1rem;border:1px solid #2a2a3a;border-radius:8px;background:#0a0a0f;color:#fff;font-size:1rem;outline:none;transition:border-color .2s}}
1051+
input[type=email]:focus{{border-color:#4f8cff}}
1052+
button{{width:100%;padding:.75rem;margin-top:1rem;border:none;border-radius:8px;background:#4f8cff;color:#fff;font-size:1rem;font-weight:600;cursor:pointer;transition:background .2s}}
1053+
button:hover{{background:#3a7aef}}
1054+
button:disabled{{background:#333;cursor:wait}}
1055+
.result{{margin-top:1rem;padding:1rem;border-radius:8px;font-size:.9rem;line-height:1.5}}
1056+
.result.ok{{background:#0d2818;border:1px solid #1a5c2e;color:#6fcf97}}
1057+
.result.err{{background:#2d1215;border:1px solid #5c1a1f;color:#cf6f6f}}
1058+
.key-box{{font-family:monospace;background:#0a0a0f;padding:.5rem .75rem;border-radius:6px;margin-top:.5rem;word-break:break-all;display:flex;align-items:center;gap:.5rem}}
1059+
.copy-btn{{background:none;border:1px solid #2a2a3a;color:#a0a0b0;padding:.25rem .5rem;border-radius:4px;cursor:pointer;font-size:.75rem;width:auto;margin:0}}
1060+
.copy-btn:hover{{border-color:#4f8cff;color:#4f8cff;background:none}}
1061+
.features{{list-style:none;margin-top:1.5rem;font-size:.85rem;color:#a0a0b0}}
1062+
.features li::before{{content:'\\2713 ';color:#6fcf97}}
1063+
.features li{{margin-bottom:.35rem}}
1064+
</style>
1065+
</head>
1066+
<body>
1067+
<div class="card">
1068+
<h1>Save your scan results</h1>
1069+
<p>Get a free API key. No password, no credit card.</p>
1070+
<form id="regform">
1071+
<label for="email">Email address</label>
1072+
<input type="email" id="email" name="email" placeholder="you@company.com" required autofocus>
1073+
<input type="hidden" id="scan_id" value="{scan_id_safe}">
1074+
<button type="submit" id="btn">Get free API key</button>
1075+
</form>
1076+
<div id="result" style="display:none"></div>
1077+
<ul class="features">
1078+
<li>Scan history across sessions</li>
1079+
<li>Compliance trend tracking</li>
1080+
<li>CI/CD integration (GitHub Actions, GitLab CI)</li>
1081+
</ul>
1082+
</div>
1083+
<script>
1084+
document.getElementById('regform').addEventListener('submit', async(e)=>{{
1085+
e.preventDefault();
1086+
const btn=document.getElementById('btn');
1087+
const res=document.getElementById('result');
1088+
const email=document.getElementById('email').value.trim();
1089+
const scanId=document.getElementById('scan_id').value;
1090+
if(!email)return;
1091+
btn.disabled=true;btn.textContent='Activating...';
1092+
res.style.display='none';
1093+
try{{
1094+
const r=await fetch('/api/register',{{
1095+
method:'POST',
1096+
headers:{{'Content-Type':'application/json'}},
1097+
body:JSON.stringify({{email:email,source:'web_register',scan_id:scanId}})
1098+
}});
1099+
const d=await r.json();
1100+
if(r.ok&&d.api_key){{
1101+
res.className='result ok';
1102+
res.innerHTML='API key activated!<div class="key-box"><span>'+d.api_key+'</span><button class="copy-btn" onclick="navigator.clipboard.writeText(\\''+d.api_key+'\\');this.textContent=\\'Copied\\'">Copy</button></div><p style="margin-top:.75rem;font-size:.85rem;color:#a0a0b0">Also sent to '+email+'</p>';
1103+
btn.textContent='Done';
1104+
}}else{{
1105+
throw new Error(d.detail||d.error||'Registration failed');
1106+
}}
1107+
}}catch(err){{
1108+
res.className='result err';
1109+
res.textContent=err.message;
1110+
btn.disabled=false;btn.textContent='Get free API key';
1111+
}}
1112+
res.style.display='block';
1113+
}});
1114+
</script>
1115+
</body>
1116+
</html>"""
1117+
return HTMLResponse(content=html)
1118+
1119+
10121120
# --- POST /api/register (MCP phone-home) ---
10131121

10141122
_MCP_REGISTER_RATE: dict = {}
@@ -1073,15 +1181,31 @@ async def mcp_register(request: Request):
10731181
except (OSError, RuntimeError) as e:
10741182
logger.warning("Welcome email failed for MCP register %s: %s", email, e)
10751183

1184+
# --- Funnel event: register completion ---
1185+
source = body.get("source", "mcp_phonehome")
1186+
scan_id = body.get("scan_id", "")
1187+
try:
1188+
with open(FUNNEL_EVENTS_LOG, "a") as _f:
1189+
_f.write(json.dumps({
1190+
"ts": datetime.now(timezone.utc).isoformat(),
1191+
"event": "register_completion",
1192+
"scan_id": scan_id[:32] if scan_id else "",
1193+
"source": source,
1194+
"client_ip_hash": hashlib.sha256(client_ip.encode()).hexdigest()[:12],
1195+
"email_hash": email[:3] + "***" if email else "",
1196+
}) + "\n")
1197+
except Exception:
1198+
pass
1199+
10761200
try:
1077-
source = body.get("source", "mcp_phonehome")
10781201
with open(CONVERSION_EVENTS_LOG, "a") as _cel:
10791202
_cel.write(json.dumps({
10801203
"ts": datetime.now(timezone.utc).isoformat(),
10811204
"event": "signup_attributed",
10821205
"plan": "free",
10831206
"email_hash": email[:3] + "***" if email else "",
10841207
"source": source,
1208+
"scan_id": scan_id[:32] if scan_id else "",
10851209
"client_ip_hash": hashlib.sha256(client_ip.encode()).hexdigest()[:12],
10861210
}) + "\n")
10871211
except OSError:
@@ -1692,6 +1816,64 @@ async def health():
16921816
return resp
16931817

16941818

1819+
# --- GET /v1/funnel-metrics (internal — aggregated funnel counters) ---
1820+
1821+
@app.get("/v1/funnel-metrics")
1822+
async def funnel_metrics(
1823+
request: Request,
1824+
days: int = 7,
1825+
x_api_key: Optional[str] = Header(None),
1826+
authorization: Optional[str] = Header(None),
1827+
):
1828+
api_key = _get_api_key(authorization, x_api_key)
1829+
if not api_key:
1830+
return _error_response("invalid_api_key", "API key required", 401)
1831+
key_info = validate_api_key(api_key)
1832+
if not key_info or not is_internal_key(api_key):
1833+
return _error_response("forbidden", "Internal key required", 403)
1834+
1835+
from datetime import timedelta
1836+
cutoff = (datetime.now(timezone.utc) - timedelta(days=min(days, 90))).isoformat()
1837+
counts = {"cta_impression": 0, "register_page_visit": 0, "register_completion": 0}
1838+
by_day: dict = {}
1839+
1840+
try:
1841+
with open(FUNNEL_EVENTS_LOG) as f:
1842+
for line in f:
1843+
line = line.strip()
1844+
if not line:
1845+
continue
1846+
try:
1847+
entry = json.loads(line)
1848+
except (json.JSONDecodeError, ValueError):
1849+
continue
1850+
ts = entry.get("ts", "")
1851+
if ts < cutoff:
1852+
continue
1853+
evt = entry.get("event", "")
1854+
if evt in counts:
1855+
counts[evt] += 1
1856+
day = ts[:10]
1857+
by_day.setdefault(day, {"cta_impression": 0, "register_page_visit": 0, "register_completion": 0})
1858+
by_day[day][evt] += 1
1859+
except FileNotFoundError:
1860+
pass
1861+
1862+
cta = counts["cta_impression"]
1863+
visits = counts["register_page_visit"]
1864+
completions = counts["register_completion"]
1865+
return {
1866+
"window_days": min(days, 90),
1867+
"cta_impressions": cta,
1868+
"register_page_visits": visits,
1869+
"register_completions": completions,
1870+
"cta_to_visit_rate": round(visits / cta, 4) if cta > 0 else None,
1871+
"visit_to_completion_rate": round(completions / visits, 4) if visits > 0 else None,
1872+
"cta_to_completion_rate": round(completions / cta, 4) if cta > 0 else None,
1873+
"by_day": dict(sorted(by_day.items())),
1874+
}
1875+
1876+
16951877
# --- POST /v1/keys/overage ---
16961878

16971879
@app.post("/v1/keys/overage")

trust_layer/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
BACKGROUND_TASKS_LOG = DATA_DIR / "background_tasks_log.jsonl"
1818
PROOF_ACCESS_LOG = DATA_DIR / "proof_access_log.jsonl"
1919
CONVERSION_EVENTS_LOG = DATA_DIR / "conversion_events.jsonl"
20+
FUNNEL_EVENTS_LOG = DATA_DIR / "funnel_events.jsonl"
2021
IDEMPOTENCY_DIR = DATA_DIR / "idempotency"
2122
AGENTS_DIR = DATA_DIR / "agents"
2223
SERVICES_DIR = DATA_DIR / "services"

0 commit comments

Comments
 (0)