Add JavaScript viewer and HTTP request initiator tracking

Introduces comprehensive request initiator visualization and JavaScript performance analysis capabilities.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-08-19 06:32:57 -05:00
parent 8075e54397
commit 488d9a2650
21 changed files with 246680 additions and 4 deletions

View File

@ -73,4 +73,4 @@ This is a React + TypeScript + Vite project called "perfviz" - a modern web appl
- **React Integration**: Proper cleanup with engine disposal in useEffect return function
- **Version-Specific Documentation**: ALWAYS check Babylon.js documentation for version 8.21.1 specifically to avoid deprecated methods and ensure current API usage
- **API Verification**: Before suggesting any Babylon.js code, verify method signatures and availability in the current version
-
- **CSS**: Use CSS modules for styling components, avoid inline styles for better performance and maintainability

244498
examples/chek-plp.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -3,13 +3,14 @@ import styles from './App.module.css'
import TraceViewer from './components/TraceViewer'
import PhaseViewer from './components/PhaseViewer'
import HTTPRequestViewer from './components/httprequestviewer/HTTPRequestViewer'
import JavaScriptViewer from './components/javascriptviewer/JavaScriptViewer'
import RequestDebugger from './components/RequestDebugger'
import TraceUpload from './components/TraceUpload'
import TraceSelector from './components/TraceSelector'
import { traceDatabase } from './utils/traceDatabase'
import { useDatabaseTraceData } from './hooks/useDatabaseTraceData'
type AppView = 'trace' | 'phases' | 'http' | 'debug'
type AppView = 'trace' | 'phases' | 'http' | 'js' | 'debug'
type AppMode = 'selector' | 'upload' | 'analysis'
type ThreeDView = 'network' | 'timeline' | null
@ -33,7 +34,7 @@ const getUrlParams = () => {
const view = segments[1] as AppView
const threeDView = segments[2] as ThreeDView
// Validate view and 3D view values
const validViews: AppView[] = ['trace', 'phases', 'http', 'debug']
const validViews: AppView[] = ['trace', 'phases', 'http', 'js', 'debug']
const validThreeDViews: (ThreeDView)[] = ['network', 'timeline']
const validatedView = validViews.includes(view) ? view : 'http'
const validatedThreeDView = validThreeDViews.includes(threeDView) ? threeDView : null
@ -42,7 +43,7 @@ const getUrlParams = () => {
const traceId = segments[0]
const view = segments[1] as AppView
// Validate view is one of the allowed values
const validViews: AppView[] = ['trace', 'phases', 'http', 'debug']
const validViews: AppView[] = ['trace', 'phases', 'http', 'js', 'debug']
const validatedView = validViews.includes(view) ? view : 'http'
return { traceId, view: validatedView, threeDView: null }
} else if (segments.length === 1) {
@ -219,6 +220,15 @@ function App() {
>
HTTP Requests
</button>
<button
className={`${styles.navButton} ${currentView === 'js' ? styles.active : ''}`}
onClick={() => {
setCurrentView('js')
updateUrlWithTraceId(selectedTraceId, 'js', null)
}}
>
JavaScript
</button>
<button
className={`${styles.navButton} ${currentView === 'debug' ? styles.active : ''}`}
onClick={() => {
@ -243,6 +253,10 @@ function App() {
<HTTPRequestViewer traceId={selectedTraceId} />
)}
{currentView === 'js' && (
<JavaScriptViewer traceId={selectedTraceId} />
)}
{currentView === 'debug' && traceData && (
<RequestDebugger traceEvents={traceData.traceEvents} />
)}

View File

@ -0,0 +1,211 @@
/* Initiator Chain styles using CSS variables from App.module.css */
.chainContainer {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-top: var(--spacing-md);
}
.chainHeader {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--color-primary);
}
.chainTitle {
color: var(--color-text-highlight);
font-size: var(--font-size-lg);
font-weight: bold;
margin: 0 0 var(--spacing-xs) 0;
}
.chainDescription {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin: 0;
}
.noChain {
color: var(--color-text-muted);
font-style: italic;
text-align: center;
padding: var(--spacing-lg);
background: var(--color-bg-light);
border-radius: var(--radius-md);
}
/* Chain tree structure */
.chainTree {
position: relative;
}
.chainNodeContainer {
position: relative;
}
.chainNode {
position: relative;
display: flex;
align-items: center;
margin-bottom: var(--spacing-sm);
transition: all 0.2s ease;
}
.chainNode.selected .nodeContent {
background: var(--color-primary);
color: white;
box-shadow: 0 2px 8px rgba(var(--color-primary-rgb), 0.3);
}
.chainNode.selected .nodeUrl,
.chainNode.selected .nodeInfo {
color: white;
}
.chainNode.selected .resourceType,
.chainNode.selected .initiatorType {
color: rgba(255, 255, 255, 0.9);
}
/* Connection lines */
.nodeConnector {
position: relative;
width: 20px;
height: 24px;
flex-shrink: 0;
}
.verticalLine {
position: absolute;
left: -10px;
top: -12px;
width: 1px;
height: 24px;
background: var(--color-border);
}
.horizontalLine {
position: absolute;
left: -10px;
top: 12px;
width: 20px;
height: 1px;
background: var(--color-border);
}
/* Node content */
.nodeContent {
display: flex;
align-items: center;
gap: var(--spacing-sm);
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-sm);
flex: 1;
transition: all 0.2s ease;
}
.nodeContent:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.nodeIcon {
font-size: var(--font-size-lg);
flex-shrink: 0;
}
.nodeDetails {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.nodeUrl {
font-size: var(--font-size-base);
font-weight: 500;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nodeInfo {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--font-size-xs);
}
.resourceType {
color: var(--color-primary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.initiatorType {
color: var(--color-text-muted);
font-style: italic;
}
.selectedIndicator {
background: rgba(255, 255, 255, 0.2);
color: white;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
/* Responsive design */
@media (max-width: 768px) {
.chainContainer {
padding: var(--spacing-md);
}
.chainNode {
margin-left: 0 !important;
}
.nodeConnector {
display: none;
}
.nodeContent {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.nodeInfo {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.selectedIndicator {
align-self: flex-end;
}
}
@media (max-width: 480px) {
.chainHeader {
margin-bottom: var(--spacing-md);
}
.nodeDetails {
gap: 2px;
}
.nodeUrl {
font-size: var(--font-size-sm);
}
}

View File

@ -0,0 +1,189 @@
import React, { useMemo } from 'react'
import type { HTTPRequest } from './types/httpRequest'
import styles from './InitiatorChain.module.css'
interface InitiatorChainProps {
requests: HTTPRequest[]
selectedRequest: HTTPRequest
}
interface ChainNode {
request: HTTPRequest
level: number
children: ChainNode[]
isSelected: boolean
}
const InitiatorChain: React.FC<InitiatorChainProps> = ({ requests, selectedRequest }) => {
// Build the initiator chain tree
const chainTree = useMemo(() => {
const buildChain = (request: HTTPRequest, level: number = 0, visited: Set<string> = new Set()): ChainNode => {
// Prevent infinite loops
if (visited.has(request.requestId)) {
return {
request,
level,
children: [],
isSelected: request.requestId === selectedRequest.requestId
}
}
visited.add(request.requestId)
// Find requests initiated by this request
const children = requests
.filter(r => {
if (!r.initiator || r.requestId === request.requestId) return false
// Check if this request was initiated by the current request
if (r.initiator.type === 'script' && r.initiator.stack) {
// Look for the current request's URL in the call stack
return r.initiator.stack.callFrames.some(frame =>
frame.url === request.url ||
frame.url.includes(new URL(request.url).pathname)
)
}
if (r.initiator.type === 'parser' && r.initiator.url) {
return r.initiator.url === request.url
}
return false
})
.map(r => buildChain(r, level + 1, new Set(visited)))
return {
request,
level,
children,
isSelected: request.requestId === selectedRequest.requestId
}
}
// Find the root requests (those without initiators or with external initiators)
const rootRequests = requests.filter(r => {
if (!r.initiator) return true
if (r.initiator.type === 'parser' && r.initiator.url) {
// Check if the initiator URL is in our request list
return !requests.some(req => req.url === r.initiator!.url)
}
return r.initiator.type === 'other' || r.initiator.type === 'preload'
})
// Build chains for each root
const chains = rootRequests.map(root => buildChain(root))
// If the selected request is not in any chain, add it as a standalone chain
const selectedInChains = chains.some(chain =>
findNodeInChain(chain, selectedRequest.requestId)
)
if (!selectedInChains) {
chains.push(buildChain(selectedRequest))
}
return chains
}, [requests, selectedRequest])
const findNodeInChain = (node: ChainNode, requestId: string): boolean => {
if (node.request.requestId === requestId) return true
return node.children.some(child => findNodeInChain(child, requestId))
}
const getResourceTypeIcon = (resourceType: string) => {
switch (resourceType.toLowerCase()) {
case 'document': return '📄'
case 'script': return '📜'
case 'stylesheet': return '🎨'
case 'image': return '🖼️'
case 'font': return '🔤'
case 'xhr': return '📡'
case 'fetch': return '📤'
default: return '📋'
}
}
const truncateUrl = (url: string, maxLength: number = 50) => {
if (url.length <= maxLength) return url
const start = url.substring(0, 20)
const end = url.substring(url.length - 25)
return `${start}...${end}`
}
const renderChainNode = (node: ChainNode) => {
const { request, level, children, isSelected } = node
const indentation = level * 20
return (
<div key={request.requestId} className={styles.chainNodeContainer}>
<div
className={`${styles.chainNode} ${isSelected ? styles.selected : ''}`}
style={{ marginLeft: `${indentation}px` }}
>
<div className={styles.nodeConnector}>
{level > 0 && (
<>
<div className={styles.verticalLine} />
<div className={styles.horizontalLine} />
</>
)}
</div>
<div className={styles.nodeContent}>
<div className={styles.nodeIcon}>
{getResourceTypeIcon(request.resourceType)}
</div>
<div className={styles.nodeDetails}>
<div className={styles.nodeUrl} title={request.url}>
{truncateUrl(request.url)}
</div>
<div className={styles.nodeInfo}>
<span className={styles.resourceType}>{request.resourceType}</span>
{request.initiator && (
<span className={styles.initiatorType}>
via {request.initiator.type}
{request.initiator.fetchType && ` (${request.initiator.fetchType})`}
</span>
)}
</div>
</div>
{isSelected && (
<div className={styles.selectedIndicator}>
📍 Selected
</div>
)}
</div>
</div>
{children.map(child => renderChainNode(child))}
</div>
)
}
if (chainTree.length === 0) {
return (
<div className={styles.noChain}>
No initiator chain information available
</div>
)
}
return (
<div className={styles.chainContainer}>
<div className={styles.chainHeader}>
<h4 className={styles.chainTitle}>🔗 Request Initiator Chain</h4>
<div className={styles.chainDescription}>
Shows how requests are initiated by other requests or by the browser
</div>
</div>
<div className={styles.chainTree}>
{chainTree.map(rootNode => renderChainNode(rootNode))}
</div>
</div>
)
}
export default InitiatorChain

View File

@ -0,0 +1,256 @@
/* Initiator View styles using CSS variables from App.module.css */
.initiatorContainer {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-top: var(--spacing-md);
}
.initiatorHeader {
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--color-primary);
}
.title {
color: var(--color-text-highlight);
font-size: var(--font-size-lg);
font-weight: bold;
margin: 0;
}
/* No initiator state */
.noInitiator {
color: var(--color-text-muted);
font-style: italic;
text-align: center;
padding: var(--spacing-lg);
background: var(--color-bg-light);
border-radius: var(--radius-md);
}
/* Initiator basic information */
.initiatorInfo {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.infoRow {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.infoLabel {
font-weight: 500;
color: var(--color-text);
min-width: 80px;
font-size: var(--font-size-sm);
}
.infoValue {
color: var(--color-text-highlight);
font-size: var(--font-size-base);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
/* Section titles */
.sectionTitle {
color: var(--color-text-highlight);
font-size: var(--font-size-md);
font-weight: bold;
margin: 0 0 var(--spacing-sm) 0;
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
/* Parser information */
.parserInfo {
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.parserDetails {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.parserUrl {
font-size: var(--font-size-base);
}
.parserLocation {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-family: var(--font-family-mono);
}
/* Call stack information */
.stackInfo {
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
}
.callStack {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
max-height: 300px;
overflow-y: auto;
}
.callFrame {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--color-bg-primary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
transition: all 0.2s ease;
}
.callFrame:hover {
background: var(--color-bg-hover);
border-color: var(--color-primary);
}
.frameIndex {
background: var(--color-primary);
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-weight: bold;
flex-shrink: 0;
margin-top: 2px;
}
.frameDetails {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.frameFunction {
font-weight: 500;
color: var(--color-text-highlight);
font-size: var(--font-size-base);
font-family: var(--font-family-mono);
word-break: break-word;
}
.frameLocation {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.frameUrl {
color: var(--color-primary);
text-decoration: none;
font-size: var(--font-size-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
.frameUrl:hover {
color: var(--color-text-highlight);
text-decoration: underline;
}
.framePosition {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 2px 6px;
border-radius: var(--radius-xs);
white-space: nowrap;
}
/* Basic info for other types */
.basicInfo {
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
text-align: center;
}
.basicInfoText {
color: var(--color-text-muted);
font-size: var(--font-size-base);
}
/* URL links */
.urlLink {
color: var(--color-primary);
text-decoration: none;
word-break: break-word;
}
.urlLink:hover {
color: var(--color-text-highlight);
text-decoration: underline;
}
/* Responsive design */
@media (max-width: 768px) {
.initiatorContainer {
padding: var(--spacing-md);
}
.infoRow {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.infoLabel {
min-width: auto;
}
.frameLocation {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.frameUrl {
max-width: 100%;
}
}
@media (max-width: 480px) {
.callFrame {
flex-direction: column;
gap: var(--spacing-xs);
}
.frameIndex {
align-self: flex-start;
}
}

View File

@ -0,0 +1,158 @@
import React from 'react'
import type { HTTPRequest, CallFrame } from './types/httpRequest'
import styles from './InitiatorView.module.css'
interface InitiatorViewProps {
request: HTTPRequest
}
const InitiatorView: React.FC<InitiatorViewProps> = ({ request }) => {
if (!request.initiator) {
return (
<div className={styles.noInitiator}>
No initiator information available
</div>
)
}
const { initiator } = request
const hasStack = initiator.stack && initiator.stack.callFrames.length > 0
const getInitiatorTypeIcon = (type: string) => {
switch (type) {
case 'parser': return '📄'
case 'script': return '📜'
case 'preload': return '⚡'
default: return '❓'
}
}
const getFetchTypeIcon = (fetchType?: string) => {
switch (fetchType) {
case 'script': return '🔧'
case 'link': return '🔗'
case 'fetch': return '📡'
case 'xhr': return '📤'
case 'img': return '🖼️'
default: return '📋'
}
}
const truncateUrl = (url: string, maxLength: number = 60) => {
if (url.length <= maxLength) return url
const start = url.substring(0, 20)
const end = url.substring(url.length - 35)
return `${start}...${end}`
}
const formatLocation = (lineNumber?: number, columnNumber?: number) => {
if (lineNumber !== undefined && columnNumber !== undefined) {
return `${lineNumber}:${columnNumber}`
}
if (lineNumber !== undefined) {
return `${lineNumber}`
}
return '-'
}
const renderCallFrame = (frame: CallFrame, index: number) => (
<div key={index} className={styles.callFrame}>
<div className={styles.frameIndex}>{index + 1}</div>
<div className={styles.frameDetails}>
<div className={styles.frameFunction}>
{frame.functionName || '(anonymous)'}
</div>
<div className={styles.frameLocation}>
<a
href={frame.url}
target="_blank"
rel="noopener noreferrer"
className={styles.frameUrl}
title={frame.url}
>
{truncateUrl(frame.url)}
</a>
<span className={styles.framePosition}>
@ {formatLocation(frame.lineNumber, frame.columnNumber)}
</span>
</div>
</div>
</div>
)
return (
<div className={styles.initiatorContainer}>
<div className={styles.initiatorHeader}>
<h4 className={styles.title}>Request Initiator</h4>
</div>
{/* Initiator Type Information */}
<div className={styles.initiatorInfo}>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Type:</span>
<span className={styles.infoValue}>
{getInitiatorTypeIcon(initiator.type)} {initiator.type}
</span>
</div>
{initiator.fetchType && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Fetch Type:</span>
<span className={styles.infoValue}>
{getFetchTypeIcon(initiator.fetchType)} {initiator.fetchType}
</span>
</div>
)}
</div>
{/* Parser-initiated requests */}
{initiator.type === 'parser' && initiator.url && (
<div className={styles.parserInfo}>
<h5 className={styles.sectionTitle}>📄 Document Parser</h5>
<div className={styles.parserDetails}>
<div className={styles.parserUrl}>
<a
href={initiator.url}
target="_blank"
rel="noopener noreferrer"
className={styles.urlLink}
title={initiator.url}
>
{truncateUrl(initiator.url)}
</a>
</div>
{(initiator.lineNumber || initiator.columnNumber) && (
<div className={styles.parserLocation}>
Line {formatLocation(initiator.lineNumber, initiator.columnNumber)}
</div>
)}
</div>
</div>
)}
{/* Script-initiated requests with call stack */}
{initiator.type === 'script' && hasStack && (
<div className={styles.stackInfo}>
<h5 className={styles.sectionTitle}>📜 Call Stack</h5>
<div className={styles.callStack}>
{initiator.stack!.callFrames.map((frame, index) =>
renderCallFrame(frame, index)
)}
</div>
</div>
)}
{/* Other types without detailed information */}
{!hasStack && initiator.type !== 'parser' && (
<div className={styles.basicInfo}>
<div className={styles.basicInfoText}>
This request was initiated by {initiator.type}
{initiator.fetchType && ` using ${initiator.fetchType}`}
</div>
</div>
)}
</div>
)
}
export default InitiatorView

View File

@ -1,5 +1,6 @@
import React from 'react'
import type { HTTPRequest } from './types/httpRequest'
import InitiatorView from './InitiatorView'
import styles from './RequestRowDetails.module.css'
// Import utility functions
@ -44,6 +45,9 @@ const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request, visibleC
</div>
</div>
{/* Initiator Information */}
<InitiatorView request={request} />
{/* Network Timing */}
<div className={styles.detailCard}>
<h4 className={styles.detailCardTitle}>Network Timing</h4>

View File

@ -214,6 +214,7 @@ const RequestsTable: React.FC<RequestsTableProps> = ({
<RequestRowSummary
key={request.requestId}
request={request}
allRequests={httpRequests}
showQueueAnalysis={showQueueAnalysis}
isExpanded={isExpanded}
onToggleRowExpansion={onToggleRowExpansion}

View File

@ -48,6 +48,19 @@ export const processHTTPRequests = (traceEvents: TraceEvent[]): HTTPRequest[] =>
if (args.data.connectionId !== undefined) {
request.connectionId = args.data.connectionId
}
// Extract initiator information
if (args.data.initiator) {
const initiator = args.data.initiator
request.initiator = {
type: initiator.type || 'other',
fetchType: initiator.fetchType,
url: initiator.url,
lineNumber: initiator.lineNumber,
columnNumber: initiator.columnNumber,
stack: initiator.stack
}
}
break
case 'ResourceReceiveResponse':

View File

@ -23,6 +23,27 @@ export interface ScreenshotEvent {
index: number
}
export interface CallFrame {
scriptId: string
url: string
lineNumber: number
columnNumber: number
functionName: string
}
export interface InitiatorStack {
callFrames: CallFrame[]
}
export interface RequestInitiator {
type: 'parser' | 'script' | 'preload' | 'other'
fetchType?: 'script' | 'link' | 'fetch' | 'xhr' | 'img' | 'other'
url?: string
lineNumber?: number
columnNumber?: number
stack?: InitiatorStack
}
export interface HTTPRequest {
requestId: string
url: string
@ -71,4 +92,5 @@ export interface HTTPRequest {
connectionReused: boolean
queueAnalysis?: QueueAnalysis
cdnAnalysis?: CDNAnalysis
initiator?: RequestInitiator
}

View File

@ -0,0 +1,184 @@
/* JavaScript Events Table styles using CSS variables from App.module.css */
.tableContainer {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
position: relative;
}
.table {
width: 100%;
border-collapse: collapse;
}
.tableHeader {
background: var(--color-bg-light);
}
.tableHeaderCell {
padding: var(--spacing-sm);
font-size: var(--font-size-md);
font-weight: bold;
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
position: relative;
white-space: nowrap;
}
.tableHeaderCell.center {
text-align: center;
}
.tableHeaderCell.left {
text-align: left;
}
.tableHeaderCell.right {
text-align: right;
}
.eventRow {
border-bottom: 1px solid var(--color-border);
transition: background-color 0.2s ease;
}
.eventRow:hover {
background: var(--color-bg-hover);
}
.eventRow td {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-base);
border-right: 1px solid var(--color-border-light);
}
.eventRow td:last-child {
border-right: none;
}
/* Event icon column */
.eventIcon {
text-align: center;
font-size: var(--font-size-lg);
width: 40px;
min-width: 40px;
}
.eventIcon.primary { color: var(--color-primary); }
.eventIcon.secondary { color: var(--color-text-muted); }
.eventIcon.success { color: var(--color-success); }
.eventIcon.warning { color: var(--color-warning); }
.eventIcon.danger { color: var(--color-danger); }
.eventIcon.info { color: var(--color-primary); }
.eventIcon.muted { color: var(--color-text-muted); }
/* Event type column */
.eventType {
font-weight: 500;
color: var(--color-text);
white-space: nowrap;
min-width: 120px;
}
/* Duration column with performance indicators */
.duration {
text-align: right;
font-family: var(--font-family-mono);
font-weight: 500;
min-width: 80px;
white-space: nowrap;
}
.duration.success {
color: var(--color-success);
background-color: transparent;
}
.duration.warning {
color: var(--color-warning);
background-color: transparent;
}
.duration.danger {
color: var(--color-danger);
background-color: transparent;
}
/* URL column */
.url {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.urlLink {
color: var(--color-primary);
text-decoration: none;
}
.urlLink:hover {
color: var(--color-text-highlight);
text-decoration: underline;
}
.inlineScript {
color: var(--color-text-muted);
font-style: italic;
}
/* Location column */
.location {
text-align: center;
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
min-width: 80px;
white-space: nowrap;
}
/* Script type column */
.scriptType {
text-align: center;
font-size: var(--font-size-sm);
min-width: 80px;
white-space: nowrap;
}
.scriptType.primary { color: var(--color-primary); }
.scriptType.secondary { color: var(--color-text-muted); }
.scriptType.success { color: var(--color-success); }
.scriptType.warning { color: var(--color-warning); }
.scriptType.info { color: var(--color-primary); }
.scriptType.muted { color: var(--color-text-muted); }
/* Streaming column */
.streaming {
text-align: center;
font-size: var(--font-size-lg);
width: 50px;
min-width: 50px;
}
/* Start time column */
.startTime {
text-align: right;
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
min-width: 100px;
white-space: nowrap;
}
/* No results message */
.noResults {
text-align: center;
color: var(--color-text-muted);
padding: var(--spacing-lg);
font-size: var(--font-size-xl);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}

View File

@ -0,0 +1,147 @@
import React from 'react'
import type { JavaScriptEvent } from './types/javascriptEvent'
import {
formatDuration,
formatEventType,
getEventIcon,
truncateUrl,
getScriptType,
formatLocation
} from './lib/formatUtils'
import {
getDurationClass,
getEventTypeClass,
getScriptTypeClass,
getStreamingIndicator
} from './lib/colorUtils'
import styles from './JavaScriptEventsTable.module.css'
interface JavaScriptEventsTableProps {
events: JavaScriptEvent[]
visibleColumns: Record<string, boolean>
}
const JavaScriptEventsTable: React.FC<JavaScriptEventsTableProps> = ({
events,
visibleColumns
}) => {
if (events.length === 0) {
return (
<div className={styles.noResults}>
No JavaScript events found in the trace data
</div>
)
}
return (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
{visibleColumns.icon && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}>
Type
</th>
)}
{visibleColumns.eventType && (
<th className={`${styles.tableHeaderCell} ${styles.left}`}>
Event
</th>
)}
{visibleColumns.duration && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}>
Duration
</th>
)}
{visibleColumns.url && (
<th className={`${styles.tableHeaderCell} ${styles.left}`}>
Script / URL
</th>
)}
{visibleColumns.location && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}>
Location
</th>
)}
{visibleColumns.scriptType && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}>
Script Type
</th>
)}
{visibleColumns.streaming && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}>
Streaming
</th>
)}
{visibleColumns.startTime && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}>
Start Time
</th>
)}
</tr>
</thead>
<tbody>
{events.map((event) => (
<tr key={event.eventId} className={styles.eventRow}>
{visibleColumns.icon && (
<td className={`${styles.eventIcon} ${getEventTypeClass(event.name)}`}>
{getEventIcon(event.name)}
</td>
)}
{visibleColumns.eventType && (
<td className={styles.eventType}>
{formatEventType(event.name)}
</td>
)}
{visibleColumns.duration && (
<td className={`${styles.duration} ${getDurationClass(event.timing.durationMs)}`}>
{formatDuration(event.timing.duration)}
</td>
)}
{visibleColumns.url && (
<td className={styles.url} title={event.url}>
{event.isInlineScript ? (
<span className={styles.inlineScript}>
📄 Inline script
</span>
) : (
<a
href={event.url}
target="_blank"
rel="noopener noreferrer"
className={styles.urlLink}
>
{truncateUrl(event.url || '')}
</a>
)}
</td>
)}
{visibleColumns.location && (
<td className={styles.location}>
{formatLocation(event.lineNumber, event.columnNumber)}
</td>
)}
{visibleColumns.scriptType && (
<td className={`${styles.scriptType} ${getScriptTypeClass(getScriptType(event))}`}>
{getScriptType(event)}
</td>
)}
{visibleColumns.streaming && (
<td className={styles.streaming} title={event.v8Data?.notStreamedReason}>
{getStreamingIndicator(event.v8Data?.streamed, event.v8Data?.notStreamedReason)}
</td>
)}
{visibleColumns.startTime && (
<td className={styles.startTime}>
{formatDuration(event.timing.startTime)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)
}
export default JavaScriptEventsTable

View File

@ -0,0 +1,182 @@
/* JavaScript Summary styles using CSS variables from App.module.css */
.summaryContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
/* Overview Stats Grid */
.statsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md);
}
.statCard {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-md);
text-align: center;
transition: all 0.2s ease;
}
.statCard:hover {
background: var(--color-bg-hover);
border-color: var(--color-primary);
}
.statValue {
font-size: var(--font-size-xxl);
font-weight: bold;
color: var(--color-text-highlight);
margin-bottom: var(--spacing-xs);
font-family: var(--font-family-mono);
}
.statLabel {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Breakdown Section */
.breakdownSection {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
}
.sectionTitle {
color: var(--color-text-highlight);
font-size: var(--font-size-xl);
font-weight: bold;
margin: 0 0 var(--spacing-md) 0;
border-bottom: 2px solid var(--color-primary);
padding-bottom: var(--spacing-xs);
}
.eventTypeGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md);
}
.eventTypeCard {
display: flex;
align-items: center;
gap: var(--spacing-md);
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
transition: all 0.2s ease;
}
.eventTypeCard:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.eventTypeIcon {
font-size: var(--font-size-xxl);
line-height: 1;
}
.eventTypeInfo {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.eventTypeCount {
font-size: var(--font-size-lg);
font-weight: bold;
color: var(--color-text-highlight);
font-family: var(--font-family-mono);
}
.eventTypeLabel {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
/* Top Scripts Section */
.topScriptsSection {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
}
.scriptsList {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.scriptCard {
display: flex;
align-items: center;
gap: var(--spacing-md);
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
transition: all 0.2s ease;
}
.scriptCard:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.scriptRank {
background: var(--color-primary);
color: white;
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
font-weight: bold;
flex-shrink: 0;
}
.scriptInfo {
flex: 1;
min-width: 0;
}
.scriptUrl {
font-size: var(--font-size-base);
color: var(--color-text);
margin-bottom: var(--spacing-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scriptStats {
display: flex;
gap: var(--spacing-md);
align-items: center;
}
.scriptTime {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-primary);
font-weight: 500;
}
.scriptEvents {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}

View File

@ -0,0 +1,111 @@
import React from 'react'
import type { JavaScriptSummary as JSummary } from './types/javascriptEvent'
import { formatDuration } from './lib/formatUtils'
import styles from './JavaScriptSummary.module.css'
interface JavaScriptSummaryProps {
summary: JSummary
}
const JavaScriptSummary: React.FC<JavaScriptSummaryProps> = ({ summary }) => {
const {
totalEvents,
totalExecutionTime,
totalCompilationTime,
totalEvaluationTime,
eventsByType,
scriptsByUrl
} = summary
const topScripts = Array.from(scriptsByUrl.values())
.sort((a, b) => b.totalTime - a.totalTime)
.slice(0, 5)
return (
<div className={styles.summaryContainer}>
{/* Overview Stats */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<div className={styles.statValue}>{totalEvents}</div>
<div className={styles.statLabel}>Total Events</div>
</div>
<div className={styles.statCard}>
<div className={styles.statValue}>{formatDuration(totalExecutionTime)}</div>
<div className={styles.statLabel}>Total Time</div>
</div>
<div className={styles.statCard}>
<div className={styles.statValue}>{formatDuration(totalCompilationTime)}</div>
<div className={styles.statLabel}>Compilation</div>
</div>
<div className={styles.statCard}>
<div className={styles.statValue}>{formatDuration(totalEvaluationTime)}</div>
<div className={styles.statLabel}>Evaluation</div>
</div>
</div>
{/* Event Type Breakdown */}
<div className={styles.breakdownSection}>
<h3 className={styles.sectionTitle}>Event Type Breakdown</h3>
<div className={styles.eventTypeGrid}>
<div className={styles.eventTypeCard}>
<div className={styles.eventTypeIcon}></div>
<div className={styles.eventTypeInfo}>
<div className={styles.eventTypeCount}>{eventsByType.compilation}</div>
<div className={styles.eventTypeLabel}>Compilation</div>
</div>
</div>
<div className={styles.eventTypeCard}>
<div className={styles.eventTypeIcon}></div>
<div className={styles.eventTypeInfo}>
<div className={styles.eventTypeCount}>{eventsByType.evaluation}</div>
<div className={styles.eventTypeLabel}>Evaluation</div>
</div>
</div>
<div className={styles.eventTypeCard}>
<div className={styles.eventTypeIcon}>📞</div>
<div className={styles.eventTypeInfo}>
<div className={styles.eventTypeCount}>{eventsByType.execution}</div>
<div className={styles.eventTypeLabel}>Execution</div>
</div>
</div>
<div className={styles.eventTypeCard}>
<div className={styles.eventTypeIcon}>🔧</div>
<div className={styles.eventTypeInfo}>
<div className={styles.eventTypeCount}>{eventsByType.other}</div>
<div className={styles.eventTypeLabel}>Other</div>
</div>
</div>
</div>
</div>
{/* Top Scripts by Time */}
<div className={styles.topScriptsSection}>
<h3 className={styles.sectionTitle}>Top Scripts by Execution Time</h3>
<div className={styles.scriptsList}>
{topScripts.map((script, index) => (
<div key={script.url} className={styles.scriptCard}>
<div className={styles.scriptRank}>#{index + 1}</div>
<div className={styles.scriptInfo}>
<div className={styles.scriptUrl} title={script.url}>
{script.isThirdParty ? '🌐' : '🏠'} {script.url}
</div>
<div className={styles.scriptStats}>
<span className={styles.scriptTime}>{formatDuration(script.totalTime)}</span>
<span className={styles.scriptEvents}>{script.events.length} events</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
export default JavaScriptSummary

View File

@ -0,0 +1,155 @@
/* JavaScript Viewer styles using CSS variables from App.module.css */
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
min-height: 100vh;
background: var(--color-bg-primary);
}
.title {
color: var(--color-text-highlight);
font-size: var(--font-size-xxl);
font-weight: bold;
margin: 0;
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--color-primary);
}
/* Loading and error states */
.loading,
.error,
.noData {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
font-size: var(--font-size-lg);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
}
.loading {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--color-primary);
}
.error {
background: var(--color-bg-secondary);
border: 1px solid var(--color-danger);
color: var(--color-danger);
}
.noData {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
/* Filters section */
.filtersContainer {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
}
.filtersTitle {
color: var(--color-text-highlight);
font-size: var(--font-size-xl);
font-weight: bold;
margin: 0 0 var(--spacing-md) 0;
border-bottom: 2px solid var(--color-primary);
padding-bottom: var(--spacing-xs);
}
.filters {
display: flex;
gap: var(--spacing-lg);
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: var(--spacing-md);
}
.filterGroup {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
min-width: 150px;
}
.filterLabel {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
margin-bottom: var(--spacing-xs);
}
.filterSelect,
.filterInput {
padding: var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-primary);
color: var(--color-text);
font-size: var(--font-size-base);
transition: all 0.2s ease;
}
.filterSelect:focus,
.filterInput:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.1);
}
.filterSelect {
cursor: pointer;
}
.filterInput::placeholder {
color: var(--color-text-muted);
}
/* Results info */
.resultsInfo {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: 1px solid var(--color-border-light);
}
/* Responsive design */
@media (max-width: 768px) {
.container {
padding: var(--spacing-md);
gap: var(--spacing-md);
}
.filters {
flex-direction: column;
gap: var(--spacing-md);
}
.filterGroup {
min-width: auto;
}
.title {
font-size: var(--font-size-xl);
}
}
@media (max-width: 480px) {
.container {
padding: var(--spacing-sm);
}
.filtersContainer {
padding: var(--spacing-md);
}
}

View File

@ -0,0 +1,160 @@
import { useState, useMemo } from 'react'
import { useDatabaseTraceData } from '../../hooks/useDatabaseTraceData'
import { processJavaScriptEvents, calculateJavaScriptSummary } from './lib/javascriptProcessor'
import JavaScriptSummary from './JavaScriptSummary'
import JavaScriptEventsTable from './JavaScriptEventsTable'
import styles from './JavaScriptViewer.module.css'
interface JavaScriptViewerProps {
traceId: string | null
}
const JavaScriptViewer: React.FC<JavaScriptViewerProps> = ({ traceId }) => {
const { traceData, loading, error } = useDatabaseTraceData(traceId)
// Column visibility state for the table
const [visibleColumns] = useState(() => ({
icon: true,
eventType: true,
duration: true,
url: true,
location: true,
scriptType: true,
streaming: true,
startTime: false
}))
// Filter states
const [eventTypeFilter, setEventTypeFilter] = useState<string>('all')
const [searchTerm, setSearchTerm] = useState('')
const [minDuration, setMinDuration] = useState<number>(0)
const jsEvents = useMemo(() => {
if (!traceData) return []
return processJavaScriptEvents(traceData.traceEvents)
}, [traceData])
const filteredEvents = useMemo(() => {
let events = jsEvents
// Filter by event type
if (eventTypeFilter !== 'all') {
events = events.filter(event => event.name === eventTypeFilter)
}
// Filter by minimum duration (in milliseconds)
if (minDuration > 0) {
events = events.filter(event => event.timing.durationMs >= minDuration)
}
// Filter by search term
if (searchTerm) {
const term = searchTerm.toLowerCase()
events = events.filter(event =>
event.url?.toLowerCase().includes(term) ||
event.fileName?.toLowerCase().includes(term) ||
event.name.toLowerCase().includes(term)
)
}
return events
}, [jsEvents, eventTypeFilter, minDuration, searchTerm])
const summary = useMemo(() => {
return calculateJavaScriptSummary(jsEvents)
}, [jsEvents])
// Extract unique event types for filter dropdown
const eventTypes = useMemo(() => {
const types = Array.from(new Set(jsEvents.map(event => event.name)))
return types.sort()
}, [jsEvents])
if (loading) {
return (
<div className={styles.loading}>
Loading JavaScript performance data...
</div>
)
}
if (error) {
return (
<div className={styles.error}>
Error loading trace data: {error}
</div>
)
}
if (!traceData) {
return (
<div className={styles.noData}>
No trace data available
</div>
)
}
return (
<div className={styles.container}>
<h2 className={styles.title}>JavaScript Performance Analysis</h2>
{/* Summary Statistics */}
<JavaScriptSummary summary={summary} />
{/* Filters */}
<div className={styles.filtersContainer}>
<h3 className={styles.filtersTitle}>Filters</h3>
<div className={styles.filters}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Event Type:</label>
<select
value={eventTypeFilter}
onChange={(e) => setEventTypeFilter(e.target.value)}
className={styles.filterSelect}
>
<option value="all">All Events</option>
{eventTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Min Duration (ms):</label>
<input
type="number"
min="0"
step="0.1"
value={minDuration}
onChange={(e) => setMinDuration(parseFloat(e.target.value) || 0)}
className={styles.filterInput}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Search:</label>
<input
type="text"
placeholder="Search URLs, files, or event types..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={styles.filterInput}
/>
</div>
</div>
<div className={styles.resultsInfo}>
Showing {filteredEvents.length} of {jsEvents.length} JavaScript events
</div>
</div>
{/* Events Table */}
<JavaScriptEventsTable
events={filteredEvents}
visibleColumns={visibleColumns}
/>
</div>
)
}
export default JavaScriptViewer

View File

@ -0,0 +1,64 @@
// Color utilities for JavaScript performance indicators
export const getDurationClass = (durationMs: number): string => {
if (durationMs > 50) {
return 'danger' // Red for > 50ms
} else if (durationMs >= 10) {
return 'warning' // Orange for 10-50ms
} else {
return 'success' // Green for < 10ms
}
}
export const getEventTypeClass = (eventName: string): string => {
switch (eventName) {
case 'EvaluateScript':
return 'primary'
case 'v8.compile':
return 'secondary'
case 'v8.callFunction':
return 'info'
case 'v8.newInstance':
return 'success'
case 'V8.DeoptimizeAllOptimizedCodeWithFunction':
return 'danger'
case 'CpuProfiler::StartProfiling':
return 'warning'
default:
return 'muted'
}
}
export const getScriptTypeClass = (scriptType: string): string => {
switch (scriptType) {
case 'Inline':
return 'warning'
case 'External':
return 'success'
case 'Bundle':
return 'primary'
case 'Minified':
return 'info'
case 'Extension':
return 'muted'
default:
return 'secondary'
}
}
export const getThirdPartyIndicator = (isThirdParty: boolean): string => {
return isThirdParty ? '🌐' : '🏠'
}
export const getStreamingIndicator = (streamed?: boolean, reason?: string): string => {
if (streamed === true) return '🚀'
if (streamed === false && reason) {
switch (reason) {
case 'inline script':
return '📄'
default:
return '⏳'
}
}
return ''
}

View File

@ -0,0 +1,97 @@
// Utility functions for formatting JavaScript performance data
export const formatDuration = (microseconds?: number): string => {
if (!microseconds || microseconds === 0) return '0ms'
const ms = microseconds / 1000
if (ms < 1) {
return `${microseconds.toFixed(0)}μs`
} else if (ms < 10) {
return `${ms.toFixed(2)}ms`
} else if (ms < 100) {
return `${ms.toFixed(1)}ms`
} else {
return `${ms.toFixed(0)}ms`
}
}
export const formatEventType = (eventName: string): string => {
switch (eventName) {
case 'EvaluateScript':
return 'Script Evaluation'
case 'v8.compile':
return 'Compilation'
case 'v8.callFunction':
return 'Function Call'
case 'v8.newInstance':
return 'Object Creation'
case 'V8.DeoptimizeAllOptimizedCodeWithFunction':
return 'Deoptimization'
case 'CpuProfiler::StartProfiling':
return 'Profiler Start'
case 'ScriptCatchup':
return 'Script Catchup'
default:
return eventName
}
}
export const getEventIcon = (eventName: string): string => {
switch (eventName) {
case 'EvaluateScript':
return '▶️'
case 'v8.compile':
return '⚙️'
case 'v8.callFunction':
return '📞'
case 'v8.newInstance':
return '🆕'
case 'V8.DeoptimizeAllOptimizedCodeWithFunction':
return '⚠️'
case 'CpuProfiler::StartProfiling':
return '📊'
case 'ScriptCatchup':
return '🔄'
default:
return '🔧'
}
}
export const truncateUrl = (url: string, maxLength: number = 80): string => {
if (!url || url.length <= maxLength) return url
// For file URLs, show the filename
if (url.includes('/')) {
const parts = url.split('/')
const filename = parts[parts.length - 1]
if (filename.length <= maxLength) {
return `.../${filename}`
}
}
// General truncation
return url.length > maxLength
? `${url.substring(0, maxLength - 3)}...`
: url
}
export const getScriptType = (event: { url?: string, isInlineScript: boolean }): string => {
if (event.isInlineScript) return 'Inline'
if (!event.url) return 'Unknown'
if (event.url.includes('.min.js')) return 'Minified'
if (event.url.includes('chunk') || event.url.includes('bundle')) return 'Bundle'
if (event.url.startsWith('chrome-extension://')) return 'Extension'
return 'External'
}
export const formatLocation = (lineNumber?: number, columnNumber?: number): string => {
if (lineNumber !== undefined && columnNumber !== undefined) {
return `${lineNumber}:${columnNumber}`
} else if (lineNumber !== undefined) {
return `Line ${lineNumber}`
}
return '-'
}

View File

@ -0,0 +1,156 @@
import type { TraceEvent } from '../../../types/trace'
import type { JavaScriptEvent, JavaScriptSummary } from '../types/javascriptEvent'
// JavaScript event names we're interested in
const JS_EVENT_NAMES = [
'EvaluateScript',
'v8.compile',
'v8.callFunction',
'v8.newInstance',
'V8.DeoptimizeAllOptimizedCodeWithFunction',
'CpuProfiler::StartProfiling',
'ScriptCatchup'
] as const
export const processJavaScriptEvents = (traceEvents: TraceEvent[]): JavaScriptEvent[] => {
const jsEvents: JavaScriptEvent[] = []
for (const event of traceEvents) {
// Filter for JavaScript-related events
if (!JS_EVENT_NAMES.includes(event.name as any)) {
continue
}
// Skip events without timing data
if (!event.dur && !event.ts) {
continue
}
const args = event.args as any
const eventId = `${event.name}-${event.ts}-${event.tid}`
// Extract URL and script information
let url = ''
let fileName = ''
let lineNumber: number | undefined
let columnNumber: number | undefined
let isInlineScript = false
if (args?.data?.url) {
url = args.data.url
fileName = args.fileName || url
isInlineScript = args.data.notStreamedReason === 'inline script' || !url.startsWith('http')
} else if (args?.fileName) {
fileName = args.fileName
url = fileName
}
if (args?.data?.lineNumber !== undefined) {
lineNumber = args.data.lineNumber
}
if (args?.data?.columnNumber !== undefined) {
columnNumber = args.data.columnNumber
}
const duration = event.dur || 0
const jsEvent: JavaScriptEvent = {
eventId,
name: event.name,
category: event.cat || '',
url,
fileName,
lineNumber,
columnNumber,
isInlineScript,
scriptId: args?.data?.scriptId?.toString(),
sampleTraceId: args?.data?.sampleTraceId?.toString(),
timing: {
startTime: event.ts,
duration,
threadDuration: event.tdur,
durationMs: duration / 1000 // Convert to milliseconds
},
v8Data: {
streamed: args?.data?.streamed,
notStreamedReason: args?.data?.notStreamedReason,
isolate: args?.data?.isolate?.toString(),
executionContextId: args?.data?.executionContextId
},
rawEvent: event
}
jsEvents.push(jsEvent)
}
// Sort by start time
return jsEvents.sort((a, b) => a.timing.startTime - b.timing.startTime)
}
export const calculateJavaScriptSummary = (events: JavaScriptEvent[]): JavaScriptSummary => {
let totalExecutionTime = 0
let totalCompilationTime = 0
let totalEvaluationTime = 0
const eventsByType = {
compilation: 0,
evaluation: 0,
execution: 0,
other: 0
}
const scriptsByUrl = new Map<string, {
url: string
events: JavaScriptEvent[]
totalTime: number
isThirdParty: boolean
}>()
for (const event of events) {
// Categorize by event type
if (event.name === 'v8.compile') {
totalCompilationTime += event.timing.duration
eventsByType.compilation++
} else if (event.name === 'EvaluateScript') {
totalEvaluationTime += event.timing.duration
eventsByType.evaluation++
} else if (event.name.includes('callFunction') || event.name.includes('newInstance')) {
totalExecutionTime += event.timing.duration
eventsByType.execution++
} else {
eventsByType.other++
}
// Group by URL/script
if (event.url) {
const url = event.isInlineScript ? '(inline scripts)' : event.url
const isThirdParty = event.url.startsWith('http') &&
!event.url.includes(window.location?.hostname || 'localhost')
if (!scriptsByUrl.has(url)) {
scriptsByUrl.set(url, {
url,
events: [],
totalTime: 0,
isThirdParty
})
}
const script = scriptsByUrl.get(url)!
script.events.push(event)
script.totalTime += event.timing.duration
}
}
return {
totalEvents: events.length,
totalExecutionTime: totalExecutionTime + totalCompilationTime + totalEvaluationTime,
totalCompilationTime,
totalEvaluationTime,
eventsByType,
scriptsByUrl
}
}

View File

@ -0,0 +1,54 @@
import type { TraceEvent } from '../../../types/trace'
export interface JavaScriptEvent {
eventId: string
name: string // EvaluateScript, v8.compile, v8.callFunction, etc.
category: string
url?: string
fileName?: string
lineNumber?: number
columnNumber?: number
isInlineScript: boolean
scriptId?: string
sampleTraceId?: string
// Timing information
timing: {
startTime: number // ts in microseconds
duration: number // dur in microseconds
threadDuration?: number // tdur in microseconds
durationMs: number // duration in milliseconds for display
}
// V8 specific data
v8Data?: {
streamed?: boolean
notStreamedReason?: string
isolate?: string
executionContextId?: number
}
// Raw trace event for debugging
rawEvent: TraceEvent
}
export interface JavaScriptSummary {
totalEvents: number
totalExecutionTime: number // in microseconds
totalCompilationTime: number // in microseconds
totalEvaluationTime: number // in microseconds
eventsByType: {
compilation: number
evaluation: number
execution: number
other: number
}
scriptsByUrl: Map<string, {
url: string
events: JavaScriptEvent[]
totalTime: number
isThirdParty: boolean
}>
}