Setup runbook — Google Search Console + GA4 Singer taps
End-to-end steps to start populatingexternal_metrics from real Google data sources. One-time OAuth setup; both taps share the same OAuth client + refresh token.
Time required: ~20 minutes the first time. Subsequent runs are zero-touch.
Prerequisites
- A Google account that owns (or has admin access to) the GSC properties and GA4 properties you want to ingest
- Python on your local machine (any 3.10+) — only needed for the one-time OAuth helper
- Poindexter operator access (DB + CLI)
external_taps (commit 5f6a4075’s follow-up — gsc_main and ga4_main, both enabled=false). The operator runbook is the path from “rows exist” to “data flowing.”
Step 1 — Google Cloud Console: enable APIs + create OAuth client
- Go to https://console.cloud.google.com/. Pick or create a project (any name; “poindexter-analytics” works).
- Enable the two APIs the taps use:
-
Create OAuth credentials:
- Go to https://console.cloud.google.com/apis/credentials
- Configure consent screen (if you haven’t already):
- User type: External
- App name:
Poindexter Analytics(anything works — only you will see this) - User support email + developer email: your email
- Scopes: leave default; the taps request the scopes themselves
- Test users: add your own Google email
- Save and continue all the way through
- Then + CREATE CREDENTIALS → OAuth client ID:
- Application type: Desktop app
- Name:
poindexter-singer-tap - Click Create
- Copy the Client ID and Client secret (the dialog shows them once; you can also re-open later).
client_id(looks like123456789-abc...apps.googleusercontent.com)client_secret(looks likeGOCSPX-...)
Step 2 — Get a refresh_token
From the repo root, in any Python environment:- Browser opens to Google’s OAuth consent screen.
- Sign in with the Google account that owns your GSC + GA4 properties.
- Click Allow for both scopes (
webmasters.readonly+analytics.readonly). - Browser shows “The authentication flow has completed.”
- Terminal prints a
refresh_tokenplus copy-paste-readypoindextercommands.
refresh_token is long-lived — Google does not expire these unless the user revokes the grant or the app goes 6+ months without using it. Store it carefully.
If you don’t see a refresh_token in the output, Google didn’t issue one (this happens when you’ve already consented). Revoke the app at https://myaccount.google.com/permissions and run the helper again.
Step 3 — Store credentials in Poindexter
Three settings. The first one (client_id) is not encrypted; the other two are.poindexter settings list --search google_oauth. The two --secret rows will show enc:v1:... ciphertext — that’s correct.
Step 4 — Update the tap rows with your specifics
The two rows shipped with empty placeholders. Update each with your real values. The two OAuth secrets you just stored in Step 3 are referenced by key, never pasted in.external_taps.config is a plain (unencrypted) jsonb column — readable by any SQL session, pgAdmin browse, or DB dump — so client_secret/refresh_token go through config.secret_fields instead, which tap.singer_subprocess resolves via site_config.get_secret() immediately before spawning the tap (see docs/integrations/tap_singer_subprocess.md). Only the non-secret client_id goes in tap_config directly.
GSC
site_urls must match an EXACT property URL registered in your GSC account (typically https://example.com for domain properties or https://www.example.com/ with the trailing slash for URL-prefix properties — check at https://search.google.com/search-console).
GA4
You’ll need your GA4 property ID — find it at https://analytics.google.com → Admin → Property → Property details → “Property ID” (a number like123456789). Note GA4’s tap expects the client id/secret fields named oauth_client_id/oauth_client_secret rather than GSC’s client_id/client_secret.
Step 5 — Install the tap binaries
The taps run as subprocesses; the worker needs them onPATH (or config.command needs to name an absolute path where they live). Two options:
Option A — install in a sidecar venv that the row’s command points at (recommended)
Isolates the taps’ dependencies from the worker’s shared Python env. ~/.poindexter is usually root-owned in the container, so create the venv directory as root first if appuser can’t write to it:
config.command at the absolute venv binary path:
Option B — install in the worker container’s shared env
tap-google-search-console’s dependency singer-python==6.0.1 pins jsonschema==2.*. The container installs --user by default (its base image’s site-packages is read-only), and pip’s resolver only checks the package it’s installing against its own dependency tree — it doesn’t check the shared env’s other already-installed packages, so it happily downgrades jsonschema, silently breaking prefect (needs >=4.18), litellm (needs >=4.0), and mcp (needs >=4.20). The running process is unaffected immediately (already-imported modules stay in memory), but the next container restart picks up the broken version. Always run docker exec poindexter-worker pip check right after installing — if it reports a conflict, pip uninstall the packages you just added (this restores the base image’s own compatible versions from /usr/local/lib/..., which --user installs only ever shadow, never overwrite) and use Option A instead.
Option A avoids this class of conflict entirely — its venv’s site-packages never touches the worker’s shared environment.
Step 6 — Test on demand
Run this inside the worker container, not on a Windows host shell. Whichever option you picked in Step 5,config.command names a path that only exists
inside poindexter-worker (a venv binary, or a bare command resolved via the
container’s PATH) — invoked from a Windows host, asyncio.create_subprocess_exec
fails with FileNotFoundError: [WinError 2] The system cannot find the file specified, because Windows has no /root/ or /home/appuser/ filesystem at
all. This isn’t a bug to fix; it’s the same class of gotcha as running the
Ollama-backed CLI commands on host instead of in-container. Also note
poindexter itself isn’t on the container’s PATH — invoke it as a module:
Note: theA successful run reportstaps runCLI command only invokes enabled rows. For the on-demand test BEFORE enabling, either temporarily flip enabled and back, or call the runner inline (still inside the container, for the same reason as above):
records: <N> and you’ll see fresh rows in external_metrics:
Step 7 — Flip enabled and let the scheduler take over
every 6 hours for both. Watch the Integrations & Admin Grafana dashboard — the total_records column on the External taps table will tick up after each run.
Troubleshooting
Where data lands
external_metrics table. The mapping config on each row determines which fields become metric rows vs which become dimensions in the jsonb. For GSC each performance row produces 4 external_metrics rows (impressions / clicks / ctr / position); for GA4 each page_metrics row produces 4 (sessions / screenPageViews / engagementRate / userEngagementDuration).
Writes are idempotent. Singer taps re-emit their whole backfill window on every run (every 6h), so the writer upserts on the natural key (source, metric_name, date, slug, dimensions) — a re-emitted row updates in place (last-write-wins) rather than appending a copy. This lets a metric that finalises over several days (GSC clicks mature over ~3 days) converge on the latest value, and keeps SUM(metric_value) aggregates (e.g. rollup_post_performance) accurate. The ux_external_metrics_natural_key unique index (NULLS NOT DISTINCT, so the site-wide performance_report_date rows with NULL slug still dedup) is the arbiter — migration 20260704_033500, which also one-time-deduped the ~382k rows the pre-upsert writer had accumulated.
Query examples:
Related
docs/integrations/tap_singer_subprocess.md— the dispatcher these rows usedocs/integrations/tap_external_metrics_writer.md— the record_handler that writes toexternal_metrics- GH-103 / GH-27 — feedback-loop tables;
external_metricsis one of the eight