diff --git a/MODULE.bazel b/MODULE.bazel index 19aadd96a..46fec68fe 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -193,6 +193,7 @@ maven.install( # Compile-only via wrapper target; not on runtime classpath "com.squareup.retrofit2:retrofit:3.0.0", + "androidx.webkit:webkit:1.12.1", ], repositories = [ "https://maven.google.com", diff --git a/Makefile b/Makefile index 00d1cbf32..e032b8750 100644 --- a/Makefile +++ b/Makefile @@ -76,3 +76,13 @@ xcframework: .PHONY: test-gradle test-gradle: platform/jvm/gradlew :capture:testDebugUnitTest -p platform/jvm + +.PHONY: fix-ts +fix-ts: + npm --prefix ./platform/webview run lint:fix + npm --prefix ./platform/webview run format + +.PHONY: build-ts +build-ts: + npm --prefix ./platform/webview run build + npm --prefix ./platform/webview run generate diff --git a/ci/license_header.py b/ci/license_header.py index 53f5edbd7..54e8fd75b 100644 --- a/ci/license_header.py +++ b/ci/license_header.py @@ -27,7 +27,7 @@ './target/', ) -extensions_to_check = ('.rs', '.toml', '.kt', '.java', '.swift') +extensions_to_check = ('.rs', '.toml', '.kt', '.java', '.swift', '.js', '.ts') def check_file(file_path): diff --git a/examples/android/BUILD b/examples/android/BUILD index 11a47069a..0fd4fbf23 100644 --- a/examples/android/BUILD +++ b/examples/android/BUILD @@ -28,6 +28,7 @@ _maven_deps = [ artifact("org.jetbrains:annotations"), artifact("androidx.metrics:metrics-performance"), artifact("com.squareup.retrofit2:retrofit"), + artifact("androidx.webkit:webkit"), ] aar_import( diff --git a/platform/jvm/capture/BUILD.bazel b/platform/jvm/capture/BUILD.bazel index c148896cc..80c9a62f7 100644 --- a/platform/jvm/capture/BUILD.bazel +++ b/platform/jvm/capture/BUILD.bazel @@ -23,6 +23,14 @@ java_library( exports = [artifact("com.squareup.retrofit2:retrofit")], ) +# WebKit compile-only wrapper: exposes API at compile time, excluded from runtime via neverlink. +java_library( + name = "webkit_compile_only", + neverlink = True, + visibility = ["//visibility:public"], + exports = [artifact("androidx.webkit:webkit")], +) + # export proguard library rules exports_files(["consumer-rules.pro"]) @@ -71,6 +79,7 @@ bitdrift_kt_android_library( artifact("androidx.metrics:metrics-performance"), # Compile-only dependency (neverlink wrapper). Not packaged nor appearing in POM. ":retrofit_compile_only", + ":webkit_compile_only", ], ) diff --git a/platform/jvm/capture/build.gradle.kts b/platform/jvm/capture/build.gradle.kts index d15408adc..8bf256fa9 100644 --- a/platform/jvm/capture/build.gradle.kts +++ b/platform/jvm/capture/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(project(":common")) implementation(libs.androidx.core) implementation(libs.androidx.startup.runtime) + implementation(libs.androidx.webkit) implementation(libs.jsr305) implementation(libs.gson) implementation(libs.performance) diff --git a/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewBridgeModels.kt b/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewBridgeModels.kt new file mode 100644 index 000000000..d5839a203 --- /dev/null +++ b/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewBridgeModels.kt @@ -0,0 +1,137 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +package io.bitdrift.capture.webview + +import com.google.gson.annotations.SerializedName + +/** + * Base class for all WebView bridge messages. + * All messages have a version, type, and timestamp. + */ +internal data class WebViewBridgeMessage( + @SerializedName("v") val version: Int = 0, + val tag: String? = null, + val type: String? = null, + val timestamp: Long? = null, + // bridgeReady + val url: String? = null, + // webVital + val metric: WebVitalMetric? = null, + val parentSpanId: String? = null, + // networkRequest + val method: String? = null, + val statusCode: Int? = null, + val durationMs: Long? = null, + val success: Boolean? = null, + val error: String? = null, + val requestType: String? = null, + val timing: NetworkTiming? = null, + // navigation + val fromUrl: String? = null, + val toUrl: String? = null, + // pageView + val action: String? = null, + val spanId: String? = null, + val reason: String? = null, + // lifecycle + val event: String? = null, + val performanceTime: Double? = null, + val visibilityState: String? = null, + // error + val name: String? = null, + val message: String? = null, + val stack: String? = null, + val filename: String? = null, + val lineno: Int? = null, + val colno: Int? = null, + // longTask + val startTime: Double? = null, + val attribution: LongTaskAttribution? = null, + // resourceError + val resourceType: String? = null, + val tagName: String? = null, + // console + val level: String? = null, + val args: List? = null, + // userInteraction + val interactionType: String? = null, + val elementId: String? = null, + val className: String? = null, + val textContent: String? = null, + val isClickable: Boolean? = null, + val clickCount: Int? = null, + val timeWindowMs: Int? = null, +) + +/** + * Web Vital metric data from the web-vitals library. + */ +internal data class WebVitalMetric( + val name: String? = null, + val value: Double? = null, + val rating: String? = null, + val delta: Double? = null, + val id: String? = null, + val navigationType: String? = null, + val entries: List? = null, +) + +/** + * Performance entry associated with a web vital metric. + * Contains different fields depending on the metric type. + */ +internal data class WebVitalEntry( + // Common fields + val startTime: Double? = null, + val entryType: String? = null, + // LCP-specific + val element: String? = null, + val url: String? = null, + val size: Long? = null, + val renderTime: Double? = null, + val loadTime: Double? = null, + // FCP-specific + val name: String? = null, + // TTFB-specific (PerformanceNavigationTiming) + val domainLookupStart: Double? = null, + val domainLookupEnd: Double? = null, + val connectStart: Double? = null, + val connectEnd: Double? = null, + val secureConnectionStart: Double? = null, + val requestStart: Double? = null, + val responseStart: Double? = null, + // INP-specific + val processingStart: Double? = null, + val processingEnd: Double? = null, + val duration: Double? = null, + val interactionId: Long? = null, + // CLS-specific + val value: Double? = null, +) + +/** + * Network timing data from the Performance API. + */ +internal data class NetworkTiming( + val transferSize: Long? = null, + val dnsMs: Long? = null, + val tlsMs: Long? = null, + val connectMs: Long? = null, + val ttfbMs: Long? = null, +) + +/** + * Long task attribution data. + */ +internal data class LongTaskAttribution( + val name: String? = null, + val containerType: String? = null, + val containerSrc: String? = null, + val containerId: String? = null, + val containerName: String? = null, +) diff --git a/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewBridgeScript.kt b/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewBridgeScript.kt new file mode 100644 index 000000000..ecf3a3f3c --- /dev/null +++ b/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewBridgeScript.kt @@ -0,0 +1,29 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated by: npm run generate +// Source: platform/webview/src/ + +package io.bitdrift.capture.webview + +/** + * Contains the bundled JavaScript SDK for WebView instrumentation. + * This script is injected into WebViews to capture performance metrics, + * network events, and user interactions. + */ +internal object WebViewBridgeScript { + /** + * The minified JavaScript bundle to inject into WebViews. + */ + const val SCRIPT: String = """ +(function() { +"use strict";var BitdriftWebView=(()=>{var De=Object.defineProperty;var _e=(e,t,r)=>t in e?De(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var h=(e,t,r)=>_e(e,typeof t!="symbol"?t+"":t,r);var x={console:{log:console.log,warn:console.warn,error:console.error,info:console.info,debug:console.debug}},Oe=()=>window.webkit?.messageHandlers?.BitdriftLogger?"ios":window.BitdriftLogger?"android":"unknown",ee=e=>{let t=Oe(),r=JSON.stringify(e);switch(t){case"ios":window.webkit?.messageHandlers?.BitdriftLogger?.postMessage(e);break;case"android":window.BitdriftLogger?.log(r);break;case"unknown":typeof console<"u"&&console.debug("[Bitdrift WebView]",e);break}},te=()=>{window.bitdrift||(window.bitdrift={log:ee})},c=e=>{window.bitdrift?(x.console.log("[Bitdrift WebView] Logging message via bridge",e),window.bitdrift.log(e)):ee(e)},l=e=>({tag:"bitdrift-webview-sdk",v:1,timestamp:Date.now(),...e}),ne=e=>typeof e=="object"&&e!==null&&"type"in e&&typeof e.type=="string"&&"v"in e&&typeof e.v=="number"&&"tag"in e&&e.tag==="bitdrift-webview-sdk";var ue=-1,b=e=>{addEventListener("pageshow",(t=>{t.persisted&&(ue=t.timeStamp,e(t))}),!0)},f=(e,t,r,n)=>{let i,o;return s=>{t.value>=0&&(s||n)&&(o=t.value-(i??0),(o||i===void 0)&&(i=t.value,t.delta=o,t.rating=((a,d)=>a>d[1]?"poor":a>d[0]?"needs-improvement":"good")(t.value,r),e(t)))}},z=e=>{requestAnimationFrame((()=>requestAnimationFrame((()=>e()))))},G=()=>{let e=performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStartG()?.activationStart??0,p=(e,t=-1)=>{let r=G(),n="navigate";return ue>=0?n="back-forward-cache":r&&(document.prerendering||R()>0?n="prerender":document.wasDiscarded?n="restore":r.type&&(n=r.type.replace(/_/g,"-"))),{name:e,value:t,rating:"good",delta:0,entries:[],id:`v5-${'$'}{Date.now()}-${'$'}{Math.floor(8999999999999*Math.random())+1e12}`,navigationType:n}},O=new WeakMap;function K(e,t){return O.get(e)||O.set(e,new t),O.get(e)}var U=class{constructor(){h(this,"t");h(this,"i",0);h(this,"o",[])}h(t){if(t.hadRecentInput)return;let r=this.o[0],n=this.o.at(-1);this.i&&r&&n&&t.startTime-n.startTime<1e3&&t.startTime-r.startTime<5e3?(this.i+=t.value,this.o.push(t)):(this.i=t.value,this.o=[t]),this.t?.(t)}},S=(e,t,r={})=>{try{if(PerformanceObserver.supportedEntryTypes.includes(e)){let n=new PerformanceObserver((i=>{Promise.resolve().then((()=>{t(i.getEntries())}))}));return n.observe({type:e,buffered:!0,...r}),n}}catch{}},J=e=>{let t=!1;return()=>{t||(e(),t=!0)}},v=-1,ge=new Set,re=()=>document.visibilityState!=="hidden"||document.prerendering?1/0:0,F=e=>{if(document.visibilityState==="hidden"){if(e.type==="visibilitychange")for(let t of ge)t();isFinite(v)||(v=e.type==="visibilitychange"?e.timeStamp:0,removeEventListener("prerenderingchange",F,!0))}},N=()=>{if(v<0){let e=R();v=(document.prerendering?void 0:globalThis.performance.getEntriesByType("visibility-state").filter((r=>r.name==="hidden"&&r.startTime>e))[0]?.startTime)??re(),addEventListener("visibilitychange",F,!0),addEventListener("prerenderingchange",F,!0),b((()=>{setTimeout((()=>{v=re()}))}))}return{get firstHiddenTime(){return v},onHidden(e){ge.add(e)}}},A=e=>{document.prerendering?addEventListener("prerenderingchange",(()=>e()),!0):e()},ie=[1800,3e3],j=(e,t={})=>{A((()=>{let r=N(),n,i=p("FCP"),o=S("paint",(s=>{for(let a of s)a.name==="first-contentful-paint"&&(o.disconnect(),a.startTime{i=p("FCP"),n=f(e,i,ie,t.reportAllChanges),z((()=>{i.value=performance.now()-s.timeStamp,n(!0)}))})))}))},oe=[.1,.25],me=(e,t={})=>{let r=N();j(J((()=>{let n,i=p("CLS",0),o=K(t,U),s=d=>{for(let u of d)o.h(u);o.i>i.value&&(i.value=o.i,i.entries=o.o,n())},a=S("layout-shift",s);a&&(n=f(e,i,oe,t.reportAllChanges),r.onHidden((()=>{s(a.takeRecords()),n(!0)})),b((()=>{o.i=0,i=p("CLS",0),n=f(e,i,oe,t.reportAllChanges),z((()=>n()))})),setTimeout(n))})))},fe=0,H=1/0,B=0,He=e=>{for(let t of e)t.interactionId&&(H=Math.min(H,t.interactionId),B=Math.max(B,t.interactionId),fe=B?(B-H)/7+1:0)},V,se=()=>V?fe:performance.interactionCount??0,Ue=()=>{"interactionCount"in performance||V||(V=S("event",He,{type:"event",buffered:!0,durationThreshold:0}))},ae=0,W=class{constructor(){h(this,"u",[]);h(this,"l",new Map);h(this,"m");h(this,"p")}v(){ae=se(),this.u.length=0,this.l.clear()}L(){let t=Math.min(this.u.length-1,Math.floor((se()-ae)/50));return this.u[t]}h(t){if(this.m?.(t),!t.interactionId&&t.entryType!=="first-input")return;let r=this.u.at(-1),n=this.l.get(t.interactionId);if(n||this.u.length<10||t.duration>r.P){if(n?t.duration>n.P?(n.entries=[t],n.P=t.duration):t.duration===n.P&&t.startTime===n.entries[0].startTime&&n.entries.push(t):(n={id:t.interactionId,entries:[t],P:t.duration},this.l.set(n.id,n),this.u.push(n)),this.u.sort(((i,o)=>o.P-i.P)),this.u.length>10){let i=this.u.splice(10);for(let o of i)this.l.delete(o.id)}this.p?.(n)}}},pe=e=>{let t=globalThis.requestIdleCallback||setTimeout;document.visibilityState==="hidden"?e():(e=J(e),addEventListener("visibilitychange",e,{once:!0,capture:!0}),t((()=>{e(),removeEventListener("visibilitychange",e,{capture:!0})})))},ce=[200,500],he=(e,t={})=>{if(!globalThis.PerformanceEventTiming||!("interactionId"in PerformanceEventTiming.prototype))return;let r=N();A((()=>{Ue();let n,i=p("INP"),o=K(t,W),s=d=>{pe((()=>{for(let g of d)o.h(g);let u=o.L();u&&u.P!==i.value&&(i.value=u.P,i.entries=u.entries,n())}))},a=S("event",s,{durationThreshold:t.durationThreshold??40});n=f(e,i,ce,t.reportAllChanges),a&&(a.observe({type:"first-input",buffered:!0}),r.onHidden((()=>{s(a.takeRecords()),n(!0)})),b((()=>{o.v(),i=p("INP"),n=f(e,i,ce,t.reportAllChanges)})))}))},X=class{constructor(){h(this,"m")}h(t){this.m?.(t)}},de=[2500,4e3],ye=(e,t={})=>{A((()=>{let r=N(),n,i=p("LCP"),o=K(t,X),s=d=>{t.reportAllChanges||(d=d.slice(-1));for(let u of d)o.h(u),u.startTime{s(a.takeRecords()),a.disconnect(),n(!0)})),u=g=>{g.isTrusted&&(pe(d),removeEventListener(g.type,u,{capture:!0}))};for(let g of["keydown","click","visibilitychange"])addEventListener(g,u,{capture:!0});b((g=>{i=p("LCP"),n=f(e,i,de,t.reportAllChanges),z((()=>{i.value=performance.now()-g.timeStamp,n(!0)}))}))}}))},le=[800,1800],${'$'}=e=>{document.prerendering?A((()=>${'$'}(e))):document.readyState!=="complete"?addEventListener("load",(()=>${'$'}(e)),!0):setTimeout(e)},we=(e,t={})=>{let r=p("TTFB"),n=f(e,r,le,t.reportAllChanges);${'$'}((()=>{let i=G();i&&(r.value=Math.max(i.responseStart-R(),0),r.entries=[i],n(!0),b((()=>{r=p("TTFB",0),n=f(e,r,le,t.reportAllChanges),n(!0)})))}))};var y=null,P=0,Fe=()=>typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,e=>{let t=Math.random()*16|0;return(e==="x"?t:t&3|8).toString(16)}),ve=()=>y,M=(e,t="navigation")=>{y&&D("navigation"),y=Fe(),t==="initial"?P=Math.round(performance.timeOrigin):P=Date.now(),c({tag:"bitdrift-webview-sdk",v:1,type:"pageView",action:"start",spanId:y,url:e,reason:t,timestamp:P})},D=e=>{if(!y)return;let t=Date.now(),r=t-P,n={tag:"bitdrift-webview-sdk",v:1,timestamp:t,type:"pageView",action:"end",spanId:y,url:window.location.href,reason:e,durationMs:r};c(n),y=null,P=0},I=(e,t)=>{let r=l({type:"lifecycle",event:e,performanceTime:performance.now(),...t});c(r)},be=()=>{M(window.location.href,"initial"),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>{I("DOMContentLoaded")}):I("DOMContentLoaded"),document.readyState!=="complete"?window.addEventListener("load",()=>{I("load")}):I("load"),document.addEventListener("visibilitychange",()=>{I("visibilitychange",{visibilityState:document.visibilityState}),document.visibilityState==="hidden"?D("hidden"):document.visibilityState==="visible"&&!y&&M(window.location.href,"navigation")}),window.addEventListener("pagehide",()=>{D("unload")}),window.addEventListener("beforeunload",()=>{D("unload")})};var Me=()=>{let e=t=>{let r=ve(),n=l({type:"webVital",metric:t,...r&&{parentSpanId:r}});c(n)};ye(e),me(e),he(e),j(e),we(e)};var Ve=0,Q=()=>`req_${'$'}{Date.now()}_${'$'}{++Ve}`,T=new Map,k=new Map,E=new Map,L=5e3,_=e=>{if(T.set(e,Date.now()),T.size>100){let t=Date.now();for(let[r,n]of T.entries())t-n>L&&T.delete(r)}},We=(e,t)=>{let r=T.get(e);if(r===void 0)return!1;let n=performance.timeOrigin+t;return Math.abs(n-r){if(k.set(e,Date.now()),k.size>100){let t=Date.now();for(let[r,n]of k.entries())t-n>L&&k.delete(r)}},Te=e=>{let t=k.get(e);return t===void 0?!1:Date.now()-t{if(E.set(e,{timestamp:Date.now(),resourceType:t,tagName:r}),E.size>100){let n=Date.now();for(let[i,o]of E.entries())n-o.timestamp>L&&E.delete(i)}},${'$'}e=e=>{let t=E.get(e);return t&&Date.now()-t.timestamptypeof performance>"u"||!performance.getEntriesByType?void 0:performance.getEntriesByType("resource").filter(n=>n.name===e).pop(),ze=()=>{let e=window.fetch;e&&(window.fetch=async(t,r)=>{let n=Q(),i=performance.now(),o,s;t instanceof Request?(o=t.url,s=t.method):(o=t.toString(),s=r?.method??"GET");try{let a=await e.call(window,t,r),d=performance.now();_(o);let u=l({type:"networkRequest",requestId:n,method:s.toUpperCase(),url:o,statusCode:a.status,durationMs:Math.round(d-i),success:a.ok,requestType:"fetch",timing:Y(o)});return c(u),a}catch(a){let d=performance.now();_(o);let u=l({type:"networkRequest",requestId:n,method:s.toUpperCase(),url:o,statusCode:0,durationMs:Math.round(d-i),success:!1,error:a instanceof Error?a.message:String(a),requestType:"fetch",timing:Y(o)});throw c(u),a}})},Ge=()=>{let e=XMLHttpRequest.prototype.open,t=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open=function(r,n,i=!0,o,s){this._bitdrift={method:r.toUpperCase(),url:n.toString(),requestId:Q()},e.call(this,r,n,i,o,s)},XMLHttpRequest.prototype.send=function(r){let n=this,i=n._bitdrift;if(!i){t.call(this,r);return}let o=performance.now(),s=()=>{let d=performance.now();_(i.url);let u=l({type:"networkRequest",requestId:i.requestId,method:i.method,url:i.url,statusCode:n.status,durationMs:Math.round(d-o),success:n.status>=200&&n.status<400,requestType:"xmlhttprequest",timing:Y(i.url)});c(u)},a=()=>{let d=performance.now();_(i.url);let u=l({type:"networkRequest",requestId:i.requestId,method:i.method,url:i.url,statusCode:0,durationMs:Math.round(d-o),success:!1,error:"Network error",requestType:"xmlhttprequest"});c(u)};n.addEventListener("load",s),n.addEventListener("error",a),n.addEventListener("abort",a),n.addEventListener("timeout",a),t.call(this,r)}},Ke=()=>{if(!(typeof PerformanceObserver>"u"))try{new PerformanceObserver(t=>{let r=t.getEntries();queueMicrotask(()=>{for(let n of r){if(We(n.name,n.responseEnd)||n.name.startsWith("data:")||n.name.startsWith("blob:"))continue;let i=Math.round(n.responseEnd-n.startTime),o=n.responseStatus??0,s=o===0?${'$'}e(n.name):{failed:!1},a=o>0?o>=200&&o<400:!s.failed,d=l({type:"networkRequest",requestId:Q(),method:"GET",url:n.name,statusCode:o,durationMs:i,success:a,requestType:n.initiatorType,timing:n});Xe(n.name),c(d)}})}).observe({type:"resource",buffered:!1})}catch{}},Ee=()=>{ze(),Ge(),Ke()};var w="",Le=()=>{w=window.location.href;let e=history.pushState;history.pushState=function(r,n,i){let o=w;e.call(this,r,n,i);let s=window.location.href;o!==s&&(w=s,Z(o,s,"pushState"),M(s,"navigation"))};let t=history.replaceState;history.replaceState=function(r,n,i){let o=w;t.call(this,r,n,i);let s=window.location.href;o!==s&&(w=s,Z(o,s,"replaceState"),M(s,"navigation"))},window.addEventListener("popstate",()=>{let r=w,n=window.location.href;r!==n&&(w=n,Z(r,n,"popstate"),M(n,"navigation"))})},Z=(e,t,r)=>{let n=l({type:"navigation",fromUrl:e,toUrl:t,method:r});c(n)};var Ce=()=>{if(!(typeof PerformanceObserver>"u"))try{new PerformanceObserver(t=>{for(let r of t.getEntries()){let i=r.attribution?.[0],o=l({type:"longTask",durationMs:r.duration,startTime:r.startTime,attribution:i});c(o)}}).observe({type:"longtask",buffered:!0})}catch{}};var Je=500,xe=()=>{window.addEventListener("error",e=>{if(e instanceof ErrorEvent)return;let t=e.target;if(!t)return;let r=t.tagName?.toLowerCase();if(!r||!["img","script","link","video","audio","source","iframe"].includes(r))return;let i=t.src||t.href||"";if(!i)return;let o=je(r,t);ke(i,o,r),setTimeout(()=>{if(Te(i))return;let s=l({type:"resourceError",resourceType:o,url:i,tagName:r});c(s)},Je)},!0)},je=(e,t)=>{switch(e){case"img":return"image";case"script":return"script";case"link":{let r=t.rel;return r==="stylesheet"?"stylesheet":r==="icon"||r==="shortcut icon"?"icon":"link"}case"video":return"video";case"audio":return"audio";case"source":return"media-source";case"iframe":return"iframe";default:return e}};var Ye=["log","warn","error","info","debug"],Se=()=>{for(let e of Ye)x.console[e]=x.console[e]??console[e],console[e]=(...t)=>{if(x.console[e]?.apply(console,t),ne(t[0]))return;let r=Re(t[0]),n=t.length>1?t.slice(1).map(Re).filter(Boolean):void 0,i=l({type:"console",level:e,message:r,args:n});c(i)}},Re=e=>{if(e===null)return"null";if(e===void 0)return"undefined";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return String(e);if(e instanceof Error)return`${'$'}{e.name}: ${'$'}{e.message}`;try{return JSON.stringify(e,null,0)}catch{return String(e)}};var Qe=new Set(["a","button","input","select","textarea","label","summary"]),Ze=new Set(["button","submit","reset","checkbox","radio","file"]),et=3,Pe=1e3,tt=100,nt=500,q=[],C=null,m=null,Ie=()=>{if(C!==null&&(clearTimeout(C),C=null),m){let e=m.clicks[m.clicks.length-1].timestamp-m.clicks[0].timestamp;Be(m.element,"rageClick",!1,m.clicks.length,e),m=null,q=[]}},qe=()=>{document.addEventListener("pointerdown",it,!0)},rt=e=>{let t=e;for(;t;){let r=t.tagName.toLowerCase();if(Qe.has(r)){if(r==="input"){let o=t.type.toLowerCase();return Ze.has(o)}return!0}let n=t.getAttribute("role");if(n==="button"||n==="link"||n==="menuitem"||t.hasAttribute("onclick")||t.hasAttribute("tabindex")||window.getComputedStyle(t).cursor==="pointer")return!0;t=t.parentElement}return!1},it=e=>{let t=e.target;if(!t)return;let r=rt(t),n=Date.now();if(r)Be(t,"click",!0);else{let i={x:e.clientX,y:e.clientY,timestamp:n,element:t};q.push(i),q=q.filter(s=>n-s.timestampMath.sqrt((s.x-e.clientX)**2+(s.y-e.clientY)**2)=et&&(m&&m.element===t?m.clicks=o:(m&&Ie(),m={element:t,clicks:o}),C!==null&&clearTimeout(C),C=setTimeout(()=>{Ie()},nt))}},Be=(e,t,r,n,i)=>{let o=e.tagName.toLowerCase(),s=e.id||void 0,a=e.className?typeof e.className=="string"?e.className:e.className.toString():void 0,d;if(e.textContent){let g=e.textContent.trim().replace(/\s+/g," ");d=g.length>50?`${'$'}{g.slice(0,50)}...`:g||void 0}let u=l({type:"userInteraction",interactionType:t,tagName:o,elementId:s,className:a?.slice(0,100),textContent:d,isClickable:r,clickCount:t==="rageClick"?n:void 0,timeWindowMs:t==="rageClick"?Pe:void 0,duration:t==="rageClick"?i:void 0});c(u)};var Ne=()=>{window.addEventListener("error",e=>{if(e.target&&e.target!==window)return;let t=e.error,r;t instanceof Error&&(r=t.stack);let n=l({type:"error",name:t?.name??"Error",message:e.message||"Unknown error",stack:r,filename:e.filename||void 0,lineno:e.lineno||void 0,colno:e.colno||void 0});c(n)})},Ae=()=>{window.addEventListener("unhandledrejection",e=>{let t="Unknown rejection reason",r;if(e.reason instanceof Error)t=e.reason.message,r=e.reason.stack;else if(typeof e.reason=="string")t=e.reason;else if(e.reason!==null&&e.reason!==void 0)try{t=JSON.stringify(e.reason)}catch{t=String(e.reason)}let n=l({type:"promiseRejection",reason:t,stack:r});c(n)})};var ot=()=>{if(window.__bitdriftBridgeInitialized)return;window.__bitdriftBridgeInitialized=!0,te();let e=l({type:"bridgeReady",url:window.location.href});c(e),be(),Ee(),Le(),Me(),Ce(),xe(),Se(),Ae(),qe(),Ne()};ot();})(); +})(); + +""" +} diff --git a/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewCapture.kt b/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewCapture.kt new file mode 100644 index 000000000..bedcebcd5 --- /dev/null +++ b/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewCapture.kt @@ -0,0 +1,206 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +package io.bitdrift.capture.webview + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import io.bitdrift.capture.Capture +import io.bitdrift.capture.ILogger +import io.bitdrift.capture.LogLevel +import io.bitdrift.capture.LoggerImpl + +/** + * WebView instrumentation for capturing page load events, performance metrics, + * and network activity from within WebViews. + * + * This class injects a JavaScript bridge to capture Core Web Vitals and network requests + * made from within the web content. + * + * Usage: + * ```kotlin + * val webView = findViewById(R.id.webView) + * WebViewCapture.instrument(webView) + * webView.loadUrl("https://example.com") + * ``` + */ +class WebViewCapture( + private val original: WebViewClient, + private val logger: ILogger? = Capture.logger(), + private val needsFallbackInjection: Boolean = false, +) : WebViewClient() { + private var bridgeReady = false + private var scriptInjected = false + + /** + * Marks the bridge as ready. Called from the JavaScript bridge handler. + */ + internal fun onBridgeReady() { + bridgeReady = true + } + + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: Bitmap?, + ) { + // Reset injection state for new page loads + if (needsFallbackInjection) { + scriptInjected = false + } + original.onPageStarted(view, url, favicon) + } + + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + // Inject script after page load for older WebViews + if (needsFallbackInjection && !scriptInjected && view != null) { + injectScriptFallback(view) + scriptInjected = true + } + original.onPageFinished(view, url) + } + + /** + * Injects the bridge script using evaluateJavascript for older WebViews. + * This is a fallback for devices that don't support DOCUMENT_START_SCRIPT. + * Note: Some early metrics (FCP, TTFB) may be missed with this approach. + */ + private fun injectScriptFallback(webview: WebView) { + try { + val script = WebViewBridgeScript.SCRIPT + // Wrap in IIFE to avoid polluting global scope and handle re-injection + val wrappedScript = + "(function() { if (window.__bitdriftBridgeInjected) return; " + + "$script window.__bitdriftBridgeInjected = true; })();" + webview.evaluateJavascript(wrappedScript, null) + } catch (e: Exception) { + logger?.log(LogLevel.WARNING, mapOf("_error" to (e.message ?: ""))) { + "Failed to inject WebView bridge script via fallback" + } + } + } + + /** + * Companion object for WebViewCapture. + */ + companion object { + private const val BRIDGE_NAME = "BitdriftLogger" + + private fun isWebkitAvailable(): Boolean = runCatching { Class.forName("androidx.webkit.WebViewFeature") }.isSuccess + + /** + * Instruments a WebView to capture Core Web Vitals and network requests. + * + * This method: + * - Enables JavaScript execution + * - Injects the Bitdrift JavaScript bridge at document start + * - Registers a native interface for receiving bridge messages + * + * @param webview The WebView to instrument + * @param logger Optional logger instance. If null, uses Capture.logger() + */ + @SuppressLint("SetJavaScriptEnabled", "RequiresFeature") + @JvmStatic + @JvmOverloads + fun instrument( + webview: WebView, + logger: ILogger? = null, + ) { + val effectiveLogger = logger ?: Capture.logger() + + if (!isWebkitAvailable()) { + effectiveLogger?.log(LogLevel.WARNING, emptyMap()) { + "androidx.webkit not available, WebView instrumentation disabled" + } + return + } + + val loggerImpl = effectiveLogger as? LoggerImpl + + // Check if we need fallback injection (older WebViews) + val needsFallback = !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) + + // Wrap existing WebViewClient + val capture = + if (WebViewFeature.isFeatureSupported(WebViewFeature.GET_WEB_VIEW_CLIENT)) { + val original = WebViewCompat.getWebViewClient(webview) + WebViewCapture(original, effectiveLogger, needsFallback) + } else { + WebViewCapture(WebViewClient(), effectiveLogger, needsFallback) + } + + webview.webViewClient = capture + + // Enable JavaScript (required for bridge) + webview.settings.javaScriptEnabled = true + + // Register JavaScript interface for receiving bridge messages + val bridgeHandler = WebViewBridgeHandler(loggerImpl, effectiveLogger, capture) + webview.addJavascriptInterface(bridgeHandler, BRIDGE_NAME) + + // Inject JavaScript at document start for early initialization + injectScript(webview, effectiveLogger) + } + + @SuppressLint("RequiresFeature") + private fun injectScript( + webview: WebView, + logger: ILogger?, + ) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + // Fallback injection will be handled in onPageFinished + logger?.log(LogLevel.DEBUG, emptyMap()) { + "WebView DOCUMENT_START_SCRIPT not supported, using onPageFinished fallback" + } + return + } + + try { + val script = WebViewBridgeScript.SCRIPT + WebViewCompat.addDocumentStartJavaScript( + webview, + script, + setOf("*"), // Apply to all frames + ) + } catch (e: Exception) { + logger?.log(LogLevel.WARNING, mapOf("_error" to (e.message ?: ""))) { + "Failed to inject WebView bridge script" + } + } + } + } +} + +/** + * JavaScript interface that receives messages from the injected bridge script. + */ +internal class WebViewBridgeHandler( + loggerImpl: LoggerImpl?, + private val logger: ILogger?, + private val capture: WebViewCapture, +) { + private val messageHandler = WebViewMessageHandler(loggerImpl) + + @JavascriptInterface + fun log(message: String) { + try { + messageHandler.handleMessage(message, capture) + } catch (e: Exception) { + logger?.log(LogLevel.WARNING, mapOf("_error" to (e.message ?: ""))) { + "Failed to handle WebView bridge message" + } + } + } +} diff --git a/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewMessageHandler.kt b/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewMessageHandler.kt new file mode 100644 index 000000000..80d93b3b4 --- /dev/null +++ b/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewMessageHandler.kt @@ -0,0 +1,644 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +package io.bitdrift.capture.webview + +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import io.bitdrift.capture.LogLevel +import io.bitdrift.capture.LogType +import io.bitdrift.capture.LoggerImpl +import io.bitdrift.capture.events.span.Span +import io.bitdrift.capture.events.span.SpanResult +import io.bitdrift.capture.network.HttpRequestInfo +import io.bitdrift.capture.network.HttpRequestMetrics +import io.bitdrift.capture.network.HttpResponse +import io.bitdrift.capture.network.HttpResponseInfo +import io.bitdrift.capture.network.HttpUrlPath +import io.bitdrift.capture.providers.toFields +import java.net.URI +import java.util.UUID + +/** + * Handles incoming messages from the WebView JavaScript bridge and routes them + * to the appropriate logging methods. + */ +internal class WebViewMessageHandler( + private val logger: LoggerImpl?, +) { + /** + * TODO(Fran): Consider switching to kotlinx.serialization + */ + private val gson by lazy { Gson() } + + private var currentPageSpanId: String? = null + private val activePageViewSpans = mutableMapOf() + + fun handleMessage( + message: String, + capture: WebViewCapture, + ) { + val bridgeMessage = + try { + gson.fromJson(message, WebViewBridgeMessage::class.java) + } catch (e: JsonSyntaxException) { + logger?.log(LogLevel.WARNING, mapOf("_raw" to message, "_error" to e.message.orEmpty())) { + "Invalid JSON from WebView bridge" + } + return + } + + // Check protocol version + if (bridgeMessage.version != 1) { + logger?.log(LogLevel.WARNING, mapOf("_version" to bridgeMessage.version.toString())) { + "Unsupported WebView bridge protocol version" + } + return + } + + val type = bridgeMessage.type ?: return + val timestamp = bridgeMessage.timestamp ?: System.currentTimeMillis() + + when (type) { + "bridgeReady" -> handleBridgeReady(bridgeMessage, capture) + "webVital" -> handleWebVital(bridgeMessage, timestamp) + "networkRequest" -> handleNetworkRequest(bridgeMessage, timestamp) + "navigation" -> handleNavigation(bridgeMessage, timestamp) + "pageView" -> handlePageView(bridgeMessage, timestamp) + "lifecycle" -> handleLifecycle(bridgeMessage, timestamp) + "error" -> handleError(bridgeMessage, timestamp) + "longTask" -> handleLongTask(bridgeMessage, timestamp) + "resourceError" -> handleResourceError(bridgeMessage, timestamp) + "console" -> handleConsole(bridgeMessage, timestamp) + "promiseRejection" -> handlePromiseRejection(bridgeMessage, timestamp) + "userInteraction" -> handleUserInteraction(bridgeMessage, timestamp) + } + } + + private fun handleBridgeReady( + msg: WebViewBridgeMessage, + capture: WebViewCapture, + ) { + capture.onBridgeReady() + + val url = msg.url ?: "" + logger?.log(LogLevel.DEBUG, mapOf("_url" to url)) { + "WebView bridge ready" + } + } + + private fun handleWebVital( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val metric = msg.metric ?: return + val name = metric.name ?: return + val value = metric.value ?: return + val rating = metric.rating ?: "unknown" + + // Extract parentSpanId from the message (set by JS SDK) + val parentSpanId = msg.parentSpanId ?: currentPageSpanId + + // Determine log level based on rating + val level = + when (rating) { + "good" -> LogLevel.DEBUG + "needs-improvement" -> LogLevel.INFO + "poor" -> LogLevel.WARNING + else -> LogLevel.DEBUG + } + + // Build common fields for all web vitals + val commonFields = + buildMap { + put("_metric", name) + put("_value", value.toString()) + put("_rating", rating) + metric.delta?.let { put("_delta", it.toString()) } + metric.id?.let { put("_metric_id", it) } + metric.navigationType?.let { put("_navigation_type", it) } + parentSpanId?.let { put("_span_parent_id", it) } + put("_source", "webview") + } + + // Duration-based metrics are logged as spans (LCP, FCP, TTFB, INP) + // CLS is a cumulative score, not a duration, so it's logged as a regular log + when (name) { + "LCP" -> handleLCPMetric(metric, timestamp, value, level, commonFields, parentSpanId) + "FCP" -> handleFCPMetric(metric, timestamp, value, level, commonFields, parentSpanId) + "TTFB" -> handleTTFBMetric(metric, timestamp, value, level, commonFields, parentSpanId) + "INP" -> handleINPMetric(metric, timestamp, value, level, commonFields, parentSpanId) + "CLS" -> handleCLSMetric(metric, level, commonFields) + else -> { + // Unknown metric type - log as regular log with UX type + logger?.log(LogType.UX, level, commonFields.toFields()) { + "webview.webVital.$name" + } + } + } + } + + /** + * Handle Largest Contentful Paint (LCP) metric. + * LCP measures loading performance - when the largest content element becomes visible. + * Logged as a span from navigation start to LCP time. + */ + private fun handleLCPMetric( + metric: WebVitalMetric, + timestamp: Long, + value: Double, + level: LogLevel, + commonFields: Map, + parentSpanId: String?, + ) { + val fields = commonFields.toMutableMap() + + // Extract LCP-specific entry data if available + metric.entries?.firstOrNull()?.let { entry -> + entry.element?.let { fields["_element"] = it } + entry.url?.let { fields["_url"] = it } + entry.size?.let { fields["_size"] = it.toString() } + entry.renderTime?.let { fields["_render_time"] = it.toString() } + entry.loadTime?.let { fields["_load_time"] = it.toString() } + } + + logDurationSpan("webview.LCP", timestamp, value, level, fields, parentSpanId) + } + + /** + * Handle First Contentful Paint (FCP) metric. + * FCP measures when the first content is painted to the screen. + * Logged as a span from navigation start to FCP time. + */ + private fun handleFCPMetric( + metric: WebVitalMetric, + timestamp: Long, + value: Double, + level: LogLevel, + commonFields: Map, + parentSpanId: String?, + ) { + val fields = commonFields.toMutableMap() + + // Extract FCP-specific entry data if available (PerformancePaintTiming) + metric.entries?.firstOrNull()?.let { entry -> + entry.name?.let { fields["_paint_type"] = it } + entry.startTime?.let { fields["_start_time"] = it.toString() } + entry.entryType?.let { fields["_entry_type"] = it } + } + + logDurationSpan("webview.FCP", timestamp, value, level, fields, parentSpanId) + } + + /** + * Handle Time to First Byte (TTFB) metric. + * TTFB measures the time from request start to receiving the first byte of the response. + * Logged as a span from navigation start to TTFB time. + */ + private fun handleTTFBMetric( + metric: WebVitalMetric, + timestamp: Long, + value: Double, + level: LogLevel, + commonFields: Map, + parentSpanId: String?, + ) { + val fields = commonFields.toMutableMap() + + // Extract TTFB-specific entry data if available (PerformanceNavigationTiming) + metric.entries?.firstOrNull()?.let { entry -> + entry.domainLookupStart?.let { fields["_dns_start"] = it.toString() } + entry.domainLookupEnd?.let { fields["_dns_end"] = it.toString() } + entry.connectStart?.let { fields["_connect_start"] = it.toString() } + entry.connectEnd?.let { fields["_connect_end"] = it.toString() } + entry.secureConnectionStart?.let { fields["_tls_start"] = it.toString() } + entry.requestStart?.let { fields["_request_start"] = it.toString() } + entry.responseStart?.let { fields["_response_start"] = it.toString() } + } + + logDurationSpan("webview.TTFB", timestamp, value, level, fields, parentSpanId) + } + + /** + * Handle Interaction to Next Paint (INP) metric. + * INP measures responsiveness - the time from user interaction to the next frame paint. + * Logged as a span representing the interaction duration. + */ + private fun handleINPMetric( + metric: WebVitalMetric, + timestamp: Long, + value: Double, + level: LogLevel, + commonFields: Map, + parentSpanId: String?, + ) { + val fields = commonFields.toMutableMap() + + // Extract INP-specific entry data if available + metric.entries?.firstOrNull()?.let { entry -> + entry.name?.let { fields["_event_type"] = it } + entry.startTime?.let { fields["_interaction_time"] = it.toString() } + entry.processingStart?.let { fields["_processing_start"] = it.toString() } + entry.processingEnd?.let { fields["_processing_end"] = it.toString() } + entry.duration?.let { fields["_duration"] = it.toString() } + entry.interactionId?.let { fields["_interaction_id"] = it.toString() } + } + + logDurationSpan("webview.INP", timestamp, value, level, fields, parentSpanId) + } + + /** + * Handle Cumulative Layout Shift (CLS) metric. + * CLS measures visual stability - the sum of all unexpected layout shift scores. + * Unlike other metrics, CLS is a score (0-1+), not a duration, so it's logged as a regular log. + */ + private fun handleCLSMetric( + metric: WebVitalMetric, + level: LogLevel, + commonFields: Map, + ) { + val fields = commonFields.toMutableMap() + + // Extract CLS-specific data from entries + val entries = metric.entries + if (!entries.isNullOrEmpty()) { + // Find the largest shift + var largestShiftValue = 0.0 + var largestShiftTime = 0.0 + + for (entry in entries) { + val shiftValue = entry.value ?: 0.0 + if (shiftValue > largestShiftValue) { + largestShiftValue = shiftValue + largestShiftTime = entry.startTime ?: 0.0 + } + } + + if (largestShiftValue > 0) { + fields["_largest_shift_value"] = largestShiftValue.toString() + fields["_largest_shift_time"] = largestShiftTime.toString() + } + + fields["_shift_count"] = entries.size.toString() + } + + logger?.log(LogType.UX, level, fields.toFields()) { + "webview.CLS" + } + } + + private fun logDurationSpan( + spanName: String, + timestamp: Long, + durationMs: Double, + level: LogLevel, + fields: Map, + parentSpanId: String?, + ) { + val startTimeMs = timestamp - durationMs.toLong() + + val result = + when (fields["_rating"]) { + "good" -> SpanResult.SUCCESS + "needs-improvement", "poor" -> SpanResult.FAILURE + else -> SpanResult.UNKNOWN + } + + val parentUuid = parentSpanId?.let { runCatching { UUID.fromString(it) }.getOrNull() } + + val span = + logger?.startSpan( + name = spanName, + level = level, + fields = fields, + startTimeMs = startTimeMs, + parentSpanId = parentUuid, + ) + span?.end(result = result, fields = fields, endTimeMs = timestamp) + } + + private fun handleNetworkRequest( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val method = msg.method ?: "GET" + val url = msg.url ?: return + val statusCode = msg.statusCode + val durationMs = msg.durationMs ?: 0 + val success = msg.success ?: false + val errorMessage = msg.error + val requestType = msg.requestType ?: "unknown" + val timing = msg.timing + + val uri = runCatching { URI(url) }.getOrNull() + val host = uri?.host + val path = uri?.path?.takeIf { it.isNotEmpty() } + val query = uri?.query + + val extraFields = + mapOf( + "_source" to "webview", + "_request_type" to requestType, + "_timestamp" to timestamp.toString(), + ) + + val requestInfo = + HttpRequestInfo( + method = method, + host = host, + path = path?.let { HttpUrlPath(it) }, + query = query, + extraFields = extraFields, + ) + + val metrics = + timing?.let { t -> + HttpRequestMetrics( + requestBodyBytesSentCount = 0, + responseBodyBytesReceivedCount = t.transferSize ?: 0, + requestHeadersBytesCount = 0, + responseHeadersBytesCount = 0, + dnsResolutionDurationMs = t.dnsMs, + tlsDurationMs = t.tlsMs, + tcpDurationMs = t.connectMs, + responseLatencyMs = t.ttfbMs, + ) + } + + val result = + when { + success -> HttpResponse.HttpResult.SUCCESS + errorMessage != null -> HttpResponse.HttpResult.FAILURE + statusCode != null && statusCode >= 400 -> HttpResponse.HttpResult.FAILURE + else -> HttpResponse.HttpResult.FAILURE + } + + val responseInfo = + HttpResponseInfo( + request = requestInfo, + response = + HttpResponse( + result = result, + statusCode = statusCode, + error = errorMessage?.let(::Exception), + ), + durationMs = durationMs, + metrics = metrics, + ) + + logger?.log(requestInfo) + logger?.log(responseInfo) + } + + private fun handlePageView( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val action = msg.action ?: return + val spanId = msg.spanId ?: return + val url = msg.url ?: "" + val reason = msg.reason ?: "" + + when (action) { + "start" -> { + currentPageSpanId = spanId + + val fields = + mapOf( + "_span_id" to spanId, + "_url" to url, + "_reason" to reason, + "_source" to "webview", + "_timestamp" to timestamp.toString(), + ) + + val span = + logger?.startSpan( + name = "webview.pageView: $url", + level = LogLevel.DEBUG, + fields = fields, + startTimeMs = timestamp, + ) + span?.let { activePageViewSpans[spanId] = it } + } + "end" -> { + val durationMs = msg.durationMs + + val fields = + buildMap { + put("_span_id", spanId) + put("_url", url) + put("_reason", reason) + put("_source", "webview") + put("_timestamp", timestamp.toString()) + durationMs?.let { put("_duration_ms", it.toString()) } + } + + activePageViewSpans.remove(spanId)?.end( + result = SpanResult.SUCCESS, + fields = fields, + endTimeMs = timestamp, + ) + + if (currentPageSpanId == spanId) { + currentPageSpanId = null + } + } + } + } + + private fun handleLifecycle( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val event = msg.event ?: return + + val fields = + buildMap { + put("_event", event) + put("_source", "webview") + put("_timestamp", timestamp.toString()) + msg.performanceTime?.let { put("_performance_time", it.toString()) } + msg.visibilityState?.let { put("_visibility_state", it) } + } + + logger?.log(LogType.UX, LogLevel.DEBUG, fields.toFields()) { + "webview.lifecycle.$event" + } + } + + private fun handleNavigation( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val fromUrl = msg.fromUrl ?: "" + val toUrl = msg.toUrl ?: "" + val method = msg.method ?: "" + + val fields = + mapOf( + "_fromUrl" to fromUrl, + "_toUrl" to toUrl, + "_method" to method, + "_source" to "webview", + "_timestamp" to timestamp.toString(), + ) + + logger?.log(LogLevel.DEBUG, fields) { + "webview.navigation" + } + } + + private fun handleError( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val name = msg.name ?: "Error" + val errorMessage = msg.message ?: "Unknown error" + + val fields = + buildMap { + put("_name", name) + put("_message", errorMessage) + put("_source", "webview") + msg.stack?.let { put("_stack", it) } + msg.filename?.let { put("_filename", it) } + msg.lineno?.let { put("_lineno", it.toString()) } + msg.colno?.let { put("_colno", it.toString()) } + put("_timestamp", timestamp.toString()) + } + + logger?.log(LogLevel.ERROR, fields) { + "webview.error" + } + } + + private fun handleLongTask( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val durationMsDouble = msg.durationMs?.toDouble() ?: return + + val fields = + buildMap { + put("_duration_ms", durationMsDouble.toString()) + put("_source", "webview") + msg.startTime?.let { put("_start_time", it.toString()) } + put("_timestamp", timestamp.toString()) + msg.attribution?.let { attr -> + attr.name?.let { put("_attribution_name", it) } + attr.containerType?.let { put("_container_type", it) } + attr.containerSrc?.let { put("_container_src", it) } + attr.containerId?.let { put("_container_id", it) } + attr.containerName?.let { put("_container_name", it) } + } + } + + val level = + when { + durationMsDouble >= 200 -> LogLevel.WARNING + durationMsDouble >= 100 -> LogLevel.INFO + else -> LogLevel.DEBUG + } + + logger?.log(LogType.UX, level, fields.toFields()) { + "webview.longTask" + } + } + + private fun handleResourceError( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val fields = + mapOf( + "_resource_type" to (msg.resourceType ?: "unknown"), + "_url" to (msg.url ?: ""), + "_tag_name" to (msg.tagName ?: ""), + "_source" to "webview", + "_timestamp" to timestamp.toString(), + ) + + logger?.log(LogLevel.WARNING, fields) { + "webview.resourceError" + } + } + + private fun handleConsole( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val level = msg.level ?: "log" + val consoleMessage = msg.message ?: "" + + val fields = + buildMap { + put("_level", level) + put("_message", consoleMessage) + put("_source", "webview") + put("_timestamp", timestamp.toString()) + msg.args?.takeIf { it.isNotEmpty() }?.let { args -> + put("_args", args.take(5).joinToString(", ")) + } + } + + val logLevel = + when (level) { + "error" -> LogLevel.ERROR + "warn" -> LogLevel.WARNING + "info" -> LogLevel.INFO + else -> LogLevel.DEBUG + } + + logger?.log(logLevel, fields) { + "webview.console.$level" + } + } + + private fun handlePromiseRejection( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val fields = + buildMap { + put("_reason", msg.reason ?: "Unknown rejection") + put("_source", "webview") + msg.stack?.let { put("_stack", it) } + put("_timestamp", timestamp.toString()) + } + + logger?.log(LogLevel.ERROR, fields) { + "webview.promiseRejection" + } + } + + private fun handleUserInteraction( + msg: WebViewBridgeMessage, + timestamp: Long, + ) { + val interactionType = msg.interactionType ?: return + + val fields = + buildMap { + put("_interaction_type", interactionType) + put("_tag_name", msg.tagName ?: "") + put("_is_clickable", (msg.isClickable ?: false).toString()) + put("_source", "webview") + msg.elementId?.let { put("_element_id", it) } + msg.className?.let { put("_class_name", it) } + msg.textContent?.let { put("_text_content", it) } + msg.clickCount?.let { put("_click_count", it.toString()) } + msg.timeWindowMs?.let { put("_time_window_ms", it.toString()) } + put("_timestamp", timestamp.toString()) + } + + val level = if (interactionType == "rageClick") LogLevel.WARNING else LogLevel.DEBUG + logger?.log(LogType.UX, level, fields.toFields()) { + "webview.userInteraction.$interactionType" + } + } +} diff --git a/platform/jvm/gradle-test-app/src/main/assets/test-page/index.html b/platform/jvm/gradle-test-app/src/main/assets/test-page/index.html new file mode 100644 index 000000000..62455fa97 --- /dev/null +++ b/platform/jvm/gradle-test-app/src/main/assets/test-page/index.html @@ -0,0 +1,211 @@ + + + + + + Bitdrift SDK Test Page + + + + +
+ + + + diff --git a/platform/jvm/gradle-test-app/src/main/assets/test-page/secondary.html b/platform/jvm/gradle-test-app/src/main/assets/test-page/secondary.html new file mode 100644 index 000000000..1b0d1356a --- /dev/null +++ b/platform/jvm/gradle-test-app/src/main/assets/test-page/secondary.html @@ -0,0 +1,27 @@ + + + + + + Secondary Page - Bitdrift SDK Test + + + +
+
+

Secondary Page

+

This is a separate HTML page for testing real navigation

+
+ +
+

🧭 Navigation

+

Navigate back to the main test page.

+ + ← Back to Main Page + +
+ +
Bitdrift Capture SDK Test Page
+
+ + diff --git a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/data/model/Actions.kt b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/data/model/Actions.kt index 025477821..5e3124470 100644 --- a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/data/model/Actions.kt +++ b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/data/model/Actions.kt @@ -67,7 +67,7 @@ sealed class FeatureFlagsTestAction : AppAction { sealed class NavigationAction : AppAction { object NavigateToConfig : NavigationAction() - object NavigateToWebView : NavigationAction() + data class NavigateToWebView(val url: String) : NavigationAction() object NavigateToCompose : NavigationAction() diff --git a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/MainScreen.kt b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/MainScreen.kt index 6e51ad314..fc6c65eb6 100644 --- a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/MainScreen.kt +++ b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/MainScreen.kt @@ -11,10 +11,18 @@ import android.widget.Toast import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -23,6 +31,11 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.commit +import io.bitdrift.capture.Capture.Logger import io.bitdrift.gradletestapp.R import io.bitdrift.gradletestapp.data.model.AppAction import io.bitdrift.gradletestapp.data.model.AppState @@ -30,19 +43,30 @@ import io.bitdrift.gradletestapp.data.model.ClearError import io.bitdrift.gradletestapp.data.model.ConfigAction import io.bitdrift.gradletestapp.data.model.DiagnosticsAction import io.bitdrift.gradletestapp.data.model.FeatureFlagsTestAction -import io.bitdrift.gradletestapp.data.model.NavigationAction import io.bitdrift.gradletestapp.data.model.NetworkTestAction import io.bitdrift.gradletestapp.data.model.SessionAction import io.bitdrift.gradletestapp.ui.compose.components.FeatureFlagsTestingCard import io.bitdrift.gradletestapp.ui.compose.components.NavigationCard -import io.bitdrift.gradletestapp.ui.compose.components.StressTestCard import io.bitdrift.gradletestapp.ui.compose.components.NetworkTestingCard import io.bitdrift.gradletestapp.ui.compose.components.SdkStatusCard import io.bitdrift.gradletestapp.ui.compose.components.SessionManagementCard import io.bitdrift.gradletestapp.ui.compose.components.SleepModeCard import io.bitdrift.gradletestapp.ui.compose.components.TestingToolsCard +import io.bitdrift.gradletestapp.ui.fragments.ConfigurationSettingsFragment import io.bitdrift.gradletestapp.ui.theme.BitdriftColors +private enum class BottomNavTab( + val label: String, + val screenName: String, + val icon: ImageVector, +) { + HOME("Home", "home_tab", Icons.Default.Home), + SDK_APIS("SDK APIs", "sdk_apis_tab", Icons.Default.PlayArrow), + STRESS_TESTS("Stress", "stress_tests_tab", Icons.Default.Warning), + NAVIGATE("Navigate", "navigate_tab", Icons.Default.Menu), + SETTINGS("Settings", "settings_tab", Icons.Default.Settings), +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( @@ -51,6 +75,12 @@ fun MainScreen( ) { val clipboardManager = LocalClipboardManager.current val context = LocalContext.current + var selectedTab by rememberSaveable { mutableIntStateOf(BottomNavTab.HOME.ordinal) } + val currentTab = BottomNavTab.entries[selectedTab] + + LaunchedEffect(currentTab) { + Logger.logScreenView(currentTab.screenName) + } Scaffold( topBar = { @@ -81,149 +111,277 @@ fun MainScreen( ), ) }, + bottomBar = { + NavigationBar( + containerColor = BitdriftColors.BackgroundPaper, + ) { + BottomNavTab.entries.forEach { tab -> + NavigationBarItem( + selected = currentTab == tab, + onClick = { selectedTab = tab.ordinal }, + icon = { + Icon( + imageVector = tab.icon, + contentDescription = tab.label, + ) + }, + label = { Text(tab.label) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = BitdriftColors.Primary, + selectedTextColor = BitdriftColors.Primary, + unselectedIconColor = BitdriftColors.TextSecondary, + unselectedTextColor = BitdriftColors.TextSecondary, + indicatorColor = BitdriftColors.Primary.copy(alpha = 0.1f), + ), + ) + } + } + }, containerColor = BitdriftColors.Background, ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(paddingValues), ) { - uiState.error?.let { error -> - Card( + ErrorBanner( + error = uiState.error, + onDismiss = { onAction(ClearError) }, + ) + + when (currentTab) { + BottomNavTab.HOME -> HomeTabContent( + uiState = uiState, + onAction = onAction, + clipboardManager = clipboardManager, + onOpenSettings = { selectedTab = BottomNavTab.SETTINGS.ordinal }, + ) + BottomNavTab.SDK_APIS -> SdkApisTabContent( + uiState = uiState, + onAction = onAction, + context = context, + ) + BottomNavTab.STRESS_TESTS -> StressTestsTabContent( + onAction = onAction, + ) + BottomNavTab.NAVIGATE -> NavigateTabContent( + onAction = onAction, + ) + BottomNavTab.SETTINGS -> SettingsTabContent() + } + + if (uiState.isLoading) { + Box( modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + contentAlignment = Alignment.Center, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.weight(1f), - ) - TextButton( - onClick = { onAction(ClearError) }, - ) { - Text(stringResource(id = android.R.string.cancel)) - } - } + CircularProgressIndicator() } } + } + } +} - val listState = rememberLazyListState() - - val toasterText = - stringResource( - R.string.log_message_toast, - uiState.config.selectedLogLevel, - ) - - LazyColumn( - state = listState, +@Composable +private fun ErrorBanner( + error: String?, + onDismiss: () -> Unit, +) { + error?.let { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Row( modifier = Modifier - .weight(1f) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(bottom = 16.dp), + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - item { - SdkStatusCard( - uiState = uiState, - onInitializeSdk = { onAction(ConfigAction.InitializeSdk) }, - onAction = onAction, - ) - } - item { - SessionManagementCard( - uiState = uiState, - onStartNewSession = { onAction(SessionAction.StartNewSession) }, - onGenerateDeviceCode = { onAction(SessionAction.GenerateDeviceCode) }, - onCopySessionUrl = { - onAction(SessionAction.CopySessionUrl) - uiState.session.sessionUrl?.let { url -> - clipboardManager.setText(AnnotatedString(url)) - } - }, - ) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismiss) { + Text(stringResource(id = android.R.string.cancel)) } - item { - TestingToolsCard( - uiState = uiState, - onLogLevelChange = { onAction(ConfigAction.UpdateLogLevel(it)) }, - onAppExitReasonChange = { - onAction(DiagnosticsAction.UpdateAppExitReason(it)) - }, - onLogMessage = { - onAction(DiagnosticsAction.LogMessage) + } + } + } +} - Toast.makeText(context, toasterText, Toast.LENGTH_SHORT).show() - }, - onAction = onAction, - ) - } - item { - StressTestCard( - onNavigateToStressTest = { onAction(NavigationAction.NavigateToStressTest) }, - ) - } - item { - SleepModeCard( - uiState = uiState, - onToggle = { enabled -> onAction(ConfigAction.SetSleepModeEnabled(enabled)) }, - ) - } - item { - NetworkTestingCard( - onOkHttpRequest = { - onAction(NetworkTestAction.PerformOkHttpRequest) - }, - onGraphQlRequest = { - onAction(NetworkTestAction.PerformGraphQlRequest) - }, - onRetrofitRequest = { - onAction(NetworkTestAction.PerformRetrofitRequest) - }, - ) - } - item { - FeatureFlagsTestingCard( - onAddOneFeatureFlag = { - onAction(FeatureFlagsTestAction.AddOneFeatureFlag) - }, - onAddManyFeatureFlags = { - onAction(FeatureFlagsTestAction.AddManyFeatureFlags) - }, - ) - } - item { - NavigationCard( - onAction = onAction, - ) - } - if (uiState.isLoading) { - item { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } +@Composable +private fun HomeTabContent( + uiState: AppState, + onAction: (AppAction) -> Unit, + clipboardManager: androidx.compose.ui.platform.ClipboardManager, + onOpenSettings: () -> Unit, +) { + val listState = rememberLazyListState() + + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + item { + SdkStatusCard( + uiState = uiState, + onInitializeSdk = { onAction(ConfigAction.InitializeSdk) }, + onOpenSettings = onOpenSettings, + ) + } + item { + SessionManagementCard( + uiState = uiState, + onStartNewSession = { onAction(SessionAction.StartNewSession) }, + onGenerateDeviceCode = { onAction(SessionAction.GenerateDeviceCode) }, + onCopySessionUrl = { + onAction(SessionAction.CopySessionUrl) + uiState.session.sessionUrl?.let { url -> + clipboardManager.setText(AnnotatedString(url)) } + }, + ) + } + } +} + +@Composable +private fun SdkApisTabContent( + uiState: AppState, + onAction: (AppAction) -> Unit, + context: android.content.Context, +) { + val listState = rememberLazyListState() + val toasterText = stringResource( + R.string.log_message_toast, + uiState.config.selectedLogLevel, + ) + + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + item { + TestingToolsCard( + uiState = uiState, + onLogLevelChange = { onAction(ConfigAction.UpdateLogLevel(it)) }, + onAppExitReasonChange = { onAction(DiagnosticsAction.UpdateAppExitReason(it)) }, + onLogMessage = { + onAction(DiagnosticsAction.LogMessage) + Toast.makeText(context, toasterText, Toast.LENGTH_SHORT).show() + }, + onAction = onAction, + ) + } + item { + SleepModeCard( + uiState = uiState, + onToggle = { enabled -> onAction(ConfigAction.SetSleepModeEnabled(enabled)) }, + ) + } + item { + FeatureFlagsTestingCard( + onAddOneFeatureFlag = { onAction(FeatureFlagsTestAction.AddOneFeatureFlag) }, + onAddManyFeatureFlags = { onAction(FeatureFlagsTestAction.AddManyFeatureFlags) }, + ) + } + item { + NetworkTestingCard( + onOkHttpRequest = { onAction(NetworkTestAction.PerformOkHttpRequest) }, + onGraphQlRequest = { onAction(NetworkTestAction.PerformGraphQlRequest) }, + onRetrofitRequest = { onAction(NetworkTestAction.PerformRetrofitRequest) }, + ) + } + } +} + +@Composable +private fun StressTestsTabContent( + onAction: (AppAction) -> Unit, +) { + StressTestScreen( + onAction = onAction, + onNavigateBack = {}, + ) +} + +@Composable +private fun NavigateTabContent( + onAction: (AppAction) -> Unit, +) { + val listState = rememberLazyListState() + + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + item { + NavigationCard(onAction = onAction) + } + } +} + +private const val SETTINGS_FRAGMENT_TAG = "settings_fragment" +private const val SETTINGS_CONTAINER_ID = 0x7f0b0001 + +@Composable +private fun SettingsTabContent() { + val context = LocalContext.current + val fragmentManager = (context as? FragmentActivity)?.supportFragmentManager + + DisposableEffect(Unit) { + onDispose { + fragmentManager?.let { fm -> + val fragment = fm.findFragmentByTag(SETTINGS_FRAGMENT_TAG) + if (fragment != null) { + fm.commit { remove(fragment) } } } } } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + FragmentContainerView(context).apply { + id = SETTINGS_CONTAINER_ID + } + }, + update = { container -> + fragmentManager?.let { fm -> + val existingFragment = fm.findFragmentByTag(SETTINGS_FRAGMENT_TAG) + if (existingFragment == null) { + fm.commit { + replace(container.id, ConfigurationSettingsFragment(), SETTINGS_FRAGMENT_TAG) + } + } + } + }, + ) } diff --git a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/StressTestScreen.kt b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/StressTestScreen.kt index cab69b538..6e1f4c26d 100644 --- a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/StressTestScreen.kt +++ b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/StressTestScreen.kt @@ -13,13 +13,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import io.bitdrift.gradletestapp.R import io.bitdrift.gradletestapp.data.model.AppAction import io.bitdrift.gradletestapp.data.model.JankType @@ -32,27 +29,22 @@ fun StressTestScreen( onAction: (AppAction) -> Unit, onNavigateBack: () -> Unit, ) { - Scaffold( - containerColor = BitdriftColors.Background, - ) { paddingValues -> - LazyColumn( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(bottom = 16.dp), - ) { - item { - MemoryPressureCard(onAction = onAction) - } - item { - JankyFramesCard(onAction = onAction) - } - item { - StrictModeCard(onAction = onAction) - } + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + item { + MemoryPressureCard(onAction = onAction) + } + item { + JankyFramesCard(onAction = onAction) + } + item { + StrictModeCard(onAction = onAction) } } } diff --git a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/NavigationCard.kt b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/NavigationCard.kt index 9587336a7..4da3c12e9 100644 --- a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/NavigationCard.kt +++ b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/NavigationCard.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import io.bitdrift.gradletestapp.R import io.bitdrift.gradletestapp.data.model.AppAction import io.bitdrift.gradletestapp.data.model.NavigationAction +import io.bitdrift.gradletestapp.ui.fragments.WebViewFragment import io.bitdrift.gradletestapp.ui.theme.BitdriftColors @OptIn(ExperimentalLayoutApi::class) @@ -48,11 +49,6 @@ fun NavigationCard(onAction: (AppAction) -> Unit) { horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - OutlinedButton( - onClick = { onAction(NavigationAction.NavigateToWebView) }, - colors = ButtonDefaults.outlinedButtonColors(contentColor = BitdriftColors.TextPrimary), - ) { Text(stringResource(id = R.string.navigate_to_web_view), maxLines = 1, softWrap = false) } - OutlinedButton( onClick = { onAction(NavigationAction.NavigateToCompose) }, colors = ButtonDefaults.outlinedButtonColors(contentColor = BitdriftColors.TextPrimary), @@ -72,6 +68,15 @@ fun NavigationCard(onAction: (AppAction) -> Unit) { onClick = { onAction(NavigationAction.InvokeService) }, colors = ButtonDefaults.outlinedButtonColors(contentColor = BitdriftColors.TextPrimary), ) { Text("Invoke Service", maxLines = 1, softWrap = false) } + + WebViewFragment.WEBVIEW_URLS.forEach { (name, url) -> + OutlinedButton( + onClick = { onAction(NavigationAction.NavigateToWebView(url)) }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = BitdriftColors.TextPrimary), + ) { + Text(name, maxLines = 1, softWrap = false) + } + } } } } diff --git a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/SdkStatusCard.kt b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/SdkStatusCard.kt index 3c35c4d0b..87d800815 100644 --- a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/SdkStatusCard.kt +++ b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/SdkStatusCard.kt @@ -19,9 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.bitdrift.gradletestapp.R -import io.bitdrift.gradletestapp.data.model.AppAction import io.bitdrift.gradletestapp.data.model.AppState -import io.bitdrift.gradletestapp.data.model.NavigationAction import io.bitdrift.gradletestapp.ui.theme.BitdriftColors /** @@ -31,7 +29,7 @@ import io.bitdrift.gradletestapp.ui.theme.BitdriftColors fun SdkStatusCard( uiState: AppState, onInitializeSdk: () -> Unit, - onAction: (AppAction) -> Unit, + onOpenSettings: () -> Unit, modifier: Modifier = Modifier, ) { Card( @@ -96,16 +94,12 @@ fun SdkStatusCard( } OutlinedButton( - onClick = { onAction(NavigationAction.NavigateToConfig) }, + onClick = onOpenSettings, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.outlinedButtonColors( contentColor = BitdriftColors.TextPrimary, ), - border = - ButtonDefaults.outlinedButtonBorder.copy( - width = 1.dp, - ), ) { Text("Settings") } diff --git a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/WebViewCard.kt b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/WebViewCard.kt new file mode 100644 index 000000000..765322176 --- /dev/null +++ b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/compose/components/WebViewCard.kt @@ -0,0 +1,69 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +package io.bitdrift.gradletestapp.ui.compose.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.bitdrift.gradletestapp.R +import io.bitdrift.gradletestapp.data.model.AppAction +import io.bitdrift.gradletestapp.data.model.NavigationAction +import io.bitdrift.gradletestapp.ui.fragments.WebViewFragment +import io.bitdrift.gradletestapp.ui.theme.BitdriftColors + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun WebViewCard(onAction: (AppAction) -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + colors = CardDefaults.cardColors(containerColor = BitdriftColors.BackgroundPaper), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(width = 1.dp, color = BitdriftColors.Border.copy(alpha = 0.3f)), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(id = R.string.webview_testing), + style = MaterialTheme.typography.titleMedium, + color = BitdriftColors.TextPrimary, + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + WebViewFragment.WEBVIEW_URLS.forEach { (name, url) -> + OutlinedButton( + onClick = { onAction(NavigationAction.NavigateToWebView(url)) }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = BitdriftColors.TextPrimary), + ) { + Text(name, maxLines = 1, softWrap = false) + } + } + } + } + } +} diff --git a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/fragments/FirstFragment.kt b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/fragments/FirstFragment.kt index dba934d9d..94283a9bf 100644 --- a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/fragments/FirstFragment.kt +++ b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/fragments/FirstFragment.kt @@ -85,7 +85,10 @@ class FirstFragment : Fragment() { is NavigationAction.NavigateToWebView -> { Logger.logScreenView("web_view_fragment") - findNavController().navigate(R.id.action_FirstFragment_to_WebViewFragment) + val bundle = Bundle().apply { + putString(WebViewFragment.ARG_URL, action.url) + } + findNavController().navigate(R.id.action_FirstFragment_to_WebViewFragment, bundle) } is NavigationAction.NavigateToCompose -> { diff --git a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/fragments/WebViewFragment.kt b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/fragments/WebViewFragment.kt index df99660b7..80d636d77 100644 --- a/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/fragments/WebViewFragment.kt +++ b/platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/fragments/WebViewFragment.kt @@ -13,8 +13,8 @@ import android.view.View import android.view.ViewGroup import android.webkit.WebView import androidx.fragment.app.Fragment +import io.bitdrift.capture.webview.WebViewCapture import io.bitdrift.gradletestapp.R -import io.bitdrift.gradletestapp.diagnostics.webview.WebViewCapture /** * A basic WebView that can be used to test multi process. @@ -32,16 +32,24 @@ class WebViewFragment : Fragment() { // Instrument the WebView with bitdrift capture WebViewCapture.instrument(webView) - webView.loadUrl(urls.random()) + val url = arguments?.getString(ARG_URL) ?: WEBVIEW_URLS.first().second + webView.loadUrl(url) return view } companion object { - private val urls = listOf( - "https://bitdrift.io/", - "https://bitdrift.io/hello", // 404 - "https://bitdrift.ai/", // timeout - "https://www.wikipedia.org/", + const val ARG_URL = "url" + + /** + * List of URLs available for WebView testing. + * Pair of (display name, URL) + */ + val WEBVIEW_URLS = listOf( + "SDK Test Page" to "file:///android_asset/test-page/index.html", + "bitdrift.io" to "https://bitdrift.io/", + "bitdrift.io/hello (404)" to "https://bitdrift.io/hello", + "bitdrift.ai (timeout)" to "https://bitdrift.ai/", + "Wikipedia" to "https://www.wikipedia.org/", ) } } diff --git a/platform/jvm/gradle-test-app/src/main/res/navigation/nav_graph.xml b/platform/jvm/gradle-test-app/src/main/res/navigation/nav_graph.xml index 95a6fcc64..a301bb1bb 100644 --- a/platform/jvm/gradle-test-app/src/main/res/navigation/nav_graph.xml +++ b/platform/jvm/gradle-test-app/src/main/res/navigation/nav_graph.xml @@ -54,6 +54,11 @@ android:label="@string/web_view_fragment_label" tools:layout="@layout/fragment_web_view"> + + diff --git a/platform/jvm/gradle-test-app/src/main/res/values-es/strings.xml b/platform/jvm/gradle-test-app/src/main/res/values-es/strings.xml index 45efd607f..add66f757 100644 --- a/platform/jvm/gradle-test-app/src/main/res/values-es/strings.xml +++ b/platform/jvm/gradle-test-app/src/main/res/values-es/strings.xml @@ -43,4 +43,5 @@ Reiniciar APP para aplicar cambios Haz click aquí para reinciar Log enviado con nivel: %1$s - \ No newline at end of file + WebView Testing + diff --git a/platform/jvm/gradle-test-app/src/main/res/values/strings.xml b/platform/jvm/gradle-test-app/src/main/res/values/strings.xml index 7d2c89a99..45a2f0fda 100644 --- a/platform/jvm/gradle-test-app/src/main/res/values/strings.xml +++ b/platform/jvm/gradle-test-app/src/main/res/values/strings.xml @@ -51,4 +51,5 @@ Restart the App to apply changes Click here to restart and apply your configuration changes Log message sent with level: %1$s - \ No newline at end of file + WebView Testing + diff --git a/platform/jvm/gradle/libs.versions.toml b/platform/jvm/gradle/libs.versions.toml index 7ae9a540c..e8f25baf1 100644 --- a/platform/jvm/gradle/libs.versions.toml +++ b/platform/jvm/gradle/libs.versions.toml @@ -3,6 +3,7 @@ androidBenchkmarkPlugin = "1.2.4" androidGradlePlugin = "8.12.0" androidxCore = "1.13.1" androidxTestCore = "1.6.1" +androidxWebkit = "1.14.0" apollo = "4.1.0" appcompat = "1.7.0" assertjCore = "3.22.0" @@ -38,6 +39,7 @@ androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "startupRuntime" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" } androidx-ui = { module = "androidx.compose.ui:ui", version.ref = "ui" } +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidxWebkit" } apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" } assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertjCore" } flatbuffers = { module = "com.google.flatbuffers:flatbuffers-java", version.ref = "flatbuffers" } diff --git a/platform/swift/source/integrations/webview/WebViewBridgeScript.swift b/platform/swift/source/integrations/webview/WebViewBridgeScript.swift new file mode 100644 index 000000000..416cf13be --- /dev/null +++ b/platform/swift/source/integrations/webview/WebViewBridgeScript.swift @@ -0,0 +1,25 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated by: npm run generate +// Source: platform/webview/src/ + +import Foundation + +/// Contains the bundled JavaScript SDK for WebView instrumentation. +/// This script is injected into WebViews to capture performance metrics, +/// network events, and user interactions. +enum WebViewBridgeScript { + /// The minified JavaScript bundle to inject into WebViews. + static let script: String = #""" + (function() { + "use strict";var BitdriftWebView=(()=>{var De=Object.defineProperty;var _e=(e,t,r)=>t in e?De(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var h=(e,t,r)=>_e(e,typeof t!="symbol"?t+"":t,r);var x={console:{log:console.log,warn:console.warn,error:console.error,info:console.info,debug:console.debug}},Oe=()=>window.webkit?.messageHandlers?.BitdriftLogger?"ios":window.BitdriftLogger?"android":"unknown",ee=e=>{let t=Oe(),r=JSON.stringify(e);switch(t){case"ios":window.webkit?.messageHandlers?.BitdriftLogger?.postMessage(e);break;case"android":window.BitdriftLogger?.log(r);break;case"unknown":typeof console<"u"&&console.debug("[Bitdrift WebView]",e);break}},te=()=>{window.bitdrift||(window.bitdrift={log:ee})},c=e=>{window.bitdrift?(x.console.log("[Bitdrift WebView] Logging message via bridge",e),window.bitdrift.log(e)):ee(e)},l=e=>({tag:"bitdrift-webview-sdk",v:1,timestamp:Date.now(),...e}),ne=e=>typeof e=="object"&&e!==null&&"type"in e&&typeof e.type=="string"&&"v"in e&&typeof e.v=="number"&&"tag"in e&&e.tag==="bitdrift-webview-sdk";var ue=-1,b=e=>{addEventListener("pageshow",(t=>{t.persisted&&(ue=t.timeStamp,e(t))}),!0)},f=(e,t,r,n)=>{let i,o;return s=>{t.value>=0&&(s||n)&&(o=t.value-(i??0),(o||i===void 0)&&(i=t.value,t.delta=o,t.rating=((a,d)=>a>d[1]?"poor":a>d[0]?"needs-improvement":"good")(t.value,r),e(t)))}},z=e=>{requestAnimationFrame((()=>requestAnimationFrame((()=>e()))))},G=()=>{let e=performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStartG()?.activationStart??0,p=(e,t=-1)=>{let r=G(),n="navigate";return ue>=0?n="back-forward-cache":r&&(document.prerendering||R()>0?n="prerender":document.wasDiscarded?n="restore":r.type&&(n=r.type.replace(/_/g,"-"))),{name:e,value:t,rating:"good",delta:0,entries:[],id:`v5-${Date.now()}-${Math.floor(8999999999999*Math.random())+1e12}`,navigationType:n}},O=new WeakMap;function K(e,t){return O.get(e)||O.set(e,new t),O.get(e)}var U=class{constructor(){h(this,"t");h(this,"i",0);h(this,"o",[])}h(t){if(t.hadRecentInput)return;let r=this.o[0],n=this.o.at(-1);this.i&&r&&n&&t.startTime-n.startTime<1e3&&t.startTime-r.startTime<5e3?(this.i+=t.value,this.o.push(t)):(this.i=t.value,this.o=[t]),this.t?.(t)}},S=(e,t,r={})=>{try{if(PerformanceObserver.supportedEntryTypes.includes(e)){let n=new PerformanceObserver((i=>{Promise.resolve().then((()=>{t(i.getEntries())}))}));return n.observe({type:e,buffered:!0,...r}),n}}catch{}},J=e=>{let t=!1;return()=>{t||(e(),t=!0)}},v=-1,ge=new Set,re=()=>document.visibilityState!=="hidden"||document.prerendering?1/0:0,F=e=>{if(document.visibilityState==="hidden"){if(e.type==="visibilitychange")for(let t of ge)t();isFinite(v)||(v=e.type==="visibilitychange"?e.timeStamp:0,removeEventListener("prerenderingchange",F,!0))}},N=()=>{if(v<0){let e=R();v=(document.prerendering?void 0:globalThis.performance.getEntriesByType("visibility-state").filter((r=>r.name==="hidden"&&r.startTime>e))[0]?.startTime)??re(),addEventListener("visibilitychange",F,!0),addEventListener("prerenderingchange",F,!0),b((()=>{setTimeout((()=>{v=re()}))}))}return{get firstHiddenTime(){return v},onHidden(e){ge.add(e)}}},A=e=>{document.prerendering?addEventListener("prerenderingchange",(()=>e()),!0):e()},ie=[1800,3e3],j=(e,t={})=>{A((()=>{let r=N(),n,i=p("FCP"),o=S("paint",(s=>{for(let a of s)a.name==="first-contentful-paint"&&(o.disconnect(),a.startTime{i=p("FCP"),n=f(e,i,ie,t.reportAllChanges),z((()=>{i.value=performance.now()-s.timeStamp,n(!0)}))})))}))},oe=[.1,.25],me=(e,t={})=>{let r=N();j(J((()=>{let n,i=p("CLS",0),o=K(t,U),s=d=>{for(let u of d)o.h(u);o.i>i.value&&(i.value=o.i,i.entries=o.o,n())},a=S("layout-shift",s);a&&(n=f(e,i,oe,t.reportAllChanges),r.onHidden((()=>{s(a.takeRecords()),n(!0)})),b((()=>{o.i=0,i=p("CLS",0),n=f(e,i,oe,t.reportAllChanges),z((()=>n()))})),setTimeout(n))})))},fe=0,H=1/0,B=0,He=e=>{for(let t of e)t.interactionId&&(H=Math.min(H,t.interactionId),B=Math.max(B,t.interactionId),fe=B?(B-H)/7+1:0)},V,se=()=>V?fe:performance.interactionCount??0,Ue=()=>{"interactionCount"in performance||V||(V=S("event",He,{type:"event",buffered:!0,durationThreshold:0}))},ae=0,W=class{constructor(){h(this,"u",[]);h(this,"l",new Map);h(this,"m");h(this,"p")}v(){ae=se(),this.u.length=0,this.l.clear()}L(){let t=Math.min(this.u.length-1,Math.floor((se()-ae)/50));return this.u[t]}h(t){if(this.m?.(t),!t.interactionId&&t.entryType!=="first-input")return;let r=this.u.at(-1),n=this.l.get(t.interactionId);if(n||this.u.length<10||t.duration>r.P){if(n?t.duration>n.P?(n.entries=[t],n.P=t.duration):t.duration===n.P&&t.startTime===n.entries[0].startTime&&n.entries.push(t):(n={id:t.interactionId,entries:[t],P:t.duration},this.l.set(n.id,n),this.u.push(n)),this.u.sort(((i,o)=>o.P-i.P)),this.u.length>10){let i=this.u.splice(10);for(let o of i)this.l.delete(o.id)}this.p?.(n)}}},pe=e=>{let t=globalThis.requestIdleCallback||setTimeout;document.visibilityState==="hidden"?e():(e=J(e),addEventListener("visibilitychange",e,{once:!0,capture:!0}),t((()=>{e(),removeEventListener("visibilitychange",e,{capture:!0})})))},ce=[200,500],he=(e,t={})=>{if(!globalThis.PerformanceEventTiming||!("interactionId"in PerformanceEventTiming.prototype))return;let r=N();A((()=>{Ue();let n,i=p("INP"),o=K(t,W),s=d=>{pe((()=>{for(let g of d)o.h(g);let u=o.L();u&&u.P!==i.value&&(i.value=u.P,i.entries=u.entries,n())}))},a=S("event",s,{durationThreshold:t.durationThreshold??40});n=f(e,i,ce,t.reportAllChanges),a&&(a.observe({type:"first-input",buffered:!0}),r.onHidden((()=>{s(a.takeRecords()),n(!0)})),b((()=>{o.v(),i=p("INP"),n=f(e,i,ce,t.reportAllChanges)})))}))},X=class{constructor(){h(this,"m")}h(t){this.m?.(t)}},de=[2500,4e3],ye=(e,t={})=>{A((()=>{let r=N(),n,i=p("LCP"),o=K(t,X),s=d=>{t.reportAllChanges||(d=d.slice(-1));for(let u of d)o.h(u),u.startTime{s(a.takeRecords()),a.disconnect(),n(!0)})),u=g=>{g.isTrusted&&(pe(d),removeEventListener(g.type,u,{capture:!0}))};for(let g of["keydown","click","visibilitychange"])addEventListener(g,u,{capture:!0});b((g=>{i=p("LCP"),n=f(e,i,de,t.reportAllChanges),z((()=>{i.value=performance.now()-g.timeStamp,n(!0)}))}))}}))},le=[800,1800],$=e=>{document.prerendering?A((()=>$(e))):document.readyState!=="complete"?addEventListener("load",(()=>$(e)),!0):setTimeout(e)},we=(e,t={})=>{let r=p("TTFB"),n=f(e,r,le,t.reportAllChanges);$((()=>{let i=G();i&&(r.value=Math.max(i.responseStart-R(),0),r.entries=[i],n(!0),b((()=>{r=p("TTFB",0),n=f(e,r,le,t.reportAllChanges),n(!0)})))}))};var y=null,P=0,Fe=()=>typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,e=>{let t=Math.random()*16|0;return(e==="x"?t:t&3|8).toString(16)}),ve=()=>y,M=(e,t="navigation")=>{y&&D("navigation"),y=Fe(),t==="initial"?P=Math.round(performance.timeOrigin):P=Date.now(),c({tag:"bitdrift-webview-sdk",v:1,type:"pageView",action:"start",spanId:y,url:e,reason:t,timestamp:P})},D=e=>{if(!y)return;let t=Date.now(),r=t-P,n={tag:"bitdrift-webview-sdk",v:1,timestamp:t,type:"pageView",action:"end",spanId:y,url:window.location.href,reason:e,durationMs:r};c(n),y=null,P=0},I=(e,t)=>{let r=l({type:"lifecycle",event:e,performanceTime:performance.now(),...t});c(r)},be=()=>{M(window.location.href,"initial"),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>{I("DOMContentLoaded")}):I("DOMContentLoaded"),document.readyState!=="complete"?window.addEventListener("load",()=>{I("load")}):I("load"),document.addEventListener("visibilitychange",()=>{I("visibilitychange",{visibilityState:document.visibilityState}),document.visibilityState==="hidden"?D("hidden"):document.visibilityState==="visible"&&!y&&M(window.location.href,"navigation")}),window.addEventListener("pagehide",()=>{D("unload")}),window.addEventListener("beforeunload",()=>{D("unload")})};var Me=()=>{let e=t=>{let r=ve(),n=l({type:"webVital",metric:t,...r&&{parentSpanId:r}});c(n)};ye(e),me(e),he(e),j(e),we(e)};var Ve=0,Q=()=>`req_${Date.now()}_${++Ve}`,T=new Map,k=new Map,E=new Map,L=5e3,_=e=>{if(T.set(e,Date.now()),T.size>100){let t=Date.now();for(let[r,n]of T.entries())t-n>L&&T.delete(r)}},We=(e,t)=>{let r=T.get(e);if(r===void 0)return!1;let n=performance.timeOrigin+t;return Math.abs(n-r){if(k.set(e,Date.now()),k.size>100){let t=Date.now();for(let[r,n]of k.entries())t-n>L&&k.delete(r)}},Te=e=>{let t=k.get(e);return t===void 0?!1:Date.now()-t{if(E.set(e,{timestamp:Date.now(),resourceType:t,tagName:r}),E.size>100){let n=Date.now();for(let[i,o]of E.entries())n-o.timestamp>L&&E.delete(i)}},$e=e=>{let t=E.get(e);return t&&Date.now()-t.timestamptypeof performance>"u"||!performance.getEntriesByType?void 0:performance.getEntriesByType("resource").filter(n=>n.name===e).pop(),ze=()=>{let e=window.fetch;e&&(window.fetch=async(t,r)=>{let n=Q(),i=performance.now(),o,s;t instanceof Request?(o=t.url,s=t.method):(o=t.toString(),s=r?.method??"GET");try{let a=await e.call(window,t,r),d=performance.now();_(o);let u=l({type:"networkRequest",requestId:n,method:s.toUpperCase(),url:o,statusCode:a.status,durationMs:Math.round(d-i),success:a.ok,requestType:"fetch",timing:Y(o)});return c(u),a}catch(a){let d=performance.now();_(o);let u=l({type:"networkRequest",requestId:n,method:s.toUpperCase(),url:o,statusCode:0,durationMs:Math.round(d-i),success:!1,error:a instanceof Error?a.message:String(a),requestType:"fetch",timing:Y(o)});throw c(u),a}})},Ge=()=>{let e=XMLHttpRequest.prototype.open,t=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open=function(r,n,i=!0,o,s){this._bitdrift={method:r.toUpperCase(),url:n.toString(),requestId:Q()},e.call(this,r,n,i,o,s)},XMLHttpRequest.prototype.send=function(r){let n=this,i=n._bitdrift;if(!i){t.call(this,r);return}let o=performance.now(),s=()=>{let d=performance.now();_(i.url);let u=l({type:"networkRequest",requestId:i.requestId,method:i.method,url:i.url,statusCode:n.status,durationMs:Math.round(d-o),success:n.status>=200&&n.status<400,requestType:"xmlhttprequest",timing:Y(i.url)});c(u)},a=()=>{let d=performance.now();_(i.url);let u=l({type:"networkRequest",requestId:i.requestId,method:i.method,url:i.url,statusCode:0,durationMs:Math.round(d-o),success:!1,error:"Network error",requestType:"xmlhttprequest"});c(u)};n.addEventListener("load",s),n.addEventListener("error",a),n.addEventListener("abort",a),n.addEventListener("timeout",a),t.call(this,r)}},Ke=()=>{if(!(typeof PerformanceObserver>"u"))try{new PerformanceObserver(t=>{let r=t.getEntries();queueMicrotask(()=>{for(let n of r){if(We(n.name,n.responseEnd)||n.name.startsWith("data:")||n.name.startsWith("blob:"))continue;let i=Math.round(n.responseEnd-n.startTime),o=n.responseStatus??0,s=o===0?$e(n.name):{failed:!1},a=o>0?o>=200&&o<400:!s.failed,d=l({type:"networkRequest",requestId:Q(),method:"GET",url:n.name,statusCode:o,durationMs:i,success:a,requestType:n.initiatorType,timing:n});Xe(n.name),c(d)}})}).observe({type:"resource",buffered:!1})}catch{}},Ee=()=>{ze(),Ge(),Ke()};var w="",Le=()=>{w=window.location.href;let e=history.pushState;history.pushState=function(r,n,i){let o=w;e.call(this,r,n,i);let s=window.location.href;o!==s&&(w=s,Z(o,s,"pushState"),M(s,"navigation"))};let t=history.replaceState;history.replaceState=function(r,n,i){let o=w;t.call(this,r,n,i);let s=window.location.href;o!==s&&(w=s,Z(o,s,"replaceState"),M(s,"navigation"))},window.addEventListener("popstate",()=>{let r=w,n=window.location.href;r!==n&&(w=n,Z(r,n,"popstate"),M(n,"navigation"))})},Z=(e,t,r)=>{let n=l({type:"navigation",fromUrl:e,toUrl:t,method:r});c(n)};var Ce=()=>{if(!(typeof PerformanceObserver>"u"))try{new PerformanceObserver(t=>{for(let r of t.getEntries()){let i=r.attribution?.[0],o=l({type:"longTask",durationMs:r.duration,startTime:r.startTime,attribution:i});c(o)}}).observe({type:"longtask",buffered:!0})}catch{}};var Je=500,xe=()=>{window.addEventListener("error",e=>{if(e instanceof ErrorEvent)return;let t=e.target;if(!t)return;let r=t.tagName?.toLowerCase();if(!r||!["img","script","link","video","audio","source","iframe"].includes(r))return;let i=t.src||t.href||"";if(!i)return;let o=je(r,t);ke(i,o,r),setTimeout(()=>{if(Te(i))return;let s=l({type:"resourceError",resourceType:o,url:i,tagName:r});c(s)},Je)},!0)},je=(e,t)=>{switch(e){case"img":return"image";case"script":return"script";case"link":{let r=t.rel;return r==="stylesheet"?"stylesheet":r==="icon"||r==="shortcut icon"?"icon":"link"}case"video":return"video";case"audio":return"audio";case"source":return"media-source";case"iframe":return"iframe";default:return e}};var Ye=["log","warn","error","info","debug"],Se=()=>{for(let e of Ye)x.console[e]=x.console[e]??console[e],console[e]=(...t)=>{if(x.console[e]?.apply(console,t),ne(t[0]))return;let r=Re(t[0]),n=t.length>1?t.slice(1).map(Re).filter(Boolean):void 0,i=l({type:"console",level:e,message:r,args:n});c(i)}},Re=e=>{if(e===null)return"null";if(e===void 0)return"undefined";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return String(e);if(e instanceof Error)return`${e.name}: ${e.message}`;try{return JSON.stringify(e,null,0)}catch{return String(e)}};var Qe=new Set(["a","button","input","select","textarea","label","summary"]),Ze=new Set(["button","submit","reset","checkbox","radio","file"]),et=3,Pe=1e3,tt=100,nt=500,q=[],C=null,m=null,Ie=()=>{if(C!==null&&(clearTimeout(C),C=null),m){let e=m.clicks[m.clicks.length-1].timestamp-m.clicks[0].timestamp;Be(m.element,"rageClick",!1,m.clicks.length,e),m=null,q=[]}},qe=()=>{document.addEventListener("pointerdown",it,!0)},rt=e=>{let t=e;for(;t;){let r=t.tagName.toLowerCase();if(Qe.has(r)){if(r==="input"){let o=t.type.toLowerCase();return Ze.has(o)}return!0}let n=t.getAttribute("role");if(n==="button"||n==="link"||n==="menuitem"||t.hasAttribute("onclick")||t.hasAttribute("tabindex")||window.getComputedStyle(t).cursor==="pointer")return!0;t=t.parentElement}return!1},it=e=>{let t=e.target;if(!t)return;let r=rt(t),n=Date.now();if(r)Be(t,"click",!0);else{let i={x:e.clientX,y:e.clientY,timestamp:n,element:t};q.push(i),q=q.filter(s=>n-s.timestampMath.sqrt((s.x-e.clientX)**2+(s.y-e.clientY)**2)=et&&(m&&m.element===t?m.clicks=o:(m&&Ie(),m={element:t,clicks:o}),C!==null&&clearTimeout(C),C=setTimeout(()=>{Ie()},nt))}},Be=(e,t,r,n,i)=>{let o=e.tagName.toLowerCase(),s=e.id||void 0,a=e.className?typeof e.className=="string"?e.className:e.className.toString():void 0,d;if(e.textContent){let g=e.textContent.trim().replace(/\s+/g," ");d=g.length>50?`${g.slice(0,50)}...`:g||void 0}let u=l({type:"userInteraction",interactionType:t,tagName:o,elementId:s,className:a?.slice(0,100),textContent:d,isClickable:r,clickCount:t==="rageClick"?n:void 0,timeWindowMs:t==="rageClick"?Pe:void 0,duration:t==="rageClick"?i:void 0});c(u)};var Ne=()=>{window.addEventListener("error",e=>{if(e.target&&e.target!==window)return;let t=e.error,r;t instanceof Error&&(r=t.stack);let n=l({type:"error",name:t?.name??"Error",message:e.message||"Unknown error",stack:r,filename:e.filename||void 0,lineno:e.lineno||void 0,colno:e.colno||void 0});c(n)})},Ae=()=>{window.addEventListener("unhandledrejection",e=>{let t="Unknown rejection reason",r;if(e.reason instanceof Error)t=e.reason.message,r=e.reason.stack;else if(typeof e.reason=="string")t=e.reason;else if(e.reason!==null&&e.reason!==void 0)try{t=JSON.stringify(e.reason)}catch{t=String(e.reason)}let n=l({type:"promiseRejection",reason:t,stack:r});c(n)})};var ot=()=>{if(window.__bitdriftBridgeInitialized)return;window.__bitdriftBridgeInitialized=!0,te();let e=l({type:"bridgeReady",url:window.location.href});c(e),be(),Ee(),Le(),Me(),Ce(),xe(),Se(),Ae(),qe(),Ne()};ot();})(); + })(); + +"""# +} diff --git a/platform/swift/source/integrations/webview/WebViewCapture.swift b/platform/swift/source/integrations/webview/WebViewCapture.swift new file mode 100644 index 000000000..59bab3bc6 --- /dev/null +++ b/platform/swift/source/integrations/webview/WebViewCapture.swift @@ -0,0 +1,230 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import Foundation +@preconcurrency import WebKit + +/// WebView instrumentation for capturing page load events, performance metrics, +/// and network activity from within WebViews. +/// +/// This class sets up a JavaScript bridge to capture Core Web Vitals and network +/// requests made from within the web content. +/// +/// Usage: +/// ```swift +/// let webView = WKWebView() +/// WebViewCapture.instrument(webView) +/// webView.load(URLRequest(url: URL(string: "https://example.com")!)) +/// ``` +public final class WebViewCapture: NSObject { + private static let bridgeName = "BitdriftLogger" + + /// Tracks instrumented WebViews to prevent double-instrumentation + private static var instrumentedWebViews = NSHashTable.weakObjects() + private static let lock = NSLock() + + /// Message handler that receives messages from the JavaScript bridge + private let messageHandler: WebViewMessageHandler + + /// Navigation delegate that wraps the original delegate + private var navigationDelegate: WebViewNavigationDelegate? + + private init(logger: Logging?) { + self.messageHandler = WebViewMessageHandler(logger: logger) + super.init() + } + + /// Instruments a WKWebView to capture page load events, Core Web Vitals, + /// and network requests. + /// + /// This method: + /// - Injects the Bitdrift JavaScript bridge at document start + /// - Registers a message handler for receiving bridge messages + /// - Wraps the navigation delegate to capture page load events + /// + /// - parameter webView: The WKWebView to instrument. + /// - parameter logger: Optional logger instance. If nil, uses `Capture.Logger.shared`. + @MainActor + public static func instrument(_ webView: WKWebView, logger: Logging? = nil) { + lock.lock() + defer { lock.unlock() } + + // Avoid double-instrumentation + if instrumentedWebViews.contains(webView) { + return + } + + let effectiveLogger = logger ?? Capture.Logger.shared + let capture = WebViewCapture(logger: effectiveLogger) + + // Add script message handler + webView.configuration.userContentController.add( + capture.messageHandler, + name: bridgeName + ) + + // Inject bridge script at document start + let script = WKUserScript( + source: WebViewBridgeScript.script, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + webView.configuration.userContentController.addUserScript(script) + + // Wrap navigation delegate for page load tracking + let navigationDelegate = WebViewNavigationDelegate( + original: webView.navigationDelegate, + logger: effectiveLogger, + messageHandler: capture.messageHandler + ) + capture.navigationDelegate = navigationDelegate + webView.navigationDelegate = navigationDelegate + + instrumentedWebViews.add(webView) + + effectiveLogger?.log( + level: .debug, + message: "WebView instrumented", + fields: nil + ) + } + + /// Removes instrumentation from a WKWebView. + /// + /// - parameter webView: The WKWebView to remove instrumentation from. + @MainActor + public static func removeInstrumentation(from webView: WKWebView) { + lock.lock() + defer { lock.unlock() } + + webView.configuration.userContentController.removeScriptMessageHandler(forName: bridgeName) + webView.configuration.userContentController.removeAllUserScripts() + + instrumentedWebViews.remove(webView) + } +} + +// MARK: - Navigation Delegate + +/// Wraps an existing WKNavigationDelegate to capture page load events +private final class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { + private weak var original: WKNavigationDelegate? + private weak var logger: Logging? + private let messageHandler: WebViewMessageHandler + + private var pageLoadStartTime: CFAbsoluteTime? + private var currentURL: URL? + + init(original: WKNavigationDelegate?, logger: Logging?, messageHandler: WebViewMessageHandler) { + self.original = original + self.logger = logger + self.messageHandler = messageHandler + super.init() + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + pageLoadStartTime = CFAbsoluteTimeGetCurrent() + currentURL = webView.url + messageHandler.bridgeReady = false + + original?.webView?(webView, didStartProvisionalNavigation: navigation) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + let duration: TimeInterval + if let startTime = pageLoadStartTime { + duration = CFAbsoluteTimeGetCurrent() - startTime + } else { + duration = 0 + } + + let fields: Fields = [ + "_url": webView.url?.absoluteString ?? "", + "_durationMs": String(Int(duration * 1000)), + "_bridgeReady": String(messageHandler.bridgeReady), + ] + + if !messageHandler.bridgeReady { + logger?.log( + level: .warning, + message: "WebView bridge not ready before page finished", + fields: fields + ) + } + + logger?.log( + level: .debug, + message: "webview.pageLoad", + fields: fields + ) + + pageLoadStartTime = nil + original?.webView?(webView, didFinish: navigation) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + let duration: TimeInterval + if let startTime = pageLoadStartTime { + duration = CFAbsoluteTimeGetCurrent() - startTime + } else { + duration = 0 + } + + let fields: Fields = [ + "_url": webView.url?.absoluteString ?? "", + "_durationMs": String(Int(duration * 1000)), + "_error": error.localizedDescription, + ] + + logger?.log( + level: .warning, + message: "webview.pageLoad.failed", + fields: fields, + error: error + ) + + pageLoadStartTime = nil + original?.webView?(webView, didFail: navigation, withError: error) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + let fields: Fields = [ + "_url": currentURL?.absoluteString ?? "", + "_error": error.localizedDescription, + ] + + logger?.log( + level: .warning, + message: "webview.pageLoad.provisionalFailed", + fields: fields, + error: error + ) + + pageLoadStartTime = nil + original?.webView?(webView, didFailProvisionalNavigation: navigation, withError: error) + } + + // Forward all other delegate methods to the original + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if let original, + original.responds(to: #selector(WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:) as (WKNavigationDelegate) -> ((WKWebView, WKNavigationAction, @escaping (WKNavigationActionPolicy) -> Void) -> Void)?)) { + original.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) + } else { + decisionHandler(.allow) + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + if let original, + original.responds(to: #selector(WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:) as (WKNavigationDelegate) -> ((WKWebView, WKNavigationResponse, @escaping (WKNavigationResponsePolicy) -> Void) -> Void)?)) { + original.webView?(webView, decidePolicyFor: navigationResponse, decisionHandler: decisionHandler) + } else { + decisionHandler(.allow) + } + } +} diff --git a/platform/swift/source/integrations/webview/WebViewMessageHandler.swift b/platform/swift/source/integrations/webview/WebViewMessageHandler.swift new file mode 100644 index 000000000..4b275eb40 --- /dev/null +++ b/platform/swift/source/integrations/webview/WebViewMessageHandler.swift @@ -0,0 +1,874 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import Foundation +@preconcurrency import WebKit + +/// Handles incoming messages from the WebView JavaScript bridge +final class WebViewMessageHandler: NSObject, WKScriptMessageHandler { + private weak var logger: Logging? + + /// Whether the bridge has signaled it's ready + var bridgeReady = false + + /// Current page view span ID for nesting child events + private var currentPageSpanId: String? + + /// Active page view spans, keyed by span ID + private var activePageViewSpans: [String: Span] = [:] + + init(logger: Logging?) { + self.logger = logger + super.init() + } + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard let body = message.body as? [String: Any] else { + logger?.log( + level: .warning, + message: "Invalid message format from WebView bridge", + fields: nil + ) + return + } + + handleMessage(body) + } + + private func handleMessage(_ message: [String: Any]) { + // Check protocol version + guard let version = message["v"] as? Int, version == 1 else { + let v = message["v"] as? Int ?? 0 + logger?.log( + level: .warning, + message: "Unsupported WebView bridge protocol version", + fields: ["_version": String(v)] + ) + return + } + + guard let type = message["type"] as? String else { return } + + switch type { + case "bridgeReady": + handleBridgeReady(message) + case "webVital": + handleWebVital(message) + case "networkRequest": + handleNetworkRequest(message) + case "navigation": + handleNavigation(message) + case "pageView": + handlePageView(message) + case "lifecycle": + handleLifecycle(message) + case "error": + handleError(message) + case "longTask": + handleLongTask(message) + case "resourceError": + handleResourceError(message) + case "console": + handleConsole(message) + case "promiseRejection": + handlePromiseRejection(message) + case "userInteraction": + handleUserInteraction(message) + default: + break + } + } + + private func handleBridgeReady(_ message: [String: Any]) { + bridgeReady = true + let url = message["url"] as? String ?? "" + logger?.log( + level: .debug, + message: "WebView bridge ready", + fields: ["_url": url] + ) + } + + private func handleWebVital(_ message: [String: Any]) { + guard let metric = message["metric"] as? [String: Any], + let name = metric["name"] as? String, + let value = metric["value"] as? Double else { return } + + let rating = metric["rating"] as? String ?? "unknown" + let delta = metric["delta"] as? Double + let id = metric["id"] as? String + let navigationType = metric["navigationType"] as? String + let entries = metric["entries"] as? [[String: Any]] + let timestamp = message["timestamp"] as? Double ?? (Date().timeIntervalSince1970 * 1000) + + // Extract parentSpanId from the message (set by JS SDK) + let parentSpanId = message["parentSpanId"] as? String ?? currentPageSpanId + + // Determine log level based on rating + let level: LogLevel + switch rating { + case "good": + level = .debug + case "needs-improvement": + level = .info + case "poor": + level = .warning + default: + level = .debug + } + + // Build common fields for all web vitals + var commonFields: Fields = [ + "_metric": name, + "_value": String(value), + "_rating": rating, + "_source": "webview", + ] + + if let d = delta { + commonFields["_delta"] = String(d) + } + if let i = id { + commonFields["_metric_id"] = i + } + if let navType = navigationType { + commonFields["_navigation_type"] = navType + } + if let pSpanId = parentSpanId { + commonFields["_span_parent_id"] = pSpanId + } + + // Duration-based metrics are logged as spans (LCP, FCP, TTFB, INP) + // CLS is a cumulative score, not a duration, so it's logged as a regular log + switch name { + case "LCP": + handleLCPMetric(entries: entries, timestamp: timestamp, value: value, level: level, commonFields: commonFields, parentSpanId: parentSpanId) + case "FCP": + handleFCPMetric(entries: entries, timestamp: timestamp, value: value, level: level, commonFields: commonFields, parentSpanId: parentSpanId) + case "TTFB": + handleTTFBMetric(entries: entries, timestamp: timestamp, value: value, level: level, commonFields: commonFields, parentSpanId: parentSpanId) + case "INP": + handleINPMetric(entries: entries, timestamp: timestamp, value: value, level: level, commonFields: commonFields, parentSpanId: parentSpanId) + case "CLS": + handleCLSMetric(entries: entries, level: level, commonFields: commonFields) + default: + // Unknown metric type - log as regular log + logger?.log( + level: level, + message: "webview.webVital.\(name)", + fields: commonFields + ) + } + } + + /// Handle Largest Contentful Paint (LCP) metric. + /// LCP measures loading performance - when the largest content element becomes visible. + /// Logged as a span from navigation start to LCP time. + /// + /// - parameter entries: The performance entries for this metric. + /// - parameter timestamp: The timestamp when the metric was captured (ms since epoch). + /// - parameter value: The metric value in milliseconds. + /// - parameter level: The log level to use. + /// - parameter commonFields: Common fields to include in the log. + /// - parameter parentSpanId: The parent span ID for correlation. + private func handleLCPMetric( + entries: [[String: Any]]?, + timestamp: Double, + value: Double, + level: LogLevel, + commonFields: Fields, + parentSpanId: String? + ) { + var fields = commonFields + + // Extract LCP-specific entry data if available + if let entry = entries?.first { + if let element = entry["element"] as? String { + fields["_element"] = element + } + if let url = entry["url"] as? String { + fields["_url"] = url + } + if let size = entry["size"] as? Int { + fields["_size"] = String(size) + } + if let renderTime = entry["renderTime"] as? Double { + fields["_render_time"] = String(renderTime) + } + if let loadTime = entry["loadTime"] as? Double { + fields["_load_time"] = String(loadTime) + } + } + + logDurationSpan(spanName: "webview.LCP", timestamp: timestamp, durationMs: value, level: level, fields: fields, parentSpanId: parentSpanId) + } + + /// Handle First Contentful Paint (FCP) metric. + /// FCP measures when the first content is painted to the screen. + /// Logged as a span from navigation start to FCP time. + /// + /// - parameter entries: The performance entries for this metric. + /// - parameter timestamp: The timestamp when the metric was captured (ms since epoch). + /// - parameter value: The metric value in milliseconds. + /// - parameter level: The log level to use. + /// - parameter commonFields: Common fields to include in the log. + /// - parameter parentSpanId: The parent span ID for correlation. + private func handleFCPMetric( + entries: [[String: Any]]?, + timestamp: Double, + value: Double, + level: LogLevel, + commonFields: Fields, + parentSpanId: String? + ) { + var fields = commonFields + + // Extract FCP-specific entry data if available (PerformancePaintTiming) + if let entry = entries?.first { + if let paintType = entry["name"] as? String { + fields["_paint_type"] = paintType + } + if let startTime = entry["startTime"] as? Double { + fields["_start_time"] = String(startTime) + } + if let entryType = entry["entryType"] as? String { + fields["_entry_type"] = entryType + } + } + + logDurationSpan(spanName: "webview.FCP", timestamp: timestamp, durationMs: value, level: level, fields: fields, parentSpanId: parentSpanId) + } + + /// Handle Time to First Byte (TTFB) metric. + /// TTFB measures the time from request start to receiving the first byte of the response. + /// Logged as a span from navigation start to TTFB time. + /// + /// - parameter entries: The performance entries for this metric. + /// - parameter timestamp: The timestamp when the metric was captured (ms since epoch). + /// - parameter value: The metric value in milliseconds. + /// - parameter level: The log level to use. + /// - parameter commonFields: Common fields to include in the log. + /// - parameter parentSpanId: The parent span ID for correlation. + private func handleTTFBMetric( + entries: [[String: Any]]?, + timestamp: Double, + value: Double, + level: LogLevel, + commonFields: Fields, + parentSpanId: String? + ) { + var fields = commonFields + + // Extract TTFB-specific entry data if available (PerformanceNavigationTiming) + if let entry = entries?.first { + if let dnsStart = entry["domainLookupStart"] as? Double { + fields["_dns_start"] = String(dnsStart) + } + if let dnsEnd = entry["domainLookupEnd"] as? Double { + fields["_dns_end"] = String(dnsEnd) + } + if let connectStart = entry["connectStart"] as? Double { + fields["_connect_start"] = String(connectStart) + } + if let connectEnd = entry["connectEnd"] as? Double { + fields["_connect_end"] = String(connectEnd) + } + if let tlsStart = entry["secureConnectionStart"] as? Double { + fields["_tls_start"] = String(tlsStart) + } + if let requestStart = entry["requestStart"] as? Double { + fields["_request_start"] = String(requestStart) + } + if let responseStart = entry["responseStart"] as? Double { + fields["_response_start"] = String(responseStart) + } + } + + logDurationSpan(spanName: "webview.TTFB", timestamp: timestamp, durationMs: value, level: level, fields: fields, parentSpanId: parentSpanId) + } + + /// Handle Interaction to Next Paint (INP) metric. + /// INP measures responsiveness - the time from user interaction to the next frame paint. + /// Logged as a span representing the interaction duration. + /// + /// - parameter entries: The performance entries for this metric. + /// - parameter timestamp: The timestamp when the metric was captured (ms since epoch). + /// - parameter value: The metric value in milliseconds. + /// - parameter level: The log level to use. + /// - parameter commonFields: Common fields to include in the log. + /// - parameter parentSpanId: The parent span ID for correlation. + private func handleINPMetric( + entries: [[String: Any]]?, + timestamp: Double, + value: Double, + level: LogLevel, + commonFields: Fields, + parentSpanId: String? + ) { + var fields = commonFields + + // Extract INP-specific entry data if available + if let entry = entries?.first { + if let eventType = entry["name"] as? String { + fields["_event_type"] = eventType + } + if let startTime = entry["startTime"] as? Double { + fields["_interaction_time"] = String(startTime) + } + if let processingStart = entry["processingStart"] as? Double { + fields["_processing_start"] = String(processingStart) + } + if let processingEnd = entry["processingEnd"] as? Double { + fields["_processing_end"] = String(processingEnd) + } + if let duration = entry["duration"] as? Double { + fields["_duration"] = String(duration) + } + if let interactionId = entry["interactionId"] as? Int { + fields["_interaction_id"] = String(interactionId) + } + } + + logDurationSpan(spanName: "webview.INP", timestamp: timestamp, durationMs: value, level: level, fields: fields, parentSpanId: parentSpanId) + } + + /// Handle Cumulative Layout Shift (CLS) metric. + /// CLS measures visual stability - the sum of all unexpected layout shift scores. + /// Unlike other metrics, CLS is a score (0-1+), not a duration, so it's logged as a regular log. + /// + /// - parameter entries: The performance entries for this metric. + /// - parameter level: The log level to use. + /// - parameter commonFields: Common fields to include in the log. + private func handleCLSMetric( + entries: [[String: Any]]?, + level: LogLevel, + commonFields: Fields + ) { + var fields = commonFields + + // Extract CLS-specific data from entries + if let entries, !entries.isEmpty { + // Find the largest shift + var largestShiftValue = 0.0 + var largestShiftTime = 0.0 + + for entry in entries { + let shiftValue = entry["value"] as? Double ?? 0.0 + if shiftValue > largestShiftValue { + largestShiftValue = shiftValue + largestShiftTime = entry["startTime"] as? Double ?? 0.0 + } + } + + if largestShiftValue > 0 { + fields["_largest_shift_value"] = String(largestShiftValue) + fields["_largest_shift_time"] = String(largestShiftTime) + } + + fields["_shift_count"] = String(entries.count) + } + + logger?.log( + level: level, + message: "webview.CLS", + fields: fields + ) + } + + /// Log a duration-based web vital as a span with custom start/end times. + /// The start time is calculated as (timestamp - value) where value is the duration in ms, + /// and end time is the timestamp when the metric was reported. + /// + /// - parameter spanName: The name of the span to create. + /// - parameter timestamp: The end timestamp in milliseconds since epoch. + /// - parameter durationMs: The duration of the span in milliseconds. + /// - parameter level: The log level to use. + /// - parameter fields: The fields to include in the span. + /// - parameter parentSpanId: The parent span ID for correlation. + private func logDurationSpan( + spanName: String, + timestamp: Double, + durationMs: Double, + level: LogLevel, + fields: Fields, + parentSpanId: String? + ) { + // Calculate start time: the metric value represents duration from navigation start + // timestamp is when the metric was captured (effectively the end time) + let startTimeMs = timestamp - durationMs + let endTimeMs = timestamp + + // Convert from milliseconds to TimeInterval (seconds) + let startTimeInterval = startTimeMs / 1000.0 + let endTimeInterval = endTimeMs / 1000.0 + + // Determine span result based on rating + let result: SpanResult + switch fields["_rating"] { + case "good": + result = .success + case "needs-improvement", "poor": + result = .failure + default: + result = .unknown + } + + // Convert parentSpanId string to UUID + let parentUUID: UUID? = parentSpanId.flatMap { UUID(uuidString: $0) } + + // Start span with custom start time + let span = logger?.startSpan( + name: spanName, + level: level, + file: nil, + line: nil, + function: nil, + fields: fields, + startTimeInterval: startTimeInterval, + parentSpanID: parentUUID + ) + + // End span with custom end time + span?.end( + result, + file: nil, + line: nil, + function: nil, + fields: fields, + endTimeInterval: endTimeInterval + ) + } + + private func handleNetworkRequest(_ message: [String: Any]) { + guard let urlString = message["url"] as? String else { return } + + let method = message["method"] as? String ?? "GET" + let statusCode = message["statusCode"] as? Int ?? 0 + let durationMs = message["durationMs"] as? Int ?? 0 + let success = message["success"] as? Bool ?? false + let error = message["error"] as? String + let requestType = message["requestType"] as? String ?? "unknown" + + // Parse URL components + let urlComponents = URLComponents(string: urlString) + let host = urlComponents?.host + let path = urlComponents?.path + let query = urlComponents?.query + + // Build extra fields for webview context + var extraFields: Fields = [ + "_source": "webview", + "_request_type": requestType, + ] + + if let err = error { + extraFields["_error"] = err + } + + // Build metrics from timing data + var metrics: HTTPRequestMetrics? + if let timing = message["timing"] as? [String: Any] { + let dnsMs = timing["dnsMs"] as? Double + let connectMs = timing["connectMs"] as? Double + let tlsMs = timing["tlsMs"] as? Double + let ttfbMs = timing["ttfbMs"] as? Double + let transferSize = timing["transferSize"] as? Int + + metrics = HTTPRequestMetrics( + responseBodyBytesReceivedCount: transferSize.map { Int64($0) }, + dnsResolutionDuration: dnsMs.map { $0 / 1000.0 }, + tlsDuration: tlsMs.map { $0 / 1000.0 }, + tcpDuration: connectMs.map { $0 / 1000.0 }, + responseLatency: ttfbMs.map { $0 / 1000.0 } + ) + } + + // Create request info + let requestInfo = HTTPRequestInfo( + method: method, + host: host, + path: path.map { HTTPURLPath(value: $0) }, + query: query, + extraFields: extraFields + ) + + // Determine result + let result: HTTPResponse.HTTPResult + if !success { + result = .failure + } else { + result = .success + } + + // Create response + let response = HTTPResponse( + result: result, + statusCode: statusCode > 0 ? statusCode : nil, + error: nil + ) + + // Create response info + let responseInfo = HTTPResponseInfo( + requestInfo: requestInfo, + response: response, + duration: TimeInterval(durationMs) / 1000.0, + metrics: metrics, + extraFields: extraFields + ) + + // Log using native HTTP logging + logger?.log(requestInfo, file: nil, line: nil, function: nil) + logger?.log(responseInfo, file: nil, line: nil, function: nil) + } + + private func handleNavigation(_ message: [String: Any]) { + let fromUrl = message["fromUrl"] as? String ?? "" + let toUrl = message["toUrl"] as? String ?? "" + let method = message["method"] as? String ?? "" + + let fields: Fields = [ + "_fromUrl": fromUrl, + "_toUrl": toUrl, + "_method": method, + "_source": "webview", + ] + + logger?.log( + level: .debug, + message: "webview.navigation", + fields: fields + ) + } + + /// Handle page view span start/end messages. + /// Page view spans group all events within a single page session. + /// + /// - parameter message: The message dictionary from the JavaScript bridge. + private func handlePageView(_ message: [String: Any]) { + guard let action = message["action"] as? String, + let spanId = message["spanId"] as? String else { return } + + let url = message["url"] as? String ?? "" + let reason = message["reason"] as? String ?? "" + let timestamp = message["timestamp"] as? Double ?? (Date().timeIntervalSince1970 * 1000) + let timestampInterval = timestamp / 1000.0 + + switch action { + case "start": + currentPageSpanId = spanId + + let fields: Fields = [ + "_span_id": spanId, + "_url": url, + "_reason": reason, + "_source": "webview", + ] + + // Start the page view span (include URL in name for visibility) + if let span = logger?.startSpan( + name: "webview.pageView: \(url)", + level: .debug, + file: nil, + line: nil, + function: nil, + fields: fields, + startTimeInterval: timestampInterval, + parentSpanID: nil + ) { + activePageViewSpans[spanId] = span + } + + case "end": + let durationMs = message["durationMs"] as? Double + + var fields: Fields = [ + "_span_id": spanId, + "_url": url, + "_reason": reason, + "_source": "webview", + ] + + if let duration = durationMs { + fields["_duration_ms"] = String(duration) + } + + // End the page view span + if let span = activePageViewSpans.removeValue(forKey: spanId) { + span.end( + .success, + file: nil, + line: nil, + function: nil, + fields: fields, + endTimeInterval: timestampInterval + ) + } + + // Clear current page span ID if it matches + if currentPageSpanId == spanId { + currentPageSpanId = nil + } + + default: + break + } + } + + /// Handle lifecycle events (DOMContentLoaded, load, visibilitychange). + /// These are markers within the page view span. + /// + /// - parameter message: The message dictionary from the JavaScript bridge. + private func handleLifecycle(_ message: [String: Any]) { + guard let event = message["event"] as? String else { return } + + let performanceTime = message["performanceTime"] as? Double + let visibilityState = message["visibilityState"] as? String + + var fields: Fields = [ + "_event": event, + "_source": "webview", + ] + + if let perfTime = performanceTime { + fields["_performance_time"] = String(perfTime) + } + if let visState = visibilityState { + fields["_visibility_state"] = visState + } + + logger?.log( + level: .debug, + message: "webview.lifecycle.\(event)", + fields: fields + ) + } + + /// Handle unhandled JavaScript errors. + /// + /// - parameter message: The message dictionary from the JavaScript bridge. + private func handleError(_ message: [String: Any]) { + let name = message["name"] as? String ?? "Error" + let errorMessage = message["message"] as? String ?? "Unknown error" + let stack = message["stack"] as? String + let filename = message["filename"] as? String + let lineno = message["lineno"] as? Int + let colno = message["colno"] as? Int + + var fields: Fields = [ + "_name": name, + "_message": errorMessage, + "_source": "webview", + ] + + if let s = stack { + fields["_stack"] = String(s.prefix(1000)) + } + if let f = filename { + fields["_filename"] = f + } + if let l = lineno { + fields["_lineno"] = String(l) + } + if let c = colno { + fields["_colno"] = String(c) + } + + logger?.log( + level: .error, + message: "webview.error", + fields: fields + ) + } + + /// Handle long task events (main thread blocked > 50ms). + /// + /// - parameter message: The message dictionary from the JavaScript bridge. + private func handleLongTask(_ message: [String: Any]) { + guard let durationMs = message["durationMs"] as? Double else { return } + + let startTime = message["startTime"] as? Double + let attribution = message["attribution"] as? [String: Any] + + var fields: Fields = [ + "_duration_ms": String(durationMs), + "_source": "webview", + ] + + if let st = startTime { + fields["_start_time"] = String(st) + } + + // Extract attribution data + if let attr = attribution { + if let name = attr["name"] as? String { + fields["_attribution_name"] = name + } + if let containerType = attr["containerType"] as? String { + fields["_container_type"] = containerType + } + if let containerSrc = attr["containerSrc"] as? String { + fields["_container_src"] = containerSrc + } + if let containerId = attr["containerId"] as? String { + fields["_container_id"] = containerId + } + if let containerName = attr["containerName"] as? String { + fields["_container_name"] = containerName + } + } + + // Determine log level based on duration + let level: LogLevel + if durationMs >= 200 { + level = .warning + } else if durationMs >= 100 { + level = .info + } else { + level = .debug + } + + logger?.log( + level: level, + message: "webview.longTask", + fields: fields + ) + } + + /// Handle resource loading failures (images, scripts, stylesheets, etc.). + /// + /// - parameter message: The message dictionary from the JavaScript bridge. + private func handleResourceError(_ message: [String: Any]) { + let resourceType = message["resourceType"] as? String ?? "unknown" + let url = message["url"] as? String ?? "" + let tagName = message["tagName"] as? String ?? "" + + let fields: Fields = [ + "_resource_type": resourceType, + "_url": url, + "_tag_name": tagName, + "_source": "webview", + ] + + logger?.log( + level: .warning, + message: "webview.resourceError", + fields: fields + ) + } + + /// Handle console messages (log, warn, error, info, debug). + /// + /// - parameter message: The message dictionary from the JavaScript bridge. + private func handleConsole(_ message: [String: Any]) { + let consoleLevel = message["level"] as? String ?? "log" + let consoleMessage = message["message"] as? String ?? "" + + var fields: Fields = [ + "_level": consoleLevel, + "_message": String(consoleMessage.prefix(500)), + "_source": "webview", + ] + + // Extract additional args if present + if let args = message["args"] as? [String], !args.isEmpty { + let argsStr = args.prefix(5).joined(separator: ", ") + fields["_args"] = String(argsStr.prefix(500)) + } + + // Map console level to LogLevel + let level: LogLevel + switch consoleLevel { + case "error": + level = .error + case "warn": + level = .warning + case "info": + level = .info + default: + level = .debug + } + + logger?.log( + level: level, + message: "webview.console.\(consoleLevel)", + fields: fields + ) + } + + /// Handle unhandled promise rejections. + /// + /// - parameter message: The message dictionary from the JavaScript bridge. + private func handlePromiseRejection(_ message: [String: Any]) { + let reason = message["reason"] as? String ?? "Unknown rejection" + let stack = message["stack"] as? String + + var fields: Fields = [ + "_reason": reason, + "_source": "webview", + ] + + if let s = stack { + fields["_stack"] = String(s.prefix(1000)) + } + + logger?.log( + level: .error, + message: "webview.promiseRejection", + fields: fields + ) + } + + /// Handle user interaction events (clicks and rage clicks). + /// + /// - parameter message: The message dictionary from the JavaScript bridge. + private func handleUserInteraction(_ message: [String: Any]) { + guard let interactionType = message["interactionType"] as? String else { return } + + let tagName = message["tagName"] as? String ?? "" + let elementId = message["elementId"] as? String + let className = message["className"] as? String + let textContent = message["textContent"] as? String + let isClickable = message["isClickable"] as? Bool ?? false + let clickCount = message["clickCount"] as? Int + let timeWindowMs = message["timeWindowMs"] as? Int + + var fields: Fields = [ + "_interaction_type": interactionType, + "_tag_name": tagName, + "_is_clickable": String(isClickable), + "_source": "webview", + ] + + if let elId = elementId { + fields["_element_id"] = elId + } + if let clsName = className { + fields["_class_name"] = String(clsName.prefix(100)) + } + if let txt = textContent { + fields["_text_content"] = String(txt.prefix(50)) + } + if let cc = clickCount { + fields["_click_count"] = String(cc) + } + if let tw = timeWindowMs { + fields["_time_window_ms"] = String(tw) + } + + // Rage clicks are more important + let level: LogLevel = interactionType == "rageClick" ? .warning : .debug + + logger?.log( + level: level, + message: "webview.userInteraction.\(interactionType)", + fields: fields + ) + } +} diff --git a/platform/webview/.gitignore b/platform/webview/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/platform/webview/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/platform/webview/.nvmrc b/platform/webview/.nvmrc new file mode 100644 index 000000000..248216ad5 --- /dev/null +++ b/platform/webview/.nvmrc @@ -0,0 +1 @@ +24.12.0 diff --git a/platform/webview/README.md b/platform/webview/README.md new file mode 100644 index 000000000..572c2ba4e --- /dev/null +++ b/platform/webview/README.md @@ -0,0 +1,69 @@ +# Bitdrift WebView JavaScript SDK + +This module contains the TypeScript source for the JavaScript bridge that gets injected into WebViews to capture: + +- **Core Web Vitals**: LCP, CLS, INP, FCP, TTFB via the `web-vitals` library +- **Network requests**: Intercepted `fetch` and `XMLHttpRequest` calls with timing data +- **SPA navigation**: History API changes (`pushState`, `replaceState`, `popstate`) +- **JavaScript errors**: Uncaught errors with stack traces + +## Building + +```bash +# Install dependencies +npm install + +# Build the bundle +npm run build + +# Generate native files (Kotlin + Swift) +npm run generate +``` + +## Output + +The build process generates: +- `dist/bitdrift-webview.js` - The minified JavaScript bundle + +The `npm run generate` command outputs directly to the SDK source directories: +- `platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewBridgeScript.kt` +- `platform/swift/source/integrations/webview/WebViewBridgeScript.swift` + +## Architecture + +``` +src/ +├── index.ts # Entry point, initializes all modules +├── types.ts # TypeScript interfaces for bridge messages +├── bridge.ts # Native bridge abstraction (iOS/Android) +├── web-vitals.ts # Core Web Vitals monitoring +├── network.ts # fetch/XHR interception +└── navigation.ts # History API tracking +``` + +## Bridge Protocol + +All messages use a versioned JSON format: + +```typescript +{ + v: 1, // Protocol version + timestamp: 1703123456789, // Unix timestamp in ms + type: 'webVital' | 'networkRequest' | 'navigation' | 'error' | 'bridgeReady', + // ... type-specific fields +} +``` + +### Message Types + +| Type | Description | +|------|-------------| +| `bridgeReady` | Sent immediately when the script executes | +| `webVital` | Core Web Vital metric (LCP, CLS, etc.) | +| `networkRequest` | Captured fetch/XHR request with timing | +| `navigation` | SPA navigation via History API | +| `error` | Uncaught JavaScript error | + +## Integration + +The `npm run generate` command automatically writes the generated files to the correct SDK locations. No manual copy step is required. diff --git a/platform/webview/biome.json b/platform/webview/biome.json new file mode 100644 index 000000000..7524dcec4 --- /dev/null +++ b/platform/webview/biome.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { "includes": ["**", "!!**/dist"] }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 4, + "lineEnding": "lf", + "lineWidth": 120, + "attributePosition": "auto", + "bracketSameLine": false, + "bracketSpacing": true, + "expand": "auto", + "useEditorconfig": true + }, + "linter": { "enabled": true, "rules": { "recommended": true } }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSameLine": false, + "quoteStyle": "single", + "attributePosition": "auto", + "bracketSpacing": true + } + }, + "html": { + "formatter": { + "indentScriptAndStyle": false, + "selfCloseVoidElements": "always" + } + }, + "overrides": [ + { + "includes": ["*.yaml", "*.yml", "*.json"], + "formatter": { "indentWidth": 2 } + } + ], + "assist": { + "enabled": true, + "actions": { "source": { "organizeImports": "on" } } + } +} diff --git a/platform/webview/package-lock.json b/platform/webview/package-lock.json new file mode 100644 index 000000000..18963f54a --- /dev/null +++ b/platform/webview/package-lock.json @@ -0,0 +1,763 @@ +{ + "name": "@bitdrift/webview-sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@bitdrift/webview-sdk", + "version": "1.0.0", + "dependencies": { + "web-vitals": "^5.1.0" + }, + "devDependencies": { + "@biomejs/biome": "2.3.11", + "@types/node": "^24.10.8", + "esbuild": "^0.27.2", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz", + "integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.11", + "@biomejs/cli-darwin-x64": "2.3.11", + "@biomejs/cli-linux-arm64": "2.3.11", + "@biomejs/cli-linux-arm64-musl": "2.3.11", + "@biomejs/cli-linux-x64": "2.3.11", + "@biomejs/cli-linux-x64-musl": "2.3.11", + "@biomejs/cli-win32-arm64": "2.3.11", + "@biomejs/cli-win32-x64": "2.3.11" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz", + "integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz", + "integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz", + "integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz", + "integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz", + "integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz", + "integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz", + "integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz", + "integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.10.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.8.tgz", + "integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-vitals": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", + "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", + "license": "Apache-2.0" + } + } +} diff --git a/platform/webview/package.json b/platform/webview/package.json new file mode 100644 index 000000000..a1f930782 --- /dev/null +++ b/platform/webview/package.json @@ -0,0 +1,25 @@ +{ + "name": "@bitdrift/webview-sdk", + "version": "1.0.0", + "private": true, + "description": "Bitdrift WebView JavaScript SDK for capturing performance metrics and network events", + "main": "dist/bitdrift-webview.js", + "scripts": { + "build": "tsx scripts/build.ts", + "watch": "tsx scripts/build.ts --watch", + "generate": "tsx scripts/generate-native.ts", + "format": "biome format --write", + "lint": "biome lint", + "lint:fix": "biome lint --write" + }, + "devDependencies": { + "@biomejs/biome": "2.3.11", + "@types/node": "^24.10.8", + "esbuild": "^0.27.2", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + }, + "dependencies": { + "web-vitals": "^5.1.0" + } +} diff --git a/platform/webview/scripts/build.ts b/platform/webview/scripts/build.ts new file mode 100644 index 000000000..0e27f319a --- /dev/null +++ b/platform/webview/scripts/build.ts @@ -0,0 +1,51 @@ +import * as esbuild from 'esbuild'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const isWatch = process.argv.includes('--watch'); + +const buildOptions: esbuild.BuildOptions = { + entryPoints: [path.join(__dirname, '../src/index.ts')], + bundle: true, + minify: true, + format: 'iife', + globalName: 'BitdriftWebView', + target: ['es2020'], + outfile: path.join(__dirname, '../dist/bitdrift-webview.js'), + sourcemap: false, + // Wrap in an IIFE that executes immediately + banner: { + js: '(function() {', + }, + footer: { + js: '})();', + }, +}; + +const build = async (): Promise => { + try { + if (isWatch) { + const ctx = await esbuild.context(buildOptions); + await ctx.watch(); + console.log('Watching for changes...'); + } else { + await esbuild.build(buildOptions); + console.log('Build complete!'); + + // Output bundle size + if (buildOptions.outfile) { + const stats = fs.statSync(buildOptions.outfile); + console.log(`Bundle size: ${(stats.size / 1024).toFixed(2)} KB`); + } + } + } catch (error) { + console.error('Build failed:', error); + process.exit(1); + } +}; + +build(); diff --git a/platform/webview/scripts/generate-native.ts b/platform/webview/scripts/generate-native.ts new file mode 100644 index 000000000..8684242c2 --- /dev/null +++ b/platform/webview/scripts/generate-native.ts @@ -0,0 +1,99 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Paths relative to the monorepo root +const repoRoot = path.join(__dirname, '../../..'); +const bundlePath = path.join(__dirname, '../dist/bitdrift-webview.js'); + +// Output directly to SDK source directories +const kotlinOutputPath = path.join( + repoRoot, + 'platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/webview/WebViewBridgeScript.kt', +); +const swiftOutputPath = path.join(repoRoot, 'platform/swift/source/integrations/webview/WebViewBridgeScript.swift'); + +// Read the bundled JavaScript +let bundleContent: string; +try { + bundleContent = fs.readFileSync(bundlePath, 'utf8'); +} catch (_error) { + console.error('Bundle not found. Run "npm run build" first.'); + process.exit(1); +} + +// Escape for Kotlin raw string (triple quotes) +const escapeForKotlin = (content: string): string => { + // In Kotlin raw strings, we need to escape $ as ${'$'} + // In JS replace(), $$ represents a literal $, so we use $${'$$'} to produce ${'$'} + return content.replace(/\$/g, "$${'$$'}"); +}; + +// Escape for Swift raw string +const escapeForSwift = (content: string): string => { + // In Swift multi-line strings with #, we need to escape \# and interpolation + // Using extended delimiters (#"..."#) so standard escapes don't apply + return content.replace(/#/g, '\\#'); +}; + +// License header for generated files +const licenseHeader = `// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +`; + +// Generate Kotlin file +const kotlinContent = `${licenseHeader}// AUTO-GENERATED FILE - DO NOT EDIT +// Generated by: npm run generate +// Source: platform/webview/src/ + +package io.bitdrift.capture.webview + +/** + * Contains the bundled JavaScript SDK for WebView instrumentation. + * This script is injected into WebViews to capture performance metrics, + * network events, and user interactions. + */ +internal object WebViewBridgeScript { + /** + * The minified JavaScript bundle to inject into WebViews. + */ + const val SCRIPT: String = """ +${escapeForKotlin(bundleContent)} +""" +} +`; + +// Generate Swift file +const swiftContent = `${licenseHeader}// AUTO-GENERATED FILE - DO NOT EDIT +// Generated by: npm run generate +// Source: platform/webview/src/ + +import Foundation + +/// Contains the bundled JavaScript SDK for WebView instrumentation. +/// This script is injected into WebViews to capture performance metrics, +/// network events, and user interactions. +enum WebViewBridgeScript { + /// The minified JavaScript bundle to inject into WebViews. + static let script: String = #""" +${escapeForSwift(bundleContent)} +"""# +} +`; + +// Write files +fs.writeFileSync(kotlinOutputPath, kotlinContent); +fs.writeFileSync(swiftOutputPath, swiftContent); + +console.log('Generated native files:'); +console.log(` - ${kotlinOutputPath}`); +console.log(` - ${swiftOutputPath}`); +console.log(`Bundle size: ${(bundleContent.length / 1024).toFixed(2)} KB`); diff --git a/platform/webview/src/bridge.ts b/platform/webview/src/bridge.ts new file mode 100644 index 000000000..c1e57cc15 --- /dev/null +++ b/platform/webview/src/bridge.ts @@ -0,0 +1,131 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import type { AnyBridgeMessage, BridgeMessage } from './types'; + +export const pristine = { + console: { + log: console.log, + warn: console.warn, + error: console.error, + info: console.info, + debug: console.debug, + }, +}; + +/** + * Platform-agnostic bridge for communicating with native code. + * Detects iOS (WKWebView) vs Android (WebView) and routes messages accordingly. + */ + +interface AndroidBridge { + log(message: string): void; +} + +interface IOSBridge { + postMessage(message: unknown): void; +} + +declare global { + interface Window { + // Android bridge + BitdriftLogger?: AndroidBridge; + // iOS bridge + webkit?: { + messageHandlers?: { + BitdriftLogger?: IOSBridge; + }; + }; + // Our global namespace + bitdrift?: { + log: (message: AnyBridgeMessage) => void; + }; + } +} + +type Platform = 'ios' | 'android' | 'unknown'; + +const detectPlatform = (): Platform => { + if (window.webkit?.messageHandlers?.BitdriftLogger) { + return 'ios'; + } + if (window.BitdriftLogger) { + return 'android'; + } + return 'unknown'; +}; + +const sendToNative = (message: AnyBridgeMessage): void => { + const platform = detectPlatform(); + const serialized = JSON.stringify(message); + + switch (platform) { + case 'ios': + window.webkit?.messageHandlers?.BitdriftLogger?.postMessage(message); + break; + case 'android': + window.BitdriftLogger?.log(serialized); + break; + case 'unknown': + // In development/testing, log to console + if (typeof console !== 'undefined') { + console.debug('[Bitdrift WebView]', message); + } + break; + } +}; + +/** + * Initialize the global bitdrift object + */ +export const initBridge = (): void => { + // Avoid re-initialization + if (window.bitdrift) { + return; + } + + window.bitdrift = { + log: sendToNative, + }; +}; + +/** + * Send a message through the bridge + */ +export const log = (message: AnyBridgeMessage): void => { + if (window.bitdrift) { + pristine.console.log('[Bitdrift WebView] Logging message via bridge', message); + window.bitdrift.log(message); + } else { + sendToNative(message); + } +}; + +/** + * Helper to create a timestamped message + */ +export const createMessage = (partial: Omit): T => { + return { + tag: 'bitdrift-webview-sdk', + v: 1, + timestamp: Date.now(), + ...partial, + } as T; +}; + +export const isAnyBridgeMessage = (obj: unknown): obj is AnyBridgeMessage => { + return ( + typeof obj === 'object' && + obj !== null && + 'type' in obj && + typeof (obj as BridgeMessage).type === 'string' && + 'v' in obj && + typeof (obj as BridgeMessage).v === 'number' && + 'tag' in obj && + (obj as BridgeMessage).tag === 'bitdrift-webview-sdk' + ); +}; diff --git a/platform/webview/src/console-capture.ts b/platform/webview/src/console-capture.ts new file mode 100644 index 000000000..3400fb295 --- /dev/null +++ b/platform/webview/src/console-capture.ts @@ -0,0 +1,60 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import { log, createMessage, pristine, isAnyBridgeMessage } from './bridge'; +import type { ConsoleMessage } from './types'; + +const LEVELS = ['log', 'warn', 'error', 'info', 'debug'] as const; + +/** + * Initialize console log capture. + * Intercepts console.log, warn, error, info, debug and sends to native. + */ +export const initConsoleCapture = (): void => { + for (const level of LEVELS) { + // Ensure pristine.console is initialized + pristine.console[level] = pristine.console[level] ?? console[level]; + + console[level] = (...args: unknown[]) => { + // Call original console method first + pristine.console[level]?.apply(console, args); + + // Avoid capturing our own bridge messages + if (isAnyBridgeMessage(args[0])) return; + + // Convert args to strings + const messageStr = stringifyArg(args[0]); + const additionalArgs = + args.length > 1 ? (args.slice(1).map(stringifyArg).filter(Boolean) as string[]) : undefined; + + const message = createMessage({ + type: 'console', + level, + message: messageStr, + args: additionalArgs, + }); + log(message); + }; + } +}; + +/** + * Convert an argument to a string representation + */ +const stringifyArg = (arg: unknown): string => { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (typeof arg === 'string') return arg; + if (typeof arg === 'number' || typeof arg === 'boolean') return String(arg); + if (arg instanceof Error) return `${arg.name}: ${arg.message}`; + + try { + return JSON.stringify(arg, null, 0); + } catch { + return String(arg); + } +}; diff --git a/platform/webview/src/error.ts b/platform/webview/src/error.ts new file mode 100644 index 000000000..1e4fac524 --- /dev/null +++ b/platform/webview/src/error.ts @@ -0,0 +1,71 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import { log, createMessage } from './bridge'; +import type { ErrorMessage, PromiseRejectionMessage } from './types'; + +/** + * Initialize unhandled error monitoring. + * Captures uncaught JavaScript errors that bubble to the window. + */ +export const initErrorMonitoring = (): void => { + window.addEventListener('error', (event: ErrorEvent) => { + // Skip if this is a resource error (handled by resource-errors.ts) + // Resource errors have event.target as an Element + if (event.target && event.target !== window) { + return; + } + + const error = event.error; + let stack: string | undefined; + + if (error instanceof Error) { + stack = error.stack; + } + + const message = createMessage({ + type: 'error', + name: error?.name ?? 'Error', + message: event.message || 'Unknown error', + stack, + filename: event.filename || undefined, + lineno: event.lineno || undefined, + colno: event.colno || undefined, + }); + log(message); + }); +}; + +/** + * Initialize unhandled promise rejection monitoring. + */ +export const initPromiseRejectionMonitoring = (): void => { + window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { + let reason = 'Unknown rejection reason'; + let stack: string | undefined; + + if (event.reason instanceof Error) { + reason = event.reason.message; + stack = event.reason.stack; + } else if (typeof event.reason === 'string') { + reason = event.reason; + } else if (event.reason !== null && event.reason !== undefined) { + try { + reason = JSON.stringify(event.reason); + } catch { + reason = String(event.reason); + } + } + + const message = createMessage({ + type: 'promiseRejection', + reason, + stack: stack, + }); + log(message); + }); +}; diff --git a/platform/webview/src/index.ts b/platform/webview/src/index.ts new file mode 100644 index 000000000..9a2c09e81 --- /dev/null +++ b/platform/webview/src/index.ts @@ -0,0 +1,64 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import { initBridge, log, createMessage } from './bridge'; +import { initWebVitals } from './web-vitals'; +import { initNetworkInterceptor } from './network'; +import { initNavigationTracking } from './navigation'; +import { initPageViewTracking } from './page-view'; +import { initLongTaskMonitoring } from './long-tasks'; +import { initResourceErrorMonitoring } from './resource-errors'; +import { initConsoleCapture } from './console-capture'; +import { initUserInteractionMonitoring } from './user-interactions'; +import { initErrorMonitoring, initPromiseRejectionMonitoring } from './error'; +import type { BridgeReadyMessage } from './types'; + +// Extend Window interface for our guard flag +declare global { + interface Window { + __bitdriftBridgeInitialized?: boolean; + } +} + +/** + * Main entry point for the Bitdrift WebView SDK. + * This runs immediately when injected into a WebView. + */ +const init = (): void => { + // Guard against multiple initializations (e.g., script injected twice) + if (window.__bitdriftBridgeInitialized) { + return; + } + window.__bitdriftBridgeInitialized = true; + + // Initialize the bridge first + initBridge(); + + // Send bridge ready signal immediately + const readyMessage = createMessage({ + type: 'bridgeReady', + url: window.location.href, + }); + log(readyMessage); + + // Initialize page view tracking first to establish parent span + initPageViewTracking(); + + // Initialize all monitoring modules + initNetworkInterceptor(); + initNavigationTracking(); + initWebVitals(); + initLongTaskMonitoring(); + initResourceErrorMonitoring(); + initConsoleCapture(); + initPromiseRejectionMonitoring(); + initUserInteractionMonitoring(); + initErrorMonitoring(); +}; + +// Run immediately +init(); diff --git a/platform/webview/src/long-tasks.ts b/platform/webview/src/long-tasks.ts new file mode 100644 index 000000000..6501b3691 --- /dev/null +++ b/platform/webview/src/long-tasks.ts @@ -0,0 +1,49 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import { log, createMessage } from './bridge'; +import type { LongTaskMessage } from './types'; + +/** + * Initialize long task monitoring using PerformanceObserver. + * Long tasks are tasks that block the main thread for > 50ms. + */ +export const initLongTaskMonitoring = (): void => { + if (typeof PerformanceObserver === 'undefined') { + return; + } + + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + const taskEntry = entry as PerformanceEntry & { + attribution?: { + name?: string; + containerType?: string; + containerSrc?: string; + containerId?: string; + containerName?: string; + }[]; + }; + + const attribution = taskEntry.attribution?.[0]; + + const message = createMessage({ + type: 'longTask', + durationMs: entry.duration, + startTime: entry.startTime, + attribution: attribution, + }); + log(message); + } + }); + + observer.observe({ type: 'longtask', buffered: true }); + } catch { + // Long task observer not supported + } +}; diff --git a/platform/webview/src/navigation.ts b/platform/webview/src/navigation.ts new file mode 100644 index 000000000..fc4d90434 --- /dev/null +++ b/platform/webview/src/navigation.ts @@ -0,0 +1,72 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import { log, createMessage } from './bridge'; +import { startPageView } from './page-view'; +import type { NavigationMessage } from './types'; + +let currentUrl = ''; + +/** + * Initialize SPA navigation tracking via History API + */ +export const initNavigationTracking = (): void => { + currentUrl = window.location.href; + + // Intercept pushState + const originalPushState = history.pushState; + history.pushState = function (data: unknown, unused: string, url?: string | URL | null): void { + const fromUrl = currentUrl; + originalPushState.call(this, data, unused, url); + const toUrl = window.location.href; + + if (fromUrl !== toUrl) { + currentUrl = toUrl; + logNavigation(fromUrl, toUrl, 'pushState'); + // Start new page view span for SPA navigation + startPageView(toUrl, 'navigation'); + } + }; + + // Intercept replaceState + const originalReplaceState = history.replaceState; + history.replaceState = function (data: unknown, unused: string, url?: string | URL | null): void { + const fromUrl = currentUrl; + originalReplaceState.call(this, data, unused, url); + const toUrl = window.location.href; + + if (fromUrl !== toUrl) { + currentUrl = toUrl; + logNavigation(fromUrl, toUrl, 'replaceState'); + // Start new page view span for SPA navigation + startPageView(toUrl, 'navigation'); + } + }; + + // Listen for popstate (back/forward navigation) + window.addEventListener('popstate', () => { + const fromUrl = currentUrl; + const toUrl = window.location.href; + + if (fromUrl !== toUrl) { + currentUrl = toUrl; + logNavigation(fromUrl, toUrl, 'popstate'); + // Start new page view span for back/forward navigation + startPageView(toUrl, 'navigation'); + } + }); +}; + +const logNavigation = (fromUrl: string, toUrl: string, method: 'pushState' | 'replaceState' | 'popstate'): void => { + const message = createMessage({ + type: 'navigation', + fromUrl, + toUrl, + method, + }); + log(message); +}; diff --git a/platform/webview/src/network.ts b/platform/webview/src/network.ts new file mode 100644 index 000000000..c7c8ef09e --- /dev/null +++ b/platform/webview/src/network.ts @@ -0,0 +1,413 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import { log, createMessage } from './bridge'; +import type { NetworkRequestMessage } from './types'; + +let requestCounter = 0; + +const generateRequestId = (): string => { + return `req_${Date.now()}_${++requestCounter}`; +}; + +/** + * Track JS-initiated requests to avoid double-logging when PerformanceObserver fires. + * Maps URL to the timestamp when the request completed. + */ +const jsInitiatedRequests = new Map(); + +/** + * Track URLs logged by the PerformanceObserver to prevent resource-errors from double-logging. + * Maps URL to the timestamp when it was logged. + */ +const observedResources = new Map(); + +/** + * Track URLs that failed via DOM error events. + * Maps URL to error metadata for correlation with PerformanceObserver. + */ +const failedResources = new Map(); + +/** + * How long to keep a URL in the deduplication set (ms). + * PerformanceObserver entries typically arrive within a few hundred ms of completion. + */ +const DEDUP_WINDOW_MS = 5000; + +/** + * Mark a URL as having been captured via JS interception. + * This prevents the PerformanceObserver from double-logging it. + */ +const markAsJsInitiated = (url: string): void => { + jsInitiatedRequests.set(url, Date.now()); + + // Periodic cleanup of old entries to prevent memory growth + if (jsInitiatedRequests.size > 100) { + const now = Date.now(); + for (const [key, timestamp] of jsInitiatedRequests.entries()) { + if (now - timestamp > DEDUP_WINDOW_MS) { + jsInitiatedRequests.delete(key); + } + } + } +}; + +/** + * Check if a URL was recently captured via JS interception. + * Returns true and removes the entry if found (allowing future requests to same URL). + */ +const wasJsInitiated = (url: string, entryEndTime: number): boolean => { + const timestamp = jsInitiatedRequests.get(url); + if (timestamp === undefined) { + return false; + } + + // Check if the entry is within our dedup window + // entryEndTime is relative to performance.timeOrigin, convert to wall clock + const entryWallTime = performance.timeOrigin + entryEndTime; + const timeDiff = Math.abs(entryWallTime - timestamp); + + if (timeDiff < DEDUP_WINDOW_MS) { + // This is likely the same request we already logged + jsInitiatedRequests.delete(url); + return true; + } + + return false; +}; + +/** + * Mark a URL as having been observed by the PerformanceObserver. + * This allows resource-errors to skip URLs already logged by the network observer. + */ +const markAsObserved = (url: string): void => { + observedResources.set(url, Date.now()); + + // Periodic cleanup of old entries + if (observedResources.size > 100) { + const now = Date.now(); + for (const [key, timestamp] of observedResources.entries()) { + if (now - timestamp > DEDUP_WINDOW_MS) { + observedResources.delete(key); + } + } + } +}; + +/** + * Check if a URL was recently logged by the network observer. + * Used by resource-errors to avoid double-logging. + */ +export const wasResourceObserved = (url: string): boolean => { + const timestamp = observedResources.get(url); + if (timestamp === undefined) { + return false; + } + + const timeDiff = Date.now() - timestamp; + if (timeDiff < DEDUP_WINDOW_MS) { + return true; + } + + // Clean up expired entry + observedResources.delete(url); + return false; +}; + +/** + * Record a resource failure from DOM error events. + * Called by resource-errors to correlate with PerformanceObserver. + */ +export const markResourceFailed = (url: string, resourceType: string, tagName: string): void => { + failedResources.set(url, { + timestamp: Date.now(), + resourceType, + tagName, + }); + + // Periodic cleanup of old entries + if (failedResources.size > 100) { + const now = Date.now(); + for (const [key, entry] of failedResources.entries()) { + if (now - entry.timestamp > DEDUP_WINDOW_MS) { + failedResources.delete(key); + } + } + } +}; + +/** + * Check if a resource was reported as failed via DOM error events. + * Consumes the entry if found (one-time check). + */ +const checkResourceFailed = (url: string): { failed: boolean; resourceType?: string; tagName?: string } => { + const entry = failedResources.get(url); + if (entry && Date.now() - entry.timestamp < DEDUP_WINDOW_MS) { + failedResources.delete(url); + return { failed: true, resourceType: entry.resourceType, tagName: entry.tagName }; + } + return { failed: false }; +}; + +/** + * Try to get resource timing data for a URL + */ +const getResourceTiming = (url: string): PerformanceResourceTiming | undefined => { + if (typeof performance === 'undefined' || !performance.getEntriesByType) { + return undefined; + } + + const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]; + // Find the most recent entry for this URL + const entry = entries.filter((e) => e.name === url).pop(); + + return entry; +}; + +/** + * Intercept fetch requests + */ +const interceptFetch = (): void => { + const originalFetch = window.fetch; + + if (!originalFetch) { + return; + } + + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const requestId = generateRequestId(); + const startTime = performance.now(); + + // Extract URL and method + let url: string; + let method: string; + + if (input instanceof Request) { + url = input.url; + method = input.method; + } else { + url = input.toString(); + method = init?.method ?? 'GET'; + } + + try { + const response = await originalFetch.call(window, input, init); + const endTime = performance.now(); + + // Mark as JS-initiated before logging to prevent PerformanceObserver duplicate + markAsJsInitiated(url); + + const message = createMessage({ + type: 'networkRequest', + requestId, + method: method.toUpperCase(), + url, + statusCode: response.status, + durationMs: Math.round(endTime - startTime), + success: response.ok, + requestType: 'fetch', + timing: getResourceTiming(url), + }); + + log(message); + return response; + } catch (error) { + const endTime = performance.now(); + + // Mark as JS-initiated before logging to prevent PerformanceObserver duplicate + markAsJsInitiated(url); + + const message = createMessage({ + type: 'networkRequest', + requestId, + method: method.toUpperCase(), + url, + statusCode: 0, + durationMs: Math.round(endTime - startTime), + success: false, + error: error instanceof Error ? error.message : String(error), + requestType: 'fetch', + timing: getResourceTiming(url), + }); + + log(message); + throw error; + } + }; +}; + +/** + * Intercept XMLHttpRequest + */ +const interceptXHR = (): void => { + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function ( + method: string, + url: string | URL, + async: boolean = true, + username?: string | null, + password?: string | null, + ): void { + // Store request info on the XHR instance + ( + this as XMLHttpRequest & { + _bitdrift?: { method: string; url: string; requestId: string }; + } + )._bitdrift = { + method: method.toUpperCase(), + url: url.toString(), + requestId: generateRequestId(), + }; + + originalOpen.call(this, method, url, async, username, password); + }; + + XMLHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null): void { + const xhr = this as XMLHttpRequest & { + _bitdrift?: { method: string; url: string; requestId: string }; + }; + const info = xhr._bitdrift; + + if (!info) { + originalSend.call(this, body); + return; + } + + const startTime = performance.now(); + + const handleComplete = (): void => { + const endTime = performance.now(); + + // Mark as JS-initiated before logging to prevent PerformanceObserver duplicate + markAsJsInitiated(info.url); + + const message = createMessage({ + type: 'networkRequest', + requestId: info.requestId, + method: info.method, + url: info.url, + statusCode: xhr.status, + durationMs: Math.round(endTime - startTime), + success: xhr.status >= 200 && xhr.status < 400, + requestType: 'xmlhttprequest', + timing: getResourceTiming(info.url), + }); + + log(message); + }; + + const handleError = (): void => { + const endTime = performance.now(); + + // Mark as JS-initiated before logging to prevent PerformanceObserver duplicate + markAsJsInitiated(info.url); + + const message = createMessage({ + type: 'networkRequest', + requestId: info.requestId, + method: info.method, + url: info.url, + statusCode: 0, + durationMs: Math.round(endTime - startTime), + success: false, + error: 'Network error', + requestType: 'xmlhttprequest', + }); + + log(message); + }; + + xhr.addEventListener('load', handleComplete); + xhr.addEventListener('error', handleError); + xhr.addEventListener('abort', handleError); + xhr.addEventListener('timeout', handleError); + + originalSend.call(this, body); + }; +}; + +/** + * Initialize PerformanceObserver to capture browser-initiated resource loads + * (images, CSS, scripts, etc.) that weren't made via JS fetch/XHR. + */ +const initResourceObserver = (): void => { + if (typeof PerformanceObserver === 'undefined') { + return; + } + + try { + const observer = new PerformanceObserver((list) => { + const entries = list.getEntries() as PerformanceResourceTiming[]; + + // Defer processing to allow any pending DOM error events to fire first. + // DOM error events are synchronous, but we can't guarantee they've been + // dispatched before PerformanceObserver fires across all browsers. + // queueMicrotask ensures we run after the current task completes. + queueMicrotask(() => { + for (const resourceEntry of entries) { + // Skip if this was already captured via fetch/XHR interception + if (wasJsInitiated(resourceEntry.name, resourceEntry.responseEnd)) { + continue; + } + + // Skip data URLs and blob URLs (usually internal/generated) + if (resourceEntry.name.startsWith('data:') || resourceEntry.name.startsWith('blob:')) { + continue; + } + + const durationMs = Math.round(resourceEntry.responseEnd - resourceEntry.startTime); + + // responseStatus is not supported in Safari (as of 2025) + const statusCode = resourceEntry.responseStatus ?? 0; + + // Determine success based on HTTP status code when available. + // If statusCode > 0, use it directly (200-399 = success). + // If statusCode = 0 (unsupported browser like Safari): + // - Check if the DOM error handler reported this URL as failed. + // - By deferring with queueMicrotask, we ensure synchronous error + // handlers have had a chance to call markResourceFailed(). + // - If not found in failed set, assume success since no error was reported. + const failureInfo = statusCode === 0 ? checkResourceFailed(resourceEntry.name) : { failed: false }; + const success = statusCode > 0 ? statusCode >= 200 && statusCode < 400 : !failureInfo.failed; + + const message = createMessage({ + type: 'networkRequest', + requestId: generateRequestId(), + method: 'GET', // Browser resource loads are typically GET + url: resourceEntry.name, + statusCode, + durationMs, + success, + requestType: resourceEntry.initiatorType, + timing: resourceEntry, + }); + + // Mark as observed so resource-errors doesn't double-log + markAsObserved(resourceEntry.name); + + log(message); + } + }); + }); + + observer.observe({ type: 'resource', buffered: false }); + } catch (_error) { + // TODO (Jackson): Log internal warning: PerformanceObserver not supported or resource type not available + } +}; + +/** + * Initialize network interception for both fetch and XHR, + * plus PerformanceObserver for browser-initiated resources. + */ +export const initNetworkInterceptor = (): void => { + interceptFetch(); + interceptXHR(); + initResourceObserver(); +}; diff --git a/platform/webview/src/page-view.ts b/platform/webview/src/page-view.ts new file mode 100644 index 000000000..0db67dff1 --- /dev/null +++ b/platform/webview/src/page-view.ts @@ -0,0 +1,176 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import { log, createMessage } from './bridge'; +import type { PageViewMessage, LifecycleMessage } from './types'; + +/** Current page view span ID */ +let currentPageSpanId: string | null = null; + +/** Start time of current page view (epoch ms) */ +let pageViewStartTimeMs: number = 0; + +/** + * Generate a unique span ID + */ +const generateSpanId = (): string => { + // Use crypto.randomUUID if available, otherwise fallback + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Fallback for older environments + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +}; + +/** + * Get the current page view span ID. + * Can be used by other modules to nest their spans/logs under the current page view. + */ +export const getCurrentPageSpanId = (): string | null => { + return currentPageSpanId; +}; + +/** + * Start a new page view span. + * This will end any existing page view span first. + * + * For the initial page view, we use performance.timeOrigin as the start time + * so that web vitals (which are measured from navigation start) fall within + * the page view span. + */ +export const startPageView = (url: string, reason: 'initial' | 'navigation' = 'navigation'): void => { + // End previous page view if exists + if (currentPageSpanId) { + endPageView('navigation'); + } + + currentPageSpanId = generateSpanId(); + + // For initial page view, use navigation start time (performance.timeOrigin) + // For SPA navigations, use current time + if (reason === 'initial') { + pageViewStartTimeMs = Math.round(performance.timeOrigin); + } else { + pageViewStartTimeMs = Date.now(); + } + + const message: PageViewMessage = { + tag: 'bitdrift-webview-sdk', + v: 1, + type: 'pageView', + action: 'start', + spanId: currentPageSpanId, + url, + reason, + // Use our calculated start time, not Date.now() + timestamp: pageViewStartTimeMs, + }; + log(message); +}; + +/** + * End the current page view span. + */ +export const endPageView = (reason: 'navigation' | 'unload' | 'hidden'): void => { + if (!currentPageSpanId) { + return; + } + + const now = Date.now(); + const durationMs = now - pageViewStartTimeMs; + + const message: PageViewMessage = { + tag: 'bitdrift-webview-sdk', + v: 1, + timestamp: now, + type: 'pageView', + action: 'end', + spanId: currentPageSpanId, + url: window.location.href, + reason, + durationMs, + }; + log(message); + + currentPageSpanId = null; + pageViewStartTimeMs = 0; +}; + +/** + * Log a lifecycle event within the current page view. + */ +const logLifecycleEvent = ( + event: 'DOMContentLoaded' | 'load' | 'visibilitychange', + details?: Record, +): void => { + const message = createMessage({ + type: 'lifecycle', + event, + performanceTime: performance.now(), + ...details, + }); + log(message); +}; + +/** + * Initialize page view tracking. + * This sets up the initial page view and lifecycle event listeners. + */ +export const initPageViewTracking = (): void => { + // Start initial page view + startPageView(window.location.href, 'initial'); + + // Track DOMContentLoaded + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + logLifecycleEvent('DOMContentLoaded'); + }); + } else { + // Already loaded, log immediately with note + logLifecycleEvent('DOMContentLoaded'); + } + + // Track window load + if (document.readyState !== 'complete') { + window.addEventListener('load', () => { + logLifecycleEvent('load'); + }); + } else { + // Already loaded + logLifecycleEvent('load'); + } + + // Track visibility changes + document.addEventListener('visibilitychange', () => { + logLifecycleEvent('visibilitychange', { + visibilityState: document.visibilityState, + }); + + // End page view when hidden (user switched tabs/apps) + // This ensures CLS/INP are captured before the page is hidden + if (document.visibilityState === 'hidden') { + endPageView('hidden'); + } else if (document.visibilityState === 'visible' && !currentPageSpanId) { + // Resume page view when becoming visible again + startPageView(window.location.href, 'navigation'); + } + }); + + // Track page unload + window.addEventListener('pagehide', () => { + endPageView('unload'); + }); + + // Fallback for browsers that don't support pagehide + window.addEventListener('beforeunload', () => { + endPageView('unload'); + }); +}; diff --git a/platform/webview/src/resource-errors.ts b/platform/webview/src/resource-errors.ts new file mode 100644 index 000000000..a6c3c8eed --- /dev/null +++ b/platform/webview/src/resource-errors.ts @@ -0,0 +1,120 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import { log, createMessage } from './bridge'; +import { wasResourceObserved, markResourceFailed } from './network'; +import type { ResourceErrorMessage } from './types'; + +/** + * Delay before logging a fallback resource error. + * This gives the PerformanceObserver time to pick up the resource and log it. + * If PerformanceObserver logs it first, we skip the fallback. + */ +const FALLBACK_DELAY_MS = 500; + +/** + * Initialize resource error monitoring. + * + * This module works in coordination with the PerformanceObserver in network.ts: + * 1. When a DOM error event fires, we record the failure via markResourceFailed() + * 2. PerformanceObserver checks this when statusCode is unavailable (Safari) + * 3. After a delay, we log any errors not picked up by PerformanceObserver + * (edge cases like CSP-blocked requests that don't create timing entries) + */ +export const initResourceErrorMonitoring = (): void => { + // Use capture phase to catch errors before they bubble + window.addEventListener( + 'error', + (event: ErrorEvent | Event) => { + // Only handle resource errors, not script errors + // Script errors have a message property, resource errors don't + if (event instanceof ErrorEvent) { + // This is a script error, not a resource error + return; + } + + const target = event.target as HTMLElement | null; + if (!target) { + return; + } + + // Check if it's a resource element + const tagName = target.tagName?.toLowerCase(); + if (!tagName) { + return; + } + + // Only track resource loading elements + const resourceElements = ['img', 'script', 'link', 'video', 'audio', 'source', 'iframe']; + if (!resourceElements.includes(tagName)) { + return; + } + + // Get the URL of the failed resource + const url = + (target as HTMLImageElement | HTMLScriptElement | HTMLIFrameElement).src || + (target as HTMLLinkElement).href || + ''; + + if (!url) { + return; + } + + const resourceType = getResourceType(tagName, target); + + // Record the failure for PerformanceObserver to consume + markResourceFailed(url, resourceType, tagName); + + // After a delay, log if PerformanceObserver didn't pick it up + // This handles edge cases like CSP-blocked requests that don't + // create Resource Timing entries + setTimeout(() => { + // If PerformanceObserver already logged this URL, skip + if (wasResourceObserved(url)) { + return; + } + + const message = createMessage({ + type: 'resourceError', + resourceType, + url, + tagName, + }); + log(message); + }, FALLBACK_DELAY_MS); + }, + true, // Use capture phase + ); +}; + +/** + * Determine the resource type based on tag and attributes + */ +const getResourceType = (tagName: string, element: HTMLElement): string => { + switch (tagName) { + case 'img': + return 'image'; + case 'script': + return 'script'; + case 'link': { + const rel = (element as HTMLLinkElement).rel; + if (rel === 'stylesheet') return 'stylesheet'; + if (rel === 'icon' || rel === 'shortcut icon') return 'icon'; + return 'link'; + } + case 'video': + return 'video'; + case 'audio': + return 'audio'; + case 'source': + return 'media-source'; + case 'iframe': + return 'iframe'; + default: + return tagName; + } +}; diff --git a/platform/webview/src/types.ts b/platform/webview/src/types.ts new file mode 100644 index 000000000..eebb3e06f --- /dev/null +++ b/platform/webview/src/types.ts @@ -0,0 +1,260 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import type { MetricType } from 'web-vitals'; + +/** + * Message types sent from JS to native bridge + */ +export type MessageType = + | 'bridgeReady' + | 'webVital' + | 'networkRequest' + | 'navigation' + | 'pageView' + | 'lifecycle' + | 'error' + | 'longTask' + | 'resourceError' + | 'console' + | 'promiseRejection' + | 'userInteraction'; + +/** + * Base interface for all bridge messages + */ +export interface BridgeMessage { + tag: 'bitdrift-webview-sdk'; + /** Protocol version for forward compatibility */ + v: 1; + /** Message type discriminator */ + type: MessageType; + /** Timestamp when the event occurred (ms since epoch) */ + timestamp: number; +} + +/** + * Sent immediately when the bridge is initialized + */ +export interface BridgeReadyMessage extends BridgeMessage { + type: 'bridgeReady'; + /** URL of the page being loaded */ + url: string; +} + +/** + * Core Web Vitals and other performance metrics + */ +export interface WebVitalMessage extends BridgeMessage { + type: 'webVital'; + metric: MetricType; + /** Parent span ID for nesting under page view */ + parentSpanId?: string; +} + +/** + * Network request captured via fetch/XHR interception + */ +export interface NetworkRequestMessage extends BridgeMessage { + type: 'networkRequest'; + /** Unique identifier for correlating start/end */ + requestId: string; + /** HTTP method */ + method: string; + /** Request URL */ + url: string; + /** HTTP status code (0 if request failed) */ + statusCode: number; + /** Request duration in milliseconds */ + durationMs: number; + /** Whether the request succeeded */ + success: boolean; + /** Error message if request failed */ + error?: string; + /** Request type: 'fetch' | 'xhr' */ + requestType: PerformanceResourceTiming['initiatorType']; + /** Resource timing data if available */ + timing?: PerformanceResourceTiming; +} + +/** + * Detailed timing from Resource Timing API + */ +export interface ResourceTimingData { + /** DNS lookup time */ + dnsMs?: number; + /** TCP connection time */ + connectMs?: number; + /** TLS handshake time */ + tlsMs?: number; + /** Time to first byte */ + ttfbMs?: number; + /** Response download time */ + downloadMs?: number; + /** Total transfer size in bytes */ + transferSize?: number; +} + +/** + * SPA navigation event via History API + */ +export interface NavigationMessage extends BridgeMessage { + type: 'navigation'; + /** Previous URL */ + fromUrl: string; + /** New URL */ + toUrl: string; + /** Navigation method: 'pushState' | 'replaceState' | 'popstate' */ + method: 'pushState' | 'replaceState' | 'popstate'; +} + +/** + * JavaScript error captured + */ +export interface ErrorMessage extends BridgeMessage { + type: 'error'; + /** Error name (e.g., "TypeError", "ReferenceError") */ + name: string; + /** Error message */ + message: string; + /** Stack trace if available */ + stack?: string; + /** Source file */ + filename?: string; + /** Line number */ + lineno?: number; + /** Column number */ + colno?: number; +} + +/** + * Page view span for grouping events within a page session + */ +export interface PageViewMessage extends BridgeMessage { + type: 'pageView'; + /** Action: start or end of page view */ + action: 'start' | 'end'; + /** Unique span ID for this page view */ + spanId: string; + /** URL of the page */ + url: string; + /** Reason for the page view event */ + reason: 'initial' | 'navigation' | 'unload' | 'hidden'; + /** Duration of page view in ms (only on end) */ + durationMs?: number; +} + +/** + * Lifecycle events within a page view + */ +export interface LifecycleMessage extends BridgeMessage { + type: 'lifecycle'; + /** Lifecycle event type */ + event: 'DOMContentLoaded' | 'load' | 'visibilitychange'; + /** Performance time when event occurred */ + performanceTime: number; + /** Visibility state (for visibilitychange) */ + visibilityState?: string; +} + +/** + * Long task detected (blocking main thread > 50ms) + */ +export interface LongTaskMessage extends BridgeMessage { + type: 'longTask'; + /** Duration of the long task in ms */ + durationMs: number; + /** Start time relative to navigation start */ + startTime: number; + /** Attribution data for the long task */ + attribution?: { + name?: string; + containerType?: string; + containerSrc?: string; + containerId?: string; + containerName?: string; + }; +} + +/** + * Resource loading failure (images, scripts, stylesheets, etc.) + */ +export interface ResourceErrorMessage extends BridgeMessage { + type: 'resourceError'; + /** Type of resource that failed */ + resourceType: string; + /** URL of the failed resource */ + url: string; + /** Tag name of the element */ + tagName: string; +} + +/** + * Console message captured + */ +export interface ConsoleMessage extends BridgeMessage { + type: 'console'; + /** Console method: log, warn, error, info, debug */ + level: 'log' | 'warn' | 'error' | 'info' | 'debug'; + /** Console message content */ + message: string; + /** Additional arguments passed to console */ + args?: string[]; +} + +/** + * Unhandled promise rejection + */ +export interface PromiseRejectionMessage extends BridgeMessage { + type: 'promiseRejection'; + /** Rejection reason/message */ + reason: string; + /** Stack trace if available */ + stack?: string; +} + +/** + * User interaction event + */ +export interface UserInteractionMessage extends BridgeMessage { + type: 'userInteraction'; + /** Interaction type */ + interactionType: 'click' | 'rageClick'; + /** Target element tag name */ + tagName: string; + /** Target element ID if present */ + elementId?: string; + /** Target element classes */ + className?: string; + /** Text content (truncated) */ + textContent?: string; + /** Whether the element is typically clickable */ + isClickable: boolean; + /** Click count (for rage clicks) */ + clickCount?: number; + /** Time window for rage clicks in ms */ + timeWindowMs?: number; + /** Actual duration of the rage click time window in ms */ + duration?: number; +} + +/** + * Union type of all possible messages + */ +export type AnyBridgeMessage = + | BridgeReadyMessage + | WebVitalMessage + | NetworkRequestMessage + | NavigationMessage + | PageViewMessage + | LifecycleMessage + | ErrorMessage + | LongTaskMessage + | ResourceErrorMessage + | ConsoleMessage + | PromiseRejectionMessage + | UserInteractionMessage; diff --git a/platform/webview/src/user-interactions.ts b/platform/webview/src/user-interactions.ts new file mode 100644 index 000000000..09bcca7a4 --- /dev/null +++ b/platform/webview/src/user-interactions.ts @@ -0,0 +1,216 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import { log, createMessage } from './bridge'; +import type { UserInteractionMessage } from './types'; + +/** Clickable element tags */ +const CLICKABLE_TAGS = new Set(['a', 'button', 'input', 'select', 'textarea', 'label', 'summary']); + +/** Input types that are clickable */ +const CLICKABLE_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'checkbox', 'radio', 'file']); + +/** Rage click detection settings */ +const RAGE_CLICK_THRESHOLD = 3; // Number of clicks to trigger rage click +const RAGE_CLICK_TIME_WINDOW_MS = 1000; // Time window for rage clicks +const RAGE_CLICK_DISTANCE_PX = 100; // Max distance between clicks to count as rage +const RAGE_CLICK_DEBOUNCE_MS = 500; // Wait time after last click before logging + +/** Track clicks for rage detection */ +interface ClickRecord { + x: number; + y: number; + timestamp: number; + element: Element; +} + +let recentClicks: ClickRecord[] = []; +let rageClickDebounceTimer: number | null = null; +let pendingRageClick: { + element: Element; + clicks: ClickRecord[]; +} | null = null; + +/** + * Flush any pending rage click immediately, logging it and clearing state. + */ +const flushPendingRageClick = (): void => { + if (rageClickDebounceTimer !== null) { + clearTimeout(rageClickDebounceTimer); + rageClickDebounceTimer = null; + } + + if (pendingRageClick) { + const timeSpan = + pendingRageClick.clicks[pendingRageClick.clicks.length - 1].timestamp - + pendingRageClick.clicks[0].timestamp; + + logUserInteraction(pendingRageClick.element, 'rageClick', false, pendingRageClick.clicks.length, timeSpan); + + pendingRageClick = null; + recentClicks = []; + } +}; + +/** + * Initialize user interaction monitoring. + * Tracks taps/clicks on clickable elements and rage clicks on non-clickable elements. + * Uses pointerdown for reliable capture on both mobile and desktop. + */ +export const initUserInteractionMonitoring = (): void => { + // Use pointerdown for reliable mobile/desktop support + // pointerdown fires immediately on touch/click without delay + document.addEventListener('pointerdown', handlePointerDown, true); +}; + +/** + * Check if an element or its ancestors are clickable + */ +const isClickable = (element: Element): boolean => { + let current: Element | null = element; + + while (current) { + const tagName = current.tagName.toLowerCase(); + + // Check tag name + if (CLICKABLE_TAGS.has(tagName)) { + // For inputs, check the type + if (tagName === 'input') { + const type = (current as HTMLInputElement).type.toLowerCase(); + return CLICKABLE_INPUT_TYPES.has(type); + } + return true; + } + + // Check for role attribute + const role = current.getAttribute('role'); + if (role === 'button' || role === 'link' || role === 'menuitem') { + return true; + } + + // Check for onclick handler or tabindex + if (current.hasAttribute('onclick') || current.hasAttribute('tabindex')) { + return true; + } + + // Check for cursor pointer style + const style = window.getComputedStyle(current); + if (style.cursor === 'pointer') { + return true; + } + + current = current.parentElement; + } + + return false; +}; + +/** + * Handle pointer down events (touch/mouse) + */ +const handlePointerDown = (event: PointerEvent): void => { + const target = event.target as Element | null; + if (!target) { + return; + } + + const clickable = isClickable(target); + const now = Date.now(); + + if (clickable) { + // Track taps/clicks on clickable elements + logUserInteraction(target, 'click', true); + } else { + // For non-clickable elements, check for rage clicks + const clickRecord: ClickRecord = { + x: event.clientX, + y: event.clientY, + timestamp: now, + element: target, + }; + + // Add to recent clicks and clean up old ones + recentClicks.push(clickRecord); + recentClicks = recentClicks.filter((click) => now - click.timestamp < RAGE_CLICK_TIME_WINDOW_MS); + + // Check for rage click pattern + const nearbyClicks = recentClicks.filter((click) => { + const distance = Math.sqrt((click.x - event.clientX) ** 2 + (click.y - event.clientY) ** 2); + return distance < RAGE_CLICK_DISTANCE_PX; + }); + + if (nearbyClicks.length >= RAGE_CLICK_THRESHOLD) { + // We've detected a rage click - start/update the debounce timer + // This allows us to capture the full rage sequence + if (pendingRageClick && pendingRageClick.element === target) { + // Update existing rage click with new clicks + pendingRageClick.clicks = nearbyClicks; + } else { + // New rage click sequence on a different element + // Flush the pending rage click first if one exists + if (pendingRageClick) { + flushPendingRageClick(); + } + + pendingRageClick = { + element: target, + clicks: nearbyClicks, + }; + } + + // Clear existing timer and set a new one + if (rageClickDebounceTimer !== null) { + clearTimeout(rageClickDebounceTimer); + } + + // Log the rage click after the debounce period + rageClickDebounceTimer = setTimeout(() => { + flushPendingRageClick(); + }, RAGE_CLICK_DEBOUNCE_MS) as unknown as number; + } + } +}; + +/** + * Log a user interaction event + */ +const logUserInteraction = ( + element: Element, + interactionType: 'click' | 'rageClick', + isClickable: boolean, + clickCount?: number, + actualTimeWindowMs?: number, +): void => { + const tagName = element.tagName.toLowerCase(); + const elementId = element.id || undefined; + const className = element.className + ? typeof element.className === 'string' + ? element.className + : (element.className as DOMTokenList).toString() + : undefined; + + // Get text content, truncated + let textContent: string | undefined; + if (element.textContent) { + const text = element.textContent.trim().replace(/\s+/g, ' '); + textContent = text.length > 50 ? `${text.slice(0, 50)}...` : text || undefined; + } + + const message = createMessage({ + type: 'userInteraction', + interactionType, + tagName, + elementId, + className: className?.slice(0, 100), + textContent, + isClickable, + clickCount: interactionType === 'rageClick' ? clickCount : undefined, + timeWindowMs: interactionType === 'rageClick' ? RAGE_CLICK_TIME_WINDOW_MS : undefined, + duration: interactionType === 'rageClick' ? actualTimeWindowMs : undefined, + }); + log(message); +}; diff --git a/platform/webview/src/web-vitals.ts b/platform/webview/src/web-vitals.ts new file mode 100644 index 000000000..2ef4c897b --- /dev/null +++ b/platform/webview/src/web-vitals.ts @@ -0,0 +1,35 @@ +// capture-sdk - bitdrift's client SDK +// Copyright Bitdrift, Inc. All rights reserved. +// +// Use of this source code is governed by a source available license that can be found in the +// LICENSE file or at: +// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt + +import { onLCP, onCLS, onINP, onFCP, onTTFB, type MetricType } from 'web-vitals'; +import { log, createMessage } from './bridge'; +import { getCurrentPageSpanId } from './page-view'; +import type { WebVitalMessage } from './types'; + +/** + * Initialize Core Web Vitals monitoring using the web-vitals library. + * Reports: LCP, CLS, INP, FCP, TTFB + */ +export const initWebVitals = (): void => { + const reportMetric = (metric: MetricType): void => { + const parentSpanId = getCurrentPageSpanId(); + const message = createMessage({ + type: 'webVital', + metric, + ...(parentSpanId && { parentSpanId }), + }); + log(message); + }; + + // Report all Core Web Vitals + // Using reportAllChanges: false (default) to get final values + onLCP(reportMetric); + onCLS(reportMetric); + onINP(reportMetric); + onFCP(reportMetric); + onTTFB(reportMetric); +}; diff --git a/platform/webview/tsconfig.json b/platform/webview/tsconfig.json new file mode 100644 index 000000000..5dcf48fab --- /dev/null +++ b/platform/webview/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "outDir": "./dist", + "rootDir": ".", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": ["src/**/*", "scripts/**/*"], + "exclude": ["node_modules", "dist"] +}