crawlscope 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18717d5f025a5e0dc10b3f5a231b52d788a7ad09fe017b803815dc1cf22b7f11
4
- data.tar.gz: 25f80cac563922d3c34d069c34b6c5521064fb4a74dd98cc54f6bb04e6de283a
3
+ metadata.gz: 4b830667c3f7647baba6ca372e78daa5f4116e2178fc585aff4f387671e71b81
4
+ data.tar.gz: 357a5ceab12f86addda14927ba03be7357f20d301ddeb58b72a87527133653ce
5
5
  SHA512:
6
- metadata.gz: 322dacf831bfa19dd16a6ebf3917e8d8bcaab462b440468945e4e9e07768a42955afe7f418b217594f9654c22490d32124f6fdb95004a20f23508f76bca6d7db
7
- data.tar.gz: b301ad0f2c7c92dd3dc436f7019c62d7958bdd402eb8a887626d938a9bfd085d8d21bdab76a5243f3e2d9684d94ffce554e5108db26995d67b63140fcadbb9e2
6
+ metadata.gz: 954a7f1044ae369e01fc571331acbf0b9ebd47f56d8047d028c6967a85cf5caefa673a3bfa05b782a814d3233f883ea13ca98a3ab961d2e8de47d788804f6158
7
+ data.tar.gz: 8a59e970c7191847a25b855e7342d0ebaab7b881540d41ac78b9b558460ffd9a679fbfff3c54ef78e22f2bd287d8009f9d58961128b9c4b74ac3773ce8dc82dd
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.0] - 2026-07-24
9
+
10
+
11
+ ### Added
12
+
13
+ - report Server-Timing metrics
14
+
15
+ - authenticate timing crawl requests
16
+
17
+
18
+
19
+
20
+ ### Fixed
21
+
22
+ - use portable crawl configuration defaults
23
+
24
+
25
+
8
26
  ## [0.7.2] - 2026-07-23
9
27
 
10
28
 
data/README.md CHANGED
@@ -93,6 +93,11 @@ crawlscope validate --url https://example.com --sitemap https://example.com/site
93
93
 
94
94
  Child sitemap indexes are supported automatically.
95
95
 
96
+ Set `CRAWLSCOPE_PROFILE_TOKEN` through the process environment or your secret
97
+ manager when the site protects diagnostic timing with `X-Profile-Token`.
98
+ Crawlscope intentionally has no `--profile-token` flag because command arguments
99
+ can be exposed through shell history and process listings.
100
+
96
101
  Validation output is grouped for terminal scanning:
97
102
 
