diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c19ba..7b1a387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +### Added + +- Playwright probes now collect INP (Interaction to Next Paint) as the responsiveness Core Web Vital. INP replaced FID in March 2024 ([web.dev](https://web.dev/blog/inp-cwv-launch)); FID was fully removed from Chrome tools by September 2024. INP is reported as `browser.performance.inp` in OpenTelemetry spans. + +### Deprecated + +- FID (First Input Delay) is still collected as `browser.performance.fid` for backward compatibility but deprecated; use INP for responsiveness monitoring going forward. + ## v0.2.0 Initial open source release. diff --git a/README.md b/README.md index b136895..fe6efa2 100644 --- a/README.md +++ b/README.md @@ -422,6 +422,10 @@ Upright.configure do |config| end ``` +#### Browser performance (Playwright) + +Playwright probes attach browser performance metrics to document spans as `browser.performance.*` (ttfb, fcp, lcp, inp, fid). Responsiveness is reported as **INP** (Interaction to Next Paint). **FID** is still included for backward compatibility but is deprecated in favor of INP. + ## Local Development ### Setup diff --git a/app/models/concerns/upright/playwright/lifecycle.rb b/app/models/concerns/upright/playwright/lifecycle.rb index 2f05b29..3f67e79 100644 --- a/app/models/concerns/upright/playwright/lifecycle.rb +++ b/app/models/concerns/upright/playwright/lifecycle.rb @@ -29,6 +29,7 @@ def with_browser(&block) def with_context(browser, **options, &block) self.context = create_context(browser, **options) + context.add_init_script(script: inp_observer_script) self.page = context.new_page run_callbacks :page_ready yield @@ -42,4 +43,10 @@ def with_context(browser, **options, &block) def create_context(browser, **options) authenticated_context(browser, options) || browser.new_context(userAgent: user_agent, **options) end + + def inp_observer_script + @inp_observer_script ||= File.read( + Upright::Engine.root.join("lib", "upright", "playwright", "inp_observer.js") + ) + end end diff --git a/lib/upright/playwright/collect_performance_metrics.js b/lib/upright/playwright/collect_performance_metrics.js index 34c8769..2536fc2 100644 --- a/lib/upright/playwright/collect_performance_metrics.js +++ b/lib/upright/playwright/collect_performance_metrics.js @@ -27,6 +27,12 @@ metrics.lcp = Math.round(lcpEntries[lcpEntries.length - 1].startTime); } + const inp = window.upright?.inp; + if (inp != null) { + metrics.inp = Math.round(inp); + } + + /** @deprecated Use INP for responsiveness. */ const [fid] = performance.getEntriesByType("first-input"); if (fid) { metrics.fid = Math.round(fid.processingStart - fid.startTime); diff --git a/lib/upright/playwright/inp_observer.js b/lib/upright/playwright/inp_observer.js new file mode 100644 index 0000000..a92d09d --- /dev/null +++ b/lib/upright/playwright/inp_observer.js @@ -0,0 +1,32 @@ +(function() { + if (typeof PerformanceObserver === "undefined" || + typeof PerformanceEventTiming === "undefined" || + !("interactionId" in PerformanceEventTiming.prototype)) { + return; + } + + const interactions = {}; + let worst = 0; + + function updateInp(entry) { + const id = entry.interactionId; + if (!id) return; + const duration = entry.duration; + const prev = interactions[id] || 0; + const max = Math.max(prev, duration); + interactions[id] = max; + if (max > worst) { + worst = max; + if (!window.upright) window.upright = {}; + window.upright.inp = Math.round(worst); + } + } + + try { + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach(updateInp); + }); + observer.observe({ type: "event", buffered: true, durationThreshold: 0 }); + observer.observe({ type: "first-input", buffered: true }); + } catch (e) {} +})(); diff --git a/test/lib/helpers/mock_playwright_helper.rb b/test/lib/helpers/mock_playwright_helper.rb index 172d122..b8033cf 100644 --- a/test/lib/helpers/mock_playwright_helper.rb +++ b/test/lib/helpers/mock_playwright_helper.rb @@ -6,11 +6,16 @@ def new_context(**options) end class MockContext - attr_reader :state + attr_reader :state, :init_script def initialize(state = nil) @state = state @closed = false + @init_script = nil + end + + def add_init_script(script: nil, path: nil) + @init_script = script if script end def new_page = MockPage.new @@ -24,5 +29,7 @@ def goto(url, **options) = nil def url = "https://example.com/" def close = nil def on(event, callback) = nil + def wait_for_load_state(state: nil) = nil + def evaluate(script) = 0 end end diff --git a/test/models/upright/probes/playwright_probe_test.rb b/test/models/upright/probes/playwright_probe_test.rb index 79061dc..5c62ea4 100644 --- a/test/models/upright/probes/playwright_probe_test.rb +++ b/test/models/upright/probes/playwright_probe_test.rb @@ -100,4 +100,32 @@ def check assert_match(/200 DOCUMENT/, log_content) end end + + test "inp observer script is injected into context before page is created" do + Rails.application.stubs(:config_for).with(:playwright).returns(server_url: "http://localhost:53333") + Playwright.stubs(:connect_to_browser_server).yields(MockPlaywrightHelper::MockBrowser.new) + + probe = StaggeredPlaywrightProbe.new + with_env("SITE_SUBDOMAIN" => "ams") { probe.perform_check } + + assert_instance_of MockPlaywrightHelper::MockContext, probe.context + script = probe.context.init_script + assert script, "expected init script to be added" + assert_includes script, "window.upright" + assert_includes script, "inp" + assert_includes script, "PerformanceObserver" + end + + test "browser performance metrics including inp are attached to document span" do + probe = StaggeredPlaywrightProbe.new + probe.page = mock("page") + probe.page.stubs(:evaluate).returns("inp" => 100, "ttfb" => 50, "fcp" => 200) + + span = mock("span") + span.expects(:set_attribute).with("browser.performance.inp", 100) + span.expects(:set_attribute).with("browser.performance.ttfb", 50) + span.expects(:set_attribute).with("browser.performance.fcp", 200) + + probe.send(:add_browser_performance_metrics_to_span, span) + end end