Private/WebsiteNetLog.ps1
|
# Reading a net log: what a page did, from the file a Chromium browser wrote while it loaded it. # # Pure, every function of it: text in, a record of the page out. The Website Kind's Gatherer # hands it the net log of the browser it started; the tests hand it fixtures trimmed from # real net logs. Kept apart from the Kind, as BrowserProfile.ps1 is from the browser Kinds, # because it changes for a different reason: when Chromium writes its net log differently. # Which sources of a net log lie beneath which: a request, the job that finds it a stream, # the stream's jobs, what those connect with, and the sockets and name lookups at the # bottom. The parse follows a source dependency only downwards in this order. Upwards # would lead from a shared socket back to every other request that used it, Edge's own # among them. Names, not numbers: the numbers are the document's (see the constants). $script:WebsiteSourceLayer = @{ URL_REQUEST = 0 HTTP_STREAM_JOB_CONTROLLER = 1 HTTP_STREAM_JOB = 2 TRANSPORT_CONNECT_JOB = 3 TCP_CONNECT_JOB = 3 SSL_CONNECT_JOB = 3 SOCKS_CONNECT_JOB = 3 HTTP_PROXY_CONNECT_JOB = 3 QUIC_SESSION_POOL_DIRECT_JOB = 3 QUIC_SESSION_POOL_PROXY_JOB = 3 QUIC_SESSION_POOL_JOB = 3 HTTP2_SESSION = 3 QUIC_SESSION = 3 SOCKET = 4 HOST_RESOLVER_IMPL_JOB = 4 } function ConvertFrom-WebsiteCertificate { <# .SYNOPSIS Who a certificate is for, who issued it and how long it is valid, from the PEM text a net log holds. Nothing for text that is no certificate. Pure. .DESCRIPTION The names are those of its subject alternative names, where it has them, and else the one in its subject: the names a browser holds the address against. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Pem) $body = ($Pem -replace '-----(BEGIN|END) CERTIFICATE-----', '' -replace '\s', '') if (-not $body) { return $null } try { $certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList (, [Convert]::FromBase64String($body)) # The extension's text is written in the language of Windows ("DNS-Name=" or # "DNS Name="); what stands after each "=" is the name either way. $alternative = @($certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' } | ForEach-Object { [regex]::Matches($_.Format($false), '=\s*([^,\r\n]+)') | ForEach-Object { $_.Groups[1].Value.Trim() } }) if (-not $alternative.Count) { $alternative = @($certificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)) } [pscustomobject]@{ Subject = $certificate.Subject Issuer = $certificate.Issuer NotBefore = $certificate.NotBefore NotAfter = $certificate.NotAfter Names = @($alternative | Where-Object { $_ }) } } catch { $null } } function Get-WebsiteSignIn { <# .SYNOPSIS Whether the page asked for a sign-in, with which schemes, whether the browser was allowed to answer with the User's Windows account, and whether the site turned the answer down. Nothing for a page that asked for none. Pure. .DESCRIPTION The schemes are what the 401 answers offered. The rest is in the request's authentication controller, which the request names as a source dependency: Chromium says there whether it may send the User's own credentials to this site (allows_default_credentials - false where the site is not in the Local Intranet zone or the AuthServerAllowlist policy), and whether the site rejected what it sent. The credentials themselves are never in a net log of the default mode. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Request, [Parameter(Mandatory)][hashtable]$BySource, [Parameter(Mandatory)][hashtable]$TypeName, [Parameter(Mandatory)][hashtable]$SourceName) if (-not $Request.Challenges.Count) { return $null } $allowed = $null $rejected = $false $controllers = @($Request.Dependencies | Where-Object { $SourceName["$($_.type)"] -eq 'HTTP_AUTH_CONTROLLER' } | ForEach-Object { "$($_.id)" } | Select-Object -Unique) foreach ($id in $controllers) { if (-not $BySource.ContainsKey($id)) { continue } foreach ($e in $BySource[$id]) { switch ($TypeName["$($e.type)"]) { 'AUTH_HANDLER_CREATE_RESULT' { $may = $e.params.allows_default_credentials if ($may -eq $true) { $allowed = $true } elseif ($may -eq $false -and $allowed -ne $true) { $allowed = $false } } 'AUTH_HANDLE_CHALLENGE' { if ("$($e.params.authorization_result)" -eq 'reject') { $rejected = $true } } } } } [pscustomobject]@{ PSTypeName = 'Gutcheck.WebsiteSignIn' Schemes = @($Request.Challenges) DefaultCredentials = $allowed Rejected = $rejected } } function Get-WebsiteProxy { <# .SYNOPSIS The proxy the page went through and how the browser found it. Pure. .DESCRIPTION How the browser was told to find one is its proxy configuration, written to the net log whenever it changed: a PAC file (pac_url), automatic detection (WPAD, auto_detect), a fixed server (single_proxy or proxy_per_scheme), or none. The one in force when the page was requested counts. from_system says it came from the User's Windows settings; a configuration without it came from somewhere else, and in a Run - where Gutcheck gives the browser no proxy of its own - that is a policy. Each attempt to fetch a PAC file is kept with where it came from: WPAD, which failing only means there is none, or the PAC address, which failing is a fault. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Events, [Parameter(Mandatory)][hashtable]$BySource, [Parameter(Mandatory)][hashtable]$TypeName, [Parameter(Mandatory)][hashtable]$SourceName, [string]$Begin, [string]$End, [AllowNull()]$Before, [Parameter(Mandatory)][scriptblock]$NetError, [AllowNull()][AllowEmptyString()][string]$Used, [AllowNull()]$ResolveMs, [bool]$SignInAsked ) $config = $null foreach ($e in $Events) { if ($null -eq $e -or $TypeName["$($e.type)"] -ne 'PROXY_CONFIG_CHANGED') { continue } if ($null -ne $Before -and $null -ne $config -and [long]$e.time -gt [long]$Before) { break } $config = $e.params.new_config } $fetches = @(foreach ($id in $BySource.Keys) { $list = $BySource[$id] if ($SourceName["$($list[0].source.type)"] -ne 'PAC_FILE_DECIDER') { continue } $from = $null; $started = $null foreach ($e in $list) { if ($TypeName["$($e.type)"] -ne 'PAC_FILE_DECIDER_FETCH_PAC_SCRIPT') { continue } if ("$($e.phase)" -eq $Begin) { $from = "$($e.params.source)"; $started = [long]$e.time } elseif ("$($e.phase)" -eq $End) { [pscustomobject]@{ Source = $from Wpad = $from -match '^WPAD' Error = & $NetError $e.params Ms = $(if ($null -ne $started) { [long]$e.time - $started }) } } } }) if ($null -eq $config -and -not $Used -and -not $fetches.Count) { return $null } $pac = "$($config.pac_url)".Trim() $server = @(@($config.single_proxy) + @($config.proxy_per_scheme) | Where-Object { $_ } | ForEach-Object { "$_".Trim('[', ']', ' ') }) -join '; ' $mode = 'Direct' if ($pac) { $mode = 'Script' } elseif ($config.auto_detect -eq $true) { $mode = 'Detect' } elseif ($server) { $mode = 'Server' } [pscustomobject]@{ PSTypeName = 'Gutcheck.WebsiteProxy' Mode = $mode AutoDetect = [bool]($config.auto_detect -eq $true) PacUrl = $pac Server = $server FromSystem = $(if ($null -eq $config) { $null } else { [bool]($config.from_system -eq $true) }) Used = $Used ResolveMs = $ResolveMs PacFetches = $fetches SignInAsked = $SignInAsked } } function Read-WebsiteNetLogDocument { <# .SYNOPSIS A net log's text as a document, mended where it was cut off. Pure. .DESCRIPTION A browser writes its net log one event a line and closes the brackets when it exits. One that was stopped - which is what the time limit does to a page that hangs - leaves the last line half written and no closing brackets. Such a log is read up to its last whole event, and says that it was cut off. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text) $none = [pscustomobject]@{ Document = $null; CutOff = $false } if (-not "$Text".Trim()) { return $none } try { return [pscustomobject]@{ Document = ($Text | ConvertFrom-Json -ErrorAction Stop); CutOff = $false } } catch { } $lines = $Text -split "`r?`n" $head = -1 for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^\s*"events"\s*:\s*\[\s*$') { $head = $i; break } } if ($head -lt 1) { return $none } $kept = New-Object System.Collections.Generic.List[string] for ($i = $head + 1; $i -lt $lines.Count; $i++) { $line = $lines[$i].Trim() if (-not $line) { continue } if (-not $line.StartsWith('{')) { break } $kept.Add($line.TrimEnd(',')) } # The last line is the one the stop may have cut. Read on its own it is cheap to try, # where trying the whole document again costs seconds on a large one. while ($kept.Count) { try { $null = $kept[$kept.Count - 1] | ConvertFrom-Json -ErrorAction Stop; break } catch { $kept.RemoveAt($kept.Count - 1) } } $mended = (($lines[0..$head]) -join "`n") + "`n" + ($kept -join ",`n") + "`n]}" try { return [pscustomobject]@{ Document = ($mended | ConvertFrom-Json -ErrorAction Stop); CutOff = $true } } catch { return $none } } function ConvertTo-WebsiteHostName { <# .SYNOPSIS The host in the forms a net log writes it - "https://host", "host:443", a whole URL - as the bare lowercase name. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Value) $text = "$Value".Trim() if (-not $text) { return '' } $uri = $null if ($text -match '^[a-z][a-z0-9+.-]*://' -and [uri]::TryCreate($text, [UriKind]::Absolute, [ref]$uri)) { return $uri.Host.ToLowerInvariant() } ($text -replace ':\d+$', '').Trim('[', ']').ToLowerInvariant() } function ConvertFrom-WebsiteNetLog { <# .SYNOPSIS Turns a net log a browser wrote into what the page did: whether it was answered, where it ended, and what happened at every host it needed. Pure. .DESCRIPTION Which requests are the page's: the navigation to the address (Chromium marks it "main frame") and every request the page started that carries the page's network isolation key - the site of the page, which Chromium partitions everything a page loads by. Edge's own requests carry its own key or none, or say that no origin started them, and are left out however many of them there are. The net log has no dependency from a navigation to what the page then loads; this key is the link. What happened at a host is read through the source dependencies, from each of the page's requests down to the stream it got, the connection that stream used and the name lookup and socket under it - downwards only, see WebsiteSourceLayer. Racing attempts are normal (Chromium tries QUIC and TCP at once and drops the loser), so a host counts as resolved, connected or encrypted when any attempt was, and as failed at that step only when none was. Event types, source types, phases and net error names are read from the document's own constants: their numbers change between browser versions. Problem is 'Unreadable' for a document that is no net log, and 'NoPage' for one in which the page was never requested. .PARAMETER Url The address the browser was asked to load. Picks the navigation where there were several; without it, the first is the page. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyString()][string]$Text, [AllowNull()][AllowEmptyString()][string]$Url ) $read = Read-WebsiteNetLogDocument -Text $Text $record = [ordered]@{ PSTypeName = 'Gutcheck.WebsitePage' Problem = $null CutOff = [bool]$read.CutOff CaptureMode = $null Url = $null FinalUrl = $null Redirects = @() Status = $null Finished = $false Error = $null ElapsedMs = $null Requests = @() Hosts = @() PageHosts = @() SignIn = $null Proxy = $null Timings = $null } $document = $read.Document $constants = Get-DataProperty $document 'constants' $events = @(Get-DataProperty $document 'events') $eventType = Get-DataProperty $constants 'logEventTypes' $source = Get-DataProperty $constants 'logSourceType' $phase = Get-DataProperty $constants 'logEventPhase' if (-not $eventType -or -not $source -or -not $phase -or -not $events.Count) { $record.Problem = 'Unreadable' return [pscustomobject]$record } $record.CaptureMode = "$(Get-DataProperty $constants 'logCaptureMode')" # Number to name, for the three tables read by name. $typeName = @{}; foreach ($p in $eventType.PSObject.Properties) { $typeName["$($p.Value)"] = $p.Name } $sourceName = @{}; foreach ($p in $source.PSObject.Properties) { $sourceName["$($p.Value)"] = $p.Name } $errorName = @{} $errors = Get-DataProperty $constants 'netError' if ($errors) { foreach ($p in $errors.PSObject.Properties) { $errorName["$($p.Value)"] = $p.Name } } $begin = "$(Get-DataProperty $phase 'PHASE_BEGIN')" $end = "$(Get-DataProperty $phase 'PHASE_END')" $netError = { param($Params) $code = $Params.net_error if ($null -eq $code) { return $null } $name = $errorName["$code"] [pscustomobject]@{ Name = $(if ($name) { $name } else { $null }); Code = [long]$code } } # Every event under its source, in the order written, which is the order things happened. $bySource = @{} foreach ($e in $events) { if ($null -eq $e -or $null -eq $e.source) { continue } $id = "$($e.source.id)" if (-not $bySource.ContainsKey($id)) { $bySource[$id] = New-Object System.Collections.Generic.List[psobject] } $bySource[$id].Add($e) } # The requests, each read once. $requests = New-Object System.Collections.Generic.List[psobject] $typed = $false foreach ($id in $bySource.Keys) { $list = $bySource[$id] if ($sourceName["$($list[0].source.type)"] -ne 'URL_REQUEST') { continue } $r = [ordered]@{ Id = $id; Urls = New-Object System.Collections.Generic.List[string]; Sites = @(); Initiator = $null MainFrame = $false; Status = $null; Redirects = New-Object System.Collections.Generic.List[psobject] Error = $null; Finished = $false; Start = $null; End = $null; Dependencies = New-Object System.Collections.Generic.List[psobject] Challenges = New-Object System.Collections.Generic.List[string]; ProxyAuth = $false; ReadBegin = $null; WaitMs = $null } foreach ($e in $list) { $name = $typeName["$($e.type)"] $at = "$($e.phase)" $p = $e.params switch ($name) { 'REQUEST_ALIVE' { if ($at -eq $begin) { $r.Start = [long]$e.time $first = "$($p.url)" if ($first -and -not $r.Urls.Count) { $r.Urls.Add($first) } } elseif ($at -eq $end) { $r.Finished = $true $r.End = [long]$e.time $failed = & $netError $p if ($failed) { $r.Error = $failed } } } 'URL_REQUEST_START_JOB' { if ($at -eq $begin) { $hop = "$($p.url)" if ($hop -and (-not $r.Urls.Count -or $r.Urls[$r.Urls.Count - 1] -ne $hop)) { $r.Urls.Add($hop) } $kind = $p.request_type if ($null -ne $kind) { $typed = $true } if ("$kind" -eq 'main frame') { $r.MainFrame = $true } $key = "$($p.network_isolation_key)".Trim() if ($key) { $r.Sites = @($r.Sites) + @(($key -split '\s+')[0]) } if (-not $r.Initiator) { $r.Initiator = "$($p.initiator)" } $r.Status = $null } elseif ($at -eq $end) { $failed = & $netError $p if ($failed -and -not $r.Error) { $r.Error = $failed } } } { $_ -in 'HTTP_TRANSACTION_READ_RESPONSE_HEADERS', 'URL_REQUEST_FAKE_RESPONSE_HEADERS_CREATED' } { $line = "$(@($p.headers)[0])" if ($line -match '^HTTP/\S+\s+(?<code>\d{3})') { $r.Status = [int]$Matches['code'] } if ($r.Status -eq 407) { $r.ProxyAuth = $true } # Waiting for the answer: from the request sent to its head arriving. if ($null -ne $r.ReadBegin) { $r.WaitMs = [long]$e.time - $r.ReadBegin; $r.ReadBegin = $null } # What a 401 asks for: the schemes it offers, first word of each challenge. if ($r.Status -eq 401) { foreach ($header in @($p.headers)) { if ("$header" -match '^(?i)www-authenticate:\s*(?<scheme>[^\s,]+)') { $scheme = $Matches['scheme'] if (-not $r.Challenges.Contains($scheme)) { $r.Challenges.Add($scheme) } } } } } 'HTTP_TRANSACTION_READ_HEADERS' { if ($at -eq $begin) { $r.ReadBegin = [long]$e.time } } 'URL_REQUEST_REDIRECTED' { $current = $null if ($r.Urls.Count) { $current = $r.Urls[$r.Urls.Count - 1] } $r.Redirects.Add([pscustomobject]@{ Url = $current; Status = $r.Status; Location = "$($p.location)" }) } } $dependency = $p.source_dependency if ($dependency) { $r.Dependencies.Add($dependency) } } if ($r.Urls.Count) { $requests.Add([pscustomobject]$r) } } # The page: the navigation to the address asked for. A document from a browser that # does not say which request is a navigation falls back to the address alone. $wanted = '' $uri = $null if ($Url -and [uri]::TryCreate($Url.Trim(), [UriKind]::Absolute, [ref]$uri)) { $wanted = $uri.AbsoluteUri } $same = { param($Request) $u = $null $wanted -and [uri]::TryCreate($Request.Urls[0], [UriKind]::Absolute, [ref]$u) -and $u.AbsoluteUri -eq $wanted } $ordered = @($requests | Sort-Object { $_.Start }) $main = $null if ($typed) { $navigations = @($ordered | Where-Object { $_.MainFrame }) $main = @($navigations | Where-Object { & $same $_ }) + @($navigations) | Select-Object -First 1 } else { $main = @($ordered | Where-Object { & $same $_ }) | Select-Object -First 1 } if (-not $main) { $record.Problem = 'NoPage' return [pscustomobject]$record } # The page's site under each address it passed through, and its origins for a request # from a browser that writes no isolation key. $sites = @($main.Sites | Where-Object { $_ } | Select-Object -Unique) $origins = @($main.Urls | ForEach-Object { $u = $null if ([uri]::TryCreate($_, [UriKind]::Absolute, [ref]$u)) { $u.GetLeftPart([UriPartial]::Authority) } }) # The key alone is not enough: Edge sends its own services requests under the page's # key while the page is open (its form-filling service, observed 2026-10-14), and # those say they were started by no origin. What the page asked for names the page. $mine = @($ordered | Where-Object { $_ -eq $main -or (@($_.Sites | Where-Object { $sites -contains $_ }).Count -and $_.Initiator -ne 'not an origin') -or (-not @($_.Sites).Count -and $origins -contains $_.Initiator) }) # What happened at each host, walked from the page's requests. A source is walked once # for the whole page: a connection two requests shared is one connection. $hosts = [ordered]@{} $hostOf = { param([string]$Name) if (-not $hosts.Contains($Name)) { $hosts[$Name] = [ordered]@{ Host = $Name; Requests = 0; Unfinished = 0 Failures = New-Object System.Collections.Generic.List[psobject] Addresses = New-Object System.Collections.Generic.List[string] Resolved = $null; ResolveError = $null; Connected = $null; ConnectError = $null RemoteAddress = $null; Tls = $null; TlsError = $null; Certificate = $null DnsMs = $null; ConnectMs = $null; TlsMs = $null } } $hosts[$Name] } $layerOf = { param($Type) $n = $sourceName["$Type"] if ($n -and $script:WebsiteSourceLayer.ContainsKey($n)) { return $script:WebsiteSourceLayer[$n] } 99 } $addAddress = { param($Facts, [string]$Address) $a = ($Address -replace ':\d+$', '').Trim('[', ']') if ($a -and -not $Facts.Addresses.Contains($a)) { $Facts.Addresses.Add($a) } } $walked = @{} # What the walk learns of the proxy: how long the page's requests waited for one to be # chosen, which one the page itself was given, and whether a proxy asked for a sign-in. $resolveBegin = @{} # When each name lookup, connection and handshake began, so that the one that # succeeded first can be timed: the page went on with that one. $stepBegin = @{} $timeStep = { param($Facts, [string]$Name, $Key, $At) if (-not $stepBegin.ContainsKey($Key)) { return } $ms = [long]$At - $stepBegin[$Key] if ($null -eq $Facts[$Name] -or $ms -lt $Facts[$Name]) { $Facts[$Name] = $ms } } $resolveMs = $null $used = $null $proxySignIn = @($mine | Where-Object { $_.ProxyAuth }).Count -gt 0 foreach ($request in $mine) { $queue = New-Object System.Collections.Generic.Queue[psobject] foreach ($d in $request.Dependencies) { $queue.Enqueue([pscustomobject]@{ Id = "$($d.id)"; Type = "$($d.type)"; Host = ''; Direct = $true }) } while ($queue.Count) { $item = $queue.Dequeue() if ($walked.ContainsKey($item.Id) -or -not $bySource.ContainsKey($item.Id)) { continue } $mineLayer = & $layerOf $item.Type if ($mineLayer -eq 0 -or $mineLayer -ge 99) { continue } $walked[$item.Id] = $true $context = $item.Host $direct = $item.Direct $lookup = '' foreach ($e in $bySource[$item.Id]) { $name = $typeName["$($e.type)"] $at = "$($e.phase)" $p = $e.params switch ($name) { 'HTTP_STREAM_JOB' { if ($at -eq $begin) { $context = ConvertTo-WebsiteHostName "$($p.destination)" } } 'PROXY_RESOLUTION_SERVICE' { if ($at -eq $begin) { $resolveBegin[$item.Id] = [long]$e.time } elseif ($at -eq $end -and $resolveBegin.ContainsKey($item.Id)) { $ms = [long]$e.time - $resolveBegin[$item.Id] if ($null -eq $resolveMs -or $ms -gt $resolveMs) { $resolveMs = $ms } } } 'PROXY_RESOLUTION_SERVICE_RESOLVED_PROXY_LIST' { if ($request -eq $main -and -not $used) { $used = "$($p.proxy_info)" } } 'HTTP_TRANSACTION_READ_TUNNEL_RESPONSE_HEADERS' { if ("$(@($p.headers)[0])" -match '^HTTP/\S+\s+407\b') { $proxySignIn = $true } } 'HTTP_STREAM_JOB_CONTROLLER_PROXY_SERVER_RESOLVED' { $chain = "$($p.proxy_chain)" $direct = (-not $chain) -or $chain -match 'direct://' } { $_ -in 'HOST_RESOLVER_MANAGER_JOB', 'HOST_RESOLVER_MANAGER_REQUEST' } { if ($at -eq $begin) { $lookup = ConvertTo-WebsiteHostName "$($p.host)"; $stepBegin["$($item.Id)|dns"] = [long]$e.time } elseif ($at -eq $end -and $context -and $lookup -eq $context) { $facts = & $hostOf $context $failed = & $netError $p if ($failed) { if ($facts.Resolved -ne $true) { $facts.Resolved = $false; $facts.ResolveError = $failed } } else { $facts.Resolved = $true; $facts.ResolveError = $null; & $timeStep $facts 'DnsMs' "$($item.Id)|dns" $e.time } } } 'HOST_RESOLVER_DNS_TASK' { if ($at -eq $end -and $context -and $lookup -eq $context) { $facts = & $hostOf $context foreach ($result in @($p.results)) { foreach ($endpoint in @($result.endpoints)) { & $addAddress $facts "$($endpoint.address)" } } } } 'TCP_CONNECT' { if ($context -and $direct) { $facts = & $hostOf $context if ($at -eq $begin) { $stepBegin["$($item.Id)|tcp"] = [long]$e.time foreach ($a in @($p.address_list)) { & $addAddress $facts "$a" } } elseif ($at -eq $end) { $failed = & $netError $p if ($failed) { if ($facts.Connected -ne $true) { $facts.Connected = $false; $facts.ConnectError = $failed } } else { $facts.Connected = $true; $facts.ConnectError = $null & $timeStep $facts 'ConnectMs' "$($item.Id)|tcp" $e.time $remote = "$($p.remote_address)" if ($remote -and -not $facts.RemoteAddress) { $facts.RemoteAddress = $remote } # A name the page had cached is not looked up again, and # a connection to it is the proof it resolved. if ($facts.Resolved -ne $true) { $facts.Resolved = $true; $facts.ResolveError = $null } } } } } 'SSL_CERTIFICATES_RECEIVED' { if ($context) { $facts = & $hostOf $context if (-not $facts.Certificate) { $facts.Certificate = ConvertFrom-WebsiteCertificate (@($p.certificates)[0]) } } } 'SSL_CONNECT' { if ($at -eq $begin) { $stepBegin["$($item.Id)|tls"] = [long]$e.time } if ($at -eq $end -and $context) { $facts = & $hostOf $context $failed = & $netError $p if ($failed) { if (-not $facts.Tls) { $facts.TlsError = $failed } } else { $version = "$($p.version)" if ($version) { $facts.Tls = $version; $facts.TlsError = $null; & $timeStep $facts 'TlsMs' "$($item.Id)|tls" $e.time } } } } } $dependency = $p.source_dependency if ($dependency -and (& $layerOf "$($dependency.type)") -gt $mineLayer) { $queue.Enqueue([pscustomobject]@{ Id = "$($dependency.id)"; Type = "$($dependency.type)"; Host = $context; Direct = $direct }) } } } } # The requests themselves, counted at every host they passed through - a redirect to a # sign-in host is a request to it - and judged at the host they ended at. $origin = [long]$main.Start $record.Requests = @(foreach ($request in $mine) { $last = $request.Urls[$request.Urls.Count - 1] $name = ConvertTo-WebsiteHostName $last foreach ($passed in @($request.Urls | ForEach-Object { ConvertTo-WebsiteHostName $_ } | Select-Object -Unique)) { (& $hostOf $passed).Requests++ } # Each request that ended in an error, with that error, as the net log says it. # Which of them count as failures is the Judge's to say: some are the browser's # own doing. $facts = & $hostOf $name if (-not $request.Finished) { $facts.Unfinished++ } elseif ($request.Error) { $facts.Failures.Add($request.Error) } [pscustomobject]@{ PSTypeName = 'Gutcheck.WebsiteRequest' Url = $last Host = $name Status = $request.Status Error = $request.Error Finished = $request.Finished StartMs = $(if ($null -ne $request.Start) { $request.Start - $origin }) EndMs = $(if ($null -ne $request.End) { $request.End - $origin }) } }) $record.Url = $main.Urls[0] $record.FinalUrl = $main.Urls[$main.Urls.Count - 1] $record.Redirects = @($main.Redirects) $record.Status = $main.Status $record.Finished = $main.Finished $record.Error = $main.Error $record.PageHosts = @($main.Urls | ForEach-Object { ConvertTo-WebsiteHostName $_ } | Select-Object -Unique) $record.SignIn = Get-WebsiteSignIn -Request $main -BySource $bySource -TypeName $typeName -SourceName $sourceName $record.Proxy = Get-WebsiteProxy -Events $events -BySource $bySource -TypeName $typeName -SourceName $sourceName ` -Begin $begin -End $end -Before $main.Start -NetError $netError -Used $used -ResolveMs $resolveMs -SignInAsked $proxySignIn if ($main.Finished -and $null -ne $main.End -and $null -ne $main.Start) { $record.ElapsedMs = $main.End - $main.Start } $record.Hosts = @(foreach ($name in $hosts.Keys) { # Every host the walk reached, those with no request of their own too (an attempt # the browser raced and dropped): Requests says which. $facts = $hosts[$name] $facts['PSTypeName'] = 'Gutcheck.WebsiteHost' $facts.Failures = @($facts.Failures) $facts.Addresses = @($facts.Addresses) [pscustomobject]$facts }) # The page's own timings: each step at the host it ended at, the wait for its answer, # and the whole load to the last of its requests that finished. $finalFacts = $null $finalHost = ConvertTo-WebsiteHostName $record.FinalUrl if ($hosts.Contains($finalHost)) { $finalFacts = $hosts[$finalHost] } $ends = @($mine | Where-Object { $_.Finished -and $null -ne $_.End } | ForEach-Object { $_.End - $origin }) $record.Timings = [pscustomobject]@{ PSTypeName = 'Gutcheck.WebsiteTimings' DnsMs = $(if ($finalFacts) { $finalFacts['DnsMs'] }) ConnectMs = $(if ($finalFacts) { $finalFacts['ConnectMs'] }) TlsMs = $(if ($finalFacts) { $finalFacts['TlsMs'] }) FirstByteMs = $main.WaitMs LoadMs = $(if ($ends.Count) { ($ends | Measure-Object -Maximum).Maximum }) } [pscustomobject]$record } |