-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCode.gs
More file actions
271 lines (240 loc) · 9.71 KB
/
Copy pathCode.gs
File metadata and controls
271 lines (240 loc) · 9.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
/**
* Code.gs — Looker Studio Community Connector entry points.
*
* Plausible Community Edition (self-hosted) → Looker Studio.
*
* Auth : USER_TOKEN (Username = API URL, Token = API key) — see Auth.gs.
* Per source: Site ID + optional comma-separated list of custom property names.
*
* The functions exported here are called by name by the Looker Studio
* framework — do not rename them.
*
* References:
* - Looker Studio Community Connector reference:
* https://developers.google.com/looker-studio/connector/reference
* - Plausible Stats API v2:
* https://plausible.io/docs/stats-api
*/
/**
* Looker Studio entry point. Determines whether detailed errors are shown.
* Kept `false` to avoid leaking response bodies to non-admin viewers.
*
* @return {boolean}
*/
function isAdminUser() {
return false;
}
/**
* Looker Studio entry point. Returns the data-source configuration form.
*
* @param {!Object} request Provided by Looker (currently unused).
* @return {!GoogleAppsScript.Data_Studio.Config}
*/
function getConfig(request) {
var cc = DataStudioApp.createCommunityConnector();
var config = cc.getConfig();
config.newInfo()
.setId('intro')
.setText(
'Plausible CE Connector\n\n' +
'Enter the Site ID of your Plausible property here. If you want to query or filter ' +
'custom properties (e.g. plan, account_type), list them comma-separated.\n\n' +
'To create a "Language Version" dimension that groups pages by URL prefix ' +
'(e.g. /de-de/, /en-gb/), enter the prefixes in the Language Version field.\n\n' +
'API URL and API Key were set during authentication — you can reset them via ' +
'"Reauthorize" in the Looker Studio data source.'
);
config.newTextInput()
.setId('siteId')
.setName('Site ID (Domain)')
.setHelpText('Exactly as registered in Plausible, e.g. example.com')
.setPlaceholder('example.com');
config.newTextInput()
.setId('customProps')
.setName('Custom Properties (comma-separated, optional)')
.setHelpText('Will be available as dimensions AND filter targets. Leave empty if not needed.')
.setPlaceholder('plan, account_type');
config.newTextInput()
.setId('languageVersionPrefixes')
.setName('Language version prefixes (comma-separated, optional)')
.setHelpText('URL prefixes for language versions, e.g. de-de, en-gb — creates the "Language Version" dimension in Looker Studio.')
.setPlaceholder('de-de, en-gb');
config.setDateRangeRequired(true);
return config.build();
}
/**
* Looker Studio entry point. Returns the schema (fields) for the active config.
*
* @param {{configParams: ?Object}} request
* @return {{schema: !Array<!Object>}}
*/
function getSchema(request) {
var configParams = (request && request.configParams) || {};
validateConfig_(configParams);
return { schema: getSchemaFields(configParams).build() };
}
/**
* Looker Studio entry point. Fetches data for one chart refresh.
*
* Pipeline:
* 1. Validate config and load stored credentials.
* 2. Resolve the requested fields (skipping filter-only fields whenever
* possible — Plausible has no notion of "fetch but don't display").
* 3. Build a Plausible v2 query body (Query.gs / Filters.gs).
* 4. Page through `/api/v2/query` (Api.gs).
* 5. Map the response back to Looker rows.
*
* @param {!Object} request Looker getData() request.
* @return {{schema: !Array, rows: !Array, filtersApplied: boolean}}
*/
function getData(request) {
var cc = DataStudioApp.createCommunityConnector();
var configParams = (request && request.configParams) || {};
validateConfig_(configParams);
var creds = loadCredentials_();
if (!creds.apiUrl || !creds.apiKey) {
cc.newUserError()
.setText('Authentication missing. Please set API URL and API Key via "Reauthorize".')
.throwException();
}
var fields = buildFields_(configParams);
// When Looker Studio limits chart slices/segments (e.g. pie chart with
// slice limit < actual data rows), it marks the grouping dimension as
// forFilterOnly:true and adds an IS_NULL condition for that field in
// request.dimensionsFilters. The IS_NULL group is how Looker computes
// the "Others" aggregate client-side. The connector must still group and
// return such dimensions — Looker expects them in the response schema.
//
// All other forFilterOnly fields (e.g. goal used only as a filter
// condition) are excluded per the Looker Studio contract.
var othersGroupingFields = {};
(request.dimensionsFilters || []).forEach(function (group) {
(group || []).forEach(function (filter) {
if (filter && filter.operator === 'IS_NULL' && filter.fieldName) {
othersGroupingFields[filter.fieldName] = true;
}
});
});
var requestedFieldIds = (request.fields || [])
.filter(function (f) {
return !f.forFilterOnly || othersGroupingFields[f.name];
})
.map(function (f) { return f.name; });
if (requestedFieldIds.length === 0) {
requestedFieldIds = (request.fields || []).map(function (f) { return f.name; });
}
var plan = buildQuery_(request, configParams.siteId, fields, requestedFieldIds);
var apiResult;
try {
apiResult = plausibleQueryAll_(creds.apiUrl, creds.apiKey, plan.body);
} catch (e) {
// handlePlausibleError_ always throws via cc.newUserError().throwException().
// The line below is therefore unreachable, but the static analyser
// doesn't know that — leave the catch empty of further references.
handlePlausibleError_(cc, e);
return; // defensive; never executed
}
if (apiResult.meta && apiResult.meta.metric_warnings) {
Object.keys(apiResult.meta.metric_warnings).forEach(function (k) {
logWarn_('Plausible warning for ' + k + ': ' +
JSON.stringify(apiResult.meta.metric_warnings[k]));
});
}
return {
schema: buildResponseSchema_(requestedFieldIds, fields),
rows: mapResponseToRows_(apiResult.results || [], plan, fields),
filtersApplied: plan.filtersApplied
};
}
// ────────────────────────── helpers ──────────────────────────
/**
* Throw a Looker user error if mandatory config fields are missing.
*
* @param {!Object} configParams
* @throws via `cc.newUserError().throwException()`
*/
function validateConfig_(configParams) {
var cc = DataStudioApp.createCommunityConnector();
if (!configParams.siteId || String(configParams.siteId).trim() === '') {
cc.newUserError()
.setText('Site ID is missing. Please enter it in the data source configuration (e.g. example.com).')
.throwException();
}
}
/**
* Build the response-side schema array (`{name, dataType}`) Looker expects
* inside `getData()`'s return value. Only metrics are NUMBER; everything
* else (including time dimensions, which Looker reads as compact STRING
* tokens like `YYYYMMDD`) maps to STRING.
*
* @param {!Array<string>} requestedFieldIds
* @param {!Array<!Object>} fields
* @return {!Array<{name: string, dataType: string}>}
*/
function buildResponseSchema_(requestedFieldIds, fields) {
return requestedFieldIds.map(function (id) {
var f = getFieldById_(fields, id);
if (!f) return { name: id, dataType: 'STRING' };
return {
name: f.id,
dataType: f.kind === 'metric' ? 'NUMBER' : 'STRING'
};
});
}
/**
* Translate a {@link PlausibleError} (or generic exception) into a
* Looker-Studio user-error and throw it.
*
* @param {!GoogleAppsScript.Data_Studio.CommunityConnector} cc
* @param {!Error} e Either a {@link PlausibleError} or a runtime exception.
* @throws Always.
*/
function handlePlausibleError_(cc, e) {
var msg;
if (e instanceof PlausibleError) {
switch (e.kind) {
case 'AUTH':
msg = 'API Key was rejected by Plausible. Please set a valid key via "Reauthorize".';
break;
case 'NOT_FOUND':
msg = 'Plausible endpoint or Site ID not found. Check API URL and Site ID.';
break;
case 'RATE_LIMIT':
msg = 'Plausible rate limit exceeded (600 requests per hour). Please try again later.';
break;
case 'NETWORK':
msg = 'Plausible endpoint not reachable. Check API URL and that the instance is running.';
break;
case 'HTTP':
if (e.status === 400) {
msg = 'Plausible rejected the query: ' + extractApiError_(e.body) +
' (Common cause: mixing session metrics like bounce_rate/visit_duration ' +
'with event dimensions like page/goal.)';
} else {
msg = 'Plausible error: ' + e.message;
}
break;
default:
msg = 'Plausible error: ' + e.message;
}
} else {
msg = 'Unexpected error: ' + (e && e.message ? e.message : e);
}
cc.newUserError().setText(msg).throwException();
}
/**
* Best-effort extraction of the human-readable `error` field from a
* Plausible error response body.
*
* @param {?string} body
* @return {string}
*/
function extractApiError_(body) {
try {
var parsed = JSON.parse(body);
if (parsed && parsed.error) return parsed.error;
} catch (e) {
// not JSON — fall through
}
return body || 'unknown error';
}