├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ └── check-web-vitals-js.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── icons
├── default128w.png
├── default16w.png
├── default256w.png
├── default48w.png
├── default512w.png
├── fast128w.png
├── fast16w.png
├── fast256w.png
├── fast48w.png
├── fast512w.png
├── icon128.png
├── icon16.png
├── icon19.png
├── icon48.png
├── slow128w-3.png
├── slow128w-cls.png
├── slow128w-fid.png
├── slow128w-inp.png
├── slow128w-lcp.png
├── slow128w.png
├── slow16w.png
├── slow256w.png
├── slow48w.png
├── slow512w.png
├── vitals128w.png
├── vitals256w.png
├── vitals48w.png
└── vitals512w.png
├── manifest.json
├── media
├── cwv-extension-badge.png
├── cwv-extension-console.png
├── cwv-extension-drilldown-2.png
├── cwv-extension-drilldown.png
├── cwv-extension-overlay.png
├── cwv-extension-screenshot.png
├── devtools-fcp.png
├── devtools-field-data.png
├── devtools-interaction-log.png
├── devtools-interaction-phases.png
├── devtools-layout-shift-log.png
├── devtools-lcp-attribution.png
├── devtools-lcp-phases.png
├── devtools-live-metrics.png
├── devtools-loaf-attribution.png
├── devtools-panel.png
└── devtools-ttfb.png
├── package-lock.json
├── package.json
├── service_worker.js
├── src
├── browser_action
│ ├── browser_action.html
│ ├── chrome.js
│ ├── core.css
│ ├── crux.js
│ ├── lodash-debounce-custom.js
│ ├── metric.js
│ ├── on-each-interaction.js
│ ├── popup.html
│ ├── popup.js
│ ├── viewer.css
│ ├── vitals.js
│ └── web-vitals.js
├── inject
│ └── inject.js
└── options
│ ├── options.html
│ └── options.js
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | indent_size = 2
9 | indent_style = space
10 | trim_trailing_whitespace = true
11 |
12 | [*.js]
13 | insert_final_newline = true
14 |
15 | [*.md]
16 | trim_trailing_whitespace = false
17 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | src/browser_action/web-vitals.js
2 | src/browser_action/popup.js
3 | src/browser_action/lodash-debounce-custom.js
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | 'env': {
3 | 'browser': true,
4 | 'es6': true,
5 | },
6 | 'extends': [
7 | 'google',
8 | ],
9 | 'globals': {
10 | 'Atomics': 'readonly',
11 | 'SharedArrayBuffer': 'readonly',
12 | },
13 | 'parser': 'babel-eslint',
14 | 'parserOptions': {
15 | 'ecmaVersion': 2018,
16 | 'sourceType': 'module',
17 | 'allowImportExportEverywhere': true
18 | },
19 | 'rules': {
20 | 'max-len': ["error", { "code": 150 }]
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Something is not working as expected
4 | labels:
5 | ---
6 |
7 | **Before you start**
8 | Please take a look at the [FAQ](https://github.com/GoogleChrome/web-vitals-extension#faq) as well as the already opened issues! If nothing fits your problem, go ahead and fill out the following template:
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior.
15 |
16 | **Expected behavior**
17 | A clear and concise description of what you expected to happen.
18 |
19 | **Version:**
20 |
21 | - OS w/ version: [e.g. iOS 13]
22 | - Browser w/ version [e.g. Chrome 81]
23 |
24 | **Additional context, screenshots, screencasts**
25 | Add any other context about the problem here.
26 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | labels:
5 | ---
6 |
7 | **Is your feature request related to a problem? Please describe.**
8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
9 |
10 | **Describe the solution you'd like**
11 | A clear and concise description of what you want to happen.
12 |
13 | **Additional context**
14 | Add any other context or screenshots about the feature request here.
15 |
--------------------------------------------------------------------------------
/.github/workflows/check-web-vitals-js.yml:
--------------------------------------------------------------------------------
1 | name: Test Web Vitals script matches node
2 | on:
3 | workflow_dispatch:
4 | pull_request:
5 | paths:
6 | - 'package.json'
7 | - 'package-lock.json'
8 | push:
9 | branches:
10 | - main
11 | jobs:
12 | install:
13 | name: Install and test
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout branch
17 | uses: actions/checkout@v3
18 | - name: Setup Node.js for use with actions
19 | uses: actions/setup-node@v3
20 | - name: Install dependencies
21 | run: npm install
22 | - name: Check web-vitals matches
23 | run: diff node_modules/web-vitals/dist/web-vitals.attribution.js src/browser_action/web-vitals.js
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | # ignore node_modules contents
3 | **/node_modules/*
4 | # don't ignore web-vitals
5 | !**/node_modules/web-vitals/
6 | # do ignore web-vitals contents
7 | **/node_modules/web-vitals/*
8 | # don't ignore web-vitals/dist
9 | !**/node_modules/web-vitals/dist/
10 | # do ignore web-vitals/dist contents
11 | **/node_modules/web-vitals/dist/*
12 | # don't ignore web-vitals.min.js
13 | !**/node_modules/web-vitals/dist/web-vitals.es5.min.js
14 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to Contribute
2 |
3 | We'd love to accept your patches and contributions to this project. There are
4 | just a few small guidelines you need to follow.
5 |
6 | ## Contributor License Agreement
7 |
8 | Contributions to this project must be accompanied by a Contributor License
9 | Agreement. You (or your employer) retain the copyright to your contribution;
10 | this simply gives us permission to use and redistribute your contributions as
11 | part of the project. Head over to to see
12 | your current agreements on file or to sign a new one.
13 |
14 | You generally only need to submit a CLA once, so if you've already submitted one
15 | (even if it was for a different project), you probably don't need to do it
16 | again.
17 |
18 | ## Code reviews
19 |
20 | All submissions, including submissions by project members, require review. We
21 | use GitHub pull requests for this purpose. Consult
22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
23 | information on using pull requests.
24 |
25 | ## Community Guidelines
26 |
27 | This project follows [Google's Open Source Community
28 | Guidelines](https://opensource.google.com/conduct/).
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright 2020 Google LLC
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > [!WARNING]
2 | > The Chrome team has been working hard to bring the best of the Web Vitals extension directly into the DevTools Performance panel. As of Chrome version 132, which became stable on January 7, 2025, we have finally ended support for the extension and encourage all users to [switch to DevTools](#devtools). Be aware that extension updates will stop and features may break without notice. [Learn more](https://developer.chrome.com/blog/web-vitals-extension)
3 |
4 | # Web Vitals Chrome Extension
5 | *A Chrome extension to measure metrics for a healthy site*
6 |
7 |
8 |
9 | This extension measures the three [Core Web Vitals](https://web.dev/articles/vitals) metrics in a way that matches how they're measured by Chrome and reported to other Google tools (e.g. [Chrome User Experience Report](https://developer.chrome.com/docs/crux), [Page Speed Insights](https://developers.google.com/speed/pagespeed/insights/), [Search Console](https://search.google.com/search-console/about)).
10 |
11 | It supports all of the [Core Web Vitals](https://web.dev/articles/vitals/#core-web-vitals) and leverages the [web-vitals](https://github.com/GoogleChrome/web-vitals) library under the hood to capture:
12 |
13 | * [Largest Contentful Paint](https://web.dev/articles/lcp)
14 | * [Cumulative Layout Shift](https://web.dev/articles/cls)
15 | * [Interaction to Next Paint](https://web.dev/articles/inp)
16 |
17 | It also supports the diagnostic metrics:
18 | * [Time to First Byte](https://web.dev/articles/ttfb)
19 | * [First Contentful Paint](https://web.dev/articles/fcp)
20 |
21 |
✅ Migrating to DevTools
22 |
23 | Now that the Web Vitals extension has reached "end of life" stage, the Chrome team has stopped maintaining it. All users are strongly encouraged to start using the comparable functionality built into the DevTools Performance panel instead.
24 |
25 | The Performance panel is a much more powerful debugging tool than the extension alone. That said, there may be some functional or ergonomic features of the extensions still missing from DevTools. If there's a critical feature of the extension blocking your migration to DevTools, please +1 the relevant feature request in the [hotlist](https://goo.gle/devtools-live-cwv-hotlist) or [file a new issue](https://issues.chromium.org/issues/new?component=1457310&template=1946799&type=feature_request&priority=P2&pli=1&title=Live%20Metrics) to help get it prioritized.
26 |
27 | 
28 |
29 | Refer to the [official DevTools Performance panel documentation](https://developer.chrome.com/docs/devtools/performance/overview) for information about using each feature. As of Chrome 132 in January 2025, here is a quick summary of the extension features supported by DevTools:
30 |
31 | #### Live metrics
32 |
33 | When you open the Performance panel, the view defaults to showing a live look at your local Core Web Vitals metrics.
34 |
35 | 
36 |
37 | #### Field data
38 |
39 | Use the "Field data" configuration flow to show field data from CrUX next to your local metrics. URL and origin-level data is available for both desktop and mobile.
40 |
41 | 
42 |
43 | #### LCP element attribution
44 |
45 | You can find a reference to the element responsible for the LCP metric at the bottom of the LCP section.
46 |
47 | 
48 |
49 | #### LCP phases
50 |
51 | Hover over the LCP metric values to display a card with more information about your local and field LCP performance, including a breakdown of your local LCP phases: TTFB, load delay, load duration, and render delay.
52 |
53 | 
54 |
55 | #### Interaction log
56 |
57 | Every interaction that get counted towards the INP metric will be added to the interaction log at the bottom of the panel.
58 |
59 | 
60 |
61 | #### Interaction phases
62 |
63 | Phases for each interaction (including the one responsible for INP) are available by expanding the entry in the interaction log. Supports input delay, processing duration, and presentation delay.
64 |
65 | 
66 |
67 | #### LoAF attribution
68 |
69 | From the expanded interaction log, the "Local duration (ms)" heading will be clickable if there is LoAF attribution data available. Clicking it will log the data to the Console panel.
70 |
71 | 
72 |
73 | #### Layout shift log
74 |
75 | Adjacent to the interaction log is the layout shift log, which groups coincidental layout shifts into clusters and assigns a score to the cluster. The worst cluster corresponding to the page's CLS score is linked from the CLS section.
76 |
77 | 
78 |
79 | #### TTFB
80 |
81 | TTFB is available as a phase of LCP on the live metrics view as well as the trace view under the "LCP by phase" insight.
82 |
83 | 
84 |
85 | #### FCP
86 |
87 | FCP is shown as a marker in the trace view.
88 |
89 | 
90 |
91 |
⚠️ Self-maintenance instructions
92 |
93 | If you're unable to migrate your workflow to DevTools for any reason, please let us know by following the instructions in the previous section to file an issue. Until your issue gets resolved, you may prefer to continue using the extension using a local copy.
94 |
95 | To get started, follow the instructions below to [install the extension from source](#install-master).
96 |
97 | To continue using field data powered by the CrUX API, you'll need to provision a new API key tied to a personal Google Cloud project. You can acquire a new key by visiting the [CrUX API docs](https://developer.chrome.com/docs/crux/api#APIKey). It doesn't cost anything to use the API, but rate limiting restrictions will apply.
98 |
99 | To stay up to date with the [web-vitals.js library](https://github.com/GoogleChrome/web-vitals), periodically run the following command:
100 |
101 | ```sh
102 | npm update web-vitals --save
103 | ```
104 |
105 |
Install from main
106 |
107 | **Google Chrome**
108 | 1. Download this repo as a [ZIP file from GitHub](https://github.com/googlechrome/web-vitals-extension/archive/main.zip).
109 | 1. Unzip the file and you should have a folder named `web-vitals-extension-main`.
110 | 1. In Chrome go to the extensions page (`chrome://extensions`).
111 | 1. Enable Developer Mode.
112 | 1. Drag the `web-vitals-extension-main` folder anywhere on the page to import it (do not delete the folder afterwards).
113 |
114 | ## Usage
115 |
116 | ### Ambient badge
117 |
118 |
119 |
120 | The Ambient Badge helps check if a page passing the Core Web Vitals thresholds.
121 |
122 | Once installed, the extension will display a disabled state badge icon until you navigate to a URL. At this point it will update the badge to green, amber or red depending on whether the URL passes the Core Web Vitals metrics thresholds.
123 |
124 | The badge has a number of states:
125 |
126 | * Disabled - gray square
127 | * Good - green circle
128 | * One or more metrics needs improvement - amber square
129 | * One or more metrics poor - red triangle
130 |
131 | If one or more metrics are failing, the badge will animate the values of these metrics (this animation can be turned off in the options screen).
132 |
133 | ### Detailed drill-down
134 |
135 |
136 |
137 | Clicking the Ambient badge icon will allow you to drill in to the individual metric values. In this mode, the extension will also say if a metric requires a user action.
138 |
139 | For example, Interaction to Next Paint requires a real interaction (e.g click/tap) with the page and will be in a `Waiting for input...` state until this is the case. We recommend consulting the web.dev documentation for [LCP](https://web.dev/articles/lcp), [CLS](https://web.dev/articles/cls), and [INP](https://web.dev/articles/inp) to get an understanding of when metric values settle.
140 |
141 | The popup combines your local Core Web Vitals experiences with real-user data from the field via the [Chrome UX Report](https://developer.chrome.com/docs/crux) (CrUX) [API](https://developer.chrome.com/docs/crux/api). This integration gives you contextual insights to help you understand how similar your individual experiences are to other desktop users on the same page. We've also added a new option to "Compare local experiences to phone field data" instead, if needed. Note that CrUX data may not be available for some pages, in which case we try to load field data for the origin as a whole.
142 |
143 |
144 |
145 | ### Overlay
146 |
147 |
148 |
149 | The overlay displays a Heads up display (HUD) which overlays your page. It is useful if you need a persistent view of your Core Web Vitals metrics during development. To enable the overlay:
150 |
151 | * Right-click on the Ambient badge and go to Options.
152 | * Check `Display HUD overlay` and click 'Save'
153 | * Reload the tab for the URL you wish to test. The overlay should now be present.
154 |
155 | ### Console logs
156 |
157 |
158 |
159 | The console logging feature of the Web Vitals extension provides a diagnostic view of all supported metrics. To enable console logs:
160 |
161 | * Right-click on the Ambient badge and go to Options.
162 | * Check `Console logging` and click 'Save'
163 | * Open the Console panel in DevTools and filter for `Web Vitals`
164 |
165 | To filter out unneeded metrics, prepend a minus sign to the metric name. For example, set the filter to `Web Vitals Extension -CLS -LCP` to filter out CLS and LCP diagnostic info.
166 |
167 | Diagnostic info for each metric is logged as a console group prepended by the extension name, `[Web Vitals Extension]`, meaning that you will need to click this line in order to toggle the group open and closed.
168 |
169 | The kinds of diagnostic info varies per metric. For example, the LCP info includes:
170 |
171 | * A reference to the LCP element
172 | * A table of [LCP sub-part metrics](https://web.dev/articles/optimize-lcp#lcp_breakdown)
173 | * An optional warning if the tab was [loaded in the background](https://web.dev/articles/lcp#lcp-background)
174 | * The full attribution object from [web-vitals](https://github.com/GoogleChrome/web-vitals#attribution)
175 |
176 |
177 | ### User Timings
178 |
179 | For some metrics (LCP and INP) the breakdowns can be saved to User Timing marks, using `performance.measure` which are then [viewable in DevTools Performance traces](https://developer.chrome.com/docs/devtools/performance-insights/#timings).
180 |
181 | For the other metrics, Chrome DevTools normally provides sufficient information so additional breakdowns are not necessary.
182 |
183 | ## Contributing
184 |
185 | Contributions to this project are welcome in the form of pull requests or issues. See [CONTRIBUTING.md](/CONTRIBUTING.md) for further details.
186 |
187 | If your feedback is related to how we measure metrics, please file an issue against [web-vitals](https://github.com/GoogleChrome/web-vitals) directly.
188 |
189 | ### How is the extension code structured?
190 |
191 | * `src/browser_action/vitals.js`: Script that leverages WebVitals.js to collect metrics and broadcast metric changes for badging and the HUD. Provides an overall score of the metrics that can be used for badging.
192 | * `src/bg/background.js`: Performs badge icon updates using data provided by vitals.js. Passes along
193 | data to `popup.js` in order to display the more detailed local metrics summary.
194 | * `src/browser_action/popup.js`: Content Script that handles rendering detailed metrics reports in the pop-up window displayed when clicking the badge icon.
195 | * `src/options/options.js`: Options UI (saved configuration) for advanced features like the HUD Overlay
196 |
197 | ## FAQ
198 |
199 | **Who is the primary audience for this extension?**
200 |
201 | The primary audience for this extension is developers who would like instant feedback on how their pages are doing on the Core Web Vitals metrics during development on a desktop machine.
202 |
203 | **How should I interpret the metrics numbers reported by this tool?**
204 |
205 | This extension reports metrics for your desktop or laptop machine. In many cases this hardware will be significantly faster than that of the median mobile phone your users may have. For this reason, it is strongly recommended that you test using tools like [Lighthouse](https://developers.google.com/web/tools/lighthouse/) and on real mobile hardware (e.g via [WebPageTest](https://webpagetest.org/easy)) to ensure all your users there have a positive experience.
206 |
207 | **What actions can I take to improve my Core Web Vitals?**
208 |
209 | We are making available a set of guides for optimizing each of the Core Web Vitals metrics if you find your page is not passing a particular threshold:
210 |
211 | * [Optimize CLS](https://web.dev/articles/optimize-cls)
212 | * [Optimize LCP](https://web.dev/articles/optimize-lcp)
213 | * [Optimize INP](https://web.dev/articles/optimize-inp)
214 | * [Optimize TTFB](https://web.dev/articles/optimize-ttfb)
215 |
216 | Lighthouse also includes additional actionability audits for these metrics.
217 |
218 | We envision users will use the extension for instant feedback on metrics (for their desktop machine) but will then go and do a Lighthouse audit for (1) a diagnostic view of how these metrics look on a median mobile device and (2) specifically what you can do to improve.
219 |
220 | ## License
221 |
222 | [Apache 2.0](/LICENSE)
223 |
--------------------------------------------------------------------------------
/icons/default128w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/default128w.png
--------------------------------------------------------------------------------
/icons/default16w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/default16w.png
--------------------------------------------------------------------------------
/icons/default256w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/default256w.png
--------------------------------------------------------------------------------
/icons/default48w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/default48w.png
--------------------------------------------------------------------------------
/icons/default512w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/default512w.png
--------------------------------------------------------------------------------
/icons/fast128w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/fast128w.png
--------------------------------------------------------------------------------
/icons/fast16w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/fast16w.png
--------------------------------------------------------------------------------
/icons/fast256w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/fast256w.png
--------------------------------------------------------------------------------
/icons/fast48w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/fast48w.png
--------------------------------------------------------------------------------
/icons/fast512w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/fast512w.png
--------------------------------------------------------------------------------
/icons/icon128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/icon128.png
--------------------------------------------------------------------------------
/icons/icon16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/icon16.png
--------------------------------------------------------------------------------
/icons/icon19.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/icon19.png
--------------------------------------------------------------------------------
/icons/icon48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/icon48.png
--------------------------------------------------------------------------------
/icons/slow128w-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w-3.png
--------------------------------------------------------------------------------
/icons/slow128w-cls.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w-cls.png
--------------------------------------------------------------------------------
/icons/slow128w-fid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w-fid.png
--------------------------------------------------------------------------------
/icons/slow128w-inp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w-inp.png
--------------------------------------------------------------------------------
/icons/slow128w-lcp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w-lcp.png
--------------------------------------------------------------------------------
/icons/slow128w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w.png
--------------------------------------------------------------------------------
/icons/slow16w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow16w.png
--------------------------------------------------------------------------------
/icons/slow256w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow256w.png
--------------------------------------------------------------------------------
/icons/slow48w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow48w.png
--------------------------------------------------------------------------------
/icons/slow512w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow512w.png
--------------------------------------------------------------------------------
/icons/vitals128w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/vitals128w.png
--------------------------------------------------------------------------------
/icons/vitals256w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/vitals256w.png
--------------------------------------------------------------------------------
/icons/vitals48w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/vitals48w.png
--------------------------------------------------------------------------------
/icons/vitals512w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/vitals512w.png
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Web Vitals",
3 | "version": "1.6.0",
4 | "manifest_version": 3,
5 | "description": "Measure metrics for a healthy site",
6 | "homepage_url": "https://web.dev/articles/vitals",
7 | "icons": {
8 | "128": "icons/vitals128w.png",
9 | "256": "icons/vitals256w.png",
10 | "512": "icons/vitals512w.png"
11 | },
12 | "action": {
13 | "default_icon": "icons/default256w.png",
14 | "default_title": "Web Vitals",
15 | "default_popup": "src/browser_action/popup.html"
16 | },
17 | "options_page": "src/options/options.html",
18 | "host_permissions": ["*://*/*"],
19 | "permissions": ["tabs", "storage", "activeTab", "scripting"],
20 | "web_accessible_resources": [
21 | {
22 | "resources": [
23 | "src/browser_action/viewer.css",
24 | "src/browser_action/web-vitals.js",
25 | "src/browser_action/on-each-interaction.js"
26 | ],
27 | "matches": ["*://*/*"]
28 | }
29 | ],
30 | "content_scripts": [
31 | {
32 | "matches": ["*://*/*"],
33 | "css": ["src/browser_action/viewer.css"],
34 | "js": ["src/browser_action/lodash-debounce-custom.js"]
35 | }
36 | ],
37 | "background": {
38 | "service_worker": "service_worker.js",
39 | "type": "module"
40 | },
41 | "content_security_policy": {
42 | "extension_pages": "default-src 'self'; connect-src https://chromeuxreport.googleapis.com;"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/media/cwv-extension-badge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-badge.png
--------------------------------------------------------------------------------
/media/cwv-extension-console.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-console.png
--------------------------------------------------------------------------------
/media/cwv-extension-drilldown-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-drilldown-2.png
--------------------------------------------------------------------------------
/media/cwv-extension-drilldown.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-drilldown.png
--------------------------------------------------------------------------------
/media/cwv-extension-overlay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-overlay.png
--------------------------------------------------------------------------------
/media/cwv-extension-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-screenshot.png
--------------------------------------------------------------------------------
/media/devtools-fcp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-fcp.png
--------------------------------------------------------------------------------
/media/devtools-field-data.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-field-data.png
--------------------------------------------------------------------------------
/media/devtools-interaction-log.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-interaction-log.png
--------------------------------------------------------------------------------
/media/devtools-interaction-phases.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-interaction-phases.png
--------------------------------------------------------------------------------
/media/devtools-layout-shift-log.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-layout-shift-log.png
--------------------------------------------------------------------------------
/media/devtools-lcp-attribution.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-lcp-attribution.png
--------------------------------------------------------------------------------
/media/devtools-lcp-phases.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-lcp-phases.png
--------------------------------------------------------------------------------
/media/devtools-live-metrics.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-live-metrics.png
--------------------------------------------------------------------------------
/media/devtools-loaf-attribution.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-loaf-attribution.png
--------------------------------------------------------------------------------
/media/devtools-panel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-panel.png
--------------------------------------------------------------------------------
/media/devtools-ttfb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-ttfb.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web-vitals-extension",
3 | "version": "1.6.0",
4 | "description": "Instant Web Vitals metrics",
5 | "main": "src/browser_action/vitals.js",
6 | "repository": "https://github.com/GoogleChrome/web-vitals-extension",
7 | "homepage": "https://web.dev/articles/vitals",
8 | "bugs": {
9 | "url": "https://github.com/GoogleChrome/web-vitals-extension/issues"
10 | },
11 | "author": "Google Chrome",
12 | "license": "Apache-2.0",
13 | "private": true,
14 | "scripts": {
15 | "lint": "npx eslint src --fix",
16 | "build": "npm install; cp node_modules/web-vitals/dist/web-vitals.attribution.js src/browser_action/web-vitals.js"
17 | },
18 | "devDependencies": {
19 | "babel-eslint": "^10.1.0",
20 | "eslint": "^6.8.0",
21 | "eslint-config-google": "^0.14.0"
22 | },
23 | "dependencies": {
24 | "web-vitals": "^4.2.3"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/service_worker.js:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2020 Google Inc. All Rights Reserved.
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 | http://www.apache.org/licenses/LICENSE-2.0
7 | Unless required by applicable law or agreed to in writing, software
8 | distributed under the License is distributed on an "AS IS" BASIS,
9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10 | See the License for the specific language governing permissions and
11 | limitations under the License.
12 | */
13 |
14 | const ONE_DAY_MS = 24 * 60 * 60 * 1000;
15 |
16 | // Get the optionsNoBadgeAnimation value
17 | // Actual default is false but lets set to true initially in case sync storage
18 | // is slow so users don't experience any animation initially.
19 | let optionsNoBadgeAnimation = true;
20 | chrome.storage.sync.get({
21 | noBadgeAnimation: false
22 | }, ({noBadgeAnimation}) => {
23 | optionsNoBadgeAnimation = noBadgeAnimation;
24 | });
25 |
26 | /**
27 | * Hash the URL and return a numeric hash as a String to be used as the key
28 | * @param {String} str
29 | * @return {String} hash
30 | */
31 | function hashCode(str) {
32 | let hash = 0;
33 | if (str.length === 0) {
34 | return '';
35 | }
36 | for (let i = 0; i < str.length; i++) {
37 | const char = str.charCodeAt(i);
38 | hash = (hash << 5) - hash + char;
39 | hash = hash & hash; // Convert to 32bit integer
40 | }
41 | return hash.toString();
42 | }
43 |
44 | function setExtensionErrorMessage(tab, errorMsg) {
45 | const key = hashCode(tab.url);
46 | chrome.storage.local.set({
47 | [key]: {
48 | type: 'error',
49 | message: errorMsg,
50 | timestamp: new Date().toISOString()
51 | }
52 | })
53 | }
54 |
55 | /**
56 | * Call vitals.js to begin collecting local WebVitals metrics.
57 | * This will cause the content script to emit an event that kicks off the badging flow.
58 | * @param {Number} tabId
59 | */
60 | function getWebVitals(tabId) {
61 | chrome.scripting.executeScript({
62 | target: { tabId: tabId },
63 | files: ['src/browser_action/vitals.js'],
64 | }, (result) => {
65 | // Catch errors such as "This page cannot be scripted due
66 | // to an ExtensionsSettings policy."
67 | const error = chrome.runtime.lastError;
68 | if (error && error.message) {
69 | console.log(error.message);
70 | chrome.tabs.get(tabId, (tab) => setExtensionErrorMessage(tab, error.message));
71 | }
72 | });
73 | }
74 |
75 | // User has navigated to a new URL in a tab
76 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
77 | const tabIdKey = tabId.toString();
78 |
79 | if (tab.active) {
80 | chrome.storage.local.set({[tabIdKey]: false});
81 | } else {
82 | chrome.storage.local.set({[tabIdKey]: true}); // tab was loaded in background
83 | }
84 |
85 | if (
86 | changeInfo.status == 'complete' &&
87 | tab.url.startsWith('http') &&
88 | tab.active
89 | ) {
90 | getWebVitals(tabId);
91 | }
92 | });
93 |
94 | // User has made a new or existing tab visible
95 | chrome.tabs.onActivated.addListener(({tabId, windowId}) => {
96 | getWebVitals(tabId);
97 | });
98 |
99 |
100 | /**
101 | *
102 | * Update the badge icon based on the overall WebVitals
103 | * pass rate (i.e good = green icon, poor = red icon)
104 | * @param {String} badgeCategory - GOOD or POOR
105 | * @param {Number} tabid
106 | */
107 | function badgeOverallPerf(badgeCategory, tabid) {
108 | chrome.tabs.query({
109 | active: true,
110 | currentWindow: true,
111 | }, function(tabs) {
112 | const currentTab = tabid || tabs[0].id;
113 |
114 | switch (badgeCategory) {
115 | case 'POOR':
116 | chrome.action.setIcon({
117 | path: '../../icons/slow128w.png',
118 | tabId: currentTab,
119 | });
120 | chrome.action.setBadgeText({
121 | text: '',
122 | tabId: currentTab,
123 | });
124 | break;
125 | case 'GOOD':
126 | chrome.action.setIcon({
127 | path: '../../icons/fast128w.png',
128 | tabId: currentTab,
129 | });
130 | break;
131 | default:
132 | chrome.action.setIcon({
133 | path: '../../icons/default128w.png',
134 | tabId: currentTab,
135 | });
136 | chrome.action.setBadgeText({
137 | text: '',
138 | tabId: currentTab,
139 | });
140 | break;
141 | }
142 | });
143 | }
144 |
145 | /**
146 | *
147 | * Badge the icon for a specific metric
148 | * @param {String} metric
149 | * @param {Number} value
150 | * @param {Number} tabid
151 | */
152 | function badgeMetric(metric, value, rating, tabid) {
153 | chrome.tabs.query({
154 | active: true,
155 | currentWindow: true,
156 | }, function(tabs) {
157 | const currentTab = tabid || tabs[0].id;
158 | const bgColor = '#000';
159 |
160 | // If URL is overall failing the thresholds, only show
161 | // a red badge for metrics actually failing (issues/22)
162 | if (metric === 'lcp' && rating === 'good') {
163 | return;
164 | }
165 | if (metric === 'cls' && rating === 'good') {
166 | return;
167 | }
168 | if (metric === 'inp' && (rating === 'good' || rating === null)) {
169 | return;
170 | }
171 |
172 | switch (metric) {
173 | case 'lcp':
174 | chrome.action.setIcon({
175 | path: '../../icons/slow128w-lcp.png',
176 | tabId: currentTab,
177 | });
178 | chrome.action.setBadgeBackgroundColor({
179 | color: bgColor,
180 | tabId: currentTab,
181 | });
182 | chrome.action.setBadgeText({
183 | text: (value / 1000).toFixed(2),
184 | tabId: currentTab,
185 | });
186 | break;
187 | case 'cls':
188 | chrome.action.setIcon({
189 | path: '../../icons/slow128w-cls.png',
190 | tabId: currentTab,
191 | });
192 | chrome.action.setBadgeBackgroundColor({
193 | color: bgColor,
194 | tabId: currentTab,
195 | });
196 | chrome.action.setBadgeText({
197 | text: (value).toFixed(2),
198 | tabId: currentTab,
199 | });
200 | break;
201 | case 'inp':
202 | chrome.action.setIcon({
203 | path: '../../icons/slow128w-inp.png',
204 | tabId: currentTab,
205 | });
206 | chrome.action.setBadgeBackgroundColor({
207 | color: bgColor,
208 | tabId: currentTab,
209 | });
210 | chrome.action.setBadgeText({
211 | text: value.toFixed(0),
212 | tabId: currentTab,
213 | });
214 | break;
215 | default:
216 | chrome.action.setIcon({
217 | path: '../../icons/default128w.png',
218 | tabId: currentTab,
219 | });
220 | chrome.action.setBadgeBackgroundColor({
221 | color: '',
222 | tabId: currentTab,
223 | });
224 | chrome.action.setBadgeText({
225 | text: '',
226 | tabId: currentTab,
227 | });
228 | break;
229 | }
230 | });
231 | }
232 |
233 | /**
234 | * Wait ms milliseconds
235 | *
236 | * @param {Number} ms
237 | * @return {Promise}
238 | */
239 | function wait(ms) {
240 | return new Promise((r) => setTimeout(r, ms));
241 | }
242 |
243 | /**
244 | * @param {number} tabId
245 | * @return {Promise}
246 | */
247 | async function doesTabExist(tabId) {
248 | try {
249 | await chrome.tabs.get(tabId);
250 | return true;
251 | } catch (_) {
252 | return false;
253 | }
254 | }
255 |
256 | /** @type {number} */
257 | let globalAnimationId = 0;
258 | /** @type {Map} */
259 | const animationsByTabId = new Map();
260 |
261 |
262 | /**
263 | * Animate badges between pass/fail -> each failing metric.
264 | * We track each animation by tabId so that we can handle "cancellation" of the animation on new information.
265 | * @param {Object} request
266 | * @param {Number} tabId
267 | */
268 | async function animateBadges(request, tabId) {
269 | const animationId = globalAnimationId;
270 | animationsByTabId.set(tabId, animationId);
271 | globalAnimationId++;
272 |
273 | const delay = 2000;
274 | // First badge overall perf
275 | badgeOverallPerf(request.passesAllThresholds, tabId);
276 |
277 | // If perf is poor, animate the sequence
278 | if (request.passesAllThresholds === 'POOR') {
279 |
280 | // However, if user has turned this off, then leave it off.
281 | // Note: if optionsNoBadgeAnimation is flipped, it won't start (or stop)
282 | // animating immediately until a status change or page reload to avoid
283 | // having to check continually. This is similar to HUD and console.logs
284 | // not appearing immediately.
285 | if (optionsNoBadgeAnimation) {
286 | return;
287 | }
288 |
289 | await wait(delay);
290 | if (animationsByTabId.get(tabId) !== animationId) return;
291 | badgeMetric('lcp', request.metrics.lcp.value, request.metrics.lcp.rating, tabId);
292 |
293 | await wait(delay);
294 | if (animationsByTabId.get(tabId) !== animationId) return;
295 | badgeMetric('inp', request.metrics.inp.value, request.metrics.inp.rating, tabId);
296 |
297 | await wait(delay);
298 | if (animationsByTabId.get(tabId) !== animationId) return;
299 | badgeMetric('cls', request.metrics.cls.value, request.metrics.cls.rating, tabId);
300 |
301 | // Loop the animation if no new information came in while we animated.
302 | await wait(delay);
303 | if (animationsByTabId.get(tabId) !== animationId) return;
304 | // Stop animating if the tab is gone
305 | if (!(await doesTabExist(tabId))) return;
306 | animateBadges(request, tabId);
307 | }
308 | }
309 |
310 | // message from content script
311 | chrome.runtime.onConnect.addListener((port) => {
312 | port.onMessage.addListener((request) => {
313 | if (request.passesAllThresholds !== undefined) {
314 | // e.g passesAllThresholds === 'GOOD' => green badge
315 | animateBadges(request, port.sender.tab.id);
316 | // Store latest metrics locally only.
317 | // The popup will load the metric values from this storage.
318 | if (port.sender.tab.url) {
319 | const key = hashCode(port.sender.tab.url);
320 | chrome.storage.local.set({[key]: request.metrics});
321 | }
322 | // send TabId to content script
323 | port.postMessage({tabId: port.sender.tab.id});
324 | }
325 | });
326 | });
327 |
328 | // Listen for changes to noBadgeAnimation option
329 | function logStorageChange(changes, area) {
330 | if (area === 'sync' && 'noBadgeAnimation' in changes) {
331 | optionsNoBadgeAnimation = changes.noBadgeAnimation.newValue;
332 | }
333 | }
334 | chrome.storage.onChanged.addListener(logStorageChange);
335 |
336 |
337 | async function clearOldCacheBackground(tabId) {
338 | if (!(await doesTabExist(tabId))) {
339 | chrome.storage.local.remove([tabId]);
340 | };
341 | }
342 |
343 | async function clearOldCache() {
344 | const now = Date.now();
345 | chrome.storage.local.get(null, results => {
346 | for (let hash in results) {
347 | if (results[hash].timestamp) {
348 | // If it's a timestamp, check if still valid
349 | const timestamp = new Date(results[hash].timestamp).getTime();
350 | if (now - timestamp > ONE_DAY_MS ) {
351 | console.log('Removing', hash, results[hash]);
352 | chrome.storage.local.remove([hash]);
353 | }
354 | } else if (typeof results[hash] === 'boolean') {
355 | // If it's a tab background status, clear that separately
356 | clearOldCacheBackground(hash);
357 | }
358 | }
359 | });
360 |
361 | }
362 |
363 | self.addEventListener('activate', _ => {
364 | clearOldCache();
365 | });
366 |
367 |
--------------------------------------------------------------------------------
/src/browser_action/browser_action.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/browser_action/chrome.js:
--------------------------------------------------------------------------------
1 | function hashCode(str) {
2 | let hash = 0;
3 | if (str.length == 0) {
4 | return '';
5 | }
6 | for (let i = 0; i < str.length; i++) {
7 | const char = str.charCodeAt(i);
8 | hash = (hash << 5) - hash + char;
9 | // Convert to 32bit integer
10 | hash = hash & hash;
11 | }
12 | return hash.toString();
13 | }
14 |
15 | export function loadLocalMetrics() {
16 | return new Promise(resolve => {
17 | chrome.tabs.query({active: true, currentWindow: true}, tabs => {
18 | const thisTab = tabs[0];
19 |
20 | // Retrieve the stored latest metrics
21 | if (thisTab.url) {
22 | const key = hashCode(thisTab.url);
23 | const loadedInBackgroundKey = thisTab.id.toString();
24 |
25 | let tabLoadedInBackground = false;
26 |
27 | chrome.storage.local.get(loadedInBackgroundKey, result => {
28 | tabLoadedInBackground = result[loadedInBackgroundKey];
29 | });
30 |
31 | chrome.storage.local.get(key, result => {
32 | if (result[key] !== undefined) {
33 | if (result[key].type && result[key].type === 'error') {
34 | // It's an error message, not a metrics object
35 | resolve({error: result[key].message});
36 | } else {
37 | resolve({
38 | metrics: result[key],
39 | background: tabLoadedInBackground
40 | });
41 | }
42 | } else {
43 | resolve({error: `Storage empty for key ${key}: ${result}`});
44 | }
45 | });
46 | }
47 | });
48 | });
49 | }
50 |
51 | export function getOptions() {
52 | return new Promise(resolve => {
53 | chrome.storage.sync.get({preferPhoneField: false}, resolve);
54 | });
55 | }
56 |
57 | export function getURL() {
58 | return new Promise(resolve => {
59 | chrome.tabs.query({active: true, lastFocusedWindow: true}, tabs => {
60 | let url = tabs[0].url;
61 | resolve(url);
62 | });
63 | });
64 | }
65 |
--------------------------------------------------------------------------------
/src/browser_action/core.css:
--------------------------------------------------------------------------------
1 | .web-vitals-chrome-extension-popup {
2 | width: 560px;
3 |
4 | font-size: 12px;
5 |
6 | --color-good: #0CCE6A;
7 | --color-good-text: #1E8E3E;
8 | --color-good-muted: hsl(149 0% 43% / 0.38);
9 | --color-needs-improvement: #FFA400;
10 | --color-needs-improvement-text: #AE6032;
11 | --color-needs-improvement-muted: hsl(39 0% 50% / 0.38);
12 | --color-poor: #FF4E42;
13 | --color-poor-text: #EB0F00;
14 | --color-poor-muted: hsl(4 0% 63% / 0.38);
15 | --color-text: #3C4043;
16 | --color-text-muted: #5F6368;
17 | --color-text-link: #3740FF;
18 | --color-light-grey: #F8F9FA;
19 | --color-dark-grey: #DADCE0;
20 | --transition-duration-easing: 250ms cubic-bezier(0.4, 0.0, 0.2, 1);
21 | }
22 |
23 | .web-vitals-chrome-extension-popup * {
24 | box-sizing: border-box;
25 | }
26 |
27 | .web-vitals-chrome-extension-popup body {
28 | display: flex;
29 | flex-direction: column;
30 | justify-content: space-between;
31 | margin: 0px;
32 | height: 100%;
33 |
34 | font-size: 1rem;
35 | line-height: 1.17rem;
36 | font-weight: 400;
37 | font-family: Roboto, sans-serif;
38 | color: var(--color-text);
39 | }
40 |
41 | .web-vitals-chrome-extension-popup header {
42 | display: flex;
43 | justify-content: space-between;
44 | align-items: center;
45 | padding: 1rem;
46 | }
47 |
48 | .web-vitals-chrome-extension-popup footer {
49 | padding: 1rem;
50 | }
51 |
52 | .web-vitals-chrome-extension-popup footer > * {
53 | margin-bottom: 0.75rem;
54 | }
55 | .web-vitals-chrome-extension-popup footer > *:last-child {
56 | margin-bottom: 0rem;
57 | }
58 |
59 | .web-vitals-chrome-extension-popup h1 {
60 | margin: 0px;
61 | font-size: 1.5rem;
62 | line-height: 2rem;
63 | font-weight: 500;
64 | font-family: "Google Sans", Roboto, sans-serif;
65 | }
66 |
67 | .web-vitals-chrome-extension-popup a {
68 | color: var(--color-text-link);
69 | text-decoration: none;
70 | }
71 | .web-vitals-chrome-extension-popup a:hover {
72 | text-decoration: underline;
73 | }
74 |
75 | .web-vitals-chrome-extension-popup strong {
76 | font-weight: 500;
77 | }
78 |
79 | .web-vitals-chrome-extension-popup .hidden {
80 | display: none;
81 | }
82 |
83 | .web-vitals-chrome-extension-popup .nowrap {
84 | white-space: nowrap;
85 | }
86 |
87 | .web-vitals-chrome-extension-popup .metadata {
88 | color: var(--color-text-muted);
89 | letter-spacing: 0.2px;
90 | }
91 |
92 | .web-vitals-chrome-extension-popup #metadata {
93 | display: flex;
94 | flex-direction: row;
95 | align-items: center;
96 | min-height: 2.34rem;
97 | }
98 |
99 | .web-vitals-chrome-extension-popup #status {
100 | text-align: right;
101 | }
102 |
103 | .web-vitals-chrome-extension-popup .info {
104 | margin-left: 1rem;
105 | text-indent: 0rem;
106 | }
107 | .web-vitals-chrome-extension-popup .info svg {
108 | fill: currentColor;
109 | }
110 |
111 | .web-vitals-chrome-extension-popup .metric-wrapper {
112 | padding: 0rem 1rem;
113 | }
114 |
115 | .web-vitals-chrome-extension-popup .metric-wrapper:hover {
116 | background-color: var(--color-light-grey);
117 | }
118 |
119 | .web-vitals-chrome-extension-popup .metric {
120 | display: grid;
121 | grid-template-areas: "metric-info metric-performance";
122 | grid-template-columns: 1fr 330px;
123 | align-items: center;
124 | border-bottom: 1px solid var(--color-dark-grey);
125 | height: 6.2rem;
126 | }
127 | .web-vitals-chrome-extension-popup .metric-wrapper:last-child .metric {
128 | border-bottom: none;
129 | }
130 |
131 | .web-vitals-chrome-extension-popup .metric-info {
132 | display: flex;
133 | align-items: baseline;
134 | grid-area: metric-info;
135 | }
136 |
137 | .web-vitals-chrome-extension-popup .metric-name {
138 | display: inline-block;
139 | font-size: 1.17rem;
140 | line-height: 1.67rem;
141 | white-space: nowrap;
142 | }
143 |
144 | .web-vitals-chrome-extension-popup .metric-performance {
145 | position: relative;
146 | }
147 |
148 | .web-vitals-chrome-extension-popup .metric-performance-local {
149 | margin-bottom: 0.33rem;
150 | white-space: nowrap;
151 | }
152 |
153 | .web-vitals-chrome-extension-popup .metric-performance-local-value-wrapper {
154 | display: inline-flex;
155 | }
156 |
157 | .web-vitals-chrome-extension-popup .metric-performance-local-value {
158 | display: inline-block;
159 | }
160 |
161 | /* If the pin is too far right, put the label before it. */
162 | .web-vitals-chrome-extension-popup .reversed .metric-performance-local-value-wrapper {
163 | flex-direction: row-reverse;
164 | transform: translateX(calc(-100% - 2rem));
165 | }
166 |
167 | .web-vitals-chrome-extension-popup .good .metric-performance-local,
168 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local,
169 | .web-vitals-chrome-extension-popup .poor .metric-performance-local {
170 | transition: margin-left var(--transition-duration-easing);
171 | position: relative;
172 | font-size: 1.17rem;
173 | line-height: 1.67rem;
174 | font-weight: 500;
175 | text-indent: 1rem;
176 | }
177 | .web-vitals-chrome-extension-popup .good .metric-performance-local {
178 | color: var(--color-good-text);
179 | }
180 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local {
181 | color: var(--color-needs-improvement-text);
182 | }
183 | .web-vitals-chrome-extension-popup .poor .metric-performance-local {
184 | color: var(--color-poor-text);
185 | }
186 |
187 | /* Pinpoint */
188 | .web-vitals-chrome-extension-popup .good .metric-performance-local::before,
189 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local::before,
190 | .web-vitals-chrome-extension-popup .poor .metric-performance-local::before {
191 | position: absolute;
192 | top: 1rem;
193 | left: -2px;
194 | content: '';
195 | width: 1px;
196 | height: 1.17rem;
197 | border: 2px solid #fff;
198 | border-bottom: 0;
199 | }
200 | .web-vitals-chrome-extension-popup .good .metric-performance-local::before {
201 | background-color: var(--color-good);
202 | }
203 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local::before {
204 | background-color: var(--color-needs-improvement);
205 | }
206 | .web-vitals-chrome-extension-popup .poor .metric-performance-local::before {
207 | background-color: var(--color-poor);
208 | }
209 | .web-vitals-chrome-extension-popup .good:hover .metric-performance-local::before,
210 | .web-vitals-chrome-extension-popup .needs-improvement:hover .metric-performance-local::before,
211 | .web-vitals-chrome-extension-popup .poor:hover .metric-performance-local::before {
212 | border-color: var(--color-light-grey);
213 | }
214 |
215 | /* Pinhead*/
216 | .web-vitals-chrome-extension-popup .good .metric-performance-local::after,
217 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local::after,
218 | .web-vitals-chrome-extension-popup .poor .metric-performance-local::after {
219 | position: absolute;
220 | top: 0.33rem;
221 | left: 0rem;
222 | content: '';
223 | display: inline-block;
224 | width: var(--pinhead-size);
225 | height: var(--pinhead-size);
226 | transform: translateX(calc((var(--pinhead-size) - 1px) / -2));
227 |
228 | --pinhead-size: calc(5 / 6 * 1rem + 1px);
229 | }
230 | .web-vitals-chrome-extension-popup .good .metric-performance-local::after {
231 | /* Circle */
232 | border-radius: 100%;
233 | background-color: var(--color-good);
234 | transform: translateX(calc((var(--pinhead-size) - 2px) / -2));
235 | }
236 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local::after {
237 | /* Square (default) */
238 | background-color: var(--color-needs-improvement);
239 | }
240 | .web-vitals-chrome-extension-popup .poor .metric-performance-local::after {
241 | /* Triangle */
242 | height: 0rem;
243 | width: 0rem;
244 | border-left: calc(var(--pinhead-size) / 2) solid transparent;
245 | border-right: calc(var(--pinhead-size) / 2) solid transparent;
246 | border-bottom: var(--pinhead-size) solid var(--color-poor);
247 | }
248 |
249 | .web-vitals-chrome-extension-popup .metric-performance {
250 | grid-area: metric-performance;
251 | display: flex;
252 | flex-direction: column;
253 | }
254 |
255 | .web-vitals-chrome-extension-popup .metric-performance-distribution {
256 | display: flex;
257 | }
258 |
259 | .web-vitals-chrome-extension-popup .metric-performance-distribution-rating {
260 | transition: width var(--transition-duration-easing);
261 | width: var(--rating-width, 33.33%);
262 | min-width: var(--min-rating-width, 0%);
263 | margin-right: 0.17rem;
264 | border-top: 4px solid;
265 |
266 | /* Hide the % when the density is too small. */
267 | box-sizing: content-box;
268 | max-height: 1rem;
269 | word-break: break-word;
270 | overflow: hidden;
271 | }
272 | .web-vitals-chrome-extension-popup .metric-performance-distribution-rating:last-child {
273 | margin-right: 0rem;
274 | }
275 |
276 | .web-vitals-chrome-extension-popup .metric-performance-distribution-rating.good {
277 | border-top-color: var(--color-good-muted);
278 | }
279 | .web-vitals-chrome-extension-popup .metric-performance-distribution-rating.needs-improvement {
280 | border-top-color: var(--color-needs-improvement-muted);
281 | }
282 | .web-vitals-chrome-extension-popup .metric-performance-distribution-rating.poor {
283 | border-top-color: var(--color-poor-muted);
284 | text-align: right;
285 | }
286 |
287 | .web-vitals-chrome-extension-popup .good .metric-performance-distribution-rating.good,
288 | .web-vitals-chrome-extension-popup .metric-wrapper:hover .metric-performance-distribution-rating.good {
289 | border-top-color: var(--color-good);
290 | }
291 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-distribution-rating.needs-improvement,
292 | .web-vitals-chrome-extension-popup .metric-wrapper:hover .metric-performance-distribution-rating.needs-improvement {
293 | border-top-color: var(--color-needs-improvement);
294 | }
295 | .web-vitals-chrome-extension-popup .poor .metric-performance-distribution-rating.poor,
296 | .web-vitals-chrome-extension-popup .metric-wrapper:hover .metric-performance-distribution-rating.poor {
297 | border-top-color: var(--color-poor);
298 | }
299 |
300 | .web-vitals-chrome-extension-popup .hovercard {
301 | transition: visibility 0s var(--hovercard-hide-delay), opacity 0s var(--hovercard-hide-delay);
302 | visibility: hidden;
303 | opacity: 0;
304 | position: absolute;
305 | top: calc(100% + 1rem);
306 | left: 0px;
307 | z-index: 1;
308 | width: 100%;
309 | box-shadow: 0px 4px 8px rgba(60, 64, 67, 0.15), 0px 1px 3px rgba(60, 64, 67, 0.3);
310 | border: 1px solid var(--color-dark-grey);
311 | border-radius: 3px;
312 | padding: 1.33rem 1rem;
313 | background-color: #fff;
314 | color: var(--color-text);
315 | font-size: 1.17rem;
316 | line-height: 1.67rem;
317 |
318 | --hovercard-show-delay: 400ms;
319 | --hovercard-hide-delay: 0ms;
320 | }
321 | .web-vitals-chrome-extension-popup .metric-wrapper:hover.good .hovercard,
322 | .web-vitals-chrome-extension-popup .metric-wrapper:hover.needs-improvement .hovercard,
323 | .web-vitals-chrome-extension-popup .metric-wrapper:hover.poor .hovercard {
324 | visibility: visible;
325 | opacity: 1;
326 | transition-delay: var(--hovercard-show-delay);
327 | }
328 | .web-vitals-chrome-extension-popup .metric-wrapper:last-child .hovercard {
329 | top: unset;
330 | bottom: calc(100% + 1rem);
331 | }
332 | .web-vitals-chrome-extension-popup .hovercard::before {
333 | position: absolute;
334 | content: '';
335 | bottom: calc(100% + var(--border-width));
336 | left: 50%;
337 | width: var(--pointer-size);
338 | height: var(--pointer-size);
339 | box-shadow: 6px 6px 0px 6px var(--background-color), 0px 0px 0px var(--border-width) var(--color-dark-grey), 4px 4px 8px rgba(60, 64, 67, 0.15), 1px 1px 3px rgba(60, 64, 67, 0.3);
340 | background-color: var(--background-color);
341 | transform: translateX(-50%) translateY(calc(var(--pointer-size) / 2 - var(--border-width))) rotate(45deg);
342 |
343 | --pointer-size: 1rem;
344 | --border-width: 1px;
345 | --background-color: #fff;
346 | }
347 | .web-vitals-chrome-extension-popup .metric-wrapper:last-child .hovercard::before {
348 | bottom: unset;
349 | top: calc(100% + var(--border-width));
350 | box-shadow: -3px -3px 0px 3px var(--background-color), 0px 0px 0px var(--border-width) var(--color-dark-grey), 4px 4px 8px rgba(60, 64, 67, 0.15), 1px 1px 3px rgba(60, 64, 67, 0.3);
351 | transform: translateX(-50%) translateY(calc(var(--pointer-size) / -2)) rotate(45deg);
352 | }
353 | .web-vitals-chrome-extension-popup .good .hovercard-local {
354 | color: var(--color-good-text);
355 | }
356 | .web-vitals-chrome-extension-popup .needs-improvement .hovercard-local {
357 | color: var(--color-needs-improvement-text);
358 | }
359 | .web-vitals-chrome-extension-popup .poor .hovercard-local {
360 | color: var(--color-poor-text);
361 | }
362 |
363 | .web-vitals-chrome-extension-popup #device-page-wrapper {
364 | white-space: nowrap;
365 | overflow: hidden;
366 | text-overflow: ellipsis;
367 | }
368 |
369 | .web-vitals-chrome-extension-popup #footer-wrapper {
370 | display:flex;
371 | justify-content:space-between;
372 | }
373 |
374 | .web-vitals-chrome-extension-popup #device-icon-desktop,
375 | .web-vitals-chrome-extension-popup #device-icon-phone {
376 | display: none;
377 | fill: currentColor;
378 | vertical-align: middle;
379 | }
380 | .web-vitals-chrome-extension-popup .device-desktop #device-icon-desktop {
381 | display: inline-block;
382 | }
383 | .web-vitals-chrome-extension-popup .device-phone #device-icon-phone {
384 | display: inline-block;
385 | }
386 |
387 | .web-vitals-chrome-extension-popup #page {
388 | vertical-align: middle;
389 | }
390 |
391 | .web-vitals-chrome-extension-popup #settings-link a svg {
392 | color: black;
393 | }
394 |
395 | .web-vitals-chrome-extension-popup #settings-link a:hover svg {
396 | color: blue;
397 | }
398 |
399 | .web-vitals-chrome-extension-popup #eol-notice {
400 | border: 4px solid var(--color-needs-improvement);
401 | margin: auto 20px;
402 | padding: 0 30px;
403 | font-size: 1.2rem;
404 | line-height: 1.8rem;
405 | }
406 | .web-vitals-chrome-extension-popup #eol-notice::backdrop {
407 | backdrop-filter: blur(1px);
408 | }
409 | .web-vitals-chrome-extension-popup button.danger {
410 | background-color: var(--color-needs-improvement);
411 | border: none;
412 | }
413 | .web-vitals-chrome-extension-popup #eol-notice > :first-child {
414 | margin-top: 30px;
415 | }
416 | .web-vitals-chrome-extension-popup #eol-notice > :last-child {
417 | margin-bottom: 30px;
418 | }
419 |
--------------------------------------------------------------------------------
/src/browser_action/crux.js:
--------------------------------------------------------------------------------
1 | import { Metric } from './metric.js';
2 |
3 | // This key only works from the Web Vitals extension.
4 | const CRUX_API_KEY = 'AIzaSyCZKhcAeiqGCp34891LPqVteT5kUMMq1og';
5 |
6 | export class CrUX {
7 |
8 | static load(pageUrl, formFactor) {
9 | const urlHelper = new URL(pageUrl);
10 | const url = urlHelper.href;
11 | const origin = urlHelper.origin;
12 |
13 | return CrUX.query({url, formFactor}).catch(e =>{
14 | // If URL data is unavailable, fall back to the origin.
15 | return CrUX.query({origin, formFactor});
16 | });
17 | }
18 |
19 | static query(request) {
20 | const ENDPOINT = `https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${CRUX_API_KEY}`;
21 | return fetch(ENDPOINT, {
22 | method: 'POST',
23 | body: JSON.stringify(request)
24 | }).then(response => {
25 | return response.json();
26 | }).then(response => {
27 | if (response.error) {
28 | return Promise.reject(response);
29 | }
30 |
31 | return response;
32 | });
33 | }
34 |
35 | static isOriginFallback(response) {
36 | return CrUX.getOrigin(response) !== undefined;
37 | }
38 |
39 | static getOrigin(response) {
40 | return response.record.key.origin;
41 | }
42 |
43 | static getNormalizedUrl(response) {
44 | return response?.urlNormalizationDetails?.normalizedUrl;
45 | }
46 |
47 | static getMetrics(response) {
48 | return Object.entries(response.record.metrics).map(([metricName, data]) => {
49 | return {
50 | id: Metric.mapCruxNameToId(metricName),
51 | data
52 | };
53 | });
54 | }
55 |
56 | static getDistribution(data) {
57 | return data.histogram.map(({density}) => density || 0);
58 | }
59 |
60 | static get FormFactor() {
61 | return {
62 | PHONE: 'PHONE',
63 | DESKTOP: 'DESKTOP'
64 | };
65 | }
66 |
67 | }
68 |
--------------------------------------------------------------------------------
/src/browser_action/lodash-debounce-custom.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * lodash 3.8.0 (Custom Build) lodash.com/license | Underscore.js 1.8.3 underscorejs.org/LICENSE
4 | * Build: `lodash compat include="debounce" --production -o lodash.compat.js`
5 | */
6 | ;(function(){function t(){}function e(t){var e=typeof t;return"function"==e||!!t&&"object"==e}function n(t){return null==t?false:g.call(t)==i?m.test(y.call(t)):!!t&&typeof t=="object"&&(d(t)?m:u).test(t)}function o(t){return typeof t!="string"&&(t=null==t?"":t+""),t&&c.test(t)?t.replace(r,"\\$&"):t}var i="[object Function]",r=/[.*+?^${}()|[\]\/\\]/g,c=RegExp(r.source),u=/^\[object .+?Constructor\]$/,l={"function":true,object:true},f=l[typeof exports]&&exports&&!exports.nodeType&&exports,a=l[typeof module]&&module&&!module.nodeType&&module,p=l[typeof self]&&self&&self.Object&&self,s=l[typeof window]&&window&&window.Object&&window,l=a&&a.exports===f&&f,p=f&&a&&typeof global=="object"&&global&&global.Object&&global||s!==(this&&this.window)&&s||p||this,d=function(){
7 | try{Object({toString:0}+"")}catch(t){return function(){return false}}return function(t){return typeof t.toString!="function"&&typeof(t+"")=="string"}}(),y=Function.prototype.toString,g=Object.prototype.toString,m=RegExp("^"+o(g).replace(/toString|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$"),b=Math.max,w=n(w=Date.now)&&w,j=w||function(){return(new Date).getTime()};t.debounce=function(t,n,o){function i(){var e=n-(j()-a);0>=e||e>n?(l&&clearTimeout(l),e=d,l=s=d=void 0,e&&(y=j(),f=t.apply(p,u),s||l||(u=p=null))):s=setTimeout(i,e);
8 |
9 | }function r(){s&&clearTimeout(s),l=s=d=void 0,(m||g!==n)&&(y=j(),f=t.apply(p,u),s||l||(u=p=null))}function c(){if(u=arguments,a=j(),p=this,d=m&&(s||!w),false===g)var e=w&&!s;else{l||w||(y=a);var o=g-(a-y),c=0>=o||o>g;c?(l&&(l=clearTimeout(l)),y=a,f=t.apply(p,u)):l||(l=setTimeout(r,o))}return c&&s?s=clearTimeout(s):s||n===g||(s=setTimeout(i,n)),e&&(c=true,f=t.apply(p,u)),!c||s||l||(u=p=null),f}var u,l,f,a,p,s,d,y=0,g=false,m=true;if(typeof t!="function")throw new TypeError("Expected a function");if(n=0>n?0:+n||0,
10 | !0===o)var w=true,m=false;else e(o)&&(w=o.leading,g="maxWait"in o&&b(+o.maxWait||0,n),m="trailing"in o?o.trailing:m);return c.cancel=function(){s&&clearTimeout(s),l&&clearTimeout(l),l=s=d=void 0},c},t.escapeRegExp=o,t.isNative=n,t.isObject=e,t.now=j,t.VERSION="3.8.0",typeof define=="function"&&typeof define.amd=="object"&&define.amd?(p._=t, define(function(){return t})):f&&a?l?(a.exports=t)._=t:f._=t:p._=t}).call(this);
--------------------------------------------------------------------------------
/src/browser_action/metric.js:
--------------------------------------------------------------------------------
1 | import {CLSThresholds, FCPThresholds, INPThresholds, LCPThresholds, TTFBThresholds} from './web-vitals.js';
2 |
3 | const assessments = {
4 | 'good': 0,
5 | 'needs-improvement': 1,
6 | 'poor': 2
7 | };
8 |
9 | const secondsFormatter = new Intl.NumberFormat(undefined, {
10 | unit: "second",
11 | style: 'unit',
12 | unitDisplay: "short",
13 | minimumFractionDigits: 3,
14 | maximumFractionDigits: 3
15 | });
16 |
17 | const millisecondsFormatter = new Intl.NumberFormat(undefined, {
18 | unit: "millisecond",
19 | style: 'unit',
20 | unitDisplay: 'short',
21 | minimumFractionDigits: 0,
22 | maximumFractionDigits: 0
23 | });
24 |
25 | const clsFormatter = new Intl.NumberFormat(undefined, {
26 | unitDisplay: 'short',
27 | minimumFractionDigits: 2,
28 | maximumFractionDigits: 2
29 | });
30 |
31 |
32 | export class Metric {
33 |
34 | constructor({id, name, local, background, thresholds, rating}) {
35 | this.id = id;
36 | this.abbr = id.toUpperCase();
37 | this.name = name;
38 | this.local = local;
39 | this.background = background;
40 | this.thresholds = thresholds;
41 | // This will be replaced with field data, if available.
42 | this.distribution = [1/3, 1/3, 1/3];
43 | this.rating = rating;
44 | }
45 |
46 | formatValue(value) {
47 | return value;
48 | }
49 |
50 | getAssessmentIndex() {
51 | return assessments[this.rating];
52 | }
53 |
54 | getRelativePosition(value) {
55 | if (!this.thresholds) {
56 | console.warn('Unable to position local value', this, '(no thresholds)');
57 | return '0%';
58 | }
59 |
60 | let relativePosition = 0;
61 | const {good, poor} = this.thresholds;
62 | // Densities smaller than this amount are visually insignificant.
63 | const MIN_PCT = this.MIN_PCT;
64 | // The poor bucket is unbounded, so a value can never really be 100%.
65 | const MAX_PCT = 0.95;
66 | // ... but we still need to use something as the upper limit.
67 | const MAX_VALUE = poor * 2.5;
68 | //
69 | let totalDensity = 0;
70 | const [pctGood, pctNeedsImprovement, pctPoor] = this.distribution.map(density => {
71 | // Rating widths aren't affected by MAX_PCT, so we don't adjust for it here.
72 | density = Math.max(density, MIN_PCT);
73 | totalDensity += density;
74 | return density;
75 | }).map(density => density / totalDensity);
76 |
77 | // The relative position is linearly interpolated for simplicity.
78 | if (value < good) {
79 | relativePosition = value * pctGood / good;
80 | } else if (value >= poor) {
81 | relativePosition = Math.min(MAX_PCT, (value - poor) / (poor * MAX_VALUE)) * pctPoor + pctGood + pctNeedsImprovement;
82 | } else {
83 | relativePosition = (value - good) * pctNeedsImprovement / (poor - good) + pctGood;
84 | }
85 |
86 | return `${relativePosition * 100}%`;
87 | }
88 |
89 | getInfo() {
90 | return;
91 | }
92 |
93 | getDensity(i, decimalPlaces=0) {
94 | const density = this.distribution[i];
95 |
96 | return `${(density * 100).toFixed(decimalPlaces)}%`;
97 | }
98 |
99 | get MIN_PCT() {
100 | return 0.02;
101 | }
102 |
103 | set distribution(distribution) {
104 | const [good, needsImprovement, poor] = distribution;
105 | let total = good + needsImprovement + poor;
106 | if (Math.abs(1 - total) > 0.0001) {
107 | console.warn('Field distribution densities don\'t sum to 100%:', good, needsImprovement, poor);
108 | }
109 |
110 | // Normalize the densities so they always sum to exactly 100%.
111 | // Consider [98.29%, 1.39%, 0.31%]. Naive rounding would produce [98%, 1%, 0%].
112 | // Since 1.39% lost the most due to rounding (0.39%), we overcompensate by making it 2%.
113 | // This way, the result adds up to 100%: [98%, 2%, 0%].
114 |
115 | // Sort the indices by those that "have the most to lose" by rounding.
116 | let sortedIndices = distribution.map(i => i - (Math.floor(i * 100) / 100));
117 | sortedIndices = Array.from(sortedIndices).sort().reverse().map(i => sortedIndices.indexOf(i));
118 | // Round all densities down to the hundredths place.
119 | // This is expected to change the total to < 1 (underflow).
120 | distribution = distribution.map(density => {
121 | return Math.floor(density * 100 / total) / 100;
122 | });
123 | // Add 1% back to the densities that "lost the most" until we reach 100%.
124 | total = distribution.reduce((total, i) => total + (i * 100), 0);
125 | for (let i = 0; i < (100 - total); i++) {
126 | const densityIndex = sortedIndices[i];
127 | distribution[densityIndex] = ((distribution[densityIndex] * 100) + 1) / 100;
128 | }
129 |
130 | this._distribution = distribution;
131 | }
132 |
133 | get distribution() {
134 | return this._distribution;
135 | }
136 |
137 | static mapCruxNameToId(cruxName) {
138 | const nameMap = {
139 | 'largest_contentful_paint': 'lcp',
140 | 'interaction_to_next_paint': 'inp',
141 | 'cumulative_layout_shift': 'cls',
142 | 'first_contentful_paint': 'fcp',
143 | 'experimental_time_to_first_byte': 'ttfb'
144 | };
145 |
146 | return nameMap[cruxName];
147 | }
148 |
149 | }
150 |
151 | export class LCP extends Metric {
152 |
153 | constructor({local, background, rating}) {
154 | const thresholds = {
155 | good: LCPThresholds[0],
156 | poor: LCPThresholds[1]
157 | };
158 |
159 | // TODO(rviscomi): Consider better defaults.
160 | local = local || 0;
161 | rating = rating || 'good';
162 |
163 | super({
164 | id: 'lcp',
165 | name: 'Largest Contentful Paint',
166 | local,
167 | background,
168 | thresholds,
169 | rating
170 | });
171 | }
172 |
173 | formatValue(value) {
174 | value /= 1000;
175 | return secondsFormatter.format(value);
176 | }
177 |
178 | getInfo() {
179 | if (this.background) {
180 | return 'LCP inflated by tab loading in the background';
181 | }
182 |
183 | return super.getInfo();
184 | }
185 |
186 | }
187 |
188 | export class INP extends Metric {
189 |
190 | constructor({local, background, rating}) {
191 | const thresholds = {
192 | good: INPThresholds[0],
193 | poor: INPThresholds[1]
194 | };
195 |
196 | super({
197 | id: 'inp',
198 | name: 'Interaction to Next Paint',
199 | local,
200 | background,
201 | thresholds,
202 | rating
203 | });
204 | }
205 |
206 | formatValue(value) {
207 | if (value === null) {
208 | return 'Waiting for input…';
209 | }
210 |
211 | return millisecondsFormatter.format(value);
212 | }
213 |
214 | }
215 |
216 | export class CLS extends Metric {
217 |
218 | constructor({local, background, rating}) {
219 | const thresholds = {
220 | good: CLSThresholds[0],
221 | poor: CLSThresholds[1]
222 | };
223 |
224 | // TODO(rviscomi): Consider better defaults.
225 | local = local || 0;
226 | rating = rating || 'good';
227 |
228 | super({
229 | id: 'cls',
230 | name: 'Cumulative Layout Shift',
231 | local,
232 | background,
233 | thresholds,
234 | rating
235 | });
236 | }
237 |
238 | formatValue(value) {
239 | return clsFormatter.format(value);
240 | }
241 |
242 | }
243 |
244 | export class FCP extends Metric {
245 |
246 | constructor({local, background, rating}) {
247 | const thresholds = {
248 | good: FCPThresholds[0],
249 | poor: FCPThresholds[1]
250 | };
251 |
252 | // TODO(rviscomi): Consider better defaults.
253 | local = local || 0;
254 | rating = rating || 'good';
255 |
256 | super({
257 | id: 'fcp',
258 | name: 'First Contentful Paint',
259 | local,
260 | background,
261 | thresholds,
262 | rating
263 | });
264 | }
265 |
266 | formatValue(value) {
267 | value /= 1000;
268 | return secondsFormatter.format(value);
269 | }
270 |
271 | getInfo() {
272 | if (this.background) {
273 | return 'FCP inflated by tab loading in the background';
274 | }
275 |
276 | return super.getInfo();
277 | }
278 |
279 | }
280 |
281 | export class TTFB extends Metric {
282 |
283 | constructor({local, background, rating}) {
284 | const thresholds = {
285 | good: TTFBThresholds[0],
286 | poor: TTFBThresholds[1]
287 | };
288 |
289 | // TODO(rviscomi): Consider better defaults.
290 | local = local || 0;
291 | rating = rating || 'good';
292 |
293 | super({
294 | id: 'ttfb',
295 | name: 'Time to First Byte',
296 | local,
297 | background,
298 | thresholds,
299 | rating
300 | });
301 | }
302 |
303 | formatValue(value) {
304 | value /= 1000;
305 | return secondsFormatter.format(value);
306 | }
307 |
308 | }
309 |
--------------------------------------------------------------------------------
/src/browser_action/on-each-interaction.js:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2023 Google Inc. All Rights Reserved.
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 | http://www.apache.org/licenses/LICENSE-2.0
7 | Unless required by applicable law or agreed to in writing, software
8 | distributed under the License is distributed on an "AS IS" BASIS,
9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10 | See the License for the specific language governing permissions and
11 | limitations under the License.
12 | */
13 |
14 | import {INPThresholds} from './web-vitals.js';
15 |
16 | /**
17 | * @param {Function} callback
18 | */
19 | export function onEachInteraction(callback) {
20 | const valueToRating = (score) => score <= INPThresholds[0] ? 'good' : score <= INPThresholds[1] ? 'needs-improvement' : 'poor';
21 |
22 | const eventObserver = new PerformanceObserver((list) => {
23 | const entries = list.getEntries();
24 | const interactions = {};
25 |
26 | const getSelector = (node, maxLen) => {
27 | let sel = '';
28 |
29 | try {
30 | while (node && node.nodeType !== 9) {
31 | const el = node;
32 | const part = el.id
33 | ? '#' + el.id
34 | : getName(el) +
35 | (el.classList &&
36 | el.classList.value &&
37 | el.classList.value.trim() &&
38 | el.classList.value.trim().length
39 | ? '.' + el.classList.value.trim().replace(/\s+/g, '.')
40 | : '');
41 | if (sel.length + part.length > (maxLen || 100) - 1) return sel || part;
42 | sel = sel ? part + '>' + sel : part;
43 | if (el.id) break;
44 | node = el.parentNode;
45 | }
46 | } catch (err) {
47 | // Do nothing...
48 | }
49 | return sel;
50 | };
51 |
52 | // Filter all events to those with interactionids
53 | for (const entry of entries.filter((entry) => entry.interactionId)) {
54 | interactions[entry.interactionId] = interactions[entry.interactionId] || [];
55 | interactions[entry.interactionId].push(entry);
56 | }
57 |
58 | // Will report as a single interaction even if parts are in separate frames.
59 | // Consider splitting by animation frame.
60 | for (const interaction of Object.values(interactions)) {
61 | const entry = interaction.reduce((prev, curr) => prev.duration >= curr.duration ? prev : curr);
62 | const value = entry.duration;
63 |
64 | // Filter down LoAFs to ones that intersected any event startTime and any processingEnd
65 | const longAnimationFrameEntries = getIntersectingLoAFs(entry.startTime, entry.startTime + entry.value)
66 |
67 | const firstEntryWithTarget = interaction.find(entry => entry.target)?.target;
68 |
69 | callback({
70 | attribution: {
71 | interactionTarget: getSelector(firstEntryWithTarget),
72 | interactionTargetElement: firstEntryWithTarget,
73 | interactionTime: entry.startTime,
74 | interactionType: entry.name.startsWith('key') ? 'keyboard' : 'pointer',
75 | longAnimationFrameEntries: longAnimationFrameEntries
76 | },
77 | entries: interaction,
78 | name: 'Interaction',
79 | rating: valueToRating(value),
80 | value,
81 | });
82 | }
83 | });
84 |
85 | eventObserver.observe({
86 | type: 'event',
87 | durationThreshold: 0,
88 | buffered: true,
89 | });
90 |
91 | let recentLoAFs = [];
92 |
93 | const getIntersectingLoAFs = (start, end) => {
94 | const intersectingLoAFs = [];
95 |
96 | for (let i = 0, loaf; (loaf = recentLoAFs[i]); i++) {
97 | // If the LoAF ends before the given start time, ignore it.
98 | if (loaf.startTime + loaf.duration < start) continue;
99 |
100 | // If the LoAF starts after the given end time, ignore it and all
101 | // subsequent pending LoAFs (because they're in time order).
102 | if (loaf.startTime > end) break;
103 |
104 | // Still here? If so this LoAF intersects with the interaction.
105 | intersectingLoAFs.push(loaf);
106 | }
107 | return intersectingLoAFs;
108 | };
109 |
110 | const loafObserver = new PerformanceObserver((list) => {
111 | // We report interactions immediately, so don't need to keep many LoAFs around.
112 | // Let's keep the last 5.
113 | recentLoAFs = recentLoAFs.concat(list.getEntries()).slice(-5);
114 |
115 | });
116 | loafObserver.observe({
117 | type: 'long-animation-frame',
118 | buffered: true,
119 | });
120 | }
121 |
--------------------------------------------------------------------------------
/src/browser_action/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
Metrics
9 |
10 |
11 |
Waiting for local data…
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
Metric Name
21 |
22 |
23 |
24 |
25 |
26 |
Waiting…
27 |
28 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
82 |
83 |
95 |
96 |
97 |
98 |
99 |
--------------------------------------------------------------------------------
/src/browser_action/popup.js:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2020 Google Inc. All Rights Reserved.
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 | http://www.apache.org/licenses/LICENSE-2.0
7 | Unless required by applicable law or agreed to in writing, software
8 | distributed under the License is distributed on an "AS IS" BASIS,
9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10 | See the License for the specific language governing permissions and
11 | limitations under the License.
12 | */
13 |
14 | import { loadLocalMetrics, getOptions, getURL } from './chrome.js';
15 | import { CrUX } from './crux.js';
16 | import { LCP, INP, CLS, FCP, TTFB } from './metric.js';
17 |
18 | class Popup {
19 |
20 | constructor({metrics, background, options, url, error}) {
21 | if (error) {
22 | console.log(error);
23 | this.setStatus('Web Vitals are unavailable for this page.\n' + error);
24 | return;
25 | }
26 |
27 | const {timestamp, ..._metrics} = metrics;
28 | // Format as a short timestamp (HH:MM:SS).
29 | const formattedTimestamp = new Date(timestamp).toLocaleTimeString('en-US', {hourCycle: 'h23'});
30 |
31 | this.timestamp = formattedTimestamp;
32 | this._metrics = _metrics;
33 | this.background = background;
34 | this.options = options;
35 | this.metrics = {};
36 | this.url = url;
37 |
38 | this.init();
39 | }
40 |
41 | init() {
42 | this.initStatus();
43 | this.initPage();
44 | this.initTimestamp();
45 | this.initMetrics();
46 | this.initFieldData();
47 | this.showEOLNotice();
48 | }
49 |
50 | initStatus() {
51 | this.setStatus('Loading field data…');
52 | }
53 |
54 | initPage() {
55 | this.setPage(this.url);
56 | }
57 |
58 | initTimestamp() {
59 | const timestamp = document.getElementById('timestamp');
60 | timestamp.innerText = this.timestamp;
61 | }
62 |
63 | initMetrics() {
64 | this.metrics.lcp = new LCP({
65 | local: this._metrics.lcp.value,
66 | rating: this._metrics.lcp.rating,
67 | background: this.background
68 | });
69 | this.metrics.cls = new CLS({
70 | local: this._metrics.cls.value,
71 | rating: this._metrics.cls.rating,
72 | background: this.background
73 | });
74 | this.metrics.inp = new INP({
75 | local: this._metrics.inp.value,
76 | rating: this._metrics.inp.rating,
77 | background: this.background
78 | });
79 | this.metrics.fcp = new FCP({
80 | local: this._metrics.fcp.value,
81 | rating: this._metrics.fcp.rating,
82 | background: this.background
83 | });
84 | this.metrics.ttfb = new TTFB({
85 | local: this._metrics.ttfb.value,
86 | rating: this._metrics.ttfb.rating,
87 | background: this.background
88 | });
89 |
90 | this.renderMetrics();
91 | }
92 |
93 | initFieldData() {
94 | const formFactor = this.options.preferPhoneField ? CrUX.FormFactor.PHONE : CrUX.FormFactor.DESKTOP;
95 | CrUX.load(this.url, formFactor).then(fieldData => {
96 | console.log('CrUX data', fieldData);
97 | this.renderFieldData(fieldData, formFactor);
98 | }).catch(e => {
99 | console.warn('Unable to load any CrUX data. See https://developer.chrome.com/blog/web-vitals-extension', e);
100 | this.setStatus('Local metrics only (field data unavailable)');
101 | });
102 | }
103 |
104 | showEOLNotice() {
105 | chrome.storage.sync.get({hideEOLNotice: false}, ({hideEOLNotice}) => {
106 | if (hideEOLNotice) {
107 | return;
108 | }
109 | const notice = document.getElementById('eol-notice');
110 | notice.showPopover();
111 | const hideNoticeToggle = document.getElementById('hide-eol-notice');
112 | hideNoticeToggle.addEventListener('change', (e) => {
113 | chrome.storage.sync.set({hideEOLNotice: e.target.checked});
114 | });
115 | });
116 | }
117 |
118 | setStatus(status) {
119 | const statusElement = document.getElementById('status');
120 |
121 | if (typeof status === 'string') {
122 | statusElement.innerText = status;
123 | } else {
124 | statusElement.replaceChildren(status);
125 | }
126 | }
127 |
128 | setPage(url) {
129 | const page = document.getElementById('page');
130 | page.innerText = url;
131 | page.title = url;
132 | }
133 |
134 | setDevice(formFactor) {
135 | const deviceElement = document.querySelector('.device-icon');
136 | deviceElement.classList.add(`device-${formFactor.toLowerCase()}`);
137 | }
138 |
139 | setHovercardText(metric, fieldData, formFactor='') {
140 | const hovercard = document.querySelector(`#${metric.id} .hovercard`);
141 | const abbr = metric.abbr;
142 | const local = metric.formatValue(metric.local);
143 | const assessment = metric.rating;
144 | let text = `Your local ${abbr} experience is ${local} and rated ${assessment}.`;
145 |
146 | if (fieldData) {
147 | const assessmentIndex = metric.getAssessmentIndex(metric.rating);
148 | const density = metric.getDensity(assessmentIndex, 0);
149 | const scope = CrUX.isOriginFallback(fieldData) ? 'origin' : 'page';
150 | text += ` ${density} of real-user ${formFactor.toLowerCase()} ${abbr} experiences on this ${scope} were also rated ${assessment}.`
151 | }
152 |
153 | hovercard.innerHTML = text;
154 | }
155 |
156 | renderMetrics() {
157 | Object.values(this.metrics).forEach(this.renderMetric.bind(this));
158 | }
159 |
160 | renderMetric(metric) {
161 | const template = document.getElementById('metric-template');
162 | const fragment = template.content.cloneNode(true);
163 | const metricElement = fragment.querySelector('.metric-wrapper');
164 | const name = fragment.querySelector('.metric-name');
165 | const local = fragment.querySelector('.metric-performance-local');
166 | const localValue = fragment.querySelector('.metric-performance-local-value');
167 | const infoElement = fragment.querySelector('.info');
168 | const info = metric.getInfo() || '';
169 | const rating = metric.rating;
170 |
171 | metricElement.id = metric.id;
172 | name.innerText = metric.name;
173 | local.style.marginLeft = metric.getRelativePosition(metric.local);
174 | localValue.innerText = metric.formatValue(metric.local);
175 | metricElement.classList.toggle(rating, !!rating);
176 | infoElement.title = info;
177 | infoElement.classList.toggle('hidden', info == '');
178 |
179 | template.parentElement.appendChild(fragment);
180 |
181 | requestAnimationFrame(_ => {
182 | // Check reversal before and after the transition is settled.
183 | this.checkReversal(metric);
184 | this.setHovercardText(metric);
185 | });
186 | this.whenSettled(metric).then(_ => this.checkReversal(metric));
187 | }
188 |
189 | checkReversal(metric) {
190 | const container = document.querySelector(`#${metric.id} .metric-performance`);
191 | const local = document.querySelector(`#${metric.id} .metric-performance-local`);
192 | const localValue = document.querySelector(`#${metric.id} .metric-performance-local-value`);
193 |
194 | const containerBoundingRect = container.getBoundingClientRect();
195 | const localValueBoundingRect = localValue.getBoundingClientRect();
196 | const isOverflow = localValueBoundingRect.right > containerBoundingRect.right;
197 |
198 | local.classList.toggle('reversed', isOverflow || local.classList.contains('reversed'));
199 | }
200 |
201 | renderFieldData(fieldData, formFactor) {
202 | if (CrUX.isOriginFallback(fieldData)) {
203 | const fragment = document.createDocumentFragment();
204 | const span = document.createElement('span');
205 | span.innerHTML = `Page-level field data is not available Comparing local metrics to origin-level ${formFactor.toLowerCase()} field data instead`;
206 | fragment.appendChild(span);
207 | this.setStatus(fragment);
208 | this.setPage(CrUX.getOrigin(fieldData));
209 | } else {
210 | this.setStatus(`Local metrics compared to ${formFactor.toLowerCase()} field data`);
211 |
212 | const normalizedUrl = CrUX.getNormalizedUrl(fieldData);
213 | if (normalizedUrl) {
214 | this.setPage(normalizedUrl);
215 | }
216 | }
217 |
218 | const metrics = CrUX.getMetrics(fieldData).forEach(({id, data}) => {
219 | const metric = this.metrics[id];
220 | if (!metric) {
221 | // The API may return additional metrics that we don't support.
222 | return;
223 | }
224 |
225 | metric.distribution = CrUX.getDistribution(data);
226 |
227 | const local = document.querySelector(`#${metric.id} .metric-performance-local`);
228 | local.style.marginLeft = metric.getRelativePosition(metric.local);
229 |
230 | ['good', 'needs-improvement', 'poor'].forEach((rating, i) => {
231 | const ratingElement = document.querySelector(`#${metric.id} .metric-performance-distribution-rating.${rating}`);
232 |
233 | ratingElement.innerText = metric.getDensity(i);
234 | ratingElement.style.setProperty('--rating-width', metric.getDensity(i, 2));
235 | ratingElement.style.setProperty('--min-rating-width', `${metric.MIN_PCT * 100}%`);
236 | });
237 |
238 | this.setDevice(formFactor);
239 | this.setHovercardText(metric, fieldData, formFactor);
240 | this.whenSettled(metric).then(_ => this.checkReversal(metric));
241 | });
242 | }
243 |
244 | whenSettled(metric) {
245 | const local = document.querySelector(`#${metric.id} .metric-performance-local`);
246 | return new Promise(resolve => {
247 | local.addEventListener('transitionend', resolve);
248 | });
249 | }
250 |
251 | }
252 |
253 | Promise.all([loadLocalMetrics(), getOptions(), getURL()]).then(([localMetrics, options, url]) => {
254 | window.popup = new Popup({...localMetrics, options, url});
255 | });
256 |
--------------------------------------------------------------------------------
/src/browser_action/vitals.js:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2020 Google Inc. All Rights Reserved.
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 | http://www.apache.org/licenses/LICENSE-2.0
7 | Unless required by applicable law or agreed to in writing, software
8 | distributed under the License is distributed on an "AS IS" BASIS,
9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10 | See the License for the specific language governing permissions and
11 | limitations under the License.
12 | */
13 |
14 | (async () => {
15 | const src = chrome.runtime.getURL('src/browser_action/web-vitals.js');
16 | const webVitals = await import(src);
17 | const { onEachInteraction } = await import(chrome.runtime.getURL('src/browser_action/on-each-interaction.js'));
18 | let overlayClosedForSession = false;
19 | let latestCLS = {};
20 | let enableLogging = localStorage.getItem('web-vitals-extension-debug')=='TRUE';
21 | let enableUserTiming = localStorage.getItem('web-vitals-extension-user-timing')=='TRUE';
22 | let tabLoadedInBackground;
23 |
24 | const COLOR_GOOD = '#0CCE6A';
25 | const COLOR_NEEDS_IMPROVEMENT = '#FFA400';
26 | const COLOR_POOR = '#FF4E42';
27 | const RATING_COLORS = {
28 | 'good': COLOR_GOOD,
29 | 'needs-improvement': COLOR_NEEDS_IMPROVEMENT,
30 | 'poor': COLOR_POOR
31 | };
32 |
33 | // CLS update frequency
34 | const DEBOUNCE_DELAY = 500;
35 |
36 | // Identifiable prefix for console logging
37 | const LOG_PREFIX = '[Web Vitals Extension]';
38 |
39 | // Registry for badge metrics
40 | const badgeMetrics = initializeMetrics();
41 |
42 | // Set up extension message port with the service worker
43 | let port = chrome.runtime.connect();
44 |
45 | // Re-establish the port connection on bfcache restore
46 | window.addEventListener('pageshow', (event) => {
47 | if (event.persisted) {
48 | // The page is restored from BFCache, set up a new connection.
49 | port = chrome.runtime.connect();
50 | }
51 | });
52 |
53 | const secondsFormatter = new Intl.NumberFormat(undefined, {
54 | unit: "second",
55 | style: 'unit',
56 | unitDisplay: "short",
57 | minimumFractionDigits: 3,
58 | maximumFractionDigits: 3
59 | });
60 |
61 | const millisecondsFormatter = new Intl.NumberFormat(undefined, {
62 | unit: "millisecond",
63 | style: 'unit',
64 | unitDisplay: "short",
65 | minimumFractionDigits: 0,
66 | maximumFractionDigits: 0
67 | });
68 |
69 | const clsFormatter = new Intl.NumberFormat(undefined, {
70 | unitDisplay: "short",
71 | minimumFractionDigits: 2,
72 | maximumFractionDigits: 2
73 | });
74 |
75 | function initializeMetrics() {
76 | let metricsState = localStorage.getItem('web-vitals-extension-metrics');
77 | if (metricsState) {
78 | metricsState = JSON.parse(metricsState);
79 |
80 | if (metricsState.navigationStart == performance.timing.navigationStart) {
81 | return metricsState;
82 | }
83 | }
84 |
85 | // Create a fresh state.
86 | // Default all metric values to null.
87 | return {
88 | lcp: {
89 | value: null,
90 | rating: null,
91 | },
92 | cls: {
93 | value: null,
94 | rating: null,
95 | },
96 | inp: {
97 | value: null,
98 | rating: null,
99 | },
100 | fcp: {
101 | value: null,
102 | rating: null,
103 | },
104 | ttfb: {
105 | value: null,
106 | rating: null,
107 | },
108 | // This is used to distinguish between navigations.
109 | // TODO: Is there a cleaner way?
110 | navigationStart: performance.timing.navigationStart
111 | };
112 |
113 | }
114 |
115 | /**
116 | * Very simple classifier for metrics values
117 | * @param {Object} metrics
118 | * @return {String} overall metrics score
119 | */
120 | function scoreBadgeMetrics(metrics) {
121 | // Note: overallScore is treated as a string rather than
122 | // a boolean to give us the flexibility of introducing a
123 | // 'NEEDS IMPROVEMENT' option here in the future.
124 | const overallScore = (
125 | metrics.lcp.rating === 'good' &&
126 | (metrics.cls.rating === 'good' || metrics.cls.rating === null) &&
127 | (metrics.inp.rating === 'good' || metrics.inp.rating === null)
128 | ) ? 'GOOD' : 'POOR';
129 | return overallScore;
130 | }
131 |
132 | /**
133 | *
134 | * Draw or update the HUD overlay to the page
135 | * @param {Object} metrics
136 | * @param {Number} tabId
137 | */
138 | function drawOverlay(metrics) {
139 |
140 | localStorage.setItem('web-vitals-extension-metrics', JSON.stringify(metrics));
141 |
142 | // Check for preferences set in options
143 | chrome.storage.sync.get({
144 | enableOverlay: false,
145 | debug: false,
146 | userTiming: false
147 | }, ({
148 | enableOverlay, debug, userTiming
149 | }) => {
150 | if (enableOverlay === true && overlayClosedForSession == false) {
151 | let overlayElement = document.getElementById('web-vitals-extension-overlay');
152 | if (overlayElement === null) {
153 | // Overlay
154 | overlayElement = document.createElement('div');
155 | overlayElement.id = 'web-vitals-extension-overlay';
156 | overlayElement.classList.add('web-vitals-chrome-extension');
157 | document.body.appendChild(overlayElement);
158 |
159 | // Overlay close button
160 | overlayClose = document.createElement('button');
161 | overlayClose.innerText = 'Close';
162 | overlayClose.id = 'web-vitals-close';
163 | overlayClose.className = 'lh-overlay-close';
164 | overlayClose.addEventListener('click', () => {
165 | overlayElement.remove();
166 | overlayClose.remove();
167 | overlayClosedForSession = true;
168 | });
169 |
170 | document.body.appendChild(overlayClose);
171 | }
172 |
173 | overlayElement.innerHTML = buildOverlayTemplate(metrics);
174 | }
175 |
176 | if (debug) {
177 | localStorage.setItem('web-vitals-extension-debug', 'TRUE');
178 | enableLogging = true;
179 | } else {
180 | localStorage.removeItem('web-vitals-extension-debug');
181 | enableLogging = false;
182 | }
183 | if (userTiming) {
184 | localStorage.setItem('web-vitals-extension-user-timing', 'TRUE');
185 | enableUserTiming = true;
186 | } else {
187 | localStorage.removeItem('web-vitals-extension-user-timing');
188 | enableUserTiming = false;
189 | }
190 | });
191 | }
192 |
193 | /**
194 | *
195 | * Broadcasts metrics updates using postMessage, triggering
196 | * updates to the badge, overlay and logs as appropriate
197 | * @param {Object} metric
198 | */
199 | function broadcastMetricsUpdates(metric) {
200 | if (badgeMetrics === undefined) {
201 | return;
202 | }
203 | if (enableUserTiming) {
204 | addUserTimings(metric);
205 | }
206 | badgeMetrics[metric.name.toLowerCase()].value = metric.value;
207 | badgeMetrics[metric.name.toLowerCase()].rating = metric.rating;
208 | badgeMetrics.timestamp = new Date().toISOString();
209 | const passes = scoreBadgeMetrics(badgeMetrics);
210 |
211 | // Broadcast metrics updates for badging
212 | try {
213 | port.postMessage({
214 | passesAllThresholds: passes,
215 | metrics: badgeMetrics,
216 | });
217 | } catch (_) {
218 | // Do nothing on error, which can happen on tab switches
219 | }
220 |
221 | drawOverlay(badgeMetrics);
222 |
223 | if (enableLogging) {
224 | logSummaryInfo(metric);
225 | }
226 | }
227 |
228 | // Listed to the message response containing the tab id
229 | // to set the tabLoadedInBackground.
230 | port.onMessage.addListener((response) => {
231 | if (response.tabId === undefined) {
232 | return;
233 | }
234 |
235 | // Only set the tabLoadedInBackground if not already set
236 | if (tabLoadedInBackground === undefined) {
237 | const key = response.tabId.toString();
238 | chrome.storage.local.get(key, result => {
239 | tabLoadedInBackground = result[key];
240 | });
241 | }
242 | });
243 |
244 | async function logSummaryInfo(metric) {
245 | let formattedValue;
246 | switch(metric.name) {
247 | case 'CLS':
248 | formattedValue = clsFormatter.format(metric.value);
249 | break;
250 | case 'INP':
251 | case 'Interaction':
252 | formattedValue = millisecondsFormatter.format(metric.value);
253 | break;
254 | default:
255 | formattedValue = secondsFormatter.format(metric.value / 1000);
256 | }
257 |
258 | // Log the EOL warning at the same time as TTFB, which should only occur once per page load.
259 | if (metric.name === 'TTFB') {
260 | console.warn(`${LOG_PREFIX} As of January 2025, support for the Web Vitals extension has ended. We encourage all users to switch to the DevTools Performance panel instead. Learn more: https://developer.chrome.com/blog/web-vitals-extension`);
261 | }
262 |
263 | console.groupCollapsed(
264 | `${LOG_PREFIX} ${metric.name} %c${formattedValue} (${metric.rating})`,
265 | `color: ${RATING_COLORS[metric.rating] || 'inherit'}`
266 | );
267 |
268 | if (metric.name == 'LCP' &&
269 | metric.attribution &&
270 | metric.attribution.lcpEntry &&
271 | metric.attribution.navigationEntry) {
272 | if (tabLoadedInBackground) {
273 | console.warn('LCP inflated by tab loading in the background');
274 | }
275 | console.log('LCP element:', metric.attribution.lcpEntry.element);
276 | console.table([{
277 | 'LCP sub-part': 'Time to first byte',
278 | 'Time (ms)': Math.round(metric.attribution.timeToFirstByte, 0),
279 | }, {
280 | 'LCP sub-part': 'Resource load delay',
281 | 'Time (ms)': Math.round(metric.attribution.resourceLoadDelay, 0),
282 | }, {
283 | 'LCP sub-part': 'Resource load duration',
284 | 'Time (ms)': Math.round(metric.attribution.resourceLoadDuration, 0),
285 | }, {
286 | 'LCP sub-part': 'Element render delay',
287 | 'Time (ms)': Math.round(metric.attribution.elementRenderDelay, 0),
288 | }]);
289 | }
290 |
291 | else if (metric.name == 'FCP' &&
292 | metric.attribution &&
293 | metric.attribution.fcpEntry &&
294 | metric.attribution.navigationEntry) {
295 | if (tabLoadedInBackground) {
296 | console.warn('FCP inflated by tab loading in the background');
297 | }
298 | console.log('FCP loadState:', metric.attribution.loadState);
299 | console.table([{
300 | 'FCP sub-part': 'Time to first byte',
301 | 'Time (ms)': Math.round(metric.attribution.timeToFirstByte, 0),
302 | }, {
303 | 'FCP sub-part': 'FCP render delay',
304 | 'Time (ms)': Math.round(metric.attribution.firstByteToFCP, 0),
305 | }]);
306 | }
307 |
308 | else if (metric.name == 'CLS' && metric.entries.length) {
309 | for (const entry of metric.entries) {
310 | console.log('Layout shift - score: ', Math.round(entry.value * 10000) / 10000);
311 | for (const source of entry.sources) {
312 | console.log(source.node);
313 | }
314 | };
315 | }
316 |
317 | else if ((metric.name == 'INP'|| metric.name == 'Interaction') && metric.attribution) {
318 | const eventTarget = metric.attribution.interactionTargetElement;
319 | console.log('Interaction target:', eventTarget || metric.attribution.interactionTarget);
320 | console.log(`Interaction event type: %c${metric.attribution.interactionType}`, 'font-family: monospace');
321 |
322 | // Sub parts are only available for INP events and not Interactions
323 | if (metric.name == 'INP') {
324 | console.table([{
325 | 'Interaction sub-part': 'Input delay',
326 | 'Time (ms)': Math.round(metric.attribution.inputDelay, 0),
327 | },
328 | {
329 | 'Interaction sub-part': 'Processing duration',
330 | 'Time (ms)': Math.round(metric.attribution.processingDuration, 0),
331 | },
332 | {
333 | 'Interaction sub-part': 'Presentation delay',
334 | 'Time (ms)': Math.round(metric.attribution.presentationDelay, 0),
335 | }]);
336 | }
337 |
338 | if (metric.attribution.longAnimationFrameEntries) {
339 |
340 | const allScripts = metric.attribution.longAnimationFrameEntries.map(a => a.scripts).flat();
341 |
342 | if (allScripts.length > 0) {
343 |
344 | const sortedScripts = allScripts.sort((a,b) => b.duration - a.duration);
345 |
346 | // Pull out the pieces of interest for console table
347 | scriptData = sortedScripts.map((a) => (
348 | {
349 | 'Duration': Math.round(a.duration, 0),
350 | 'Type': a.invokerType || null,
351 | 'Invoker': a.invoker || null,
352 | 'Function': a.sourceFunctionName || null,
353 | 'Source (links below)': a.sourceURL || null,
354 | 'Char position': a.sourceCharPosition || null
355 | }
356 | ));
357 | console.log("Long Animation Frame scripts:");
358 | console.table(scriptData);
359 |
360 | // Get a list of scripts by sourceURL so we can log to console for
361 | // easy linked lookup. We won't include sourceCharPosition as
362 | // Devtools doesn't support linking to a character position and only
363 | // line numbers.
364 | const scriptsBySource = sortedScripts.reduce((acc, {sourceURL, duration}) => {
365 | if (sourceURL) { // Exclude empty URLs
366 | (acc[sourceURL] = acc[sourceURL] || []).push(duration);
367 | }
368 | return acc;
369 | }, {});
370 |
371 | for (const [key, value] of Object.entries(scriptsBySource)) {
372 | console.log(`Script source link: ${key} (Duration${value.length > 1 ? 's' : ''}: ${value})`);
373 | }
374 |
375 | }
376 | }
377 | }
378 |
379 | else if (metric.name == 'TTFB' &&
380 | metric.attribution &&
381 | metric.attribution.navigationEntry) {
382 | console.log('TTFB navigation type:', metric.navigationType);
383 | console.table([{
384 | 'TTFB sub-part': 'Waiting duration',
385 | 'Time (ms)': Math.round(metric.attribution.waitingDuration, 0),
386 | }, {
387 | 'TTFB sub-part': 'Cache duration',
388 | 'Time (ms)': Math.round(metric.attribution.cacheDuration, 0),
389 | }, {
390 | 'TTFB sub-part': 'DNS duration',
391 | 'Time (ms)': Math.round(metric.attribution.dnsDuration, 0),
392 | }, {
393 | 'TTFB sub-part': 'Connection duration',
394 | 'Time (ms)': Math.round(metric.attribution.connectionDuration, 0),
395 | }, {
396 | 'TTFB sub-part': 'Request duration',
397 | 'Time (ms)': Math.round(metric.attribution.requestDuration, 0),
398 | }]);
399 | }
400 |
401 | console.log(metric);
402 | console.groupEnd();
403 | }
404 |
405 | function addUserTimings(metric) {
406 | switch (metric.name) {
407 | case "LCP":
408 | if (!(metric.attribution && metric.attribution.lcpEntry && metric.attribution.navigationEntry)) {
409 | break;
410 | }
411 |
412 | const navEntry = metric.attribution.navigationEntry;
413 | // Set the start time to the later of the actual start time or the activationStart (for prerender) or 0
414 | const startTime = Math.max(navEntry.startTime, navEntry.activationStart) || 0;
415 | // Add the performance marks for the Performance Panel
416 | performance.measure(`${LOG_PREFIX} LCP.timeToFirstByte`, {
417 | start: startTime,
418 | duration: metric.attribution.timeToFirstByte,
419 | });
420 | performance.measure(`${LOG_PREFIX} LCP.resourceLoadDelay`, {
421 | start: startTime + metric.attribution.timeToFirstByte,
422 | duration: metric.attribution.resourceLoadDelay,
423 | });
424 | performance.measure(`${LOG_PREFIX} LCP.resourceLoadDuration`, {
425 | start:
426 | startTime +
427 | metric.attribution.timeToFirstByte +
428 | metric.attribution.resourceLoadDelay,
429 | duration: metric.attribution.resourceLoadDuration,
430 | });
431 | performance.measure(`${LOG_PREFIX} LCP.elementRenderDelay`, {
432 | duration: metric.attribution.elementRenderDelay,
433 | end: metric.value
434 | });
435 | break;
436 |
437 | case "INP":
438 | if (!(metric.attribution)) {
439 | break;
440 | }
441 |
442 | const attribution = metric.attribution;
443 | const interactionTime = attribution.interactionTime;
444 | const inputDelay = attribution.inputDelay;
445 | const processingDuration = attribution.processingDuration;
446 | const presentationDelay = attribution.presentationDelay;
447 |
448 | performance.measure(`${LOG_PREFIX} INP.inputDelay (${metric.attribution.interactionType})`, {
449 | start: interactionTime,
450 | end: interactionTime + inputDelay,
451 | });
452 | performance.measure(`${LOG_PREFIX} INP.processingTime (${metric.attribution.interactionType})`, {
453 | start: interactionTime + inputDelay,
454 | end: interactionTime + inputDelay + processingDuration,
455 | });
456 | performance.measure(`${LOG_PREFIX} INP.presentationDelay (${metric.attribution.interactionType})`, {
457 | start: interactionTime + inputDelay + processingDuration,
458 | end: interactionTime + inputDelay + processingDuration + presentationDelay,
459 | });
460 | break;
461 | }
462 | }
463 |
464 | /**
465 | * Broadcasts the latest CLS value
466 | */
467 | function broadcastCLS() {
468 | broadcastMetricsUpdates(latestCLS);
469 | }
470 |
471 | /**
472 | * Debounces the broadcast of CLS values for stability.
473 | * broadcastCLS is invoked on the trailing edge of the
474 | * DEBOUNCE_DELAY timeout if invoked more than once during
475 | * the wait timeout.
476 | */
477 | let debouncedCLSBroadcast = () => {};
478 | if (typeof _ !== 'undefined') {
479 | debouncedCLSBroadcast = _.debounce(broadcastCLS, DEBOUNCE_DELAY, {
480 | leading: true,
481 | trailing: true,
482 | maxWait: 1000});
483 | }
484 | /**
485 | *
486 | * Fetches Web Vitals metrics via WebVitals.js
487 | */
488 | function fetchWebPerfMetrics() {
489 | // web-vitals.js doesn't have a way to remove previous listeners, so we'll save whether
490 | // we've already installed the listeners before installing them again.
491 | // See https://github.com/GoogleChrome/web-vitals/issues/55.
492 | if (self._hasInstalledPerfMetrics) return;
493 | self._hasInstalledPerfMetrics = true;
494 |
495 | webVitals.onCLS((metric) => {
496 | // As CLS values can fire frequently in the case
497 | // of animations or highly-dynamic content, we
498 | // debounce the broadcast of the metric.
499 | latestCLS = metric;
500 | debouncedCLSBroadcast();
501 | }, { reportAllChanges: true });
502 |
503 | webVitals.onLCP(broadcastMetricsUpdates, { reportAllChanges: true });
504 | webVitals.onINP((metric) => {
505 | broadcastMetricsUpdates(metric)
506 | }, { reportAllChanges: true });
507 | webVitals.onFCP(broadcastMetricsUpdates, { reportAllChanges: true });
508 | webVitals.onTTFB(broadcastMetricsUpdates, { reportAllChanges: true });
509 |
510 | if (enableLogging) {
511 | onEachInteraction((metric) => {
512 | logSummaryInfo(metric, false);
513 | });
514 | }
515 | }
516 |
517 | /**
518 | * Build a template of metrics
519 | * @param {Object} metrics The metrics
520 | * @return {String} a populated template of metrics
521 | */
522 | function buildOverlayTemplate(metrics) {
523 | return `
524 |
525 |
526 |
527 |
528 | Metrics
529 |
530 |
531 |
532 |
533 |
534 |
535 | Largest Contentful Paint
536 | ${tabLoadedInBackground ? 'Value inflated as tab was loaded in background' : ''}
537 |