Add Google Search Console plugin#63
Conversation
|
@claude review |
| Country: row.keys?.[0], | ||
| Clicks: row.clicks, | ||
| Impressions: row.impressions, | ||
| CTR: Number((row.ctr * 100).toFixed(2)), |
There was a problem hiding this comment.
Could you clarify the intent here? You're rounding/stringifying and then converting back to a number which might lose some precision.
There was a problem hiding this comment.
GSC returns ctr as a decimal number between 0 and 1, intent was to show CTR as a percentage and to round it - but I see what you're saying! I can do it as CTR: Math.round(row.ctr * 10000) / 100 if that's cleaner
There was a problem hiding this comment.
I would lave all the formatting to SquaredUp - if you just return this as, you can then set the metadata to:
- shape percentage
- range between 0 and 1: true
- decimal places: 2
There was a problem hiding this comment.
Sounds good, will do! :)
| Date: new Date(row.keys?.[0]), | ||
| Clicks: row.clicks, | ||
| Impressions: row.impressions, | ||
| CTR: Number((row.ctr * 100).toFixed(2)), |
| Page: row.keys?.[0], | ||
| Clicks: row.clicks, | ||
| Impressions: row.impressions, | ||
| CTR: Number((row.ctr * 100).toFixed(2)), |
| { | ||
| "name": "pageUrLs", | ||
| "displayName": "Page URLs", | ||
| "description": "Returns a list off URLs with impressions in the last year", |
There was a problem hiding this comment.
This doesn't look quite right - the timeframe is 'now -> whatever end date selected in SquaredUp'
There was a problem hiding this comment.
This stream is used for object discovery / indexing where I wanted to pull pages seen in the last year regardless of the dashboard timeframe (the API always requires a startDate and endDate)
You reckon I need to update it somehow or is it okay for that?
| "dimensions": [ | ||
| "page" | ||
| ], | ||
| "rowLimit": 25 |
There was a problem hiding this comment.
Do this or other streams need paging configured?
There was a problem hiding this comment.
Doesn't need explicit paging but I definitively need to check the rowLimit configs, for one this stream should be way higher than 25!
| "getArgs": [], | ||
| "headers": [], | ||
| "postBody": { | ||
| "startDate": "{{new Date(new Date(timeframe.start).getTime() - (new Date(timeframe.end).getTime() - new Date(timeframe.start).getTime()) - 86400000).toISOString().split('T')[0]}}", |
There was a problem hiding this comment.
Can't quite get my head around what happens if selected yearly ranges or something here - do we need to restrict the available timeframes?
There was a problem hiding this comment.
I don’t think we need to restrict timeframes, as the calculation should work within 1 day error - I guess the main consideration is performance but I've not been able to test with a large dataset (and GSC data has a max query timeframe of 16 months)
| @@ -0,0 +1,59 @@ | |||
| { | |||
| "name": "previousPeriodQueriesByPage", | |||
There was a problem hiding this comment.
Given we have data/time selection in SquaredUp - do we need these 'previous period' streams, or can the user effectively create them by setting the date appropriately on two tiles?
There was a problem hiding this comment.
For sure they could have tiles with fixed timeframes, but the intent was to enable fully timeframe-responsive comparison dashboards, it felt a bit of a letdown having the entire dashboards refresh and then have two tiles need re-configuring
E.g. if a user changes the dashboard from Last 30 days to Last quarter, the Organic Search Performance tiles will automatically update, but as fixed comparison tiles they would remain fixed until reconfigured
It's definitely not a necessity, but it makes the pre-built dashboards feel very smooth!
(Side note, I should probably name the tiles Period performance comparison or something to make it clearer)
Let me know what you think! Happy to go with fixed if needed
There was a problem hiding this comment.
This is a cool feature request tbf, being able to offset a tile from the dashboard timeframe.
Smart way to achieve it
| "rowLimit": 25000, | ||
| "dataState": "final" | ||
| }, | ||
| "postRequestScript": "postRequest/script2.js", |
There was a problem hiding this comment.
Worth renaming this script name (and script1) to be more identifiable
|
@claude review |
| "author": { | ||
| "name": "Daniel.Hodgson@squaredup.com", | ||
| "type": "community" | ||
| }, |
There was a problem hiding this comment.
🟡 metadata.json doesn't follow the repo's metadata conventions: author.name is set to a raw email (Daniel.Hodgson@squaredup.com) rather than a GitHub handle (e.g. @Daniel-Hodgson-SquaredUp) or an organisation name, and the file is missing the links array (the repo guidelines expect two links — one with category: source pointing at the GitHub repo and one with category: documentation pointing at the markdown docs). Compare with plugins/GoogleSheets/v1/metadata.json for the expected shape — without these, in-product source/docs links won't render and a personal email gets published in the plugin catalogue.
Extended reasoning...
The repo review guidelines (which apply to every plugin metadata.json) call out two things this file does not do:
author.name- Should typically be a GitHub username, prefixed with @ OR an organisation name. For example @username1 or Contoso Inc.
links- Should typically contain two links, one link with category: source linking to the GitHub repository, and another link with category: documentation linking to the markdown documentation in the repository.
(1) author.name is an email, not a GitHub handle. plugins/GoogleSearchConsole/v1/metadata.json line 6 has "name": "Daniel.Hodgson@squaredup.com". Every other community-authored plugin in this repo follows the @handle convention (e.g. @clarkd, @kieranlangton, @TimWheeler-SQUP, @kmichalski0, @blackgrouse). This plugin is the only outlier and the only one shipping a personal email address publicly in the plugin catalogue — minor privacy concern beyond the convention violation. The PR comment author shows the GitHub handle is Daniel-Hodgson-SquaredUp, so @Daniel-Hodgson-SquaredUp would conform.
(2) The links array is missing. The file's keys end at base.config.oauth2Scope — there is no links field at all. Nearly every other plugin in the repo (GoogleSheets, Phare, NinjaOne, Snowflake, UptimeRobot, UniFi, etc.) ships a links array with both a source and a documentation entry. A README does exist at plugins/GoogleSearchConsole/v1/docs/README.md, so the in-product surface that consumes metadata.links.documentation will have nothing to render, and the source-repo link will likewise be absent — a material UX regression vs the rest of the catalogue.
Step-by-step proof using a peer plugin as reference:
- Open
plugins/GoogleSheets/v1/metadata.json(or any peer). You'll seeauthor.namelike"@kieranlangton"and alinksarray like:"links": [ { "category": "source", "url": "https://github.com/squaredup/plugins/tree/main/plugins/GoogleSheets/v1" }, { "category": "documentation", "url": "https://github.com/squaredup/plugins/blob/main/plugins/GoogleSheets/v1/docs/README.md" } ]
- Open the file in this PR: author.name is an email, no
linkskey. - When the product renders the plugin catalogue it pulls these fields straight from metadata.json — so the GSC plugin will show a raw email as the author and have no source/docs links available next to it.
How to fix:
"author": {
"name": "@Daniel-Hodgson-SquaredUp",
"type": "community"
},and add a top-level links array (placement matches other plugins):
"links": [
{
"category": "source",
"url": "https://github.com/squaredup/plugins/tree/main/plugins/GoogleSearchConsole/v1"
},
{
"category": "documentation",
"url": "https://github.com/squaredup/plugins/blob/main/plugins/GoogleSearchConsole/v1/docs/README.md"
}
]Severity is nit — purely metadata/convention, no functional impact — but it is explicitly called out by the repo review guidelines and trivially fixable before merge.
…he "Biggest Ranking Changes" tile of the "Page Overview" dashboard
…he "Organic Search Performance " tile of the "Site Overview" dashboard
🧩 Plugin PR Summary📦 Modified Plugins
📋 Results
🔍 Validation Details✅
|
| "Clicks Change %", | ||
| "Impressions Change %", | ||
| "CTR Change %", | ||
| "Position Change" | ||
| ], | ||
| "resizedColumns": { | ||
| "columnWidths": { | ||
| "Position Change": 135, | ||
| "Impressions Change %": 177, | ||
| "CTR Change %": 122, | ||
| "Clicks Change %": 135 | ||
| } | ||
| }, |
There was a problem hiding this comment.
🔴 Five tiles across the default pageOverview.dash.json (4 SQL tiles) and siteOverview.dash.json (1 SQL tile) ship a tile-level scope block whose gremlin query and queryDetail.ids are hardcoded to node-1dwkxV4HX7tME4WtZkj2r04TirpbysXMf3ej9-a1txyPzIR6b4Z1ctJ0Ks — a node id from your test tenant that won't exist in any customer's tenant. This is leftover dashboard-editor state from the dev environment, and it overrides the (correctly variable-scoped) inner dataStream.dataSourceConfig.scope, so customers will get empty or mis-scoped tiles out of the box. Fix: delete the outer scope block on each affected tile (peer plugins like NinjaOne, UniFi, and Huntress ship object-scoped dashboards with no tile-level scope and rely entirely on the inner variable resolution).
Extended reasoning...
What the bug is
Searching plugins/GoogleSearchConsole/v1/defaultContent/ for node- finds 5 tiles with the exact same hardcoded id node-1dwkxV4HX7tME4WtZkj2r04TirpbysXMf3ej9-a1txyPzIR6b4Z1ctJ0Ks:
pageOverview.dash.json— Organic search performance tile (line ~158)pageOverview.dash.json— Biggest Ranking Changes tile (line ~270)pageOverview.dash.json— Ranking distribution tile (line ~366)pageOverview.dash.json— Query opportunities tile (line ~462)siteOverview.dash.json— Organic Search Performance tile (line ~178)
Each occurrence has the shape:
"scope": {
"query": "g.V().has('id', within(ids_<random>))",
"bindings": {
"ids_<random>": ["node-1dwkxV4HX7tME4WtZkj2r04TirpbysXMf3ej9-a1txyPzIR6b4Z1ctJ0Ks"]
},
"queryDetail": {
"ids": ["node-1dwkxV4HX7tME4WtZkj2r04TirpbysXMf3ej9-a1txyPzIR6b4Z1ctJ0Ks"]
}
}That node-... id is a SquaredUp-generated tenant node id from the dev environment that was used to author the dashboards. It is not a portable reference and will not resolve in any other tenant.
Why it ships broken
The id is bound by literal value, not by template ({{...}}) or by variable. When the dashboard is installed in a customer tenant the gremlin query g.V().has('id', within('node-1dwkxV4HX7tME4WtZkj2r04TirpbysXMf3ej9-...')) matches zero vertices because that exact id doesn't exist there. The inner dataStream.dataSourceConfig.scope.variable on each tile is set correctly (it references {{variables.[Google Search Console Page]}} — except the siteOverview tile, which doesn't use a page variable at all), but the tile-level scope block takes precedence as an explicit override. So either tiles render empty, or — worse — they appear to load but with the wrong scope.
Step-by-step proof
- A customer installs the GSC plugin and opens the default Page Overview dashboard with a real GSC page selected for
[Google Search Console Page]. - The Organic search performance tile is asked to resolve its scope. It looks at
config.scopeand runsg.V().has('id', within('node-1dwkxV4HX7tME4WtZkj2r04TirpbysXMf3ej9-a1txyPzIR6b4Z1ctJ0Ks')). - No vertex with that id exists in the customer's graph (it's the dev's tenant node id), so the gremlin query returns
[]. - With an empty scope, the SQL/data-stream query is run against zero objects, and the tile renders empty — or with a 'No data' state — on a freshly-installed default dashboard.
- Repeat for the other 4 tiles. Five out of the two dashboards' headline tiles will be broken on first open.
Cross-check against peer plugins
I ran grep -l '"node-"' plugins/*/v*/defaultContent/*.dash.json and only MicrosoftDefender (already under review) and this PR ship raw node-* ids. Other object-scoped default dashboards in the repo — NinjaOne's deviceDetail.dash.json, UniFi's networkDevice.dash.json, Huntress's agentsByOrganization.dash.json — ship no tile-level scope block at all. They rely solely on the inner dataStream.dataSourceConfig.scope.variable which the dashboard variable resolves at render time per-tenant. DattoRMM's dashboards use template-parameterised gremlin queries ({{configId}}) when they need a tile-level scope.
The canonical pattern when an outer scope IS needed is variable-based, not raw-id, e.g. { variable: {{variables...}}, scope: {{scopes...}}, workspace: {{workspaceId}} }. None of the GSC tiles need an outer scope at all — the inner scope.variable is already there.
Impact
Every customer who installs this plugin will see broken / empty tiles on the two default dashboards (Page Overview and Site Overview). These are the out-of-the-box experience the plugin is sold on (see the PR description: 'pre-built dashboards feel very smooth'), so the first impression is the worst-case one. Beyond correctness, this also leaks the developer's test-tenant node id into the public plugin catalogue.
How to fix
Delete the outer scope block on each of the 5 tiles. Concretely, in pageOverview.dash.json remove the block at lines ~158-170, ~270-282, ~366-378, ~462-474, and in siteOverview.dash.json remove the block at lines ~178-190. The inner dataStream.dataSourceConfig.scope is already correctly variable-based on the page-scoped tiles and the site-summary tile doesn't need any scope override at all. No other changes are needed.
| "postBody": { | ||
| "startDate": "{{new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}}", | ||
| "endDate": "{{new Date().toISOString().split('T')[0]}}", | ||
| "dimensions": [ | ||
| "page" | ||
| ], | ||
| "rowLimit": 25 | ||
| }, |
There was a problem hiding this comment.
🔴 The pageUrLs data stream sets rowLimit: 25 (line 21), but this stream is the sole source for object indexing in indexDefinitions/default.json (populating the gsc-page source type used by the Page Overview dashboard, the "Google Search Console Pages" scope variable, and every {{scope?.[0]?.name}} filter). With a 25-row cap, any site with more than 25 indexed pages will silently miss the rest from object discovery and page selection. You already flagged this in the PR thread ("this stream should be way higher than 25!") — raising it to 25000 to match the other streams would fix it.
Extended reasoning...
What the bug is
plugins/GoogleSearchConsole/v1/dataStreams/pageUrLs.json sets rowLimit: 25 on line 21. Every other data stream in this plugin (countryBreakdown, deviceBreakdown, pageDistribution, pagePerformanceOverTime, pagePerformanceSummary, pages, previousPeriodPagePerformance, previousPeriodPerformanceOverTime, previousPeriodQueriesByPage, queries, queriesByPage, sitePerformanceOverTime) uses rowLimit: 25000. The 25 looks like a debug value left in by mistake.
Why this matters more than for any other stream
indexDefinitions/default.json uses only pageUrLs as the source for object indexing. Its single step imports rows from pageUrLs and maps each row's value to an object id with type: "gsc-page". That gsc-page source type is then referenced by:
defaultContent/scopes.json— the "Google Search Console Pages" scope and its associated "Google Search Console Page" variable, which is the only way users pick a page on the page-level dashboard.defaultContent/pageOverview.dash.json— every tile binds viascope.[Google Search Console Pages]and the{{variables.[Google Search Console Page]}}substitution.- The
scope?.[0]?.name/scope?.[0]?.rawId?.[0]filter expressions inpageDistribution.json,pagePerformanceOverTime.json,pagePerformanceSummary.json,previousPeriodPagePerformance.json,previousPeriodQueriesByPage.json, andqueriesByPage.json— these all rely on a page object existing in the index in order to be selectable.
So pageUrLs is the bottleneck for the entire page-level surface of the plugin.
Step-by-step proof for a realistic site (say 200 indexed pages)
- The indexer issues the
pageUrLsrequest withstartDate= one year ago,endDate= today, dimensions["page"], androwLimit: 25. - GSC returns the top 25 pages by clicks (its default ordering) for the requested period.
- The post-request script (
pageUrLs.js) maps those 25 rows to{label, value}pairs. - The index step in
indexDefinitions/default.jsoncreates exactly 25gsc-pageobjects. - The "Google Search Console Page" scope variable is populated from those 25 objects only — the other 175 pages on the site simply don't exist as selectable objects.
- A user trying to view, say, their TEST PR #50 most-clicked page in the Page Overview dashboard cannot select it; it isn't in the dropdown. Even if they paste the URL into a tile manually,
indexDefinitions/default.jsonwill still treat it as an unknown object.
Why existing code doesn't prevent this
There's no auto-paging on this stream (paging.mode: "none"), no fallback path that fetches more rows, and no validation that the page set is complete. The stream simply returns the first 25 results and the index is built from exactly that.
Author has already confirmed
In inline comment 3428036489 the author wrote: "Doesn't need explicit paging but I definitively need to check the rowLimit configs, for one this stream should be way higher than 25!" — so this is already acknowledged as unintended, but the diff still shows 25 and no follow-up commit has changed it.
How to fix
Change line 21 of plugins/GoogleSearchConsole/v1/dataStreams/pageUrLs.json from "rowLimit": 25 to "rowLimit": 25000, matching every other stream in this plugin. (25000 is the GSC API maximum per request, so this is also the largest single-request fix.) If a site has more than 25 000 indexed pages, real paging would be needed — but the immediate user-visible bug is the 25-vs-25000 typo.
🔬 also observed by verify-runtime
| "timeframes": true, | ||
| "tags": [] |
There was a problem hiding this comment.
🟡 All 14 new data stream JSON files in this PR ship with "tags": [] (e.g. countryBreakdown.json:30, pages.json:29, queries.json, sitePerformanceOverTime.json, etc.). The repo review guidelines explicitly state tags are mandatory and should reuse an existing category from other plugins where possible — peer plugins like GoogleSheets, Huntress, and AutoTask all populate them. Consider something like ["SEO", "Analytics"] across all 14 streams to aid discoverability.
Extended reasoning...
What the bug is
The repo review guidelines for plugin authors (see the REVIEW.md / contribution guidance applied across this repo) are explicit:
tags— Mandatory. Reuse an existing category from other plugins where possible.
Every one of the 14 new data stream JSON files added in this PR ships with an empty tags array:
plugins/GoogleSearchConsole/v1/dataStreams/countryBreakdown.json:30→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/deviceBreakdown.json:30→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/pageDistribution.json→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/pagePerformanceOverTime.json→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/pagePerformanceSummary.json→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/pageUrLs.json:29→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/pages.json:29→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/previousPeriodPagePerformance.json→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/previousPeriodPerformanceOverTime.json→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/previousPeriodQueriesByPage.json→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/queries.json→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/queriesByPage.json→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/sitePerformanceOverTime.json→"tags": []plugins/GoogleSearchConsole/v1/dataStreams/sitePerformanceSummary.json→"tags": []
Comparison to peer plugins
A quick comparison against other plugins in the repo confirms the convention is widely followed:
plugins/Huntress/v1/dataStreams/*.jsonpopulates tags such as["Security", "Incidents"].plugins/AutoTask/v1/dataStreams/*.jsonuses tags like["Tickets"],["Resources"].plugins/Checkly,plugins/GoogleSheets,plugins/NinjaOne, and others all populatetagswith descriptive categories such as["Monitoring"],["Security"],["Backup"],["Ticketing"].
The GSC plugin is essentially the only PR shipping uniformly empty tag arrays across every data stream.
Step-by-step proof of impact
- A user installs the GSC plugin and opens the data stream picker in SquaredUp.
- They filter or browse by tag (e.g. searching for an analytics or SEO-related stream).
- Because every GSC data stream has
"tags": [], none of them show up under any category — they are only discoverable by exact name. - Contrast with a peer plugin like Huntress: filtering by
Securitysurfaces all of its data streams immediately.
Why existing code does not prevent it
The schema does not reject empty arrays — MicrosoftDefender (added in commit efb12f9) also ships with empty tags, so prior precedent shows the validator does not enforce this. The guideline is documented in the review process rather than the schema, which is why it slipped through.
Impact
No functional impact — data streams still execute correctly and dashboards still render. The cost is purely discoverability: users browsing the plugin catalogue by category will not find these streams under any SEO/Analytics tag, and the plugin will look inconsistent next to peers that follow the convention.
How to fix
A one-line edit in each of the 14 files. Suggested values (reusing categories used elsewhere in the repo):
"tags": ["SEO", "Analytics"]or, if SEO is too narrow as a single tag, ["Analytics"] alone (the plugin already uses "category": "Analytics" in metadata.json, so reusing that here is consistent with the existing self-categorisation).
| { | ||
| "name": "pageUrLs", | ||
| "displayName": "Page URLs", | ||
| "description": "Returns a list off URLs with impressions in the last year", |
There was a problem hiding this comment.
🟡 Typo in the user-facing description: line 4 reads "description": "Returns a list off URLs with impressions in the last year" — "off" should be "of". This text appears in the data stream picker, so users will see it.
Extended reasoning...
What the bug is
plugins/GoogleSearchConsole/v1/dataStreams/pageUrLs.json line 4:
"description": "Returns a list off URLs with impressions in the last year","a list off URLs" should be "a list of URLs". A small but plainly wrong typo.
Why it matters
The description field on a data stream is user-facing — it is surfaced in the SquaredUp data stream picker when users browse and select streams when building tiles. Every customer who installs this plugin and goes to add a tile will see this typo next to the "Page URLs" stream name.
How it manifests
- User installs Google Search Console plugin from the catalogue.
- User adds a tile and opens the data stream picker.
- Picker lists "Page URLs" with the subtitle "Returns a list off URLs with impressions in the last year".
There's no code path that filters or sanitises this string — it is rendered as-is from the JSON.
Why existing code doesn't prevent it
Plugin validation (google-search-console plugin validation passed per the PR-summary bot output) checks structural validity of description (must be a string), not spelling. No automated linter is going to catch a real-word typo where both spellings are valid English words.
How to fix
One-character edit:
- "description": "Returns a list off URLs with impressions in the last year",
+ "description": "Returns a list of URLs with impressions in the last year",Severity
Pure cosmetic typo, no functional impact — nit. But it ships visibly to every user of the plugin and is trivial to fix before merge.
What does this change do?
Adds a new Google Search Console plugin that enables users to connect a GSC property via OAuth 2.0 and visualize search performance data directly within SquaredUp.
The plugin includes the following data streams. along with default dashboards for both site-level and page-level SEO analysis:
Why is it useful?
Google Search Console contains valuable SEO data and is one of the most widely used tools in the industry. It is also currently a gap in the SquaredUp plugin catalog.
This plugin allows users to:
The included dashboards provide an out-of-the-box SEO reporting experience with minimal configuration required.
Testing performed
Validation
Manual testing
Tested against a live Google Search Console property. A Google Pages test site was created and populated with content, with impressions and clicks generated over several weeks to produce representative Search Console data:
https://danielchodgson.github.io/gsc-lcp-test-site/index.html
Verified:
Screenshots
Known limitations / follow-ups