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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@
{
"text": "<short summary> Summary must be in present tense, not capitalized, no period at the end. Max 100 characters for the full header."
}
]
],
"chat.tools.terminal.autoApprove": {
"rtk": true
}
}
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Available commands: `lab`, `psi`, `crux`, `crux-history`, `links`, `sitemap`, `l

| Command | Source | Result | Options |
|---------|--------|--------|---------|
| `lab` | Local Lighthouse audit (headless Chrome) | JSON report with performance scores and Web Vitals | `--profile`, `--network`, `--device`, `--urls`, `--urls-file`, `--skip-audits`, `--blocked-url-patterns` |
| `lab` | Local Lighthouse audit (headless Chrome) | JSON report with performance scores and Web Vitals | `--profile`, `--network`, `--device`, `--urls`, `--urls-file`, `--skip-audits`, `--blocked-url-patterns`, `--no-strip-json-props` |
| `psi` | PageSpeed Insights API (real-user data + Lighthouse) | JSON with field metrics and lab scores | `--api-key`, `--api-key-path`, `--urls`, `--urls-file`, `--category`, `--concurrency`, `--delay` |
| `crux` | CrUX API (origin or page, 28-day rolling average) | JSON with p75 Web Vitals and metric distributions | `--scope`, `--api-key`, `--api-key-path`, `--urls`, `--urls-file`, `--concurrency`, `--delay` |
| `crux-history` | CrUX History API (~6 months of weekly data points) | JSON with historical Web Vitals over time | `--scope`, `--api-key`, `--api-key-path`, `--urls`, `--urls-file`, `--concurrency`, `--delay` |
Expand Down Expand Up @@ -95,6 +95,10 @@ node bin/web-perf.js lab --skip-audits=full-page-screenshot,screenshot-thumbnail
node bin/web-perf.js lab --blocked-url-patterns='*.google-analytics.com,*.facebook.net' <url>
node bin/web-perf.js lab --profile=low --blocked-url-patterns='*.ads.example.com' <url>

# Strip unneeded properties (i18n, timing) from JSON output (default: enabled)
node bin/web-perf.js lab --profile=low <url> # JSON excludes i18n, timing
node bin/web-perf.js lab --no-strip-json-props <url> # JSON includes all properties (raw Lighthouse output)

# Multiple URLs (<url> argument is ignored when --urls or --urls-file is provided)
node bin/web-perf.js lab --urls=<url1>,<url2> --profile=low
node bin/web-perf.js lab --urls-file=<urls.txt> --profile=all
Expand All @@ -110,6 +114,7 @@ node bin/web-perf.js lab --urls-file=<urls.txt> --profile=all
| `--urls-file <path>` | No | Path to a file with one URL per line |
| `--skip-audits <audits>` | No | Comma-separated Lighthouse audits to skip. Default: `full-page-screenshot,screenshot-thumbnails,final-screenshot,valid-source-maps` |
| `--blocked-url-patterns <patterns>` | No | Comma-separated URL patterns to block during the audit (e.g. `*.google-analytics.com,*.facebook.net`). Uses Chrome DevTools Protocol to prevent matching assets from being downloaded |
| `--no-strip-json-props` | No | Disable stripping of unneeded properties (`i18n`, `timing`) from JSON output. Omit or leave blank to strip (default). See [ADR-001](docs/decisions/ADR-001-strip-json-props.md) for rationale |

Run `list-profiles`, `list-networks`, or `list-devices` to see all available presets:

Expand Down
9 changes: 6 additions & 3 deletions bin/web-perf.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ const { program } = require('commander');

const { name, version } = require('../package.json');

async function labAction(url, options) {
async function labAction(url, options, cmd) {
try {
const chromeLauncher = require('chrome-launcher');
const { promptLab, parseSkipAuditsFlag, parseBlockedUrlPatternsFlag } = require('../lib/prompts');
const { runLab, CHROME_FLAGS } = require('../lib/lab');
const { formatElapsed } = require('../lib/utils');
const logger = require('../lib/logger');
const resolved = await promptLab(url, options);
const stripJsonPropsOpt = cmd.getOptionValueSource('stripJsonProps') === 'cli' ? options.stripJsonProps : undefined;
const resolved = await promptLab(url, { ...options, stripJsonProps: stripJsonPropsOpt });
const skipAudits = parseSkipAuditsFlag(options.skipAudits) || resolved.skipAudits;
const blockedUrlPatterns = parseBlockedUrlPatternsFlag(options.blockedUrlPatterns) || resolved.blockedUrlPatterns;
const stripJsonProps = resolved.stripJsonProps ?? options.stripJsonProps;

const totalUrls = resolved.urls.length;
const totalRuns = totalUrls * resolved.runs.length;
Expand Down Expand Up @@ -43,7 +45,7 @@ async function labAction(url, options) {
}
try {
// eslint-disable-next-line no-await-in-loop
const outputPath = await runLab(targetUrl, { ...run, skipAudits, blockedUrlPatterns, port: chrome.port, silent: isBatch });
const outputPath = await runLab(targetUrl, { ...run, skipAudits, blockedUrlPatterns, stripJsonProps, port: chrome.port, silent: isBatch });
results.push({ url: targetUrl, profile: label, outputPath });
if (!isBatch) {
const elapsed = formatElapsed(Date.now() - startTime);
Expand Down Expand Up @@ -386,6 +388,7 @@ program
.option('--urls-file <path>', 'Path to a file with one URL per line')
.option('--skip-audits <audits>', 'Comma-separated audits to skip (default: full-page-screenshot,screenshot-thumbnails,final-screenshot,valid-source-maps)')
.option('--blocked-url-patterns <patterns>', 'Comma-separated URL patterns to block during audit (e.g. *.google-analytics.com,*.facebook.net)')
.option('--no-strip-json-props', 'Disable stripping of unneeded properties (i18n, timing) from JSON output')
.action(labAction);

program
Expand Down
77 changes: 77 additions & 0 deletions docs/decisions/ADR-001-strip-json-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# ADR-001: Strip Noise Properties from Lighthouse JSON Output

## Status
Accepted

## Date
2026-04-09

## Context

Lighthouse JSON reports are large (~20 MB per run) and contain metadata properties (`i18n` for localization data, `timing` for internal Lighthouse timings) that are rarely useful for performance analysis. These properties waste storage and network bandwidth when uploading results to external systems.

Users running multiple audits need file sizes to be reasonable. Performance engineers analyzing Lighthouse data care about performance metrics (LCP, CLS, FID, etc.), not Lighthouse internals.

## Decision

Add `--no-strip-json-props` CLI flag to the `lab` command. When enabled (default), strip `i18n` and `timing` from the root level of the Lighthouse JSON before writing the file. Users can disable with `--no-strip-json-props` to get the raw unmodified output.

## Alternatives Considered

### 1. Post-processing (external tool)
- Pros: Doesn't touch the lab command; users can choose
- Cons: Requires manual setup; doesn't solve the problem for most users; adds complexity
- Rejected: Better to make the useful default the default

### 2. Compress the output (gzip)
- Pros: Smaller files, doesn't lose data
- Cons: Requires decompression; doesn't address the core issue (the data is unused)
- Rejected: Stripping unused data is more direct than compression; allows cleaner JSON for human inspection

### 3. Deep/recursive stripping
- Pros: Removes `i18n`/`timing` everywhere in the tree, not just root
- Cons: Slower; future-proofing for data we don't currently see nested
- Rejected: Start with shallow (root-level only); upgrade to deep later if needed

### 4. Configurable property list
- Pros: Flexible for future use cases
- Cons: Complexity; users should rarely need this
- Rejected: Start with hardcoded `STRIP_KEYS`; make configurable if demand emerges

## Implementation Details

### Module: `lib/strip-props.js`
Standalone utility with `stripJsonProps(obj, keys = STRIP_KEYS)`. Shallow removal only — future upgrades can add a `{ deep: true }` option without changing the signature.

### CLI Flag: Commander's `--no-*` Negation
Used Commander's `--no-strip-json-props` pattern: `options.stripJsonProps` is `true` by default, `false` when the flag is passed. This achieves opt-out semantics naturally.

**Bug Found & Fixed:** Commander always sets the default, so `options.stripJsonProps` is never `undefined`. The interactive prompt couldn't distinguish "user didn't specify" from "Commander default". Fixed with `cmd.getOptionValueSource('stripJsonProps')` to detect explicit CLI flags vs. defaults.

### Behavior

```bash
# Default (strips i18n, timing)
node bin/web-perf.js lab <url>

