Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions app/models/concerns/upright/playwright/lifecycle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
6 changes: 6 additions & 0 deletions lib/upright/playwright/collect_performance_metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions lib/upright/playwright/inp_observer.js
Original file line number Diff line number Diff line change
@@ -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) {}
})();
9 changes: 8 additions & 1 deletion test/lib/helpers/mock_playwright_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
28 changes: 28 additions & 0 deletions test/models/upright/probes/playwright_probe_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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