This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A Looker Studio Community Connector (Apps Script project) that bridges a self-hosted Plausible Community Edition instance to Looker Studio via Plausible's Stats API v2 (POST /api/v2/query). It is not a Node project — there is no package.json, no test runner, no linter. Source lives in flat .gs files at the repo root.
There is no local build, test, or lint step. The project runs only inside Google's Apps Script environment.
# Push changes to the linked Apps Script project (after `clasp login` + filling .clasp.json scriptId)
clasp push
# Open the Apps Script editor in the browser
clasp open
# First-time setup (creates the Apps Script project and writes scriptId into .clasp.json)
clasp create --type standalone --title "Plausible CE Connector" --rootDir .To verify changes: in the Apps Script editor → Bereitstellen → Test-Bereitstellungen → Looker Studio, then exercise auth/config/charts in the resulting Looker Studio data source. The verification scenarios listed in the README's filter-mapping section (EQUALS, IN_LIST EXCLUDE, CONTAINS, custom-property filter, goal filter) are the canonical end-to-end checks.
.claspignore whitelists exactly the eight source files that get pushed — anything else (README, plan files, this file) stays local.
All .gs files share a single global scope when Apps Script loads them — file boundaries are organizational only. Functions defined in Util.gs are directly callable from Code.gs without any import. This is why the codebase has no require/import.
The Looker Studio framework calls a fixed set of top-level entry-point functions by exact name: getAuthType, getConfig, getSchema, getData, isAdminUser, setCredentials, resetAuth, isAuthValid. These live in Code.gs and Auth.gs. Renaming any of them silently breaks the connector. Everything else in the codebase uses a trailing-underscore convention (loadCredentials_, translateFilters_, plausibleQuery_) to mark "private — not a Looker entry point". When adding a helper, give it a trailing underscore unless Looker is meant to call it.
Looker Studio Plausible CE
────────────── ─────────────
getData(request)
└─ Code.gs::getData
├─ loadCredentials_ (Util.gs: UserProperties → {apiUrl, apiKey})
├─ buildFields_ (Schema.gs: STATIC_FIELDS + dynamic prop_* dims)
├─ buildQuery_ (Query.gs)
│ └─ translateFilters_ (Filters.gs: dimensionsFilters → Plausible DSL)
├─ plausibleQueryAll_ (Api.gs: POST /api/v2/query, paginated) ──► Plausible
└─ mapResponseToRows_ (Query.gs: Plausible results → Looker rows)
Schema.gs::STATIC_FIELDS is the single source of truth for what's exposed to Looker. Each field carries:
id— the Looker field ID (e.g.bounce_rate,prop_plan)plausible— the API-side dimension or metric name (e.g.bounce_rate,event:props:plan)kind—'metric'or'dimension'looker.type— Looker FieldType string, resolved at runtime viacc.FieldType[...]- optional
scale— multiplier applied inconvertMetricValue_
Custom properties from configParams.customProps get appended dynamically by buildFields_ as prop_<name> fields mapped to event:props:<name>. Both schema generation and filter translation must go through buildFields_ / buildFieldToPlausibleMap_ — never hardcode field-id ↔ Plausible-name pairs anywhere else.
- Percent scaling: Plausible returns
bounce_rate,scroll_depth,conversion_rate,group_conversion_rate,percentageas 0–100. Looker'sPERCENTtype expects 0–1. Thescale: 0.01field property +convertMetricValue_handle this. If you add a new percent metric, setscaleor values will display 100× too large. - Goal-filter heuristic (
Filters.gs::translateOne_): a filter onevent:goalis wrapped inhas_done/has_not_doneonly whengoalis not also a requested dimension. Without this, "visitors who completed Goal X grouped by source" returns goal-event counts, not session counts. Don't simplify away this branch. - Auth probe tolerates 404/400 (
Auth.gs::validateCredentials_): the probe runs before a Site ID exists, so an unknown-site response is treated as auth-success. Only401/403and network errors fail the credential check. forFilterOnlyfield handling (Code.gs::getData): When Looker Studio limits chart slices/segments to N < actual data rows (pie chart, donut, treemap, etc.), it marks the grouping dimension asforFilterOnly: trueinrequest.fieldsAND adds a pairedIS_NULLfilter condition for that field inrequest.dimensionsFilters. The connector must keep such dimensions in the Plausible query and response schema — Looker still expects grouped data. To distinguish these "Others-grouping" dimensions from true filter-only fields (e.g.goalused as a filter condition, which must be excluded from the query), the connector scansrequest.dimensionsFiltersforIS_NULLoperators: anyforFilterOnlyfield whose name appears in anIS_NULLentry is treated as a grouping dimension and included; all otherforFilterOnlyfields are excluded as before. Don't collapse this logic — the two cases look identical except for theIS_NULLsignal.- Filter "all or none": when any Looker filter can't be translated (numeric ops on metrics,
IS_NULL),translateFilters_skips that filter and the response is returned withfiltersApplied: false— Looker then filters client-side. Don't change this to a hard error. - Pagination cap:
MAX_PAGES = 10×PAGE_SIZE = 10000= 100k rows. Apps Script has a 6-minute execution limit; raising the cap risks timeouts. urlFetchWhitelistis intentionally absent fromappsscript.jsonbecause the Plausible URL is user-supplied at auth time. This means the connector can only be deployed privately/per-workspace, not to the Looker Marketplace — that's a design constraint, not an oversight.- Date format conversion is per-granularity (
Util.gs::formatLookerDate_).time:weekrequires ISO-week math viaisoWeekFromDate_— Plausible returns the week's Monday date, Looker expectsYYYYWW.
Api.gs defines a PlausibleError class with kind-tagged variants (AUTH, NOT_FOUND, RATE_LIMIT, NETWORK, HTTP, PARSE). Code.gs::handlePlausibleError_ is the single place that translates these into German user-facing messages via cc.newUserError().setText(...).throwException(). Add new error kinds in both places.