Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
724a126
feat: add /v1/geo-breakdown endpoint for geographic CWV breakdown (#94)
alonkochba Mar 23, 2026
6351949
test: add tests for /v1/geo-breakdown
max-ostapenko Mar 23, 2026
1789b9e
fix: update CDN cache duration in setCommonHeaders function
max-ostapenko Mar 24, 2026
3b751a4
fix: update CDN cache tag and duration in response headers
max-ostapenko Mar 24, 2026
0f7fe75
feat: add ETag support for caching in report responses
max-ostapenko Mar 24, 2026
e210b14
test: add ETag header tests for /v1/technologies and /v1/adoption routes
max-ostapenko Mar 24, 2026
de409d3
Merge branch 'main' into development
max-ostapenko Mar 24, 2026
553a80e
feat: implement CWV distribution endpoint with BigQuery integration a…
max-ostapenko Mar 27, 2026
6c18a4f
fix: remove unnecessary useLegacySql option from BigQuery query options
max-ostapenko Mar 30, 2026
72f5100
Merge remote-tracking branch 'origin/main' into development
max-ostapenko Mar 30, 2026
a3af58d
Merge branch 'main' into development
max-ostapenko Apr 6, 2026
22b7b44
feat: add workflow for testing
max-ostapenko Apr 6, 2026
a8d033f
fix: update ingress_settings default value to allow all traffic
max-ostapenko Apr 6, 2026
31e5765
feat: add geo filter support to CWV distribution endpoint and update …
max-ostapenko Apr 9, 2026
ceda97d
feat: add geo breakdown endpoint to readme and CWV distribution to MC…
max-ostapenko Apr 9, 2026
03e052e
feat: update CWV distribution query to handle 'ALL' technology case a…
max-ostapenko Apr 13, 2026
0301339
feat: configure ingress settings and increase Cloud Run service resou…
max-ostapenko Apr 15, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
if: github.head_ref != 'development'
steps:
- uses: actions/checkout@v6
- run: |
Expand Down
125 changes: 125 additions & 0 deletions src/controllers/cwvDistributionController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { bigquery } from '../utils/db.js';
import { convertToArray } from '../utils/helpers.js';
import {
handleControllerError,
generateETag,
isModified,
sendValidationError
} from '../utils/controllerHelpers.js';

/**
* Build the BigQuery SQL for the CWV distribution histogram.
* rank is applied to p.rank only (no rank column on device_summary).
*/
const buildQuery = (rankFilter) => {
const rankClause = rankFilter ? 'AND p.rank <= @rank' : '';

return `WITH metrics AS (
SELECT
client,
t.technology,
root_page,
ANY_VALUE(p75_lcp) AS lcp,
ANY_VALUE(p75_inp) AS inp,
ANY_VALUE(p75_cls) AS cls,
ANY_VALUE(p75_fcp) AS fcp,
ANY_VALUE(p75_ttfb) AS ttfb
FROM
\`httparchive.crawl.pages\` p,
UNNEST(technologies) t,
\`chrome-ux-report.materialized.device_summary\` c
WHERE
p.date = @date AND
c.date = @date AND
t.technology IN UNNEST(@technologies) AND
root_page = origin || '/' AND
IF(client = 'mobile', 'phone', 'desktop') = device
${rankClause}
GROUP BY
client,
t.technology,
root_page
)

SELECT
client,
technology,
bucket AS loading_bucket,
bucket / 4 AS inp_bucket,
bucket / 2000 AS cls_bucket,
COUNT(DISTINCT root_page WHERE lcp = bucket) AS lcp_origins,
COUNT(DISTINCT root_page WHERE inp = bucket / 4) AS inp_origins,
COUNT(DISTINCT root_page WHERE cls = bucket / 2000) AS cls_origins,
COUNT(DISTINCT root_page WHERE fcp = bucket) AS fcp_origins,
COUNT(DISTINCT root_page WHERE ttfb = bucket) AS ttfb_origins
FROM
metrics,
UNNEST(GENERATE_ARRAY(0.0, 10000.0, 100.0)) AS bucket
GROUP BY
client,
technology,
bucket
ORDER BY
client,
technology,
bucket`;
};

/**
* GET /v1/cwv-distribution
*
* Query parameters:
* technology (required) - comma-separated list of technologies, e.g. "Wix,WordPress"
* date (required) - crawl date in YYYY-MM-DD format, e.g. "2026-02-01"
* rank (optional) - numeric rank ceiling, e.g. "10000". Omit or set to "ALL" to include all ranks.
*/
export const listCWVDistributionData = async (req, res) => {
try {
const params = req.query;

const errors = [];
if (!params.technology) errors.push(['technology', 'missing technology parameter']);
if (!params.date) errors.push(['date', 'missing date parameter']);
if (errors.length > 0) {
sendValidationError(res, errors);
return;
}

const technologies = convertToArray(params.technology);
const date = params.date;
const rankParam = params.rank && params.rank !== 'ALL' ? params.rank : null;

const queryStr = buildQuery(rankParam !== null);

const queryOptions = {
query: queryStr,
params: {
technologies,
date,
...(rankParam !== null && { rank: parseInt(rankParam, 10) })
},
types: {
technologies: ['STRING'],
date: 'STRING',
...(rankParam !== null && { rank: 'INT64' })
}
};

const [rows] = await bigquery.query(queryOptions);

const jsonData = JSON.stringify(rows);
const etag = generateETag(jsonData);
res.setHeader('ETag', `"${etag}"`);
if (!isModified(req, etag)) {
res.statusCode = 304;
res.end();
return;
}

res.statusCode = 200;
res.end(jsonData);

} catch (error) {
handleControllerError(res, error, 'fetching CWV distribution data');
}
};
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const CONTROLLER_MODULES = {
geos: './controllers/geosController.js',
versions: './controllers/versionsController.js',
static: './controllers/cdnController.js',
cwvDistribution: './controllers/cwvDistributionController.js',
};

const controllers = {};
Expand All @@ -37,6 +38,7 @@ const V1_ROUTES = {
'/v1/geos': ['geos', 'listGeos'],
'/v1/versions': ['versions', 'listVersions'],
'/v1/geo-breakdown': ['geoBreakdown', 'listGeoBreakdownData'],
'/v1/cwv-distribution': ['cwvDistribution', 'listCWVDistributionData']
};

// Helper function to set CORS headers
Expand Down
87 changes: 87 additions & 0 deletions src/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"run": "docker run -p 8080:8080 report-api"
},
"dependencies": {
"@google-cloud/bigquery": "^7.9.1",
"@google-cloud/firestore": "8.3.0",
"@google-cloud/functions-framework": "^5.0.2",
"@google-cloud/storage": "7.19.0",
Expand Down
54 changes: 53 additions & 1 deletion src/tests/routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,14 @@ jest.unstable_mockModule('../utils/db.js', () => {
collection: jest.fn().mockImplementation((collectionName) => mockQuery)
};

const mockBigQueryInstance = {
query: jest.fn().mockResolvedValue([[]])
};

return {
firestore: mockFirestoreInstance,
firestoreOld: mockFirestoreInstance
firestoreOld: mockFirestoreInstance,
bigquery: mockBigQueryInstance
};
});

