Metric Scoring & Data Normalization
Automated site health monitoring fails not from a lack of data but from a lack of determinism. Raw telemetry from crawlers, synthetic tests, and real-user monitoring arrives in incompatible units, sampled at different device tiers, and timestamped across misaligned timezone boundaries. This reference covers the full pipeline that SREs, SEO engineers, and agency teams use to transform that raw signal into reproducible composite health scores, actionable threshold alerts, and deployment-gating CI checks.
Pipeline Architecture: Ingest → Normalize → Score → Alert → Remediate
The diagram below shows the ordered execution chain covered by this section. Each stage has a corresponding guide that covers its configuration in detail.
| Stage | Component | Function |
|---|---|---|
| Ingest | Crawler / Log ingestion | Extracts DOM snapshots, network waterfalls, JS traces |
| Normalize | Cross-device calibration engine | Aligns LCP, CLS, INP distributions across mobile and desktop |
| Score | Weighted metric aggregation | Computes composite indices from normalised telemetry |
| Alert | Threshold breach router | Triggers PagerDuty / Slack on SLO violations |
| Remediate | Automated ticket & CI gate | Opens Jira / Linear tickets and blocks deployments |
Phase 1 — Raw Data Ingestion and Pre-Processing
Problem framing
Every downstream scoring decision is only as reliable as the data entering the pipeline. Ingesting unfiltered scanner traffic, misresolved redirect targets, or duplicate session URLs corrupts baseline percentiles immediately. The ingestion layer must enforce a canonical URL contract before any metric value is recorded.
Route headless crawlers, server access logs, RUM APIs, and Lighthouse CI runners into a single queue. The automated crawling pipeline provides the upstream tooling that feeds this stage; establish strict extraction schemas before connecting any new source.
#!/usr/bin/env python3
"""
ingest/validate.py — canonical URL normalisation and payload validation.
Requires: jsonschema>=4.21.0 (pin in requirements.txt)
"""
import re
import json
import jsonschema
from pathlib import Path
# Strip UTM parameters, session IDs, and click-tracking suffixes
PARAM_PATTERN = re.compile(
r'[?&](utm_\w+|sid|session_id|fbclid|gclid|mc_\w+)=[^&]*',
re.IGNORECASE
)
# CLS is unitless with no hard ceiling; practical upper bound set at 2.0
CRAWL_SCHEMA = {
"type": "object",
"required": ["url", "status_code", "lcp_ms", "cls_score", "inp_ms"],
"properties": {
"url": {"type": "string", "format": "uri"},
"status_code": {"type": "integer", "minimum": 200, "maximum": 599},
"lcp_ms": {"type": "number", "minimum": 0},
"cls_score": {"type": "number", "minimum": 0, "maximum": 2.0},
"inp_ms": {"type": "number", "minimum": 0}
},
"additionalProperties": True
}
def normalize_url(raw_url: str) -> str:
"""Strip tracking params and trailing slashes for canonical key derivation."""
clean = PARAM_PATTERN.sub('', raw_url)
clean = re.sub(r'[?&]$', '', clean) # remove dangling separator
return clean.rstrip('/')
def validate_payload(record: dict) -> bool:
try:
jsonschema.validate(instance=record, schema=CRAWL_SCHEMA)
return True
except jsonschema.ValidationError as exc:
print(f"[WARN] Payload rejected — {exc.message}: {json.dumps(record)[:200]}")
return False
def ingest_batch(records: list[dict]) -> list[dict]:
clean = []
for r in records:
r["url"] = normalize_url(r["url"])
if validate_payload(r):
clean.append(r)
return clean
Verification steps:
- Run
python -m pytest tests/test_ingest.py -v— all URL normalisation cases must pass. - Check queue depth in your ingestion broker; it should drain within the crawl cadence window.
- Spot-check five random records from the normalised stream against source URLs to confirm canonical resolution.
Common mistakes:
- Ingesting unfiltered bot and scanner traffic into baseline calculations — segment by
user_agentbefore the validation gate. - Failing to resolve relative URLs before normalisation — convert to absolute paths using the crawl origin as base.
- Overlooking timezone discrepancies in server log timestamps — parse all timestamps to UTC at ingestion, not at query time.
Phase 2 — Cross-Device Normalisation and Baseline Calibration
Deterministic device profiles
Mobile and desktop LCP distributions are not comparable without a normalisation step. A 3 000 ms LCP on a throttled mobile profile is not equivalent to 3 000 ms on a fibre-connected desktop. For normalizing performance data across device types, map every synthetic run to a named profile with explicit CPU throttle multipliers and network constraints.
# lighthouserc.yml — pin lighthouse version in package.json (e.g. "lighthouse": "12.3.0")
ci:
collect:
numberOfRuns: 5 # median of 5 reduces single-run variance
settings:
chromeFlags: "--no-sandbox --disable-dev-shm-usage"
profiles:
mobile:
preset: "mobile"
throttling:
cpuSlowdownMultiplier: 4
rttMs: 150
throughputKbps: 1638 # Equivalent to slow 4G
desktop:
preset: "desktop"
throttling:
cpuSlowdownMultiplier: 1
rttMs: 40
throughputKbps: 10240
Rolling baseline computation
Compute device-class baselines using a 30-day rolling window so seasonal traffic patterns do not corrupt the reference distribution. The SQL below targets BigQuery but the PERCENTILE_CONT window function is standard SQL and portable to PostgreSQL or DuckDB.
-- Rolling p75 baseline per device class, updated daily
-- Table: `project.dataset.raw_telemetry`
SELECT
device_type,
metric_name,
DATE(collection_ts) AS collection_date,
PERCENTILE_CONT(metric_value, 0.75) OVER (
PARTITION BY device_type, metric_name
ORDER BY UNIX_DATE(DATE(collection_ts))
RANGE BETWEEN 29 PRECEDING AND CURRENT ROW
) AS p75_baseline,
PERCENTILE_CONT(metric_value, 0.90) OVER (
PARTITION BY device_type, metric_name
ORDER BY UNIX_DATE(DATE(collection_ts))
RANGE BETWEEN 29 PRECEDING AND CURRENT ROW
) AS p90_baseline
FROM `project.dataset.raw_telemetry`
WHERE collection_ts >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 60 DAY)
Idempotency guard: Store the computed baseline snapshot keyed on (device_type, metric_name, snapshot_date) with a UNIQUE constraint. Re-running the pipeline on the same day overwrites the same row rather than appending a duplicate.
Common mistakes:
- Using raw desktop metrics as universal baselines for mobile-first indexing — always segment before computing percentiles.
- Ignoring network latency variance in synthetic testing environments — use a fixed emulation profile, not the host machine's real connection.
- Hardcoding device profiles inline instead of parameterising via environment variables — treat
CPU_THROTTLE_MULTIPLIERandNETWORK_PROFILEas config, not constants.
Phase 3 — Threshold Configuration and Algorithmic Scoring
Section-aware thresholds
Uniform thresholds fail because checkout flows, authentication pages, and blog content have different conversion stakes. Calibrating thresholds for different site sections covers the full workflow; the JSON below shows the minimum viable threshold matrix for two section types.
{
"version": "2.1.0",
"thresholds": {
"checkout": {
"lcp_ms": {"info": 2000, "warning": 2500, "critical": 4000},
"inp_ms": {"info": 150, "warning": 200, "critical": 500},
"cls_score":{"info": 0.05, "warning": 0.1, "critical": 0.25}
},
"content": {
"lcp_ms": {"info": 2500, "warning": 3000, "critical": 5000},
"inp_ms": {"info": 200, "warning": 300, "critical": 600},
"cls_score":{"info": 0.1, "warning": 0.2, "critical": 0.4}
},
"auth": {
"lcp_ms": {"info": 2000, "warning": 2500, "critical": 3500},
"inp_ms": {"info": 100, "warning": 200, "critical": 400},
"cls_score":{"info": 0.05, "warning": 0.1, "critical": 0.2}
}
}
}
Store this file in version control alongside your application code. Any PR that modifies threshold values should trigger a scoring-engine diff review.
Weighted composite scoring
The custom health score algorithm design workflow explains how to derive per-section weight matrices. The function below implements the composite scorer with a regression penalty.
#!/usr/bin/env python3
"""
scoring/composite.py — weighted health score with regression decay.
"""
from __future__ import annotations
from typing import Any
# Default weights: adjust per section via scored_sections config
DEFAULT_WEIGHTS: dict[str, float] = {
"lcp_score": 0.40,
"inp_score": 0.35,
"cls_score": 0.15,
"ttfb_score": 0.10,
}
def normalise_metric(value: float, warning: float, critical: float) -> float:
"""Map raw metric value to [0, 100] score; higher = better."""
if value <= warning:
# Linear scale from 100 down to 50 between 0 and warning
return 100.0 - (value / warning) * 50.0
if value <= critical:
# Linear scale from 50 down to 0 between warning and critical
return 50.0 - ((value - warning) / (critical - warning)) * 50.0
return 0.0
def calculate_weighted_score(
raw_metrics: dict[str, float],
thresholds: dict[str, dict[str, float]],
weights: dict[str, float] | None = None,
regression_count: int = 0,
decay: float = 0.95,
) -> float:
"""Return a 0–100 composite health score with optional regression penalty."""
w = weights or DEFAULT_WEIGHTS
normalised: dict[str, float] = {}
metric_map = {
"lcp_score": ("lcp_ms", "warning", "critical"),
"inp_score": ("inp_ms", "warning", "critical"),
"cls_score": ("cls_score","warning", "critical"),
"ttfb_score": ("ttfb_ms", "warning", "critical"),
}
for score_key, (raw_key, warn_key, crit_key) in metric_map.items():
if raw_key in raw_metrics and score_key in thresholds:
t = thresholds[score_key]
normalised[score_key] = normalise_metric(
raw_metrics[raw_key], t[warn_key], t[crit_key]
)
raw_score = sum(normalised.get(k, 0) * v for k, v in w.items())
penalty = decay ** regression_count if regression_count > 0 else 1.0
return round(max(0.0, min(100.0, raw_score * penalty)), 2)
Verification steps:
- Unit-test
normalise_metric(2500, 2500, 4000)— should return exactly50.0. - Confirm the composite score for a perfect payload is
100.0(all metrics at zero). - Verify that
regression_count=5withdecay=0.95yields approximately 77 % of the base score.
Common mistakes:
- Applying uniform thresholds across pages with fundamentally different user journeys.
- Over-weighting vanity metrics (e.g. raw byte size) over conversion-critical signals (INP on checkout).
- Failing to version-control threshold JSON alongside application code — threshold drift causes silent scoring regressions.
Phase 4 — CI/CD Integration and Release Cycle Tracking
Embedding audit runners into pull-request pipelines
Integrate the scoring engine into your CI workflow on pull-request merges, not on every commit — full audits are expensive and noise increases false-positive block rates. Tracking metric trends across release cycles covers the correlation of deployment tags with score deltas; the workflow below wires the audit run to the CI merge event.
# .github/workflows/site-health-audit.yml
name: Site Health Audit
on:
pull_request:
branches: [main, "release/**"]
paths:
- "src/**"
- "public/**"
- "package*.json"
workflow_dispatch: # allow manual runs for hotfix validation
env:
NODE_VERSION: "20.x"
LIGHTHOUSE_VERSION: "12.3.0"
AUDIT_BASELINE_BUCKET: "gs://your-bucket/baselines"
jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Warm cache (eliminate cold-start noise)
run: |
TARGET_URL="${{ vars.STAGING_URL }}"
for i in 1 2 3; do curl -s -o /dev/null "$TARGET_URL"; done
- name: Run Lighthouse CI
run: |
npx --yes "lighthouse@${{ env.LIGHTHOUSE_VERSION }}" \
"${{ vars.STAGING_URL }}" \
--output=json \
--output-path=./audit-results/lhci-report.json \
--chrome-flags="--no-sandbox --disable-dev-shm-usage"
- name: Diff against baseline
run: node scripts/diff-metrics.js \
--report ./audit-results/lhci-report.json \
--baseline "${{ env.AUDIT_BASELINE_BUCKET }}/main-latest.json" \
--threshold-config config/scoring/thresholds.json
env:
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: audit-results-${{ github.sha }}
path: ./audit-results/
retention-days: 90
Webhook payload for alert routing
When a score crosses a critical threshold, the scoring engine emits a structured webhook. Include a stable dedup_key (hash of metric name + deployment tag) so downstream PagerDuty or Opsgenie can suppress duplicate alerts within a single deployment window.
{
"event": "metric_regression",
"severity": "critical",
"section": "checkout",
"metrics": {
"lcp_ms": {"value": 4200, "threshold_critical": 4000, "delta_pct": 5.0},
"inp_ms": {"value": 550, "threshold_critical": 500, "delta_pct": 10.0}
},
"deployment_tag": "v2.4.1-rc3",
"baseline_snapshot": "2026-06-20",
"dedup_key": "sha256:8f3ac14...c91b"
}
Rate-limiting guard: Wrap the alert emitter in a per-dedup_key TTL cache (e.g. Redis SET NX EX 3600) to prevent alert floods during a deployment that triggers multiple consecutive threshold breaches.
Common mistakes:
- Running full multi-URL audits on every commit — scope the trigger to PR merges and include a
paths:filter. - Failing to isolate
STAGING_URLandPRODUCTION_URLenvironment variables — a mis-pointed audit pollutes production baselines. - Skipping the cache warm-up requests before Lighthouse runs — cold-start TTFB readings inflate LCP and skew composite scores.
Phase 5 — Validation, Forecasting, and Automated Remediation
Scoring accuracy validation against RUM
Synthetic audit scores must be validated against real-user monitoring telemetry before being treated as authoritative. Correlate p75 LCP from the Chrome UX Report (CrUX) or your own RUM SDK against your synthetic composite score on a weekly cadence. A synthetic score that consistently diverges from RUM by more than 15 percentage points signals a miscalibrated device profile or environment.
Time-series forecasting for proactive breach detection
Use a time-series model to predict which pages are trending toward a critical threshold before the next deployment. The Prophet configuration below is suitable for weekly-seasonal traffic patterns.
#!/usr/bin/env python3
"""
forecasting/lcp_breach.py — LCP threshold breach forecast.
Requires: prophet>=1.1.5, pandas>=2.0.0 (pin in requirements.txt)
"""
from __future__ import annotations
import pandas as pd
from prophet import Prophet # type: ignore[import]
CRITICAL_LCP_MS = 4000.0
def forecast_lcp_breach(
historical_df: pd.DataFrame, # columns: date (datetime), lcp_ms (float)
horizon_days: int = 14,
) -> pd.DataFrame:
"""
Returns forecast DataFrame with columns:
ds, yhat, yhat_lower, yhat_upper, will_breach (bool)
"""
model = Prophet(
yearly_seasonality=False,
weekly_seasonality=True,
daily_seasonality=False,
changepoint_prior_scale=0.05, # conservative — site metrics are slow-moving
interval_width=0.90,
)
fit_df = historical_df.rename(columns={"date": "ds", "lcp_ms": "y"})
model.fit(fit_df)
future = model.make_future_dataframe(periods=horizon_days)
forecast = model.predict(future)
result = forecast[["ds", "yhat", "yhat_lower", "yhat_upper"]].copy()
result["will_breach"] = result["yhat_upper"] >= CRITICAL_LCP_MS
return result
Automated remediation ticket creation
When a threshold breach is confirmed (not just forecast), open a structured remediation ticket. Gate automated ticket creation behind a confidence check — only fire for breaches that persist across two consecutive audit runs to eliminate transient spikes.
#!/usr/bin/env python3
"""
remediation/jira_ticket.py — automated remediation ticket via Jira REST API v3.
"""
from __future__ import annotations
import os
import requests
JIRA_BASE_URL = os.environ["JIRA_BASE_URL"] # e.g. https://your-org.atlassian.net
JIRA_TOKEN = os.environ["JIRA_API_TOKEN"]
JIRA_PROJECT = os.environ.get("JIRA_PROJECT_KEY", "SITE-OPS")
def create_remediation_ticket(
metric: str,
value: float,
threshold: float,
deployment_tag: str,
section: str,
) -> str:
"""Opens a Jira Bug ticket; returns the created issue key (e.g. SITE-OPS-42)."""
summary = (
f"[Auto] {metric} breach on {section}: "
f"{value:.0f} > {threshold:.0f} @ {deployment_tag}"
)
payload = {
"fields": {
"project": {"key": JIRA_PROJECT},
"summary": summary,
"description": {
"type": "doc", "version": 1,
"content": [{"type": "paragraph", "content": [
{"type": "text", "text": (
f"Automated detection: {metric} value {value:.0f} exceeds "
f"critical threshold {threshold:.0f} on deployment {deployment_tag}. "
"Review diff-metrics report attached to the CI run."
)}
]}]
},
"issuetype": {"name": "Bug"},
"priority": {"name": "High"},
"labels": ["site-health", "automated", section],
}
}
headers = {
"Authorization": f"Bearer {JIRA_TOKEN}",
"Content-Type": "application/json",
}
resp = requests.post(
f"{JIRA_BASE_URL}/rest/api/3/issue",
json=payload, headers=headers, timeout=15
)
resp.raise_for_status()
return resp.json()["key"]
Rollback procedure: If automated tickets are opening for a legitimate threshold change (not a real regression), set REMEDIATION_DRY_RUN=true in your CI environment to log ticket payloads without calling the Jira API, then update the threshold JSON in a follow-up PR.
Cross-Cutting Concerns
Data retention and versioning
| Artifact | Format | Retention | Storage |
|---|---|---|---|
| Raw telemetry records | Parquet, gzip compressed | 90 days | Object storage (versioned bucket) |
| Computed baselines | JSON, keyed by snapshot date | 1 year | Object storage |
| Composite score time-series | Parquet | 2 years | Data warehouse |
| Audit run reports | JSON + HTML | 90 days | CI artifact store |
- Pin all crawler and audit tool versions in lockfiles (
package-lock.json,requirements.txt). Any version bump must be a deliberate PR, not an incidental upgrade. - Containerise the scoring engine with a deterministic base image (pin the digest, not just the tag).
- Keep threshold configuration files (
thresholds.json) and scoring weights under the same version-control review workflow as application code.
Environment parity
- Enforce identical
NODE_ENV,LIGHTHOUSE_VERSION, and device-profile settings across local, staging, and CI environments using a.env.auditfile checked into source control (secrets injected at runtime, not committed). - Use
docker run --rmto execute audit scripts locally and in CI from the same image — eliminates "works on my machine" scoring variance. - Establish a scheduled weekly audit against production (not just PR-triggered staging audits) to detect organic performance drift that deployment diffs would miss.
Failure Modes and Rollback
The following failure patterns are specific to metric scoring pipelines. Each entry includes a root cause, a diagnostic command, and a recovery path.
1. Baseline drift from cold-start noise
Root cause: audit runs execute before server-side caches warm up, inflating TTFB and LCP values.
Diagnostic: compare p50_lcp on the first run vs. the median of runs 2–5 in a multi-run batch.
Fix: prepend three curl -s -o /dev/null "$URL" warm-up requests before each Lighthouse invocation.
2. Device-class contamination in percentile computation
Root cause: mobile and desktop telemetry pooled in the same PERCENTILE_CONT window.
Diagnostic: SELECT device_type, COUNT(*) FROM raw_telemetry GROUP BY device_type — any single-value result means the partition split failed.
Fix: verify the PARTITION BY device_type clause is present and that the ETL job populates device_type before the baseline query runs.
3. Scoring engine version skew between CI and production
Root cause: package.json specifies a range (^12) rather than an exact version; npm resolves a newer minor that changes scoring weights.
Fix: npm install [email protected] --save-exact and commit the updated package-lock.json.
4. Alert flood on legitimate threshold change
Root cause: threshold JSON updated without clearing the alert deduplication cache.
Fix: redis-cli DEL "dedup:*" (or equivalent key namespace flush) after deploying a threshold change.
5. Jira API token expiry causing silent ticket loss
Root cause: bearer token rotated without updating the CI secret.
Diagnostic: curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $JIRA_API_TOKEN" "$JIRA_BASE_URL/rest/api/3/myself" — expect 200.
Fix: rotate the token in Jira, update JIRA_API_TOKEN in your CI secrets store, re-run the failed pipeline.
6. Prophet forecast instability on short history
Root cause: fewer than 30 data points in the training window; the model overfits changepoints.
Fix: set changepoint_prior_scale=0.001 and disable weekly_seasonality until you have at least 60 daily data points; fall back to a simple linear trend in the interim.
FAQ
What is the correct percentile to use as a scoring baseline?
Use p75 for LCP and INP thresholds, matching the Chrome UX Report and Core Web Vitals programme definitions. Use p90 for server-side TTFB when your SLO budget is tighter. Always segment by device class before computing the percentile — pooling mobile and desktop inflates apparent desktop headroom and masks real mobile regressions.
How do I prevent scoring drift between staging and production?
Pin the exact audit tool version in both environments, run cache warm-up requests before each measurement window, and store scoring engine logic in version control alongside application code so any change triggers a diff alert. Schedule a weekly production audit run to catch organic drift that PR-triggered staging audits miss.
Should LCP, CLS, and INP carry equal weight in a composite health score?
No. Weight by conversion impact for the page template: checkout pages should penalise INP regressions most heavily, content pages should weight LCP first. Use the custom health score algorithm workflow to configure per-section weight matrices and document the rationale alongside the threshold JSON.
Related
- Normalizing Performance Data Across Device Types — standardised synthetic profiles and rolling baseline computation
- Designing Custom Health Score Algorithms — weight matrices, composite index design, and dashboard wiring
- Calibrating Error Thresholds for Different Site Sections — section-aware severity tiers and e-commerce vs. content tolerances
- Tracking Metric Trends Across Release Cycles — deployment-tag correlation, regression detection, and trend dashboards
- Building Score Aggregation Pipelines — roll per-URL scores up to section and site level with coverage weighting
- Percentile Normalization Across Metric Distributions — convert skewed metrics into comparable percentile ranks
- Health Score Reference Tables — canonical thresholds, severity tiers, and default metric weights
- Automated Crawling Pipeline & Tooling — upstream data collection that feeds the ingestion stage of this pipeline
- Monitoring, Alerting & Remediation — turn scoring regressions into routed alerts and remediation playbooks