98
103
  ```text
@@ -141,10 +146,69 @@ puts result.issues.to_a.map(&:message)
141
146
  - `urls`: sitemap URLs selected for validation
142
147
  - `pages`: fetched page snapshots
143
148
  - `issues`: structured issues with `code`, `severity`, `category`, `url`, and `message`
149
+ - `server_timing_summary`: aggregate response timing data when pages publish it
144
150
 
145
151
  `result.ok?` returns `false` when an error is present. Warnings and notices
146
152
  remain available through `result.issues` without making the result fail.
147
153
 
154
+ ## Server Timing
155
+
156
+ Crawlscope parses the
157
+ [`Server-Timing`](https://www.w3.org/TR/server-timing/) response header for both
158
+ HTTP and browser-rendered crawls. Each page exposes its parsed header through
159
+ `page.server_timing`:
160
+
161
+ ```ruby
162
+ page.server_timing.each do |metric|
163
+ puts [metric.name, metric.duration, metric.description].compact.join(": ")
164
+ end
165
+ ```
166
+
167
+ The validation report adds a `Server Timing` section only when at least one page
168
+ publishes the header. It includes:
169
+
170
+ - header coverage and the number of pages publishing durations
171
+ - sample and page counts with average, p50, p95, and maximum per metric
172
+ - non-duration signals, including cache status and routing descriptions
173
+ - the ten pages with the largest individual metric
174
+ - the number of malformed entries ignored during parsing
175
+
176
+ `dur` values are reported as milliseconds, as recommended by the specification.
177
+ Crawlscope does not add durations together because metrics such as `total`,
178
+ `app`, and `db` may overlap. A page's worst-offender entry is its largest
179
+ individual metric instead.
180
+
181
+ The current HTTP and browser transports expose response headers, not response
182
+ trailers. Metrics published only in a `Server-Timing` trailer are therefore not
183
+ available to Crawlscope.
184
+
185
+ Rails 8 applications can enable `ActionDispatch::ServerTiming` with:
186
+
187
+ ```ruby
188
+ config.server_timing = true
189
+ ```
190
+
191
+ Applications that expose timing only to authenticated diagnostics can set
192
+ `config.profile_token`. When this value is present, Crawlscope sends it as
193
+ `X-Profile-Token` on same-origin sitemap, HTTP, and browser document requests.
194
+ The header survives same-origin redirects but is removed before a cross-origin
195
+ redirect, and browser subresources never receive it. Keep the token in the host
196
+ application's secret store rather than a URL, command argument, report, or
197
+ checked-in configuration.
198
+
199
+ `CRAWLSCOPE_PROFILE_TOKEN` is the portable default for Rails, the standalone
200
+ CLI, Rake tasks, and plain Ruby callers. Rails applications may instead assign
201
+ `config.profile_token` from encrypted credentials when that is their established
202
+ secret-management boundary; explicit configuration takes precedence over the
203
+ environment.
204
+
205
+ New Rails applications enable it in development by default; production remains
206
+ opt-in. Rails publishes the Active Support notification names observed during
207
+ each request and sums repeated events with the same name. The exact metrics
208
+ therefore depend on the application and request, but commonly include controller,
209
+ view, database, and cache instrumentation. Crawlscope accepts Rails' dotted
210
+ metric names and reports each metric independently.
211
+
148
212
  ## Rails Usage
149
213
 
150
214
  Run the install generator after adding the gem:
@@ -162,10 +226,11 @@ Customize the `Crawlscope.configure` block inside the generated initializer:
162
226
 
163
227
  ```ruby
164
228
  Crawlscope.configure do |config|
165
- config.base_url = -> { "https://example.com" }
166
- config.sitemap_path = -> { Rails.public_path.join("sitemap.xml").to_s }
167
- config.site_name = "Example"
168
- config.schema_registry = -> { Crawlscope::SchemaRegistry.default }
229
+ config.base_url = -> { ENV.fetch("CRAWLSCOPE_BASE_URL", "http://localhost:3000") }
230
+ config.sitemap_path = lambda {
231
+ ENV.fetch("SITEMAP", "#{config.base_url.to_s.chomp("/")}/sitemap.xml")
232
+ }
233
+ config.site_name = ENV.fetch("CRAWLSCOPE_SITE_NAME", "Application")
169
234
  end