# Explicitly enable
node bin/web-perf.js lab --strip-json-props <url> # same as default

# Disable (raw Lighthouse output)
node bin/web-perf.js lab --no-strip-json-props <url>

# Interactive prompt (no CLI flags provided)
node bin/web-perf.js lab
# ? Allow strip unneeded properties? (Y/n)
```

## Consequences

- Default behavior removes ~5–10% of file size (depends on Lighthouse version)
- Raw mode (`--no-strip-json-props`) avoids the parse/stringify cycle, preserving byte-for-byte original output
- Stripping is shallow today; upgrading to recursive is a non-breaking change (same API)
- Test coverage includes regression test for the Commander flag-source bug (prevents silent reoccurrence)

## Related Decisions

Future: Consider deep/recursive stripping if use cases emerge for nested properties.
38 changes: 22 additions & 16 deletions lib/lab.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const lighthouse = require('lighthouse').default;
const logger = require('./logger');
const { resolveProfileSettings } = require('./profiles');
const { SKIPPABLE_AUDITS } = require('./prompts');
const { stripJsonProps } = require('./strip-props');
const { ensureCommandDir, buildFilename } = require('./utils');

// Audits skipped by default — derived from SKIPPABLE_AUDITS to avoid duplication
Expand All @@ -24,6 +25,21 @@ const CHROME_FLAGS = [
'--ignore-certificate-errors', // ignore certificate errors (useful for testing sites with self-signed certs)
];

function buildLighthouseConfig(labOptions, profileSettings = {}) {
const rawSkipAudits = labOptions.skipAudits || DEFAULT_SKIP_AUDITS;
const disableFullPageScreenshot = rawSkipAudits.includes('full-page-screenshot');
const skipAudits = rawSkipAudits.filter((a) => a !== 'full-page-screenshot');
const blockedUrlPatterns = labOptions.blockedUrlPatterns || [];
const settings = {
...profileSettings,
skipAudits,
...(disableFullPageScreenshot && { disableFullPageScreenshot: true }),
...(blockedUrlPatterns.length > 0 && { blockedUrlPatterns }),
};
const hasSettings = Object.keys(profileSettings).length > 0 || skipAudits.length > 0 || disableFullPageScreenshot || blockedUrlPatterns.length > 0;
return hasSettings ? { extends: 'lighthouse:default', settings } : undefined;
}

async function runLab(url, labOptions = {}) {
ensureCommandDir('lab');

Expand Down Expand Up @@ -57,7 +73,12 @@ async function runLab(url, labOptions = {}) {

const suffix = labOptions.profile || (labOptions.network || labOptions.device ? 'custom' : undefined);
const outputPath = buildFilename(url, 'lab', suffix);
fs.writeFileSync(outputPath, result.report);
if (labOptions.stripJsonProps !== false) {
const reportObj = stripJsonProps(JSON.parse(result.report));
fs.writeFileSync(outputPath, JSON.stringify(reportObj, null, 2));
} else {
fs.writeFileSync(outputPath, result.report);
}

return outputPath;
} finally {
Expand All @@ -67,19 +88,4 @@ async function runLab(url, labOptions = {}) {
}
}

function buildLighthouseConfig(labOptions, profileSettings = {}) {
const rawSkipAudits = labOptions.skipAudits || DEFAULT_SKIP_AUDITS;
const disableFullPageScreenshot = rawSkipAudits.includes('full-page-screenshot');
const skipAudits = rawSkipAudits.filter((a) => a !== 'full-page-screenshot');
const blockedUrlPatterns = labOptions.blockedUrlPatterns || [];
const settings = {
...profileSettings,
skipAudits,
...(disableFullPageScreenshot && { disableFullPageScreenshot: true }),
...(blockedUrlPatterns.length > 0 && { blockedUrlPatterns }),
};
const hasSettings = Object.keys(profileSettings).length > 0 || skipAudits.length > 0 || disableFullPageScreenshot || blockedUrlPatterns.length > 0;
return hasSettings ? { extends: 'lighthouse:default', settings } : undefined;
}

module.exports = { runLab, buildLighthouseConfig, CHROME_FLAGS, DEFAULT_SKIP_AUDITS };
Loading
Loading