Add Google Search Console plugin#63
Conversation
🧩 Plugin PR Summary📦 Modified Plugins
📋 Results
🔍 Validation Details✅
|
|
@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
| "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 |
| "tableName": "dataset2" | ||
| } | ||
| ], | ||
| "sql": "SELECT\r\n c.Query,\r\n ROUND(p.Position - c.Position, 1) AS PositionChange,\r\n c.Position AS CurrentPosition,\r\n p.Position AS PreviousPosition,\r\n c.Clicks,\r\n c.Impressions\r\nFROM dataset1 c\r\nINNER JOIN dataset2 p\r\n ON c.Query = p.Query\r\nWHERE\r\n c.Impressions > 0\r\n OR p.Impressions > 0\r\nORDER BY PositionChange DESC\r\nLIMIT 10" |
There was a problem hiding this comment.
🔴 The 'Biggest Ranking Changes' tile binds its SQL aliases backwards: dataset1 is the previous-period stream and dataset2 is the current-period stream, but the join reads FROM dataset1 c INNER JOIN dataset2 p, so alias c (current) holds previous-period rows and p (previous) holds current-period rows. As a result CurrentPosition/PreviousPosition are swapped, Clicks/Impressions show previous-period values, and PositionChange = p.Position - c.Position is sign-flipped — ORDER BY PositionChange DESC surfaces the worst regressions at the top of a panel titled 'Biggest Ranking Changes'. Fix by swapping the aliases so c binds to dataset2 and p binds to dataset1 (matching the sibling 'Organic search performance' tile in the same dashboard).
Extended reasoning...
What the bug is
In plugins/GoogleSearchConsole/v1/defaultContent/pageOverview.dash.json, the Biggest Ranking Changes tile defines two datasets and joins them with their alias names inverted relative to the data they hold.
Looking at the tile definition (lines ~180-241):
dataset1.config.dataStream.name=previousPeriodQueriesByPage→ previous perioddataset2.config.dataStream.name=queriesByPage→ current period
And the SQL:
SELECT
c.Query,
ROUND(p.Position - c.Position, 1) AS PositionChange,
c.Position AS CurrentPosition,
p.Position AS PreviousPosition,
c.Clicks,
c.Impressions
FROM dataset1 c
INNER JOIN dataset2 p
ON c.Query = p.Query
...
ORDER BY PositionChange DESC
LIMIT 10The alias c (clearly meant for "current") is bound to dataset1 = previous period, and p ("previous") is bound to dataset2 = current period. Every column in the SELECT therefore returns the opposite period from what its name implies.
Step-by-step proof
Consider a query whose rank improved from position 20 → 5 between the two periods:
- In
previousPeriodQueriesByPage(dataset1, bound to aliasc):Position = 20 - In
queriesByPage(dataset2, bound to aliasp):Position = 5 CurrentPosition = c.Position = 20❌ (should be 5 — this is the previous position)PreviousPosition = p.Position = 5❌ (should be 20 — this is the current position)PositionChange = p.Position - c.Position = 5 - 20 = -15❌ (a 15-rank improvement reports as-15)ORDER BY PositionChange DESCputs the largest positive values on top — i.e. queries wherecurrent_position - previous_positionis large and positive, which are queries that regressed the most. A panel titled "Biggest Ranking Changes" defaulting to "top opportunities" semantics is now showing the biggest losses.c.Clicksandc.Impressionsare also previous-period values shown without any indication they're historical, on a tile users will read as current performance.
Why existing code doesn't prevent it
This is a default dashboard shipped with the plugin — there is no runtime validation that aliases match their semantic names. The SQL is syntactically valid, the join executes successfully, and column types match — so nothing fails loudly. The bug is purely a data-correctness issue that ships visible to every customer who installs the plugin.
Cross-check against the sibling tile
The Organic search performance tile in the same dashboard uses the same two datasets in the same order (dataset1 = previousPeriodPagePerformance, dataset2 = pagePerformanceSummary), but its SQL correctly does previous_period AS (... FROM dataset1) and current_period AS (... FROM dataset2), and computes previous_period.Position - current_period.Position for Position Change. That tile is the canonical correct mapping in this file; the Biggest Ranking Changes tile is the outlier.
Impact
Every default install of this plugin will ship a dashboard tile that:
- Displays
CurrentPositionandPreviousPositionvalues swapped - Displays previous-period
Clicks/Impressionswhile implying they're current - Sorts to surface biggest regressions when the tile name implies improvements
Users acting on this data to identify "biggest movers" will draw the opposite conclusion.
How to fix
Swap the aliases so they match the underlying datasets:
FROM dataset2 c
INNER JOIN dataset1 p
ON c.Query = p.QueryNo other changes needed — PositionChange = p.Position - c.Position then evaluates as previous - current (positive = improvement), and CurrentPosition/PreviousPosition/c.Clicks/c.Impressions all match their column names.
| "tableName": "dataset2" | ||
| } | ||
| ], | ||
| "sql": "WITH current_period AS (\r\n SELECT\r\n SUM(Clicks) AS Clicks,\r\n SUM(Impressions) AS Impressions,\r\n SUM(Clicks) / NULLIF(SUM(Impressions), 0) * 100 AS CTR,\r\n AVG(Position) AS Position\r\n FROM dataset1\r\n),\r\n\r\nprevious_period AS (\r\n SELECT\r\n SUM(Clicks) AS Clicks,\r\n SUM(Impressions) AS Impressions,\r\n SUM(Clicks) / NULLIF(SUM(Impressions), 0) * 100 AS CTR,\r\n AVG(Position) AS Position\r\n FROM dataset2\r\n)\r\n\r\nSELECT\r\n ROUND(((current_period.Clicks - previous_period.Clicks) / NULLIF(previous_period.Clicks, 0)) * 100, 2) AS \"Clicks Change %\",\r\n\r\n ROUND(((current_period.Impressions - previous_period.Impressions) / NULLIF(previous_period.Impressions, 0)) * 100, 2) AS \"Impressions Change %\",\r\n\r\n ROUND(((current_period.CTR - previous_period.CTR) / NULLIF(previous_period.CTR, 0)) * 100, 2) AS \"CTR Change %\",\r\n\r\n ROUND(previous_period.Position - current_period.Position, 2) AS \"Position Change\"\r\n\r\nFROM current_period\r\nCROSS JOIN previous_period" |
There was a problem hiding this comment.
🔴 The SQL in the 'Organic Search Performance' tile has its CTE aliases swapped relative to the dataset bindings: dataset1 is bound to previousPeriodPerformanceOverTime but the SQL declares current_period AS (... FROM dataset1), and dataset2 is bound to sitePerformanceOverTime but declared as previous_period AS (... FROM dataset2). As a result, every output column (Clicks/Impressions/CTR Change % and Position Change) is sign-flipped — a user whose clicks doubled this period vs the previous would see a large negative 'Clicks Change %'. Fix: swap the FROM clauses so current_period reads FROM dataset2 and previous_period reads FROM dataset1 (this matches the analogous pageOverview tile, which has the correct mapping).
Extended reasoning...
The bug
In plugins/GoogleSearchConsole/v1/defaultContent/siteOverview.dash.json (the Organic Search Performance tile around lines 112–211), the dashboard wires two datasets into a SQL tile and then references them in a CTE with inverted aliases.
The table bindings are:
dataset1→previousPeriodPerformanceOverTime(lines 131–137)dataset2→sitePerformanceOverTime(lines 144–150)
But the SQL (line 153) declares:
WITH current_period AS (
SELECT SUM(Clicks) AS Clicks, ... FROM dataset1
),
previous_period AS (
SELECT SUM(Clicks) AS Clicks, ... FROM dataset2
)
SELECT
ROUND(((current_period.Clicks - previous_period.Clicks)
/ NULLIF(previous_period.Clicks, 0)) * 100, 2) AS "Clicks Change %",
...
ROUND(previous_period.Position - current_period.Position, 2) AS "Position Change"
FROM current_period CROSS JOIN previous_periodSo current_period is computed over the previous period's rows, and previous_period is computed over the current period's rows. The arithmetic then computes (previous_actual − current_actual) / current_actual × 100 for each rate metric, and current_actual − previous_actual for Position — every output is sign-flipped, and the percentage divisor is wrong.
Cross-check against pageOverview
The sibling pageOverview.dash.json tile uses the exact same template but with the correct mapping: dataset1 is bound to previousPeriodPagePerformance and the SQL aliases it as previous_period; dataset2 is bound to pagePerformance and aliased as current_period. The arithmetic in both files is identical in spirit ((current − previous)/previous × 100 for rate metrics, previous − current for position), confirming that the intent is for the previous-period stream to be aliased previous_period. The siteOverview tile was clearly meant to mirror pageOverview but the CTE aliases were transposed.
Step-by-step proof
Consider a site whose clicks doubled this period vs the previous one:
sitePerformanceOverTime(current period, bound todataset2) returns rows summing to 200 clicks.previousPeriodPerformanceOverTime(previous period, bound todataset1) returns rows summing to 100 clicks.
With the SQL as written:
current_period.Clicks= SUM fromdataset1= 100 (actually the previous period)previous_period.Clicks= SUM fromdataset2= 200 (actually the current period)"Clicks Change %"= ROUND(((100 − 200) / NULLIF(200, 0)) × 100, 2) = −50%
A user whose clicks doubled is shown a 50% decrease. With the fix (swap the FROM clauses):
current_period.Clicks= SUM fromdataset2= 200previous_period.Clicks= SUM fromdataset1= 100"Clicks Change %"= ROUND(((200 − 100) / NULLIF(100, 0)) × 100, 2) = +100% ✓
The same flip applies to Impressions Change %, CTR Change %, and Position Change.
Impact and fix
This ships incorrect numbers on a default out-of-the-box dashboard — the very first thing users see after configuring the plugin will display the wrong direction of change for the headline comparison tile. The fix is a one-line edit in siteOverview.dash.json: swap the dataset references so current_period AS (... FROM dataset2) and previous_period AS (... FROM dataset1), mirroring the pattern in pageOverview.dash.json.
| "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.
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