Expand Down Expand Up @@ -835,4 +840,51 @@ describe('API Routes', () => {
});
});
});

describe('GET /v1/cwv-distribution', () => {
it('should return 400 when technology is missing', async () => {
const res = await request(app).get('/v1/cwv-distribution?date=2026-02-01');
expect(res.statusCode).toEqual(400);
expect(res.body).toHaveProperty('errors');
});

it('should return 400 when date is missing', async () => {
const res = await request(app).get('/v1/cwv-distribution?technology=Wix');
expect(res.statusCode).toEqual(400);
expect(res.body).toHaveProperty('errors');
});

it('should return 400 when both technology and date are missing', async () => {
const res = await request(app).get('/v1/cwv-distribution');
expect(res.statusCode).toEqual(400);
expect(res.body).toHaveProperty('errors');
});

it('should return 200 with valid technology and date', async () => {
const res = await request(app).get('/v1/cwv-distribution?technology=Wix&date=2026-02-01');
expect(res.statusCode).toEqual(200);
expect(Array.isArray(res.body)).toBe(true);
});

it('should return 200 with multiple technologies', async () => {
const res = await request(app).get('/v1/cwv-distribution?technology=Wix,WordPress&date=2026-02-01');
expect(res.statusCode).toEqual(200);
expect(Array.isArray(res.body)).toBe(true);
});

it('should return 200 with rank filter applied', async () => {
const res = await request(app).get('/v1/cwv-distribution?technology=Wix&date=2026-02-01&rank=10000');
expect(res.statusCode).toEqual(200);
expect(Array.isArray(res.body)).toBe(true);
});

it('should handle CORS preflight requests', async () => {
const res = await request(app)
.options('/v1/cwv-distribution')
.set('Origin', 'http://example.com')
.set('Access-Control-Request-Method', 'GET');
expect(res.statusCode).toEqual(204);
expect(res.headers['access-control-allow-origin']).toEqual('*');
});
});
});
8 changes: 8 additions & 0 deletions src/utils/db.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Firestore } from '@google-cloud/firestore';
import { BigQuery } from '@google-cloud/bigquery';

// Initialize Firestore with basic optimizations (default connection using env variables)
const firestore = new Firestore({
Expand Down Expand Up @@ -30,3 +31,10 @@ const firestoreOld = new Firestore({

// Export both connections - maintain backward compatibility
export { firestore, firestoreOld };

// Initialize BigQuery client
const bigquery = new BigQuery({
projectId: process.env.PROJECT
});

export { bigquery };
2 changes: 1 addition & 1 deletion terraform/run-service/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ variable "available_cpu" {
}
variable "ingress_settings" {
type = string
default = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
default = "INGRESS_TRAFFIC_ALL"
description = "String value that controls what traffic can reach the function. Check ingress documentation to see the impact of each settings value. Changes to this field will recreate the cloud function."
}
variable "timeout" {
Expand Down
Loading