170
235
  ```
171
236
 
@@ -193,6 +258,7 @@ Available environment overrides:
193
258
 
194
259
  - `URL`
195
260
  - `SITEMAP`
261
+ - `CRAWLSCOPE_PROFILE_TOKEN`
196
262
  - `RULES=metadata,links`
197
263
  - `JS=1` or `RENDERER=browser`
198
264
  - `TIMEOUT=30`
@@ -226,10 +292,10 @@ bundle exec rake 'crawlscope:validate:ldjson[https://example.com/article]'
226
292
 
227
293
  `crawlscope:validate` runs all default sitemap rules: indexability, metadata,
228
294
  structured data, uniqueness, content quality, and links. `URL` is the site
229
- base. Without `SITEMAP`, Crawlscope uses the configured sitemap path, then
230
- falls back to `/sitemap.xml`. With `SITEMAP`, Crawlscope uses `URL` as the site
231
- base and validates URLs from that sitemap. `SITEMAP` may be a full URL or a
232
- local file path.
295
+ base. Without `SITEMAP`, Crawlscope uses the configured sitemap URL, then fetches
296
+ `/sitemap.xml` from `URL` over HTTP. With `SITEMAP`, Crawlscope uses `URL` as
297
+ the site base and validates URLs from that sitemap. `SITEMAP` may be a full URL
298
+ or an explicitly selected local file path.
233
299
 
234
300
  Plain `rake` does not pass `--url` style flags to tasks. Use `URL=...` or the
235
301
  task-argument form above instead.
@@ -4,13 +4,15 @@ require "nokogiri"
4
4
 
5
5
  module Crawlscope
6
6
  class Browser
7
- def initialize(base_url:, timeout_seconds:, network_idle_timeout_seconds:, scroll_page:)
7
+ def initialize(base_url:, timeout_seconds:, network_idle_timeout_seconds:, scroll_page:, profile_token: nil)
8
8
  @base_url = base_url
9
9
  @timeout_seconds = timeout_seconds
10
10
  @network_idle_timeout_seconds = network_idle_timeout_seconds
11
+ @profile_token = profile_token
11
12
  @scroll_page = scroll_page
12
13
  @browser = build_browser
13
14
  @page = @browser.create_page
15
+ configure_profile_requests
14
16
  end
15
17
 
16
18
  def close
@@ -72,6 +74,21 @@ module Crawlscope
72
74
  )
73
75
  end
74
76
 
77
+ def configure_profile_requests
78
+ return if @profile_token.to_s.empty?
79
+
80
+ @page.network.intercept(resource_type: :document)
81
+ @page.on(:request) do |request|
82
+ headers = RequestHeaders.add_profile_token(
83
+ request.headers.dup,
84
+ url: request.url,
85
+ base_url: @base_url,
86
+ profile_token: @profile_token
87
+ )
88
+ request.continue(headers: headers.map { |name, value| {name: name.to_s, value: value.to_s} })
89
+ end
90
+ end
91
+
75
92
  def scroll_for_render
76
93
  @page.evaluate("(function() { if (document.body) { window.scrollTo(0, document.body.scrollHeight); } })()")
77
94
  wait_for_network_idle
@@ -11,7 +11,7 @@ module Crawlscope
11
11
  RENDERERS = %i[http browser].freeze
12
12
  DEFAULT_TIMEOUT_SECONDS = 20
13
13
 
14
- attr_writer :allowed_statuses, :base_url, :browser_factory, :concurrency, :fetch_executor, :network_idle_timeout_seconds, :output, :renderer, :rule_registry, :schema_registry, :scroll_page, :site_name, :sitemap_path, :timeout_seconds
14
+ attr_writer :allowed_statuses, :base_url, :browser_factory, :concurrency, :fetch_executor, :network_idle_timeout_seconds, :output, :profile_token, :renderer, :rule_registry, :schema_registry, :scroll_page, :site_name, :sitemap_path, :timeout_seconds
15
15
 
16
16
  def allowed_statuses
17
17
  value = resolve(@allowed_statuses)
@@ -59,6 +59,10 @@ module Crawlscope
59
59
  value.nil? ? $stdout : value
60
60
  end
61
61
 
62
+ def profile_token
63
+ resolve(@profile_token) || ENV["CRAWLSCOPE_PROFILE_TOKEN"]
64
+ end
65
+
62
66
  def renderer
63
67
  value = resolve(@renderer)
64
68
  normalized_value = value.to_s.strip
@@ -93,6 +97,7 @@ module Crawlscope
93
97
  concurrency: concurrency,
94
98
  fetch_executor: fetch_executor,
95
99
  network_idle_timeout_seconds: network_idle_timeout_seconds,
100
+ profile_token: profile_token,
96
101
  renderer: renderer,
97
102
  timeout_seconds: timeout_seconds,
98
103
  allowed_statuses: allowed_statuses,
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Crawlscope
4
4
  class Crawl
5
- def initialize(base_url:, sitemap_path:, rules:, schema_registry:, browser_factory: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: nil, network_idle_timeout_seconds: Configuration::DEFAULT_BROWSER_NETWORK_IDLE_TIMEOUT_SECONDS, renderer: :http, scroll_page: Configuration::DEFAULT_BROWSER_SCROLL_PAGE, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS, allowed_statuses: Configuration::DEFAULT_ALLOWED_STATUSES)
5
+ def initialize(base_url:, sitemap_path:, rules:, schema_registry:, browser_factory: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: nil, network_idle_timeout_seconds: Configuration::DEFAULT_BROWSER_NETWORK_IDLE_TIMEOUT_SECONDS, profile_token: nil, renderer: :http, scroll_page: Configuration::DEFAULT_BROWSER_SCROLL_PAGE, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS, allowed_statuses: Configuration::DEFAULT_ALLOWED_STATUSES)
6
6
  @base_url = base_url
7
7
  @sitemap_path = sitemap_path
8
8
  @rules = Array(rules)
@@ -10,6 +10,7 @@ module Crawlscope
10
10
  @browser_factory = browser_factory
11
11
  @concurrency = concurrency
12
12
  @network_idle_timeout_seconds = network_idle_timeout_seconds
13
+ @profile_token = profile_token
13
14
  @renderer = renderer.to_sym
14
15
  @fetch_executor = fetch_executor || default_fetch_executor
15
16
  @scroll_page = scroll_page
@@ -49,6 +50,7 @@ module Crawlscope
49
50
  adapter: http_adapter,
50
51
  concurrency: @concurrency,
51
52
  fetch_executor: @fetch_executor,
53
+ profile_token: @profile_token,
52
54
  timeout_seconds: @timeout_seconds
53
55
  ).urls(base_url: @base_url)
54
56
  raise ValidationError, "No URLs found in sitemap: #{@sitemap_path}" if urls.empty?
@@ -61,6 +63,7 @@ module Crawlscope
61
63
  base_url: @base_url,
62
64
  timeout_seconds: @timeout_seconds,
63
65
  network_idle_timeout_seconds: @network_idle_timeout_seconds,
66
+ profile_token: @profile_token,
64
67
  scroll_page: @scroll_page
65
68
  )
66
69
  rescue LoadError => error
@@ -71,7 +74,7 @@ module Crawlscope
71
74
  if @renderer == :browser
72
75
  (@browser_factory || method(:browser)).call
73
76
  else
74
- Http.new(base_url: @base_url, timeout_seconds: @timeout_seconds, adapter: http_adapter)
77
+ Http.new(base_url: @base_url, timeout_seconds: @timeout_seconds, adapter: http_adapter, profile_token: @profile_token)
75
78
  end
76
79
  end
77
80
 
@@ -10,10 +10,11 @@ module Crawlscope
10
10
  MAX_REDIRECTS = 5
11
11
  USER_AGENT = "Mozilla/5.0 (compatible; Crawlscope/1.0)"
12
12
 
13
- def initialize(base_url:, timeout_seconds:, adapter: nil)
13
+ def initialize(base_url:, timeout_seconds:, adapter: nil, profile_token: nil)
14
14
  @base_url = base_url
15
15
  @timeout_seconds = timeout_seconds
16
16
  @adapter = adapter
17
+ @profile_token = profile_token
17
18
  @connections_by_thread = Concurrent::Map.new
18
19
  end
19
20
 
@@ -28,6 +29,12 @@ module Crawlscope
28
29
  def fetch(url)
29
30
  response = connection.get(url) do |request|
30
31
  request.headers["User-Agent"] = USER_AGENT
32
+ RequestHeaders.add_profile_token(
33
+ request.headers,
34
+ url: url,
35
+ base_url: @base_url,
36
+ profile_token: @profile_token
37
+ )
31
38
  end
32
39
 
33
40
  final_url = response.env.url.to_s
@@ -67,7 +74,11 @@ module Crawlscope
67
74
  def connection
68
75
  @connections_by_thread.compute_if_absent(Thread.current.object_id) do
69
76
  Faraday.new do |faraday|
70
- faraday.response :follow_redirects, limit: MAX_REDIRECTS
77
+ faraday.response(
78
+ :follow_redirects,
79
+ limit: MAX_REDIRECTS,
80
+ callback: RequestHeaders.method(:strip_profile_token_on_cross_origin_redirect)
81
+ )
71
82
  faraday.options.timeout = @timeout_seconds
72
83
  faraday.options.open_timeout = @timeout_seconds
73
84
  faraday.adapter @adapter if @adapter
@@ -19,5 +19,13 @@ module Crawlscope
19
19
  def html?
20
20
  !doc.nil?
21
21
  end
22
+
23
+ def header(name)
24
+ headers.find { |key, _value| key.to_s.casecmp?(name.to_s) }&.last
25
+ end
26
+
27
+ def server_timing
28
+ @server_timing ||= ServerTiming.new(header("server-timing"))
29
+ end
22
30
  end
23
31
  end
@@ -19,13 +19,18 @@ module Crawlscope
19
19
 
20
20
  if result.issues.size.zero?
21
21
  @io.puts("Status: OK")
22
- return
22
+ else
23
+ @io.puts("Status: #{status_for(result.issues)}")
24
+ @io.puts("Issues: #{result.issues.size} total (#{severity_summary(result.issues)})")
23
25
  end
24
26
 
25
- @io.puts("Status: #{status_for(result.issues)}")
26
- @io.puts("Issues: #{result.issues.size} total (#{severity_summary(result.issues)})")
27
- @io.puts("")
27
+ ServerTiming::Reporter.new(io: @io).report(
28
+ result.server_timing_summary,
29
+ base_url: result.base_url
30
+ )
31
+ return if result.issues.size.zero?
28
32
 
33
+ @io.puts("")
29
34
  report_summary(result.issues)
30
35
  @io.puts("")
31
36
  report_issue_groups(result.issues, base_url: result.base_url)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Crawlscope
6
+ module RequestHeaders
7
+ PROFILE_TOKEN = "X-Profile-Token"
8
+
9
+ module_function
10
+
11
+ def add_profile_token(headers, url:, base_url:, profile_token:)
12
+ return headers if profile_token.to_s.empty?
13
+ return headers unless same_origin?(url, base_url: base_url)
14
+
15
+ headers[PROFILE_TOKEN] = profile_token
16
+ headers
17
+ end
18
+
19
+ def strip_profile_token_on_cross_origin_redirect(response_env, request_env)
20
+ return if same_origin?(request_env[:url], base_url: response_env[:url])
21
+
22
+ request_env[:request_headers].delete(PROFILE_TOKEN)
23
+ end
24
+
25
+ def same_origin?(url, base_url:)
26
+ url_uri = URI.join(base_url.to_s, url.to_s)
27
+ base_uri = URI.parse(base_url.to_s)
28
+
29
+ [url_uri.scheme, url_uri.host, url_uri.port] == [base_uri.scheme, base_uri.host, base_uri.port]
30
+ rescue URI::Error
31
+ false
32
+ end
33
+ end
34
+ end
@@ -5,5 +5,9 @@ module Crawlscope
5
5
  def ok?
6
6
  issues.none?(&:error?)
7
7
  end
8
+
9
+ def server_timing_summary
10
+ ServerTiming::Summary.new(pages)
11
+ end
8
12
  end
9
13
  end
@@ -44,17 +44,7 @@ module Crawlscope
44
44
  value = @configuration.sitemap_path
45
45
  return value unless value.to_s.strip.empty?
46
46
 
47
- local_path = File.expand_path("public/sitemap.xml", Dir.pwd)
48
- return local_path if local_path_default?(base_url: base_url) && File.exist?(local_path)
49
-
50
47
  "#{base_url.to_s.chomp("/")}/sitemap.xml"
51
48
  end
52
-
53
- def local_path_default?(base_url:)
54
- host = URI.parse(base_url.to_s).host.to_s
55
- ["localhost", "127.0.0.1"].include?(host)
56
- rescue URI::InvalidURIError
57
- false
58
- end
59
49
  end
60
50
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Crawlscope
6
+ class ServerTiming
7
+ class Reporter
8
+ def initialize(io:)
9
+ @io = io
10
+ end
11
+
12
+ def report(summary, base_url:)
13
+ return unless summary.present?
14
+
15
+ @io.puts("")
16
+ @io.puts("Server Timing:")
17
+ @io.puts(
18
+ " Coverage: #{summary.coverage}/#{summary.pages.size} pages " \
19
+ "(#{percentage(summary.coverage, summary.pages.size)}); " \
20
+ "durations on #{summary.duration_pages} #{pluralize(:page, summary.duration_pages)}"
21
+ )
22
+
23
+ report_metrics(summary.metrics)
24
+ report_signals(summary.signals)
25
+ report_worst_pages(summary.worst_pages, base_url: base_url)
26
+ @io.puts(" Ignored malformed entries: #{summary.invalid_count}") if summary.invalid_count.positive?
27
+ end
28
+
29
+ private
30
+
31
+ def report_metrics(metrics)
32
+ return if metrics.empty?
33
+
34
+ @io.puts(" Duration metrics (dur; milliseconds assumed):")
35
+ metrics.each do |metric|
36
+ label = metric[:name]
37
+ label += " #{description(metric[:description])}" if metric[:description]
38
+
39
+ @io.puts(
40
+ " #{label}: " \
41
+ "#{metric[:samples]} #{pluralize(:sample, metric[:samples])} / " \
42
+ "#{metric[:pages]} #{pluralize(:page, metric[:pages])}; " \
43
+ "avg #{duration(metric[:average])}; " \
44
+ "p50 #{duration(metric[:p50])}; " \
45
+ "p95 #{duration(metric[:p95])}; " \
46
+ "max #{duration(metric[:maximum])}"
47
+ )
48
+ end
49
+ end
50
+
51
+ def report_signals(signals)
52
+ return if signals.empty?
53
+
54
+ @io.puts(" Signals:")
55
+ signals.each do |signal|
56
+ label = signal[:name]
57
+ label += " #{description(signal[:description])}" if signal[:description]
58
+
59
+ @io.puts(
60
+ " #{label}: " \
61
+ "#{signal[:samples]} #{pluralize(:sample, signal[:samples])} / " \
62
+ "#{signal[:pages]} #{pluralize(:page, signal[:pages])}"
63
+ )
64
+ end
65
+ end
66
+
67
+ def report_worst_pages(samples, base_url:)
68
+ return if samples.empty?
69
+
70
+ @io.puts(" Worst pages:")
71
+ samples.each do |sample|
72
+ metric = sample[:metric]
73
+ detail = metric.description ? " #{description(metric.description)}" : ""
74
+ @io.puts(
75
+ " #{relative_url(sample[:page].url, base_url: base_url)}: " \
76
+ "#{duration(metric.duration)} (#{metric.name}#{detail})"
77
+ )
78
+ end
79
+ end
80
+
81
+ def duration(value)
82
+ "#{format("%.3f", value).sub(/\.?0+\z/, "")}ms"
83
+ end
84
+
85
+ def description(value)
86
+ value = value.to_s
87
+ value = "#{value[0, 77]}..." if value.length > 80
88
+ value.inspect
89
+ end
90
+
91
+ def percentage(numerator, denominator)
92
+ return "0.0%" if denominator.zero?
93
+
94
+ format("%.1f%%", numerator.fdiv(denominator) * 100)
95
+ end
96
+
97
+ def relative_url(url, base_url:)
98
+ uri = URI.parse(url)
99
+ base_uri = URI.parse(base_url)
100
+ return url unless uri.host == base_uri.host && uri.scheme == base_uri.scheme && uri.port == base_uri.port
101
+
102
+ uri.request_uri
103
+ rescue URI::InvalidURIError
104
+ url
105
+ end
106
+
107
+ def pluralize(word, count)
108
+ (count == 1) ? word.to_s : "#{word}s"
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crawlscope
4
+ class ServerTiming
5
+ class Summary
6
+ WORST_PAGE_LIMIT = 10
7
+
8
+ attr_reader :pages
9
+
10
+ def initialize(pages)
11
+ @pages = pages
12
+ @timings = pages.filter_map do |page|
13
+ timing = page.server_timing
14
+ [page, timing] if timing.present?
15
+ end
16
+ end
17
+
18
+ def present?
19
+ @timings.any?
20
+ end
21
+
22
+ def duration_pages
23
+ @timings.count { |_page, timing| timing.any?(&:timed?) }
24
+ end
25
+
26
+ def invalid_count
27
+ @timings.sum { |_page, timing| timing.invalid_count }
28
+ end
29
+
30
+ def metrics
31
+ timed_metrics
32
+ .group_by { |_page, metric| [metric.name, metric.description] }
33
+ .map { |(name, description), samples| metric_summary(name, description, samples) }
34
+ .sort_by { |metric| [-metric[:p95], -metric[:maximum], metric[:name]] }
35
+ end
36
+
37
+ def signals
38
+ all_metrics
39
+ .reject { |_page, metric| metric.timed? }
40
+ .group_by { |_page, metric| [metric.name, metric.description] }
41
+ .map { |(name, description), samples| signal_summary(name, description, samples) }
42
+ .sort_by { |signal| [-signal[:samples], signal[:name], signal[:description].to_s] }
43
+ end
44
+
45
+ def worst_pages(limit: WORST_PAGE_LIMIT)
46
+ @timings
47
+ .filter_map { |page, timing| worst_metric(page, timing) }
48
+ .min_by(limit) { |sample| [-sample[:metric].duration, sample[:page].url] }
49
+ .sort_by { |sample| [-sample[:metric].duration, sample[:page].url] }
50
+ end
51
+
52
+ def coverage
53
+ @timings.size
54
+ end
55
+
56
+ private
57
+
58
+ def timed_metrics
59
+ all_metrics.select { |_page, metric| metric.timed? }
60
+ end
61
+
62
+ def all_metrics
63
+ @all_metrics ||= @timings.flat_map do |page, timing|
64
+ timing.map { |metric| [page, metric] }
65
+ end
66
+ end
67
+
68
+ def metric_summary(name, description, samples)
69
+ durations = samples.map { |_page, metric| metric.duration }.sort
70
+
71
+ {
72
+ name: name,
73
+ description: description,
74
+ samples: samples.size,
75
+ pages: samples.map(&:first).uniq.size,
76
+ average: durations.sum.fdiv(durations.size),
77
+ p50: percentile(durations, 0.50),
78
+ p95: percentile(durations, 0.95),
79
+ maximum: durations.last
80
+ }
81
+ end
82
+
83
+ def signal_summary(name, description, samples)
84
+ {
85
+ name: name,
86
+ description: description,
87
+ samples: samples.size,
88
+ pages: samples.map(&:first).uniq.size
89
+ }
90
+ end
91
+
92
+ def percentile(values, percentile)
93
+ position = (values.size - 1) * percentile
94
+ lower = values[position.floor]
95
+ upper = values[position.ceil]
96
+
97
+ lower + (upper - lower) * (position - position.floor)
98
+ end
99
+
100
+ def worst_metric(page, timing)
101
+ if (metric = timing.select(&:timed?).max_by(&:duration))
102
+ {page: page, metric: metric}
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end