├── _config.yml ├── .gitignore ├── docs ├── images │ ├── mcc_id.jpg │ ├── api_library.png │ ├── dev_token_1.jpg │ ├── get_psi_key.png │ ├── ads_dev_token.png │ ├── enable_psi_api.png │ ├── new_app_engine.png │ ├── select_ads_api.png │ ├── select_psi_api.png │ ├── choose_bq_table.png │ ├── copy_data_source.png │ ├── enable_bigquery.png │ ├── firestore_native.png │ ├── new_psi_api_key.png │ ├── restrict_psi_key.png │ ├── select_cloud_tasks.png │ ├── use_report_template.png │ ├── choose_psi_api_project.png │ ├── config_oauth_consent.png │ ├── select_new_data_source.png │ ├── psi_click_create_credentials.png │ └── Forward_Slash_Lockup_white_CMYK.webp ├── betterweb.css ├── index.md ├── credentials.md └── deploy.md ├── Default-Service ├── package.json ├── service.yaml └── default-service.js ├── Config-Service ├── service.yaml ├── requirements.txt ├── config_complete.tpl ├── start_config.tpl └── main.py ├── LH-Task-Handler ├── service.yaml ├── package.json └── lh-task-handler.js ├── Ads-Task-Handler ├── service.yaml ├── requirements.txt └── main.py ├── cron.yaml ├── Controller-Service ├── requirements.txt ├── service.yaml └── main.py ├── dispatch.yaml ├── queue.yaml ├── schemas ├── lh_data.json └── ads_data.json ├── CONTRIBUTING.md ├── README.md ├── install.sh └── LICENSE /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .eslintrc.json 3 | package-lock.json 4 | .gcloudignore 5 | -------------------------------------------------------------------------------- /docs/images/mcc_id.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/mcc_id.jpg -------------------------------------------------------------------------------- /docs/images/api_library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/api_library.png -------------------------------------------------------------------------------- /docs/images/dev_token_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/dev_token_1.jpg -------------------------------------------------------------------------------- /docs/images/get_psi_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/get_psi_key.png -------------------------------------------------------------------------------- /docs/images/ads_dev_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/ads_dev_token.png -------------------------------------------------------------------------------- /docs/images/enable_psi_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/enable_psi_api.png -------------------------------------------------------------------------------- /docs/images/new_app_engine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/new_app_engine.png -------------------------------------------------------------------------------- /docs/images/select_ads_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/select_ads_api.png -------------------------------------------------------------------------------- /docs/images/select_psi_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/select_psi_api.png -------------------------------------------------------------------------------- /docs/images/choose_bq_table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/choose_bq_table.png -------------------------------------------------------------------------------- /docs/images/copy_data_source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/copy_data_source.png -------------------------------------------------------------------------------- /docs/images/enable_bigquery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/enable_bigquery.png -------------------------------------------------------------------------------- /docs/images/firestore_native.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/firestore_native.png -------------------------------------------------------------------------------- /docs/images/new_psi_api_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/new_psi_api_key.png -------------------------------------------------------------------------------- /docs/images/restrict_psi_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/restrict_psi_key.png -------------------------------------------------------------------------------- /docs/images/select_cloud_tasks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/select_cloud_tasks.png -------------------------------------------------------------------------------- /docs/images/use_report_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/use_report_template.png -------------------------------------------------------------------------------- /docs/images/choose_psi_api_project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/choose_psi_api_project.png -------------------------------------------------------------------------------- /docs/images/config_oauth_consent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/config_oauth_consent.png -------------------------------------------------------------------------------- /docs/images/select_new_data_source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/select_new_data_source.png -------------------------------------------------------------------------------- /docs/images/psi_click_create_credentials.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/psi_click_create_credentials.png -------------------------------------------------------------------------------- /docs/images/Forward_Slash_Lockup_white_CMYK.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/speed-opportunity-finder/HEAD/docs/images/Forward_Slash_Lockup_white_CMYK.webp -------------------------------------------------------------------------------- /Default-Service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "default-service", 3 | "version": "1.0.0", 4 | "description": "A simple server that lets people know this isn't anything that can help them.", 5 | "main": "default-service.js", 6 | "scripts": { 7 | "start": "node default-service.js", 8 | "test": "test" 9 | }, 10 | "engines": { 11 | "node": ">=10.0.0" 12 | }, 13 | "author": "Adam Read", 14 | "license": "Apache-2.0", 15 | "dependencies": { 16 | "express": "^4.17.1" 17 | }, 18 | "devDependencies": { 19 | "eslint": "^6.5.1", 20 | "eslint-config-google": "^0.14.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Config-Service/service.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google Inc. 2 | # 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 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | runtime: python38 16 | service: config-service 17 | -------------------------------------------------------------------------------- /Default-Service/service.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google Inc. 2 | # 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 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | runtime: nodejs10 16 | env: standard 17 | service: default 18 | -------------------------------------------------------------------------------- /LH-Task-Handler/service.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google Inc. 2 | # 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 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | runtime: nodejs10 16 | env: standard 17 | service: lh-task-handler 18 | -------------------------------------------------------------------------------- /Ads-Task-Handler/service.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google Inc. 2 | # 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 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | runtime: python38 16 | env: standard 17 | service: ads-task-handler 18 | instance_class: F2 19 | -------------------------------------------------------------------------------- /Ads-Task-Handler/requirements.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google Inc. 2 | # 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 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | bottle 16 | googleads 17 | google-cloud-firestore 18 | google-cloud-logging 19 | google-cloud-bigquery 20 | -------------------------------------------------------------------------------- /Config-Service/requirements.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google Inc. 2 | # 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 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | bottle 16 | google_auth_oauthlib 17 | oauthlib 18 | google-cloud-firestore 19 | google-cloud-logging 20 | -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google LLC 2 | # 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 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | cron: 16 | - description: "Starts data collection every evening" 17 | url: /controller 18 | schedule: every day 20:00 19 | -------------------------------------------------------------------------------- /Controller-Service/requirements.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google Inc. 2 | # 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 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | bottle 16 | googleads 17 | google-cloud-bigquery 18 | google-cloud-firestore 19 | google-cloud-logging 20 | google-cloud-tasks==1.5.0 21 | -------------------------------------------------------------------------------- /Controller-Service/service.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google Inc. 2 | # 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 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | runtime: python38 16 | service: controller-service 17 | basic_scaling: 18 | max_instances: 1 19 | env_variables: 20 | APP_LOCATION: europe-west1 21 | -------------------------------------------------------------------------------- /LH-Task-Handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lh-task-handler", 3 | "version": "1.0.0", 4 | "description": "Cloud task handler for agency dashboard lighthouse audits.", 5 | "main": "lh-task-handler.js", 6 | "scripts": { 7 | "start": "node lh-task-handler.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "adamread", 11 | "license": "Apache-2.0", 12 | "dependencies": { 13 | "@google-cloud/bigquery": "^4.5.0", 14 | "@google-cloud/firestore": "^3.4.1", 15 | "@google-cloud/logging": "^6.0.0", 16 | "express": "^4.17.1", 17 | "request": "^2.88.0", 18 | "request-promise-native": "^1.0.8" 19 | }, 20 | "devDependencies": { 21 | "eslint": "^6.7.2", 22 | "eslint-config-google": "^0.14.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /dispatch.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google Inc. 2 | # 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 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # This file configures the routes required for the agency dashboard solution. 16 | 17 | dispatch: 18 | - url: "*/config*" 19 | service: config-service 20 | - url: "*/controller" 21 | service: controller-service 22 | -------------------------------------------------------------------------------- /Config-Service/config_complete.tpl: -------------------------------------------------------------------------------- 1 | 16 | 17 |

Speed Opportunity Finder Configuration

18 | 19 |

Congratulations!

20 |

The Agency Dashboard backend should now have access to your Ads data.

21 |
22 | -------------------------------------------------------------------------------- /queue.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google Inc. 2 | # 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 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # This file configures the Google Cloud Task Queues required for the agency 16 | # dashboard solution. 17 | 18 | queue: 19 | - name: ads-queue 20 | target: ads-task-handler 21 | rate: 50/s 22 | retry_parameters: 23 | task_retry_limit: 3 24 | task_age_limit: 1h 25 | 26 | - name: lh-queue 27 | target: lh-task-handler 28 | rate: 36/m 29 | retry_parameters: 30 | task_retry_limit: 3 31 | task_age_limit: 2h 32 | min_backoff_seconds: 30 33 | -------------------------------------------------------------------------------- /docs/betterweb.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500,400italic|Google+Sans:400,500,400italic|Google+Sans+Display|Material+Icons&display=swap'); 2 | 3 | body { 4 | font-family: 'Roboto', sans-serif; 5 | font-size: 16pt; 6 | color: #3c4043; 7 | padding: 2%; 8 | margin-left: 6%; 9 | margin-right: 6%; 10 | } 11 | 12 | div.header { 13 | display: flex; 14 | background: #4285f4; 15 | justify-content: space-around; 16 | padding: 0; 17 | margin: 0; 18 | } 19 | 20 | .logo { 21 | padding: 5px; 22 | width: 20%; 23 | margin: auto; 24 | margin-left: 10px; 25 | max-height: 195px; 26 | max-width: 195px; 27 | } 28 | 29 | .title { 30 | padding: 5px; 31 | margin: auto; 32 | margin-left: 10px; 33 | } 34 | 35 | h1 { 36 | font-family: 'Google Sans Display', sans-serif; 37 | font-size: 60pt; 38 | color: #fff; 39 | } 40 | 41 | h2 { 42 | font-family: 'Google Sans Display', sans-serif; 43 | font-size: 48pt; 44 | color: #3c4043; 45 | } 46 | 47 | h3 { 48 | font-family: 'Google Sans', sans-serif; 49 | font-size: 36pt; 50 | color: #9aa0a6; 51 | } 52 | 53 | li { 54 | margin-bottom: 8pt; 55 | } 56 | 57 | li img { 58 | vertical-align: middle; 59 | } 60 | -------------------------------------------------------------------------------- /Default-Service/default-service.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * @fileoverview This is an empty default service for the agency dashboard 19 | * solution. 20 | */ 21 | 'use strict'; 22 | 23 | const express = require('express'); 24 | 25 | const app = express(); 26 | 27 | app.all(/.*/, (req, res) => { 28 | res.status(200) 29 | .send('This is not the app you were looking for.') 30 | .end(); 31 | }); 32 | 33 | const PORT = process.env.PORT || 8081; 34 | app.listen(PORT, () => { 35 | console.log(`Default app listening on port ${PORT}`); 36 | }); 37 | 38 | module.exports = app; 39 | -------------------------------------------------------------------------------- /schemas/lh_data.json: -------------------------------------------------------------------------------- 1 | [{"name":"date","type":"DATETIME","mode":"REQUIRED"},{"name":"url","type":"STRING","mode":"REQUIRED"},{"name":"lhscore","type":"FLOAT","mode":"NULLABLE"},{"name":"first_contentful_paint","type":"FLOAT","mode":"NULLABLE"},{"name":"first_meaningful_paint","type":"FLOAT","mode":"NULLABLE"},{"name":"speed_index","type":"FLOAT","mode":"NULLABLE"},{"name":"estimated_input_latency","type":"FLOAT","mode":"NULLABLE"},{"name":"total_blocking_time","type":"FLOAT","mode":"NULLABLE"},{"name":"max_potential_fid","type":"FLOAT","mode":"NULLABLE"},{"name":"server_response_time","type":"FLOAT","mode":"NULLABLE"},{"name":"first_cpu_idle","type":"FLOAT","mode":"NULLABLE"},{"name":"interactive","type":"FLOAT","mode":"NULLABLE"},{"name":"mainthread_work_breakdown","type":"FLOAT","mode":"NULLABLE"},{"name":"bootup_time","type":"FLOAT","mode":"NULLABLE"},{"name":"network_requests","type":"FLOAT","mode":"NULLABLE"},{"name":"network_rtt","type":"FLOAT","mode":"NULLABLE"},{"name":"network_server_latency","type":"FLOAT","mode":"NULLABLE"},{"name":"total_byte_weight","type":"FLOAT","mode":"NULLABLE"},{"name":"dom_size","type":"FLOAT","mode":"NULLABLE"},{"name":"image_size","type":"FLOAT"},{"name":"script_size","type":"FLOAT"},{"name":"font_size","type":"FLOAT"},{"name":"stylesheet_size","type":"FLOAT"},{"name":"document_size","type":"FLOAT"},{"name":"other_size","type":"FLOAT"},{"name":"media_size","type":"FLOAT"},{"name":"third_party_size","type":"FLOAT"},{"name":"error_code","type":"INTEGER"},{"name":"error_message","type":"STRING"},{"name":"largest_contentful_paint","type":"FLOAT","mode":"NULLABLE"},{"name":"cumulative_layout_shift","type":"FLOAT","mode":"NULLABLE"}] 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | # How to Contribute 18 | 19 | We'd love to accept your patches and contributions to this project. There are 20 | just a few small guidelines you need to follow. 21 | 22 | ## Contributor License Agreement 23 | 24 | Contributions to this project must be accompanied by a Contributor License 25 | Agreement. You (or your employer) retain the copyright to your contribution; 26 | this simply gives us permission to use and redistribute your contributions as 27 | part of the project. Head over to to see 28 | your current agreements on file or to sign a new one. 29 | 30 | You generally only need to submit a CLA once, so if you've already submitted one 31 | (even if it was for a different project), you probably don't need to do it 32 | again. 33 | 34 | ## Code reviews 35 | 36 | All submissions, including submissions by project members, require review. We 37 | use GitHub pull requests for this purpose. Consult 38 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 39 | information on using pull requests. 40 | 41 | ## Community Guidelines 42 | 43 | This project follows 44 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 45 | -------------------------------------------------------------------------------- /Config-Service/start_config.tpl: -------------------------------------------------------------------------------- 1 | 16 | 17 |

Speed Opportunity Finder Configuration

18 | % if defined('error'): 19 | 20 |

There was an error with the uploaded configuration:

21 |

{{error}}

22 |

Please try again

23 |
24 | % end 25 | % if client_config_exists: 26 | 27 |

Your existing credentials will be overwritten 28 | when submitting this form.

29 |
30 | % end 31 |
32 |

Please fill in your credentials:

33 | 36 |
37 | 40 |
41 | 44 |
45 | 48 |
49 | 52 |
53 | 54 |
55 | -------------------------------------------------------------------------------- /schemas/ads_data.json: -------------------------------------------------------------------------------- 1 | [{"name":"CID","type":"STRING","mode":"REQUIRED"},{"name":"CampaignId","type":"STRING","mode":"NULLABLE"},{"name":"CampaignName","type":"STRING","mode":"NULLABLE"},{"name":"CampaignStatus","type":"STRING","mode":"NULLABLE"},{"name":"UnexpandedFinalUrlString","type":"STRING","mode":"NULLABLE"},{"name":"BaseUrl","type":"STRING","mode":"REQUIRED"},{"name":"Date","type":"DATETIME","mode":"REQUIRED"},{"name":"Device","type":"STRING","mode":"NULLABLE"},{"name":"ActiveViewCpm","type":"FLOAT","mode":"NULLABLE"},{"name":"ActiveViewCtr","type":"FLOAT","mode":"NULLABLE"},{"name":"ActiveViewImpressions","type":"FLOAT","mode":"NULLABLE"},{"name":"ActiveViewMeasurability","type":"FLOAT","mode":"NULLABLE"},{"name":"ActiveViewMeasurableCost","type":"FLOAT","mode":"NULLABLE"},{"name":"ActiveViewMeasurableImpressions","type":"FLOAT","mode":"NULLABLE"},{"name":"ActiveViewViewability","type":"FLOAT","mode":"NULLABLE"},{"name":"AllConversions","type":"FLOAT","mode":"NULLABLE"},{"name":"AverageCost","type":"FLOAT","mode":"NULLABLE"},{"name":"AverageCpc","type":"FLOAT","mode":"NULLABLE"},{"name":"AverageCpe","type":"FLOAT","mode":"NULLABLE"},{"name":"AverageCpm","type":"FLOAT","mode":"NULLABLE"},{"name":"AverageCpv","type":"FLOAT","mode":"NULLABLE"},{"name":"AveragePosition","type":"FLOAT","mode":"NULLABLE"},{"name":"Clicks","type":"FLOAT","mode":"NULLABLE"},{"name":"ConversionRate","type":"FLOAT","mode":"NULLABLE"},{"name":"Conversions","type":"FLOAT","mode":"NULLABLE"},{"name":"ConversionValue","type":"FLOAT","mode":"NULLABLE"},{"name":"Cost","type":"FLOAT","mode":"NULLABLE"},{"name":"CostPerConversion","type":"FLOAT","mode":"NULLABLE"},{"name":"CrossDeviceConversions","type":"FLOAT","mode":"NULLABLE"},{"name":"Ctr","type":"FLOAT","mode":"NULLABLE"},{"name":"EngagementRate","type":"FLOAT","mode":"NULLABLE"},{"name":"Engagements","type":"FLOAT","mode":"NULLABLE"},{"name":"Impressions","type":"FLOAT","mode":"NULLABLE"},{"name":"InteractionRate","type":"FLOAT","mode":"NULLABLE"},{"name":"Interactions","type":"FLOAT","mode":"NULLABLE"},{"name":"InteractionTypes","type":"STRING","mode":"NULLABLE"},{"name":"PercentageMobileFriendlyClicks","type":"FLOAT","mode":"NULLABLE"},{"name":"PercentageValidAcceleratedMobilePagesClicks","type":"FLOAT","mode":"NULLABLE"},{"name":"SpeedScore","type":"FLOAT","mode":"NULLABLE"},{"name":"ValuePerConversion","type":"FLOAT","mode":"NULLABLE"},{"name":"VideoViewRate","type":"FLOAT","mode":"NULLABLE"},{"name":"VideoViews","type":"FLOAT","mode":"NULLABLE"},{"name":"ClientName","type":"STRING"}] 2 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Speed Opportunity Finder - Home 3 | --- 4 | 5 | # Landing Page Speed Opportunity Finder 6 | 7 | **This is not an officially supported Google product** 8 | 9 | Copyright 2019 Google LLC. This solution, including any related sample code or 10 | data, is made available on an “as is,” “as available,” and “with all faults” 11 | basis, solely for illustrative purposes, and without warranty or representation 12 | of any kind. This solution is experimental, unsupported and provided solely for 13 | your convenience. Your use of it is subject to your agreements with Google, as 14 | applicable, and may constitute a beta feature as defined under those agreements. 15 | To the extent that you make any data available to Google in connection with your 16 | use of the solution, you represent and warrant that you have all necessary and 17 | appropriate rights, consents and permissions to permit Google to use and process 18 | that data. By using any portion of this solution, you acknowledge, assume and 19 | accept all risks, known and unknown, associated with its usage, including with 20 | respect to your deployment of any portion of this solution in your systems, or 21 | usage in connection with your business, if at all. 22 | 23 | ## Overview 24 | 25 | The speed opportunity finder solution is a set of services to automate the 26 | collection of data used to create reports on how landing page web performance 27 | metrics impact Ads business metrics. 28 | 29 | The data collected comes from Google Ads and the PageSpeed Insights API. An Ads 30 | Management account (MCC) is used to determine which Ads accounts are included in 31 | the data collection. The landing pages from the included accounts are audited 32 | via PageSpeed Insights (PSI). The solution runs on Google App Engine and all of 33 | the data is stored in BigQuery. 34 | 35 | The BigQuery tables are meant to be used as data sources for DataStudio 36 | dashboards. An example dashboard is provided as part of the solution, but it is 37 | meant to be copied and customized for the end client being targeted. 38 | 39 | ## Installation 40 | There are three major steps to installing the Speed Opportunity Finder: 41 | 42 | 1. [Deploy the solution](./deploy.html) to Google App Engine. 43 | 1. [Gather the required credentials](./credentials.html). 44 | 1. [Complete the deployment](./deploy.html#finish-deployment) 45 | 46 | Please look over the [credentials page](./credentials.html) before starting the 47 | deployment. The requirements for a Ads API devloper key may result in a delay 48 | before the tool can be deployed. 49 | 50 | ## Updating to Lighthouse v6 51 | 52 | Lighthouse v6 introduced the Core Web Vitals metrics and made a breaking change 53 | to the report ([Release notes](https://github.com/GoogleChrome/lighthouse/releases/tag/v6.0.0)). 54 | 55 | To check if your deployment needs to be updated, check the lh_data bigquery 56 | table schma for a column named `time_to_first_byte`. If the column is present, 57 | you need to follow the following steps to update your deployment before 58 | deploying the latest version of the tool. If the column is missing, you're 59 | already up to date. 60 | 61 | To update an exising deployment to Lighthouse v6: 62 | 1. Add the following columns to the lh_data table schema: 63 | 1. `{"name": "largest_contentful_paint","type":"FLOAT","mode":"NULLABLE"}` 64 | 1. `{"name":"cumulative_layout_shift","type":"FLOAT","mode":"NULLABLE"}` 65 | 1. Ensure your Speed Opportuniy Finder project is the active project in your 66 | console using `gcloud config set project ` 67 | 1. Run the following command to update the name of time_to_first_byte: 68 | ``` 69 | bq query \ 70 | --destination_table agency_dashboard.lh_data \ 71 | --replace \ 72 | --use_legacy_sql=false \ 73 | 'SELECT 74 | * EXCEPT(time_to_first_byte), 75 | time_to_first_byte AS server_response_time 76 | FROM 77 | agency_dashboard.lh_data' 78 | ``` 79 | 1. Update the name of the column in your datastudio data sources by reconnecting 80 | the data source. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Landing Page Speed Opportunity Finder 2 | 3 | **This is not an officially supported Google product** 4 | 5 | Copyright 2019 Google LLC. This solution, including any related sample code or 6 | data, is made available on an “as is,” “as available,” and “with all faults” 7 | basis, solely for illustrative purposes, and without warranty or representation 8 | of any kind. This solution is experimental, unsupported and provided solely for 9 | your convenience. Your use of it is subject to your agreements with Google, as 10 | applicable, and may constitute a beta feature as defined under those agreements. 11 | To the extent that you make any data available to Google in connection with your 12 | use of the solution, you represent and warrant that you have all necessary and 13 | appropriate rights, consents and permissions to permit Google to use and process 14 | that data. By using any portion of this solution, you acknowledge, assume and 15 | accept all risks, known and unknown, associated with its usage, including with 16 | respect to your deployment of any portion of this solution in your systems, or 17 | usage in connection with your business, if at all. 18 | 19 | ## Overview 20 | 21 | The speed opportunity finder solution is a set of services to automate the 22 | collection of data used to create reports on how landing page web performance 23 | metrics impact Ads business metrics. 24 | 25 | The data collected comes from Google Ads and the PageSpeed Insights API. An Ads 26 | Management account (MCC) is used to determine which Ads accounts are included in 27 | the data collection. The landing pages from the included accounts are audited 28 | via PageSpeed Insights (PSI). The solution runs on Google App Engine and all of 29 | the data is stored in BigQuery. 30 | 31 | The BigQuery tables are meant to be used as data sources for DataStudio 32 | dashboards. An example dashboard is provided as part of the solution, but it is 33 | meant to be copied and customized for the end client being targeted. 34 | 35 | ## Installation 36 | There are three major steps to installing the Speed Opportunity Finder: 37 | 38 | 1. [Deploy the solution](https://google.github.io/speed-opportunity-finder/deploy.html) to Google App Engine. 39 | 1. [Gather the required credentials](https://google.github.io/speed-opportunity-finder/credentials.html). 40 | 1. [Complete the deployment](https://google.github.io/speed-opportunity-finder/deploy.html#finish-deployment) 41 | 42 | Please look over the [credentials page](https://google.github.io/speed-opportunity-finder/credentials.html) 43 | before starting the deployment. The requirements for a Ads API devloper key may 44 | result in a delay before the tool can be deployed. 45 | 46 | ## Updating to Lighthouse v6 47 | 48 | Lighthouse v6 introduced the Core Web Vitals metrics and made a breaking change 49 | to the report ([Release notes](https://github.com/GoogleChrome/lighthouse/releases/tag/v6.0.0)). 50 | 51 | To check if your deployment needs to be updated, check the lh_data bigquery 52 | table schma for a column named `time_to_first_byte`. If the column is present, 53 | you need to follow the following steps to update your deployment before 54 | deploying the latest version of the tool. If the column is missing, you're 55 | already up to date. 56 | 57 | To update an exising deployment to Lighthouse v6: 58 | 1. Add the following columns to the lh_data table schema: 59 | 1. `{"name": "largest_contentful_paint","type":"FLOAT","mode":"NULLABLE"}` 60 | 1. `{"name":"cumulative_layout_shift","type":"FLOAT","mode":"NULLABLE"}` 61 | 1. Ensure your Speed Opportuniy Finder project is the active project in your 62 | console using `gcloud config set project ` 63 | 1. Run the following command to update the name of time_to_first_byte: 64 | ``` 65 | bq query \ 66 | --destination_table agency_dashboard.lh_data \ 67 | --replace \ 68 | --use_legacy_sql=false \ 69 | 'SELECT 70 | * EXCEPT(time_to_first_byte), 71 | time_to_first_byte AS server_response_time 72 | FROM 73 | agency_dashboard.lh_data' 74 | ``` 75 | 1. Update the name of the column in your datastudio data sources by reconnecting 76 | the data source. 77 | -------------------------------------------------------------------------------- /docs/credentials.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Speed Opportunity Finder - Credentials 3 | --- 4 | 5 | 20 | 21 | # Landing Page Speed Opportunity Finder 22 | 23 | ## Gathering the required credentials 24 | 25 | There are a number of credentials required to use the agency dashboard solution. 26 | 27 | 28 | * **Ads Management Account ID** - This is the ID of the management account (MCC) 29 | that the solution will pull reports for. 30 | * **OAuth Client ID & Client Secret** - These are the credentials created 31 | for the Cloud project for the Ads API. 32 | * **Ads Developer Token** - This is a production developer token for the MCC you 33 | are using with the solution. 34 | * **PageSpeed Insights API key** - This is an API key for using pagespeed 35 | insights. This API runs the lighthouse test for you. 36 | 37 | 38 | ### Finding your MCC ID 39 | 40 | Your management account ID can be found on the Google Ads website once you have 41 | logged in. At the top of the page next to the name of the account is a 10-digit 42 | number in the format DDD-DDD-DDDD. This is the MCC ID.
43 | MCC ID location 44 | 45 | ### Creating the Client ID & Secret 46 | 47 | The Client ID and Secret are specific to the Google Cloud project you are 48 | hosting the solution on and can be created in the *API & Services* panel of 49 | the Cloud Console. The process is 50 | [documented on the Google developers site.](https://developers.google.com/adwords/api/docs/guides/authentication#create_a_client_id_and_client_secret") 51 | 52 | You will need to follow the instructions for a web app. The steps should 53 | be: 54 | 1. Configure the consent screen by clicking the button at the top-right of the 55 | pane and filling out the form.
56 | Configure the OAuth consent screen 57 | 58 | 1. Return to the Credentials page and select **Create credentials** and then 59 | **OAuth client ID** 60 | 1. Choose *Web application* as the application type. 61 | 1. Name the client ID 62 | 1. Add the URL to the config service end_config page to the list of authorized 63 | redirect URIs. The URI will have the form `https://control-service-dot- 64 | <defaultHostname>.appspot.com/config_end` If you're not sure of your default 65 | hostname, you can use the command `gcloud app describe` to find it. 66 | 1. Create the **Client ID** and **Client Secret**. 67 | 1. Copy the ID and secret for use in the solution configuration. 68 | 69 | ### Getting a Google Ads Developer Token 70 | 71 | You will require a production developer token to be able to use the Opportunity 72 | Finder with real accounts. 73 | 74 | You can find your developer token in the Google Ads frontend. Select the *API 75 | Center* option from the *Tools & Settings* menu.
76 | API Center menu item 77 |
78 | The Developer token should be the first item on the page. If the access level is 79 | *Test Account*, please click the link to apply for basic access. The solution 80 | will not work without a developer token with access to production accounts.
81 | Ads developer token 82 | 83 | ### Requesting a PageSpeed Insights API Key 84 | 85 | To make bulk requests to the Google PageSpeed Insights (PSI) API, you will 86 | require an API key. This is free, and allows you to make a maximum of 25k 87 | requests per day. The key is connected to a Google Cloud project, so you will 88 | need to create one before getting the PSI API key. 89 | 90 | To create an API key, follow the following steps: 91 | 1. From the cloud console side menu, open the **APIs & Services -> Credentials** 92 | page. 93 | 1. Click the *CREATE CREDENTIALS* button and select *API Key* 94 | 1. On the resulting dialog, click *RESTRICT KEY* 95 | 1. On the resutling page, rename the API key to something memorable. 96 | 1. Under API restrictions, choose the radio button labeled *Restrict key* 97 | 1. Using the dropdown, select the PageSpeed Insights API.
98 | Restrict the PSI API key 99 | 1. Save the changes made to the API key, and copy the key for use with the tool. -------------------------------------------------------------------------------- /docs/deploy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Speed Opportunity Finder - Deployment 3 | --- 4 | 19 | 20 | # Landing Page Speed Opportunity Finder 21 | 22 | ## Deploying the solution 23 | 24 | 1. If you haven't already, create a 25 | [new Google Cloud Project](https://cloud.google.com/resource-manager/docs/creating-managing-projects"). 26 | You can also reuse an existing project if you prefer, but you will need to 27 | ensure none of the existing deployed apps and big query tables conflict with 28 | those required by the opportunity finder. 29 | 30 | 1. In the Google cloud console, clone the 31 | [solution repository](https://github.com/google/speed-opportunity-finder) 32 | 33 | 1. Run the installer script with the command `$ bash install.sh`. If you don't 34 | already have an app engine application for this cloud project, you will be asked 35 | to create one. Follow the directions and be sure to select the correct region. 36 | 37 | 1. Enable Firestore in native mode for the project. Please be sure to select 38 | native mode. This cannot be changed, so if you choose Datastore mode, or are 39 | trying to reuse a project where Datastore mode is already in use, you will need 40 | to create a new project.
41 | Choose Firestore native mode 42 | 43 | ## Finishing the deployment {#finish-deployment} 44 | 45 | 1. Collect all of the [credentials](./credentials) required to use the tool. 46 | 47 | 1. Enter the credentials you gathered on the *config* page of your deployed app. 48 | This should be located at `http://config-service.<defaultHostname>.appspot.com/config` 49 | 50 | 1. Once the credentials are entered, click the *Start OAuth Flow* button and 51 | complete the OAuth flow to provide the solution access to your Ads accounts. If 52 | successful, the page below will be shown. **Please Note**: This step must be 53 | carried out by someone with access to the MCC you are using for the solution. 54 | 55 | 1. To test the solution and start the first round of data collection, ping the 56 | controller service. This should be located at `https://controller-service.< 57 | defaultHostname>.appspot.com`. Please note that the page will likely time out 58 | before you receive a reply. To see if it worked, check the Cloud logs and the 59 | BigQuery tables. 60 | 61 | 1. Set the Cloud project firewall rules to only allow access to the services, 62 | excepting the config service, from the app itself. This will ensure outside 63 | actors are not using project resources or adding unwanted data to the BigQuery 64 | tables. 65 | 66 | ## Attaching the dashboard 67 | 68 | The final step is to attach the Data Studio dashboard to the backend. Follow 69 | the steps below to and be sure to copy the data sources first to ensure the 70 | calculated fields in the data sources are maintained. 71 | 72 | 1. Make a copy of both of the data studio data sources and connect them 73 | to appropriate BigQuery tables in your cloud project. The connectors can 74 | be found here: 75 | * [lh_data data source](https://datastudio.google.com/datasources/62e27e18-338c-4f54-80a7-fe3d43302858) 76 | * [ads_data data source](https://datastudio.google.com/datasources/b689944c-7d8a-4123-8fbf-4eaa5bedd2c9) 77 | Use the copy icon, located next to the *CREATE REPORT* button, to make the copy.
78 | Data source copy button 79 | 80 | 1. After confirming you want to copy the data source, a new window should open. 81 | Select your project, the agency_dashboard dataset, and the appropriate table for 82 | the connector being copied.
83 | Choose the bigquery table 84 | 85 | 1. Make a copy of the 86 | [dashboard template](https://datastudio.google.com/u/2/reporting/3638a403-30c9-49e9-82a9-9f79ddd8999c/page/X3bCB/preview) 87 | To copy the template, click the *USE TEMPLATE* button at the top right of the page.
88 | Click use template 89 | 90 | 1. In the resulting dialog, choose the BigQuery data sources you created with 91 | your project tables.
92 | Select the new data sources 93 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2020 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # Installation script for the agency dashboard solution. Please see the included 18 | # README file for more information. 19 | 20 | set -eu 21 | 22 | ####################################### 23 | # Prints the standard error message and exits. 24 | ####################################### 25 | function err() { 26 | echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: Error $*. Please check the output 27 | above this and address the issue before trying again." >&2 28 | exit 1 29 | } 30 | 31 | ####################################### 32 | # Enables the Google cloud services required for the solution. 33 | ####################################### 34 | function enable_gcloud_services() { 35 | declare -a gcloud_services 36 | gcloud_services=("bigquery" "googleads" "cloudtasks" "firestore") 37 | gcloud_services+=("pagespeedonline") 38 | 39 | local gservice 40 | for gservice in "${gcloud_services[@]}"; do 41 | if ! gcloud services enable "${gservice}".googleapis.com; then 42 | err "enabling ${gservice}" 43 | fi 44 | done 45 | } 46 | 47 | ####################################### 48 | # Deploys the solution's service to app engine. 49 | # 50 | # The default service must be deployed first. 51 | ####################################### 52 | function deploy_solution_services() { 53 | declare -a solution_services 54 | solution_services=("Ads-Task-Handler" "Config-Service" "Controller-Service") 55 | solution_services+=("LH-Task-Handler") 56 | 57 | if ! gcloud app describe; then 58 | if ! gcloud app create; then 59 | echo "This solution requires an app engine project. Please create one." >&2 60 | exit 1 61 | fi 62 | fi 63 | if ! gcloud app deploy -q Default-Service/service.yaml; then 64 | err "deploying Default-Service" 65 | fi 66 | # the location chosen for the service is needed as a environment variable 67 | # in the controller service to add tasks to the task queues. 68 | if ! grep -qF 'APP_LOCATION' Controller-Service/service.yaml; then 69 | app_location="$(gcloud tasks locations list | awk 'FNR==2 {print $1}')" 70 | echo " APP_LOCATION: ${app_location}" >> Controller-Service/service.yaml 71 | fi 72 | 73 | local service 74 | for service in "${solution_services[@]}"; do 75 | 76 | if ! gcloud app deploy -q "${service}"/service.yaml; then 77 | err "deploying ${service} service" 78 | fi 79 | done 80 | } 81 | 82 | ####################################### 83 | # Creates the bigquery tables and views required for the solution. 84 | ####################################### 85 | function create_bq_tables() { 86 | declare -a solution_tables 87 | solution_tables=("ads_data" "lh_data") 88 | 89 | local bq_datasets 90 | bq_datasets=$(bq ls) 91 | 92 | if ! [[ "${bq_datasets}" =~ agency_dashboard ]]; then 93 | if ! bq mk --dataset \ 94 | --description "Agency dashboard data" \ 95 | "${project_id}":agency_dashboard; then 96 | err "creating bigquery dataset" 97 | fi 98 | fi 99 | 100 | local bq_tables 101 | bq_tables=$(bq ls agency_dashboard) 102 | 103 | local table 104 | for table in "${solution_tables[@]}"; do 105 | if ! [[ "${bq_tables}" =~ $table ]]; then 106 | if ! bq mk --table agency_dashboard."${table}" schemas/"${table}".json; then 107 | err "creating bigquery table ${table}" 108 | fi 109 | fi 110 | done 111 | 112 | if ! [[ "${bq_tables}" =~ "base_urls" ]]; then 113 | if ! bq mk --use_legacy_sql=false --view \ 114 | "SELECT DISTINCT BaseUrl FROM \`${project_id}.agency_dashboard.ads_data\` \ 115 | WHERE Cost > 0 \ 116 | AND Date = (SELECT MAX(Date) FROM \`${project_id}.agency_dashboard.ads_data\`)" \ 117 | agency_dashboard.base_urls; then 118 | err "creating bigquery view" 119 | fi 120 | fi 121 | } 122 | 123 | ####################################### 124 | # Deploys the configuration files for the solution. 125 | # 126 | # The dispatch rules must be deployed before the scheduler rules so that the 127 | # controller endpoint exists. 128 | ####################################### 129 | function deploy_config_files() { 130 | if ! gcloud app deploy -q queue.yaml; then 131 | err "deploying task queues" 132 | fi 133 | 134 | if ! gcloud app deploy -q dispatch.yaml; then 135 | err "deploying the dispatch rules" 136 | fi 137 | 138 | if ! gcloud app deploy -q cron.yaml; then 139 | err "deploying scheduled jobs" 140 | fi 141 | } 142 | 143 | function main() { 144 | local script_location 145 | local changed_dir 146 | script_location="${0%/*}" 147 | if [[ "${0}" != "${script_location}" ]] && [[ -n "${script_location}" ]]; then 148 | cd "${script_location}" || (echo "Could not cd to script location"; return 1) 149 | changed_dir=$(true) 150 | fi 151 | #get the project ID and set the default project 152 | read -rp 'Please enter your Google Cloud Project ID: ' project_id 153 | if [[ -z "$project_id" ]]; then 154 | echo "A project ID is required to continue." >&2 155 | exit 1 156 | fi 157 | if ! gcloud config set project "$project_id"; then 158 | err "setting the default cloud project" 159 | fi 160 | 161 | echo "Enabling Google cloud services" 162 | enable_gcloud_services 163 | echo "Deploying solution app engine services" 164 | deploy_solution_services 165 | echo "Creating bigquery tables" 166 | create_bq_tables 167 | echo "Deploying final configuration files" 168 | deploy_config_files 169 | 170 | if [[ "${changed_dir}" ]]; then 171 | cd - || echo "Could not change back to original dir." 172 | fi 173 | } 174 | 175 | main "$@" 176 | 177 | 178 | -------------------------------------------------------------------------------- /LH-Task-Handler/lh-task-handler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * @fileoverview A Cloud Tasks task handler for the agency dashboard. 19 | * 20 | * This takes a single url as a query parameter, performs a lighthouse audit on 21 | * it, and then inserts the relevant metrics into bigquery. 22 | */ 23 | 24 | const express = require('express'); 25 | const {BigQuery} = require('@google-cloud/bigquery'); 26 | const request = require('request-promise-native'); 27 | const {Logging} = require('@google-cloud/logging'); 28 | const Firestore = require('@google-cloud/firestore'); 29 | 30 | const app = express(); 31 | app.enable('trust proxy'); 32 | 33 | const logging = new Logging(process.env.GOOGLE_CLOUD_PROJECT); 34 | const log = logging.log('agency-lh-task'); 35 | 36 | const AUDITS = { 37 | 'largest-contentful-paint': 'largest_contentful_paint', 38 | 'total-blocking-time': 'total_blocking_time', 39 | 'cumulative-layout-shift': 'cumulative_layout_shift', 40 | 'first-contentful-paint': 'first_contentful_paint', 41 | 'first-meaningful-paint': 'first_meaningful_paint', 42 | 'speed-index': 'speed_index', 43 | 'estimated-input-latency': 'estimated_input_latency', 44 | 'total-blocking-time': 'total_blocking_time', 45 | 'max-potential-fid': 'max_potential_fid', 46 | 'server-response-time': 'server_response_time', 47 | 'first-cpu-idle': 'first_cpu_idle', 48 | 'interactive': 'interactive', 49 | 'mainthread-work-breakdown': 'mainthread_work_breakdown', 50 | 'bootup-time': 'bootup_time', 51 | 'network-requests': 'network_requests', 52 | 'network-rtt': 'network_rtt', 53 | 'network-server-latency': 'network_server_latency', 54 | 'total-byte-weight': 'total_byte_weight', 55 | 'dom-size': 'dom_size', 56 | }; 57 | 58 | /** 59 | * Responds to get requests to handle the case of performing a lighthouse audit 60 | * using the lighthouse audit service and then inserting the results into 61 | * bigquery. 62 | */ 63 | app.get('*', async (req, res, next) => { 64 | const testUrl = req.query.url; 65 | if (!testUrl) { 66 | log.error('Missing query parameter'); 67 | res.status(200).json({'error': 'Missing query parameter'}); 68 | return; 69 | } 70 | 71 | try { 72 | const firestore = new Firestore(); 73 | const credentialDoc = firestore.doc('agency_ads/credentials'); 74 | const credentialSnapshot = await credentialDoc.get(); 75 | const psiApiKey = credentialSnapshot.get('psi_api_token'); 76 | 77 | const apiUrl = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'; 78 | const stdParams = `category=performance&strategy=mobile&key=${psiApiKey}`; 79 | const requestUrl = `${apiUrl}?url=${testUrl}&${stdParams}`; 80 | 81 | let psiResult = undefined; 82 | const row = {}; 83 | 84 | try { 85 | psiResult = await request(requestUrl, {json: true}); 86 | } catch (error) { 87 | psiError = JSON.parse(error.message.slice(6)); 88 | if ('error' in psiError) { 89 | const today = new Date(); 90 | row.date = today.toISOString().slice(0, 10); 91 | row.url = testUrl; 92 | // If the page returned an error, we store the error code. 93 | // If PSI returns an error, we set the error code to -2 if the quota was 94 | // exceeeded or to -1 for everything else. 95 | if (psiError.error.message.includes('Status code')) { 96 | row.error_code = psiError.error.message 97 | .match(/Status code: (\d+)/)[1]; 98 | } else if (psiError.error.code === 429) { 99 | row.error_code = -2; 100 | } else { 101 | row.error_code = -1; 102 | } 103 | row.error_message = psiError.error.message; 104 | 105 | log.error(`Lighthouse Error (${requestUrl}): ${psiError.error.message}`); 106 | } 107 | } 108 | if (psiResult) { 109 | const lhAudit = psiResult.lighthouseResult; 110 | row.date = lhAudit.fetchTime.slice(0, 10); 111 | row.url = testUrl; 112 | row.lhscore = lhAudit.categories.performance.score; 113 | for (a of Object.keys(AUDITS)) { 114 | try { 115 | row[AUDITS[a]] = lhAudit.audits[a].numericValue; 116 | } catch (e) { 117 | if (e instanceof TypeError) { 118 | log.error(`Problem accessing ${a} in the PSI audit object.`); 119 | } else { 120 | throw e; 121 | } 122 | } 123 | } 124 | // the individual sizes of resources 125 | for (part of lhAudit.audits['resource-summary'].details.items) { 126 | if (part.resourceType === 'total') { 127 | continue; 128 | } else { 129 | const rowName = part.resourceType.replace('-', '_') + '_size'; 130 | row[rowName] = part.transferSize; 131 | } 132 | } 133 | } 134 | 135 | const bqClient = new BigQuery(); 136 | const table = bqClient.dataset('agency_dashboard').table('lh_data'); 137 | await table.insert(row); 138 | log.info(`Success for ${testUrl}`); 139 | res.status(201).json({'url': testUrl}); 140 | } catch (err) { 141 | console.error(`LH Task ERROR: ${err.message}`); 142 | if ('errors' in err) { 143 | console.error(`ERROR CAUGHT: ${err.errors[0].row}`); 144 | } 145 | if ('name' in err && err.name === 'PartialFailureError') { 146 | for (e of err.errors) { 147 | for (e2 of e.errors) { 148 | log.error(`BigQuery error: ${e2.message}`); 149 | } 150 | } 151 | } 152 | return next(err); 153 | } 154 | }); 155 | 156 | const PORT = process.env.PORT || 8082; 157 | app.listen(PORT, () => { 158 | console.log(`lh-task-handler started on port ${PORT}`); 159 | }); 160 | -------------------------------------------------------------------------------- /Controller-Service/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020 Google Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | """This service drives the agency dashboard solution by triggering landing page 17 | 18 | report requests and lighthouse audits. 19 | 20 | This module runs as a web service and is designed to be targeted by Google Cloud 21 | Scheduler. Using credentials stored in firestore, it first requests all of the 22 | CIDs associated with the stored MCC ID from Ads. Using those CIDs, it creates 23 | Cloud tasks to have landing page reports retrieved and stored in bigquery. Once 24 | the landing page report tasks have been completed, it creates tasks to run 25 | lighthouse audits on all of the URLs in the project's base_urls bigquery table 26 | and have them stored in bigquery. 27 | """ 28 | 29 | import datetime 30 | import logging 31 | import os 32 | import time 33 | import urllib 34 | 35 | from bottle import Bottle 36 | from bottle import HTTPError 37 | from googleads import adwords 38 | 39 | import google.cloud.bigquery 40 | import google.cloud.exceptions 41 | import google.cloud.firestore 42 | import google.cloud.logging 43 | import google.cloud.tasks 44 | 45 | app = Bottle() 46 | 47 | 48 | def get_cids(ads_client, mcc_id): 49 | """Fetches all of the cids under the given mcc. 50 | 51 | The cids are placed in a set because it's possible to have a client attached 52 | to multiple mccs. 53 | 54 | Args: 55 | ads_client: an instance of AdwordsClient already authenticated for the MCC 56 | to be used. 57 | mcc_id: the mcc to get the client accounts for. 58 | 59 | Returns: 60 | A set of all of the client ids under the given mcc. 61 | """ 62 | cids = set() 63 | mcc_ids = set() 64 | mcc_ids.add(mcc_id) 65 | 66 | while mcc_ids: 67 | current_mcc = mcc_ids.pop() 68 | ads_client.SetClientCustomerId(current_mcc) 69 | mcc_service = ads_client.GetService( 70 | 'ManagedCustomerService', version='v201809') 71 | selector = {'fields': ['CustomerId', 'Name', 'CanManageClients']} 72 | result = mcc_service.get(selector) 73 | 74 | for record in result.entries: 75 | if str(record.customerId) == mcc_id: 76 | continue 77 | elif record.canManageClients: 78 | mcc_ids.add(record.customerId) 79 | else: 80 | cids.add((record.customerId, record.name)) 81 | 82 | return cids 83 | 84 | 85 | @app.route('/') 86 | @app.route('/controller') 87 | def start_update(): 88 | """This route triggers the process of updating the ads and lighthouse data.""" 89 | 90 | logging_client = google.cloud.logging.Client() 91 | logging_handler = logging_client.get_default_handler() 92 | logger = logging.getLogger('Controller-Service') 93 | logger.addHandler(logging_handler) 94 | 95 | project_name = os.environ['GOOGLE_CLOUD_PROJECT'] 96 | project_location = os.environ['APP_LOCATION'] 97 | today = datetime.date.today().isoformat() 98 | 99 | ads_client = None 100 | task_client = None 101 | ads_queue_path = None 102 | last_run_dates = None 103 | config_doc = None 104 | 105 | try: 106 | storage_client = google.cloud.firestore.Client() 107 | credentials_doc = ( 108 | storage_client.collection('agency_ads').document('credentials').get()) 109 | mcc_id = credentials_doc.get('mcc_id') 110 | developer_token = credentials_doc.get('developer_token') 111 | client_id = credentials_doc.get('client_id') 112 | client_secret = credentials_doc.get('client_secret') 113 | refresh_token = credentials_doc.get('refresh_token') 114 | ads_credentials = ('adwords:\n' + f' client_customer_id: {mcc_id}\n' + 115 | f' developer_token: {developer_token}\n' + 116 | f' client_id: {client_id}\n' + 117 | f' client_secret: {client_secret}\n' + 118 | f' refresh_token: {refresh_token}') 119 | ads_client = adwords.AdWordsClient.LoadFromString(ads_credentials) 120 | except google.cloud.exceptions.NotFound: 121 | logger.exception('Unable to load ads credentials.') 122 | raise HTTPError(500, 'Unable to load Ads credentials.') 123 | 124 | try: 125 | config_doc = storage_client.collection('agency_ads').document('config') 126 | config_doc_snapshot = config_doc.get() 127 | last_run_dates = config_doc_snapshot.get('last_run') 128 | except google.cloud.exceptions.NotFound: 129 | logger.exception('Exception retrieving last run dates.') 130 | raise HTTPError(500, 'Config document not found in firestore') 131 | except KeyError: 132 | logger.info('Last run dates not in firestore.') 133 | last_run_dates = {} 134 | if last_run_dates is None: 135 | config_doc.create({'last_run': {}}) 136 | last_run_dates = {} 137 | 138 | try: 139 | task_client = google.cloud.tasks.CloudTasksClient() 140 | ads_queue_path = task_client.queue_path(project_name, project_location, 141 | 'ads-queue') 142 | except: 143 | logger.exception('Exception creating tasks client') 144 | raise HTTPError(500, 'Exception creating tasks client.') 145 | 146 | try: 147 | cids = get_cids(ads_client, mcc_id.replace('-', '')) 148 | except: 149 | logger.exception('Exception while getting cids') 150 | raise HTTPError(500, 'Exception while getting cids') 151 | for client in cids: 152 | try: 153 | cid = client[0] 154 | client_name = client[1] 155 | task_url = (f'http://ads-task-handler.{project_name}.appspot.com' + 156 | f'?cid={cid}&' + f'name={urllib.parse.quote(client_name)}') 157 | if cid in last_run_dates: 158 | task_url += f'&startdate={last_run_dates[cid]}' 159 | task = {'http_request': {'http_method': 'GET', 'url': task_url}} 160 | except TypeError: 161 | logger.exception('Error creating task_url for record %s - %s', cid, 162 | client_name) 163 | continue 164 | try: 165 | task_client.create_task(ads_queue_path, task) 166 | config_doc.update({f'last_run.{cid}': today}) 167 | except (google.api_core.exceptions.GoogleAPICallError, 168 | google.api_core.exceptions.RetryError, ValueError): 169 | logger.exception('Exception queing ads queries (url = %s)', task_url) 170 | except google.cloud.exceptions.NotFound: 171 | logger.exception('Exception updating ads last_run firebase doc.') 172 | 173 | # polling the queue to ensure all the URLs are available before starting the 174 | # lighthouse tests. It would be nice to have a better, parallel way to do 175 | # this. 176 | ads_queue_size = True 177 | while ads_queue_size: 178 | time.sleep(30) 179 | ads_queue_list = list(task_client.list_tasks(ads_queue_path)) 180 | ads_queue_size = len(ads_queue_list) 181 | 182 | try: 183 | bigquery_client = google.cloud.bigquery.Client() 184 | url_query = f'''SELECT BaseUrl 185 | FROM `{project_name}.agency_dashboard.base_urls`''' 186 | query_response = bigquery_client.query(url_query) 187 | except: 188 | logger.exception('Exception querying for URLs') 189 | raise HTTPError(500, 'Exception querying for URLs') 190 | 191 | try: 192 | lh_queue_path = task_client.queue_path(project_name, project_location, 193 | 'lh-queue') 194 | for row in query_response: 195 | url = urllib.parse.quote(row['BaseUrl']) 196 | task = { 197 | 'http_request': { 198 | 'http_method': 199 | 'GET', 200 | 'url': 201 | f'http://lh-task-handler.{project_name}.appspot.com?url={url}' 202 | } 203 | } 204 | task_client.create_task(lh_queue_path, task) 205 | except: 206 | logger.exception('Excpetion queue lh tasks.') 207 | raise HTTPError(500, 'Exception queuing lh tasks.') 208 | 209 | 210 | if __name__ == '__main__': 211 | app.run(host='localhost', port=8084) 212 | -------------------------------------------------------------------------------- /Config-Service/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020 Google Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | """This module provides a service for adding credentials to the agency 17 | dashboard. 18 | 19 | The module is used in conjunction with the rest of the agency dashboard solution 20 | to provide the credentials required to acccess the Ads accounts that will be the 21 | basis for reporting. 22 | 23 | This must be deployed as part of the agency dashboard Google Cloud AppEngine 24 | app. Optionally, this component can be left out of the deployment and the 25 | required credentials can be inserted manually into the project firestore store. 26 | 27 | The required credentials must be located in a document stored at 28 | /agency_ads/credentials and have the following fields: 29 | - mcc_id: the account id of the management account to be used with the app 30 | - client_id: the client ID created in the Google API console 31 | - client_secret: the client secret generated with the above client id 32 | - developer_token: the developer token for the account to be used with the app 33 | - refresh_token: an oauth2 refresh token generated using the client id and 34 | secret above 35 | """ 36 | 37 | import logging 38 | import urllib.parse 39 | 40 | from bottle import Bottle 41 | from bottle import HTTPError 42 | from bottle import redirect 43 | from bottle import request 44 | from bottle import template 45 | from bottle import view 46 | from google_auth_oauthlib.flow import Flow 47 | from oauthlib.oauth2.rfc6749.errors import InvalidGrantError 48 | 49 | import google.cloud.exceptions 50 | import google.cloud.firestore 51 | import google.cloud.logging 52 | 53 | app = Bottle() 54 | logging_client = google.cloud.logging.Client() 55 | logging_handler = logging_client.get_default_handler() 56 | logger = logging.getLogger('Config-Service') 57 | logger.addHandler(logging_handler) 58 | 59 | 60 | def client_config_exists(): 61 | """Retrives the Adwords client configuation from firestore. 62 | 63 | Retrieves the Adwords client configuration from firestore if it is available. 64 | If the client_config doc is not found in the data store, a NotFound exception 65 | is raised. 66 | 67 | Returns: 68 | A dict containing the web client configuration for Google Adwords 69 | 70 | Raises: 71 | google.cloud.exceptions.NotFound: the client_config document was not in the 72 | agency_ads collection 73 | """ 74 | storage_client = google.cloud.firestore.Client() 75 | try: 76 | client_config = ( 77 | storage_client.collection('agency_ads').document('credentials').get()) 78 | try: 79 | client_config.get('client_id') 80 | except KeyError: 81 | return False 82 | except google.cloud.exceptions.NotFound: 83 | return False 84 | 85 | return True 86 | 87 | 88 | @app.route('/config') 89 | @view('start_config') 90 | def start_ads_config(): 91 | """This route starts the oauth authentication flow. 92 | 93 | The route displays a page that allows the user to input the information 94 | required 95 | 96 | The route first checks for the existance of the config. If there is an 97 | existing config, a message is show to the user to help them avoid writing over 98 | it. 99 | 100 | Returns: 101 | An html page with fields for entering credentials. 102 | 103 | """ 104 | return {'client_config_exists': client_config_exists()} 105 | 106 | 107 | @app.route('/config_end') 108 | def end_ads_config(): 109 | """This route completes the oauth flow and saves the returned refresh token. 110 | 111 | The oauth state saved in the start of the flow is also removed from the 112 | credentials doc. 113 | 114 | Returns: 115 | On success, an HTML page is returned letting the user know the authorization 116 | was successful. On failure, the user is returned the page to enter their 117 | credentials with an error message. 118 | """ 119 | storage_client = google.cloud.firestore.Client() 120 | try: 121 | credentials_doc = ( 122 | storage_client.collection('agency_ads').document('credentials').get()) 123 | client_id = credentials_doc.get('client_id') 124 | client_secret = credentials_doc.get('client_secret') 125 | oauth_state = credentials_doc.get('oauth_state') 126 | except (google.cloud.exceptions.NotFound, KeyError): 127 | logger.exception('Unable to load ads credentials.') 128 | return template( 129 | 'start_config', error='Error loading credentials after oauth') 130 | 131 | auth_code = request.query.get('code') 132 | if not auth_code: 133 | return template('start_config', error='No authorization code in request.') 134 | 135 | client_config = { 136 | 'web': { 137 | 'client_id': client_id, 138 | 'client_secret': client_secret, 139 | 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', 140 | 'token_uri': 'https://accounts.google.com/o/oauth2/token', 141 | } 142 | } 143 | flow = Flow.from_client_config( 144 | client_config, 145 | scopes=['https://www.googleapis.com/auth/adwords'], 146 | state=oauth_state) 147 | 148 | req = urllib.parse.urlparse(request.url) 149 | redirect_uri = f'{req.scheme}://{req.hostname}/config_end' 150 | flow.redirect_uri = redirect_uri 151 | try: 152 | flow.fetch_token(code=auth_code) 153 | except InvalidGrantError: 154 | logger.exception('Error fetching refresh token after oauth') 155 | return template('start_config', error='Error retreiving refresh token.') 156 | 157 | storage_client = google.cloud.firestore.Client() 158 | try: 159 | credentials_doc = storage_client.collection('agency_ads').document( 160 | 'credentials') 161 | credentials_doc.update({ 162 | 'refresh_token': flow.credentials.refresh_token, 163 | 'oauth_state': google.cloud.firestore.DELETE_FIELD 164 | }) 165 | except google.cloud.exceptions.NotFound: 166 | logger.exception('Error finding or updating credentials in firestore.') 167 | return template( 168 | 'start_config', error='Error updating credentials doc in firestore') 169 | 170 | return template('config_complete') 171 | 172 | 173 | @app.route('/config_upload_client', method='POST') 174 | def save_client_config(): 175 | """Route used to save the client configuration JSON file to firestore.""" 176 | mcc_id = request.forms.get('mcc_id') 177 | client_id = request.forms.get('client_id') 178 | client_secret = request.forms.get('client_secret') 179 | developer_token = request.forms.get('developer_token') 180 | psi_api_token = request.forms.get('psi_api_token') 181 | 182 | client_config = { 183 | 'web': { 184 | 'client_id': client_id, 185 | 'client_secret': client_secret, 186 | 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', 187 | 'token_uri': 'https://accounts.google.com/o/oauth2/token', 188 | } 189 | } 190 | flow = Flow.from_client_config( 191 | client_config, scopes=['https://www.googleapis.com/auth/adwords']) 192 | req = urllib.parse.urlparse(request.url) 193 | redirect_uri = f'{req.scheme}://{req.hostname}/config_end' 194 | flow.redirect_uri = redirect_uri 195 | auth_url, oauth_state = flow.authorization_url(prompt='consent') 196 | 197 | storage_client = google.cloud.firestore.Client() 198 | try: 199 | credentials_doc = storage_client.collection('agency_ads').document( 200 | 'credentials') 201 | credentials_content = { 202 | 'mcc_id': mcc_id, 203 | 'client_id': client_id, 204 | 'client_secret': client_secret, 205 | 'developer_token': developer_token, 206 | 'psi_api_token': psi_api_token, 207 | 'oauth_state': oauth_state 208 | } 209 | credentials_doc.set(credentials_content) 210 | except google.cloud.exceptions.NotFound: 211 | logger.exception('Unable to find ads credentials.') 212 | raise HTTPError(500, 'Unable to find ads credentials.') 213 | 214 | redirect(auth_url) 215 | -------------------------------------------------------------------------------- /Ads-Task-Handler/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020 Google Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | """This service retrieves and forwards landing page reports in CSV format. 17 | 18 | The Ads-Task-Handler downloads the landing page report for the Google Ads 19 | account with the given CID. The report is then enriched with the name provided, 20 | the CID, and a base URL for the landing page. The base URL is the landing page 21 | URL stripped of parameters after {ignore} and any trailing '?' or '/'. 22 | 23 | The enriched landing page report is then loaded into the 24 | agency_dashboard.ads_data biqquery table of the working Google CLoud project. 25 | """ 26 | 27 | import csv 28 | from datetime import datetime 29 | import logging 30 | import os 31 | 32 | from bottle import Bottle 33 | from bottle import HTTPError 34 | from bottle import request 35 | from googleads import adwords 36 | 37 | from google.cloud import bigquery 38 | from google.cloud import firestore 39 | import google.cloud.exceptions 40 | import google.cloud.logging 41 | 42 | app = Bottle() 43 | 44 | logging_client = google.cloud.logging.Client() 45 | logging_handler = logging_client.get_default_handler() 46 | logger = logging.getLogger('Ads-Service') 47 | logger.setLevel(logging.INFO) 48 | logger.addHandler(logging_handler) 49 | 50 | # The columns of the landing page report with the name as returned by the API as 51 | # the key and the name used in the select statement as the value. 52 | REPORT_COLS = { 53 | 'Campaign ID': 'CampaignId', 54 | 'Campaign': 'CampaignName', 55 | 'Campaign state': 'CampaignStatus', 56 | 'Landing page': 'UnexpandedFinalUrlString', 57 | 'Day': 'Date', 58 | 'Device': 'Device', 59 | 'Active View avg. CPM': 'ActiveViewCpm', 60 | 'Active View viewable CTR': 'ActiveViewCtr', 61 | 'Active View viewable impressions': 'ActiveViewImpressions', 62 | 'Active View measurable impr. / impr.': 'ActiveViewMeasurability', 63 | 'Active View measurable cost': 'ActiveViewMeasurableCost', 64 | 'Active View measurable impr.': 'ActiveViewMeasurableImpressions', 65 | 'Active View viewable impr. / measurable impr.': 'ActiveViewViewability', 66 | 'All conv.': 'AllConversions', 67 | 'Avg. Cost': 'AverageCost', 68 | 'Avg. CPC': 'AverageCpc', 69 | 'Avg. CPE': 'AverageCpe', 70 | 'Avg. CPM': 'AverageCpm', 71 | 'Avg. CPV': 'AverageCpv', 72 | 'Avg. position': 'AveragePosition', 73 | 'Clicks': 'Clicks', 74 | 'Conv. rate': 'ConversionRate', 75 | 'Conversions': 'Conversions', 76 | 'Total conv. value': 'ConversionValue', 77 | 'Cost': 'Cost', 78 | 'Cost / conv.': 'CostPerConversion', 79 | 'Cross-device conv.': 'CrossDeviceConversions', 80 | 'CTR': 'Ctr', 81 | 'Engagement rate': 'EngagementRate', 82 | 'Engagements': 'Engagements', 83 | 'Impressions': 'Impressions', 84 | 'Interaction Rate': 'InteractionRate', 85 | 'Interactions': 'Interactions', 86 | 'Interaction Types': 'InteractionTypes', 87 | 'Mobile-friendly click rate': 'PercentageMobileFriendlyClicks', 88 | 'Valid AMP click rate': 'PercentageValidAcceleratedMobilePagesClicks', 89 | 'Mobile speed score': 'SpeedScore', 90 | 'Value / conv.': 'ValuePerConversion', 91 | 'View rate': 'VideoViewRate' 92 | } 93 | 94 | PROJECT_NAME = os.environ['GOOGLE_CLOUD_PROJECT'] 95 | 96 | 97 | @app.route('/') 98 | def export_landing_page_report(): 99 | """This route triggers the download of the Ads landing page report. 100 | 101 | The landing page report for the client is downloaded using credentials stored 102 | in the project firestore datastore. The report is downloaded either for the 103 | last 30 days if never run before, or from the last run date to today if there 104 | is a date stored in firestore. The last run date is updated after the report 105 | is downloaded from Ads. 106 | 107 | Returns: 108 | The landing page report in CSV format 109 | 110 | Raises: 111 | HTTPError: Used to cause bottle to return a 500 error to the client. 112 | """ 113 | customer_id = request.params.get('cid') 114 | customer_name = request.params.get('name') 115 | start_date = request.params.get('startdate') 116 | if not customer_id: 117 | logger.error('Client customer id (cid) not included in request') 118 | raise HTTPError(400, 119 | 'Customer client id not provided as cid query parameter.') 120 | 121 | storage_client = firestore.Client() 122 | 123 | try: 124 | credentials_doc = ( 125 | storage_client.collection('agency_ads').document('credentials').get()) 126 | developer_token = credentials_doc.get('developer_token') 127 | client_id = credentials_doc.get('client_id') 128 | client_secret = credentials_doc.get('client_secret') 129 | refresh_token = credentials_doc.get('refresh_token') 130 | ads_credentials = ('adwords:\n' + f' client_customer_id: {customer_id}\n' + 131 | f' developer_token: {developer_token}\n' + 132 | f' client_id: {client_id}\n' + 133 | f' client_secret: {client_secret}\n' + 134 | f' refresh_token: {refresh_token}') 135 | except google.cloud.exceptions.NotFound: 136 | logger.exception('Unable to load ads credentials.') 137 | raise HTTPError(500, 'Unable to load Ads credentials.') 138 | 139 | ads_client = adwords.AdWordsClient.LoadFromString(ads_credentials) 140 | landing_page_query = adwords.ReportQueryBuilder() 141 | # selecting campaign attributes, unexpanded final url, device, 142 | # date, and all of the landing page metrics. 143 | landing_page_query.Select(','.join(REPORT_COLS.values())) 144 | landing_page_query.From('LANDING_PAGE_REPORT') 145 | if not start_date: 146 | landing_page_query.During(date_range='YESTERDAY') 147 | else: 148 | try: 149 | start_date = datetime.date.fromisoformat(start_date) 150 | today = datetime.date.today() 151 | except ValueError: 152 | logger.info('Invalid date passed in startdate parameter.') 153 | raise HTTPError(400, 'Invalid date in startdate parameter.') 154 | if start_date == datetime.date.today(): 155 | landing_page_query.During(date_range='TODAY') 156 | else: 157 | if today < start_date: 158 | logger.error('Last run date in the future (start_date: %s)', start_date) 159 | raise HTTPError(400, 160 | 'startdate in the future (start_date: %s)' % start_date) 161 | 162 | landing_page_query.During( 163 | start_date=start_date.strftime('%Y%m%d'), 164 | end_date=today.strftime('%Y%m%d')) 165 | 166 | landing_page_query = landing_page_query.Build() 167 | 168 | report_downloader = ads_client.GetReportDownloader(version='v201809') 169 | try: 170 | landing_page_report = ( 171 | report_downloader.DownloadReportAsStreamWithAwql( 172 | landing_page_query, 173 | 'CSV', 174 | skip_report_header=True, 175 | skip_report_summary=True)) 176 | except Exception as e: 177 | logger.exception('Problem with retrieving landing page report') 178 | raise HTTPError(500, 'Unable to retrieve landing page report %s' % e) 179 | 180 | ads_cols = [] 181 | ads_rows = [] 182 | try: 183 | while True: 184 | report_line = landing_page_report.readline() 185 | if not report_line: break 186 | 187 | report_line = report_line.decode().replace('\n', '') 188 | report_row = report_line.split(',') 189 | 190 | if not ads_cols: 191 | ads_cols = report_row 192 | continue 193 | 194 | report_row = dict(zip(ads_cols, report_row)) 195 | # replace row keys 196 | report_row = {REPORT_COLS[key]: val for key, val in report_row.items()} 197 | base_url = report_row['UnexpandedFinalUrlString'] 198 | # removes parameters after ignore and, if the url then ends with a lone 199 | # ?, it too is removed. 200 | if '{ignore}' in base_url: 201 | base_url = base_url[0:base_url.index('{ignore}')] 202 | if base_url.endswith('?'): 203 | base_url = base_url[0:-1] 204 | report_row['BaseUrl'] = base_url 205 | report_row['CID'] = customer_id 206 | report_row['ClientName'] = customer_name 207 | # Ads reports return percentages as strings with %, so we change them 208 | # back to numbers between 0 and 1 209 | # we also need to change -- to 0 to insert values. 210 | for k, v in report_row.items(): 211 | if v.endswith('%'): 212 | report_row[k] = float(v[0:-1]) / 100 213 | elif v.isdecimal(): 214 | report_row[k] = float(v) 215 | elif v == ' --': 216 | report_row[k] = None 217 | 218 | ads_rows.append(report_row) 219 | except OSError as e: 220 | logger.exception('Problem reading the landing page report: %s', e) 221 | raise HTTPError(500, 'Unable to read landing page report.') 222 | finally: 223 | landing_page_report.close() 224 | 225 | if ads_rows: 226 | csv_file_name = '/tmp/ads_data.' + str(datetime.timestamp(datetime.now())) 227 | with open(csv_file_name, 'w', newline='') as csv_file: 228 | try: 229 | csv_writer = csv.DictWriter(csv_file, fieldnames=ads_cols) 230 | csv_writer.writeheader() 231 | csv_writer.writerows(ads_rows) 232 | except OSError as os_error: 233 | logger.exception( 234 | 'Problem writing the landing page report to a temp file: %s', e) 235 | raise HTTPError(500, 'Unable to write landing page report to file.') 236 | try: 237 | bq_client = bigquery.Client() 238 | bq_table = bq_client.get_table( 239 | f'{PROJECT_NAME}.agency_dashboard.ads_data') 240 | bq_job_config = bigquery.LoadJobConfig() 241 | bq_job_config.source_format = bigquery.SourceFormat.CSV 242 | bq_job_config.skip_leading_rows = 1 243 | bq_job_config.autodetect = True 244 | with open(csv_file_name, 'rb') as csv_file: 245 | try: 246 | bq_job = bq_client.load_table_from_file( 247 | csv_file, bq_table, job_config=bq_job_config) 248 | bq_job_result = bq_job.result() 249 | except Exception as ex: 250 | logger.exception('Problem loading ads data into bigquery: %s', ex) 251 | raise ex 252 | except google.cloud.exceptions.GoogleCloudError as gce: 253 | logger.exception('Problem loading ads data into bigquery: %s', 254 | gce.message) 255 | raise gce 256 | 257 | if __name__ == '__main__': 258 | app.run(host='localhost', port=8090) 259 | -------------------------------------------------------------------------------- /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 [yyyy] [name of copyright owner] 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. 203 | --------------------------------------------------------------------------------