@@ -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" )
0 commit comments