Skip to content

Commit

Permalink
feat: network payload capture draft 2 (#18736)
Browse files Browse the repository at this point in the history
requires: PostHog/posthog-js#902

We don't want to support running posthog/network and rrweb/network plugin at the same time. These changes support that change
improve timeline display
allow raw json view for debugging
  • Loading branch information
pauldambra authored Nov 21, 2023
1 parent d868e27 commit a5e2ca1
Show file tree
Hide file tree
Showing 10 changed files with 444 additions and 171 deletions.
Binary file modified frontend/__snapshots__/components-networkrequesttiming--basic.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions frontend/src/loadPostHogJS.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export function loadPostHogJS(): void {
bootstrap: window.POSTHOG_USER_IDENTITY_WITH_FLAGS ? window.POSTHOG_USER_IDENTITY_WITH_FLAGS : {},
opt_in_site_apps: true,
loaded: (posthog) => {
if (posthog.webPerformance) {
posthog.webPerformance._forceAllowLocalhost = true
if (posthog.sessionRecording) {
posthog.sessionRecording._forceAllowLocalhostNetworkCapture = true
}

if (window.IMPERSONATED_SESSION) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export function ItemPerformanceEvent({
expanded,
setExpanded,
}: ItemPerformanceEvent): JSX.Element {
const [activeTab, setActiveTab] = useState<'timings' | 'headers' | 'payload' | 'response_body'>('timings')
const [activeTab, setActiveTab] = useState<'timings' | 'headers' | 'payload' | 'response_body' | 'raw'>('timings')

const bytes = humanizeBytes(item.encoded_body_size || item.decoded_body_size || 0)
const startTime = item.start_time || item.fetch_start || 0
Expand Down Expand Up @@ -176,7 +176,11 @@ export function ItemPerformanceEvent({
return acc
}

if (['response_headers', 'request_headers', 'request_body', 'response_body', 'response_status'].includes(key)) {
if (
['response_headers', 'request_headers', 'request_body', 'response_body', 'response_status', 'raw'].includes(
key
)
) {
return acc
}

Expand Down Expand Up @@ -392,6 +396,17 @@ export function ItemPerformanceEvent({
),
}
: false,
// raw is only available if the feature flag is enabled
// TODO before proper release we should put raw behind its own flag
{
key: 'raw',
label: 'Json',
content: (
<CodeSnippet language={Language.JSON} wrap thing="performance event">
{JSON.stringify(item.raw, null, 2)}
</CodeSnippet>
),
},
]}
/>
</FlaggedFeature>
Expand Down Expand Up @@ -470,6 +485,11 @@ function StatusRow({ item }: { item: PerformanceEvent }): JSX.Element | null {
let statusRow = null
let methodRow = null

let fromDiskCache = false
if (item.transfer_size === 0 && item.response_body && item.response_status && item.response_status < 400) {
fromDiskCache = true
}

if (item.response_status) {
const statusDescription = `${item.response_status} ${friendlyHttpStatus[item.response_status] || ''}`

Expand All @@ -483,7 +503,10 @@ function StatusRow({ item }: { item: PerformanceEvent }): JSX.Element | null {
statusRow = (
<div className="flex gap-4 items-center justify-between overflow-hidden">
<div className="font-semibold">Status code</div>
<LemonTag type={statusType}>{statusDescription}</LemonTag>
<div>
<LemonTag type={statusType}>{statusDescription}</LemonTag>
{fromDiskCache && <span className={'text-muted'}> (from cache)</span>}
</div>
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,30 @@ export function Basic(): JSX.Element {
<NetworkRequestTiming
performanceEvent={
{
connect_end: 9525.599999964237,
connect_start: 9525.599999964237,
// fake an event with every step visible
start_time: 10,
redirect_start: 10,
redirect_end: 20,
fetch_start: 20,
domain_lookup_start: 30,
domain_lookup_end: 40,
connect_start: 40,
secure_connection_start: 45,
connect_end: 50,
request_start: 60,
response_start: 70,
response_end: 80,
load_event_end: 90,
duration: 90,

decoded_body_size: 18260,
domain_lookup_end: 9525.599999964237,
domain_lookup_start: 9525.599999964237,
duration: 935.5,
encoded_body_size: 18260,
entry_type: 'resource',
fetch_start: 9525.599999964237,
initiator_type: 'fetch',
name: 'http://localhost:8000/api/organizations/@current/plugins/repository/',
next_hop_protocol: 'http/1.1',
redirect_end: 0,
redirect_start: 0,
render_blocking_status: 'non-blocking',
request_start: 9803.099999964237,
response_end: 10461.099999964237,
response_start: 10428.399999976158,
response_status: 200,
secure_connection_start: 0,
start_time: 9525.599999964237,
time_origin: '1699990397357',
timestamp: 1699990406882,
transfer_size: 18560,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { getSeriesColor } from 'lib/colors'
import { humanFriendlyMilliseconds } from 'lib/utils'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { useState } from 'react'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
import { SimpleKeyValueList } from 'scenes/session-recordings/player/inspector/components/SimpleKeyValueList'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
Expand Down Expand Up @@ -67,6 +66,8 @@ function colorForSection(section: (typeof perfSections)[number]): string {
}
}

type PerformanceMeasures = Record<string, EventPerformanceMeasure>

/**
* There are defined sections to performance measurement. We may have data for some or all of them
*
Expand Down Expand Up @@ -109,85 +110,114 @@ function colorForSection(section: (typeof perfSections)[number]): string {
*
* see https://nicj.net/resourcetiming-in-practice/
*/
function calculatePerformanceParts(perfEntry: PerformanceEvent): Record<string, EventPerformanceMeasure> {
export function calculatePerformanceParts(perfEntry: PerformanceEvent): PerformanceMeasures {
const performanceParts: Record<string, EventPerformanceMeasure> = {}

if (perfEntry.redirect_start && perfEntry.redirect_end) {
performanceParts['redirect'] = {
start: perfEntry.redirect_start,
end: perfEntry.redirect_end,
color: colorForSection('redirect'),
if (isPresent(perfEntry.redirect_start) && isPresent(perfEntry.redirect_end)) {
if (perfEntry.redirect_end - perfEntry.redirect_start > 0) {
performanceParts['redirect'] = {
start: perfEntry.redirect_start,
end: perfEntry.redirect_end,
color: colorForSection('redirect'),
}
}
}

if (perfEntry.fetch_start && perfEntry.domain_lookup_start) {
performanceParts['app cache'] = {
start: perfEntry.fetch_start,
end: perfEntry.domain_lookup_start,
color: colorForSection('app cache'),
if (isPresent(perfEntry.fetch_start) && isPresent(perfEntry.domain_lookup_start)) {
if (perfEntry.domain_lookup_start - perfEntry.fetch_start > 0) {
performanceParts['app cache'] = {
start: perfEntry.fetch_start,
end: perfEntry.domain_lookup_start,
color: colorForSection('app cache'),
}
}
}

if (perfEntry.domain_lookup_end && perfEntry.domain_lookup_start) {
performanceParts['dns lookup'] = {
start: perfEntry.domain_lookup_start,
end: perfEntry.domain_lookup_end,
color: colorForSection('dns lookup'),
if (isPresent(perfEntry.domain_lookup_end) && isPresent(perfEntry.domain_lookup_start)) {
if (perfEntry.domain_lookup_end - perfEntry.domain_lookup_start > 0) {
performanceParts['dns lookup'] = {
start: perfEntry.domain_lookup_start,
end: perfEntry.domain_lookup_end,
color: colorForSection('dns lookup'),
}
}
}

if (perfEntry.connect_end && perfEntry.connect_start) {
performanceParts['connection time'] = {
start: perfEntry.connect_start,
end: perfEntry.connect_end,
color: colorForSection('connection time'),
}

if (perfEntry.secure_connection_start) {
performanceParts['tls time'] = {
start: perfEntry.secure_connection_start,
if (isPresent(perfEntry.connect_end) && isPresent(perfEntry.connect_start)) {
if (perfEntry.connect_end - perfEntry.connect_start > 0) {
performanceParts['connection time'] = {
start: perfEntry.connect_start,
end: perfEntry.connect_end,
color: colorForSection('tls time'),
reducedHeight: true,
color: colorForSection('connection time'),
}

if (isPresent(perfEntry.secure_connection_start) && perfEntry.secure_connection_start > 0) {
performanceParts['tls time'] = {
start: perfEntry.secure_connection_start,
end: perfEntry.connect_end,
color: colorForSection('tls time'),
reducedHeight: true,
}
}
}
}

if (perfEntry.connect_end && perfEntry.request_start && perfEntry.connect_end !== perfEntry.request_start) {
performanceParts['request queuing time'] = {
start: perfEntry.connect_end,
end: perfEntry.request_start,
color: colorForSection('request queuing time'),
if (
isPresent(perfEntry.connect_end) &&
isPresent(perfEntry.request_start) &&
perfEntry.connect_end !== perfEntry.request_start
) {
if (perfEntry.request_start - perfEntry.connect_end > 0) {
performanceParts['request queuing time'] = {
start: perfEntry.connect_end,
end: perfEntry.request_start,
color: colorForSection('request queuing time'),
}
}
}

if (perfEntry.response_start && perfEntry.request_start) {
performanceParts['waiting for first byte'] = {
start: perfEntry.request_start,
end: perfEntry.response_start,
color: colorForSection('waiting for first byte'),
if (isPresent(perfEntry.response_start) && isPresent(perfEntry.request_start)) {
if (perfEntry.response_start - perfEntry.request_start > 0) {
performanceParts['waiting for first byte'] = {
start: perfEntry.request_start,
end: perfEntry.response_start,
color: colorForSection('waiting for first byte'),
}
}
}

if (perfEntry.response_start && perfEntry.response_end) {
performanceParts['receiving response'] = {
start: perfEntry.response_start,
end: perfEntry.response_end,
color: colorForSection('receiving response'),
if (isPresent(perfEntry.response_start) && isPresent(perfEntry.response_end)) {
if (perfEntry.response_end - perfEntry.response_start > 0) {
// if loading from disk cache then response_start is 0 but fetch_start is not
let start = perfEntry.response_start
if (perfEntry.response_start === 0 && isPresent(perfEntry.fetch_start)) {
start = perfEntry.fetch_start
}
performanceParts['receiving response'] = {
start: start,
end: perfEntry.response_end,
color: colorForSection('receiving response'),
}
}
}

if (perfEntry.response_end && perfEntry.load_event_end) {
performanceParts['document processing'] = {
start: perfEntry.response_end,
end: perfEntry.load_event_end,
color: colorForSection('document processing'),
if (isPresent(perfEntry.response_end) && isPresent(perfEntry.load_event_end)) {
if (perfEntry.load_event_end - perfEntry.response_end > 0) {
performanceParts['document processing'] = {
start: perfEntry.response_end,
end: perfEntry.load_event_end,
color: colorForSection('document processing'),
}
}
}

return performanceParts
}

function percentage(partDuration: number, totalDuration: number, min: number): number {
return Math.min(Math.max(min, (partDuration / totalDuration) * 100), 100)
}

function percentagesWithinEventRange({
partStart,
partEnd,
Expand All @@ -203,20 +233,20 @@ function percentagesWithinEventRange({
const partStartRelativeToTimeline = partStart - rangeStart
const partDuration = partEnd - partStart

const partPercentage = Math.max(0.1, (partDuration / totalDuration) * 100) //less than 0.1% is not visible
const partStartPercentage = (partStartRelativeToTimeline / totalDuration) * 100
const partPercentage = percentage(partDuration, totalDuration, 0.1)
const partStartPercentage = percentage(partStartRelativeToTimeline, totalDuration, 0)
return { startPercentage: `${partStartPercentage}%`, widthPercentage: `${partPercentage}%` }
}

const TimeLineView = ({ performanceEvent }: { performanceEvent: PerformanceEvent }): JSX.Element => {
const TimeLineView = ({ performanceEvent }: { performanceEvent: PerformanceEvent }): JSX.Element | null => {
const rangeStart = performanceEvent.start_time
const rangeEnd = performanceEvent.response_end
const rangeEnd = performanceEvent.load_event_end ? performanceEvent.load_event_end : performanceEvent.response_end
if (typeof rangeStart === 'number' && typeof rangeEnd === 'number') {
const performanceParts = calculatePerformanceParts(performanceEvent)
const timings = calculatePerformanceParts(performanceEvent)
return (
<div className={'font-semibold text-xs'}>
{perfSections.map((section) => {
const matchedSection = performanceParts[section]
const matchedSection = timings[section]
const start = matchedSection?.start
const end = matchedSection?.end
const partDuration = end - start
Expand Down Expand Up @@ -263,7 +293,7 @@ const TimeLineView = ({ performanceEvent }: { performanceEvent: PerformanceEvent
</div>
)
}
return <LemonBanner type={'warning'}>Cannot render performance timeline for this request</LemonBanner>
return null
}

const TableView = ({ performanceEvent }: { performanceEvent: PerformanceEvent }): JSX.Element => {
Expand All @@ -283,11 +313,15 @@ export const NetworkRequestTiming = ({
}): JSX.Element | null => {
const [timelineMode, setTimelineMode] = useState<boolean>(true)

// if timeline view renders null then we fall back to table view
const timelineView = timelineMode ? <TimeLineView performanceEvent={performanceEvent} /> : null

return (
<div className={'flex flex-col space-y-2'}>
<div className={'flex flex-row justify-end'}>
<LemonButton
type={'secondary'}
size={'xsmall'}
status={'stealth'}
onClick={() => setTimelineMode(!timelineMode)}
data-attr={`switch-timing-to-${timelineMode ? 'table' : 'timeline'}-view`}
Expand All @@ -296,11 +330,11 @@ export const NetworkRequestTiming = ({
</LemonButton>
</div>
<LemonDivider dashed={true} />
{timelineMode ? (
<TimeLineView performanceEvent={performanceEvent} />
) : (
<TableView performanceEvent={performanceEvent} />
)}
{timelineMode && timelineView ? timelineView : <TableView performanceEvent={performanceEvent} />}
</div>
)
}

function isPresent(x: number | undefined): x is number {
return typeof x === 'number'
}
Loading

0 comments on commit a5e2ca1

Please sign in to comment.