@rawdash/connector-gcp-billing 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -23
- package/dist/index.js +282 -162
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bqQueryResponseSchema } from '@rawdash/connector-gcp-shared';
|
|
1
2
|
import { BaseConnector, ConnectorCost, ConnectorContext, SyncOptions, StorageHandle, SyncResult, MetricSample, ConnectorDoc } from '@rawdash/core';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
|
|
@@ -8,10 +9,10 @@ declare const configFields: z.ZodObject<{
|
|
|
8
9
|
bqDataset: z.ZodString;
|
|
9
10
|
bqLocation: z.ZodOptional<z.ZodString>;
|
|
10
11
|
groupBy: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
12
|
+
location: "location";
|
|
11
13
|
service: "service";
|
|
12
14
|
project: "project";
|
|
13
15
|
sku: "sku";
|
|
14
|
-
location: "location";
|
|
15
16
|
}>>>;
|
|
16
17
|
lookbackDays: z.ZodOptional<z.ZodNumber>;
|
|
17
18
|
serviceAccountJson: z.ZodObject<{
|
|
@@ -33,26 +34,6 @@ declare const gcpBillingCredentials: {
|
|
|
33
34
|
};
|
|
34
35
|
};
|
|
35
36
|
type GcpBillingCredentials = typeof gcpBillingCredentials;
|
|
36
|
-
declare const bqQueryResponseSchema: z.ZodObject<{
|
|
37
|
-
jobComplete: z.ZodOptional<z.ZodBoolean>;
|
|
38
|
-
schema: z.ZodOptional<z.ZodObject<{
|
|
39
|
-
fields: z.ZodArray<z.ZodObject<{
|
|
40
|
-
name: z.ZodString;
|
|
41
|
-
type: z.ZodString;
|
|
42
|
-
}, z.core.$strip>>;
|
|
43
|
-
}, z.core.$strip>>;
|
|
44
|
-
rows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
45
|
-
f: z.ZodArray<z.ZodObject<{
|
|
46
|
-
v: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
47
|
-
}, z.core.$strip>>;
|
|
48
|
-
}, z.core.$strip>>>;
|
|
49
|
-
pageToken: z.ZodOptional<z.ZodString>;
|
|
50
|
-
jobReference: z.ZodOptional<z.ZodObject<{
|
|
51
|
-
projectId: z.ZodString;
|
|
52
|
-
jobId: z.ZodString;
|
|
53
|
-
location: z.ZodOptional<z.ZodString>;
|
|
54
|
-
}, z.core.$strip>>;
|
|
55
|
-
}, z.core.$strip>;
|
|
56
37
|
declare const gcpBillingResources: {
|
|
57
38
|
readonly gcp_cost_daily: {
|
|
58
39
|
readonly shape: "metric";
|
|
@@ -196,9 +177,9 @@ declare class GcpBillingConnector extends BaseConnector<GcpBillingSettings, GcpB
|
|
|
196
177
|
auth: "required";
|
|
197
178
|
};
|
|
198
179
|
};
|
|
199
|
-
private
|
|
180
|
+
private tokenProvider?;
|
|
200
181
|
private getAccessToken;
|
|
201
|
-
private
|
|
182
|
+
private fetchBigQueryPage;
|
|
202
183
|
sync(options: SyncOptions, storage: StorageHandle, signal?: AbortSignal): Promise<SyncResult>;
|
|
203
184
|
}
|
|
204
185
|
interface CostWindow {
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// ../gcp-shared/dist/index.js
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { z as z2 } from "zod";
|
|
4
|
+
import { z as z3 } from "zod";
|
|
4
5
|
var serviceAccountKeySchema = z.object({
|
|
5
6
|
client_email: z.string().min(1),
|
|
6
7
|
private_key: z.string().min(1),
|
|
@@ -77,6 +78,15 @@ async function buildServiceAccountJwt(serviceAccountJson, scope) {
|
|
|
77
78
|
body
|
|
78
79
|
};
|
|
79
80
|
}
|
|
81
|
+
function buildRefreshTokenGrant(credentials) {
|
|
82
|
+
const body = new URLSearchParams({
|
|
83
|
+
grant_type: "refresh_token",
|
|
84
|
+
refresh_token: credentials.refreshToken,
|
|
85
|
+
client_id: credentials.clientId,
|
|
86
|
+
client_secret: credentials.clientSecret
|
|
87
|
+
}).toString();
|
|
88
|
+
return { url: "https://oauth2.googleapis.com/token", body };
|
|
89
|
+
}
|
|
80
90
|
var gcpAuthConfigShape = {
|
|
81
91
|
serviceAccountJson: z2.object({ $secret: z2.string().trim().min(1) }).meta({
|
|
82
92
|
label: "Service Account JSON",
|
|
@@ -84,8 +94,6 @@ var gcpAuthConfigShape = {
|
|
|
84
94
|
secret: true
|
|
85
95
|
})
|
|
86
96
|
};
|
|
87
|
-
|
|
88
|
-
// ../../connector-shared/dist/index.js
|
|
89
97
|
var HttpClientError = class extends Error {
|
|
90
98
|
response;
|
|
91
99
|
constructor(message, response) {
|
|
@@ -99,10 +107,227 @@ var AuthError = class extends HttpClientError {
|
|
|
99
107
|
};
|
|
100
108
|
var HTTP_CLIENT_VERSION = "0.0.0";
|
|
101
109
|
var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
|
|
110
|
+
function parseEpoch(value, unit) {
|
|
111
|
+
if (value === null || value === void 0) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (unit === "iso") {
|
|
115
|
+
if (typeof value !== "string") {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const ms = new Date(value).getTime();
|
|
119
|
+
return Number.isFinite(ms) ? ms : null;
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === "string" && value.trim() === "") {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
125
|
+
if (!Number.isFinite(n)) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const result = unit === "s" ? n * 1e3 : n;
|
|
129
|
+
return Number.isFinite(result) ? result : null;
|
|
130
|
+
}
|
|
131
|
+
var BQ_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
132
|
+
var BQ_DATASET_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
133
|
+
var BQ_API_BASE = "https://bigquery.googleapis.com/bigquery/v2";
|
|
134
|
+
var BQ_READONLY_SCOPE = "https://www.googleapis.com/auth/bigquery.readonly";
|
|
135
|
+
var BQ_PAGE_SIZE = 1e4;
|
|
136
|
+
var BQ_QUERY_TIMEOUT_MS = 3e4;
|
|
137
|
+
var bqQueryResponseSchema = z3.object({
|
|
138
|
+
jobComplete: z3.boolean().optional(),
|
|
139
|
+
schema: z3.object({
|
|
140
|
+
fields: z3.array(z3.object({ name: z3.string(), type: z3.string() }))
|
|
141
|
+
}).optional(),
|
|
142
|
+
rows: z3.array(
|
|
143
|
+
z3.object({
|
|
144
|
+
f: z3.array(z3.object({ v: z3.string().nullable().optional() }))
|
|
145
|
+
})
|
|
146
|
+
).optional(),
|
|
147
|
+
pageToken: z3.string().optional(),
|
|
148
|
+
jobReference: z3.object({
|
|
149
|
+
projectId: z3.string(),
|
|
150
|
+
jobId: z3.string(),
|
|
151
|
+
location: z3.string().optional()
|
|
152
|
+
}).optional()
|
|
153
|
+
});
|
|
154
|
+
function buildBigQueryPageRequest(opts) {
|
|
155
|
+
const pageSize = opts.pageSize ?? BQ_PAGE_SIZE;
|
|
156
|
+
const timeoutMs = opts.timeoutMs ?? BQ_QUERY_TIMEOUT_MS;
|
|
157
|
+
if (opts.pageToken === void 0) {
|
|
158
|
+
const url2 = `${BQ_API_BASE}/projects/${encodeURIComponent(
|
|
159
|
+
opts.projectId
|
|
160
|
+
)}/queries`;
|
|
161
|
+
const body = {
|
|
162
|
+
query: opts.sql,
|
|
163
|
+
useLegacySql: false,
|
|
164
|
+
maxResults: pageSize,
|
|
165
|
+
timeoutMs
|
|
166
|
+
};
|
|
167
|
+
if (opts.location !== void 0) {
|
|
168
|
+
body["location"] = opts.location;
|
|
169
|
+
}
|
|
170
|
+
return { method: "POST", url: url2, body: JSON.stringify(body) };
|
|
171
|
+
}
|
|
172
|
+
if (opts.jobReference === void 0) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"cannot fetch the next page of BigQuery results without a jobReference"
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const params = new URLSearchParams({
|
|
178
|
+
pageToken: opts.pageToken,
|
|
179
|
+
maxResults: String(pageSize),
|
|
180
|
+
timeoutMs: String(timeoutMs)
|
|
181
|
+
});
|
|
182
|
+
const location = opts.jobReference.location ?? opts.location;
|
|
183
|
+
if (location !== void 0) {
|
|
184
|
+
params.set("location", location);
|
|
185
|
+
}
|
|
186
|
+
const url = `${BQ_API_BASE}/projects/${encodeURIComponent(
|
|
187
|
+
opts.jobReference.projectId
|
|
188
|
+
)}/queries/${encodeURIComponent(opts.jobReference.jobId)}?${params.toString()}`;
|
|
189
|
+
return { method: "GET", url };
|
|
190
|
+
}
|
|
191
|
+
async function collectBigQueryPages(opts) {
|
|
192
|
+
const rows = [];
|
|
193
|
+
let pageToken;
|
|
194
|
+
let jobReference;
|
|
195
|
+
let page = 0;
|
|
196
|
+
const phaseStart = Date.now();
|
|
197
|
+
do {
|
|
198
|
+
if (opts.signal?.aborted) {
|
|
199
|
+
return { rows, aborted: true };
|
|
200
|
+
}
|
|
201
|
+
const request = buildBigQueryPageRequest({
|
|
202
|
+
projectId: opts.projectId,
|
|
203
|
+
sql: opts.sql,
|
|
204
|
+
pageToken,
|
|
205
|
+
jobReference,
|
|
206
|
+
location: opts.location,
|
|
207
|
+
pageSize: opts.pageSize
|
|
208
|
+
});
|
|
209
|
+
let response;
|
|
210
|
+
try {
|
|
211
|
+
response = await opts.fetchPage(request, opts.signal);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
opts.logger?.warn("fetch page failed", {
|
|
214
|
+
resource: opts.resource,
|
|
215
|
+
page: page + 1,
|
|
216
|
+
error: err instanceof Error ? err.message : String(err)
|
|
217
|
+
});
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
if (response.jobComplete === false) {
|
|
221
|
+
throw new Error(opts.jobIncompleteMessage);
|
|
222
|
+
}
|
|
223
|
+
if (response.jobReference !== void 0) {
|
|
224
|
+
jobReference = response.jobReference;
|
|
225
|
+
}
|
|
226
|
+
const pageRows = opts.mapRows(response);
|
|
227
|
+
rows.push(...pageRows);
|
|
228
|
+
pageToken = typeof response.pageToken === "string" && response.pageToken.length > 0 ? response.pageToken : void 0;
|
|
229
|
+
page += 1;
|
|
230
|
+
opts.logger?.info("fetched page", {
|
|
231
|
+
resource: opts.resource,
|
|
232
|
+
page,
|
|
233
|
+
items: pageRows.length,
|
|
234
|
+
next: pageToken ?? null
|
|
235
|
+
});
|
|
236
|
+
} while (pageToken !== void 0);
|
|
237
|
+
opts.logger?.info("resource done", {
|
|
238
|
+
resource: opts.resource,
|
|
239
|
+
pages: page,
|
|
240
|
+
items: rows.length,
|
|
241
|
+
duration_ms: Date.now() - phaseStart
|
|
242
|
+
});
|
|
243
|
+
return { rows, aborted: false };
|
|
244
|
+
}
|
|
245
|
+
function indexBqFields(response) {
|
|
246
|
+
const fieldIndex = {};
|
|
247
|
+
(response.schema?.fields ?? []).forEach((field, idx) => {
|
|
248
|
+
fieldIndex[field.name] = idx;
|
|
249
|
+
});
|
|
250
|
+
return fieldIndex;
|
|
251
|
+
}
|
|
252
|
+
function readBqCell(cells, fieldIndex, name) {
|
|
253
|
+
const idx = fieldIndex[name];
|
|
254
|
+
if (idx === void 0) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const raw = cells[idx]?.v;
|
|
258
|
+
if (raw === void 0 || raw === null) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return raw;
|
|
262
|
+
}
|
|
263
|
+
function parseBqDateOrEpoch(value) {
|
|
264
|
+
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
265
|
+
if (dateMatch) {
|
|
266
|
+
return Date.UTC(
|
|
267
|
+
Number(dateMatch[1]),
|
|
268
|
+
Number(dateMatch[2]) - 1,
|
|
269
|
+
Number(dateMatch[3])
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return parseEpoch(value, "iso");
|
|
273
|
+
}
|
|
274
|
+
var GcpAccessTokenProvider = class {
|
|
275
|
+
constructor(opts) {
|
|
276
|
+
this.opts = opts;
|
|
277
|
+
}
|
|
278
|
+
opts;
|
|
279
|
+
cached = null;
|
|
280
|
+
async resolveGrant() {
|
|
281
|
+
const serviceAccountJson = this.opts.getServiceAccountJson();
|
|
282
|
+
if (serviceAccountJson) {
|
|
283
|
+
return buildServiceAccountJwt(serviceAccountJson, this.opts.scope);
|
|
284
|
+
}
|
|
285
|
+
const refreshTokenCredentials = this.opts.getRefreshTokenCredentials?.();
|
|
286
|
+
if (refreshTokenCredentials) {
|
|
287
|
+
return buildRefreshTokenGrant(refreshTokenCredentials);
|
|
288
|
+
}
|
|
289
|
+
throw new AuthError(
|
|
290
|
+
`${this.opts.connectorId}: missing serviceAccountJson or refresh-token credentials`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
async getToken(signal) {
|
|
294
|
+
if (this.cached && Date.now() < this.cached.expiresAt) {
|
|
295
|
+
return this.cached.token;
|
|
296
|
+
}
|
|
297
|
+
const { url, body } = await this.resolveGrant();
|
|
298
|
+
const res = await this.opts.post(url, {
|
|
299
|
+
resource: "oauth_token",
|
|
300
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
301
|
+
body,
|
|
302
|
+
signal
|
|
303
|
+
});
|
|
304
|
+
const expiresIn = res.body.expires_in ?? 3600;
|
|
305
|
+
this.cached = {
|
|
306
|
+
token: res.body.access_token,
|
|
307
|
+
expiresAt: Date.now() + (expiresIn - 60) * 1e3
|
|
308
|
+
};
|
|
309
|
+
return this.cached.token;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
var MS_PER_DAY = 864e5;
|
|
313
|
+
function pad2(n) {
|
|
314
|
+
return String(n).padStart(2, "0");
|
|
315
|
+
}
|
|
316
|
+
function toDateStr(ms) {
|
|
317
|
+
const d = new Date(ms);
|
|
318
|
+
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
|
319
|
+
}
|
|
320
|
+
function startOfUtcDay(ms) {
|
|
321
|
+
return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ../../connector-shared/dist/index.js
|
|
325
|
+
var HTTP_CLIENT_VERSION2 = "0.0.0";
|
|
326
|
+
var DEFAULT_USER_AGENT2 = `rawdash-connector/${HTTP_CLIENT_VERSION2} (+https://rawdash.dev)`;
|
|
102
327
|
function connectorUserAgent(connectorId) {
|
|
103
|
-
return `rawdash-connector-${connectorId}/${
|
|
328
|
+
return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION2} (+https://rawdash.dev)`;
|
|
104
329
|
}
|
|
105
|
-
function
|
|
330
|
+
function parseEpoch2(value, unit) {
|
|
106
331
|
if (value === null || value === void 0) {
|
|
107
332
|
return null;
|
|
108
333
|
}
|
|
@@ -132,19 +357,17 @@ import {
|
|
|
132
357
|
defineResources,
|
|
133
358
|
schemasFromResources
|
|
134
359
|
} from "@rawdash/core";
|
|
135
|
-
import { z as
|
|
136
|
-
var BQ_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
137
|
-
var BQ_DATASET_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
360
|
+
import { z as z4 } from "zod";
|
|
138
361
|
var DIMENSION_VALUES = ["service", "project", "sku", "location"];
|
|
139
362
|
var configFields = defineConfigFields(
|
|
140
|
-
|
|
363
|
+
z4.object({
|
|
141
364
|
...gcpAuthConfigShape,
|
|
142
|
-
bqProject:
|
|
365
|
+
bqProject: z4.string().regex(BQ_IDENT_RE, "bqProject must be a valid GCP project id").meta({
|
|
143
366
|
label: "BigQuery project ID",
|
|
144
367
|
description: "Project that hosts the BigQuery billing-export dataset (also the project used to bill the BigQuery queries this connector runs).",
|
|
145
368
|
placeholder: "my-billing-project"
|
|
146
369
|
}),
|
|
147
|
-
bqDataset:
|
|
370
|
+
bqDataset: z4.string().regex(
|
|
148
371
|
BQ_DATASET_RE,
|
|
149
372
|
"bqDataset must be a valid BigQuery dataset id (letters, digits, and underscores; must start with a letter or underscore)"
|
|
150
373
|
).meta({
|
|
@@ -152,12 +375,12 @@ var configFields = defineConfigFields(
|
|
|
152
375
|
description: "BigQuery dataset containing the Cloud Billing export tables (gcp_billing_export_v1_*).",
|
|
153
376
|
placeholder: "billing_export"
|
|
154
377
|
}),
|
|
155
|
-
bqLocation:
|
|
378
|
+
bqLocation: z4.string().min(1).optional().meta({
|
|
156
379
|
label: "BigQuery location",
|
|
157
380
|
description: "Region or multi-region of the billing dataset (e.g. US, EU, us-central1). Defaults to US.",
|
|
158
381
|
placeholder: "US"
|
|
159
382
|
}),
|
|
160
|
-
groupBy:
|
|
383
|
+
groupBy: z4.array(z4.enum(DIMENSION_VALUES)).nonempty().max(
|
|
161
384
|
3,
|
|
162
385
|
"groupBy accepts at most three dimensions to keep query cardinality bounded"
|
|
163
386
|
).refine(
|
|
@@ -167,7 +390,7 @@ var configFields = defineConfigFields(
|
|
|
167
390
|
label: "Group by (optional)",
|
|
168
391
|
description: 'Dimensions to break daily costs down by. Pick from service, project, sku, location. Defaults to ["service"].'
|
|
169
392
|
}),
|
|
170
|
-
lookbackDays:
|
|
393
|
+
lookbackDays: z4.number().int().positive().max(720).optional().meta({
|
|
171
394
|
label: "Backfill window (days)",
|
|
172
395
|
description: "How many days of history to query on a full sync. Defaults to 90.",
|
|
173
396
|
placeholder: "90"
|
|
@@ -181,6 +404,7 @@ var doc = defineConnectorDoc({
|
|
|
181
404
|
tagline: "Track Google Cloud spend over time from the Cloud Billing -> BigQuery export, optionally broken down by service, project, SKU, or location.",
|
|
182
405
|
vendor: {
|
|
183
406
|
name: "Google Cloud",
|
|
407
|
+
domain: "cloud.google.com",
|
|
184
408
|
apiDocs: "https://cloud.google.com/billing/docs/how-to/export-data-bigquery",
|
|
185
409
|
website: "https://cloud.google.com/billing"
|
|
186
410
|
},
|
|
@@ -202,13 +426,9 @@ var doc = defineConnectorDoc({
|
|
|
202
426
|
"Cost data is back-revised by GCP for several days; an incremental sync refetches the trailing 5 days to pick up corrections."
|
|
203
427
|
]
|
|
204
428
|
});
|
|
205
|
-
var BQ_API_BASE = "https://bigquery.googleapis.com/bigquery/v2";
|
|
206
|
-
var BQ_SCOPE = "https://www.googleapis.com/auth/bigquery.readonly";
|
|
207
429
|
var COST_METRIC_NAME = "gcp_cost_daily";
|
|
208
430
|
var DEFAULT_LOOKBACK_DAYS = 90;
|
|
209
431
|
var INCREMENTAL_LOOKBACK_DAYS = 5;
|
|
210
|
-
var MS_PER_DAY = 864e5;
|
|
211
|
-
var PAGE_SIZE = 1e4;
|
|
212
432
|
var DEFAULT_GROUP_BY = ["service"];
|
|
213
433
|
var gcpBillingCredentials = {
|
|
214
434
|
serviceAccountJson: {
|
|
@@ -216,23 +436,6 @@ var gcpBillingCredentials = {
|
|
|
216
436
|
auth: "required"
|
|
217
437
|
}
|
|
218
438
|
};
|
|
219
|
-
var bqQueryResponseSchema = z3.object({
|
|
220
|
-
jobComplete: z3.boolean().optional(),
|
|
221
|
-
schema: z3.object({
|
|
222
|
-
fields: z3.array(z3.object({ name: z3.string(), type: z3.string() }))
|
|
223
|
-
}).optional(),
|
|
224
|
-
rows: z3.array(
|
|
225
|
-
z3.object({
|
|
226
|
-
f: z3.array(z3.object({ v: z3.string().nullable().optional() }))
|
|
227
|
-
})
|
|
228
|
-
).optional(),
|
|
229
|
-
pageToken: z3.string().optional(),
|
|
230
|
-
jobReference: z3.object({
|
|
231
|
-
projectId: z3.string(),
|
|
232
|
-
jobId: z3.string(),
|
|
233
|
-
location: z3.string().optional()
|
|
234
|
-
}).optional()
|
|
235
|
-
});
|
|
236
439
|
var gcpBillingResources = defineResources({
|
|
237
440
|
[COST_METRIC_NAME]: {
|
|
238
441
|
shape: "metric",
|
|
@@ -294,56 +497,35 @@ var GcpBillingConnector = class _GcpBillingConnector extends BaseConnector {
|
|
|
294
497
|
}
|
|
295
498
|
id = id;
|
|
296
499
|
credentials = gcpBillingCredentials;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
throw new AuthError(`${this.id}: missing serviceAccountJson credential`);
|
|
305
|
-
}
|
|
306
|
-
const { url, body } = await buildServiceAccountJwt(
|
|
307
|
-
serviceAccountJson,
|
|
308
|
-
BQ_SCOPE
|
|
309
|
-
);
|
|
310
|
-
const res = await this.post(url, {
|
|
311
|
-
resource: "oauth_token",
|
|
312
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
313
|
-
body,
|
|
314
|
-
signal
|
|
500
|
+
tokenProvider;
|
|
501
|
+
getAccessToken(signal) {
|
|
502
|
+
this.tokenProvider ??= new GcpAccessTokenProvider({
|
|
503
|
+
connectorId: this.id,
|
|
504
|
+
scope: BQ_READONLY_SCOPE,
|
|
505
|
+
getServiceAccountJson: () => this.creds.serviceAccountJson,
|
|
506
|
+
post: (url, opts) => this.post(url, opts)
|
|
315
507
|
});
|
|
316
|
-
|
|
317
|
-
this.cachedToken = {
|
|
318
|
-
token: res.body.access_token,
|
|
319
|
-
expiresAt: Date.now() + (expiresIn - 60) * 1e3
|
|
320
|
-
};
|
|
321
|
-
return this.cachedToken.token;
|
|
508
|
+
return this.tokenProvider.getToken(signal);
|
|
322
509
|
}
|
|
323
|
-
async
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
useLegacySql: false,
|
|
330
|
-
maxResults: PAGE_SIZE,
|
|
331
|
-
timeoutMs: 3e4
|
|
510
|
+
async fetchBigQueryPage(request, signal) {
|
|
511
|
+
const accessToken = await this.getAccessToken(signal);
|
|
512
|
+
const headers = {
|
|
513
|
+
Authorization: `Bearer ${accessToken}`,
|
|
514
|
+
"Content-Type": "application/json",
|
|
515
|
+
"User-Agent": connectorUserAgent(this.id)
|
|
332
516
|
};
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
517
|
+
if (request.method === "POST") {
|
|
518
|
+
const res2 = await this.post(request.url, {
|
|
519
|
+
resource: "daily_cost",
|
|
520
|
+
headers,
|
|
521
|
+
body: request.body,
|
|
522
|
+
signal
|
|
523
|
+
});
|
|
524
|
+
return res2.body;
|
|
338
525
|
}
|
|
339
|
-
const res = await this.
|
|
526
|
+
const res = await this.get(request.url, {
|
|
340
527
|
resource: "daily_cost",
|
|
341
|
-
headers
|
|
342
|
-
Authorization: `Bearer ${accessToken}`,
|
|
343
|
-
"Content-Type": "application/json",
|
|
344
|
-
"User-Agent": connectorUserAgent(this.id)
|
|
345
|
-
},
|
|
346
|
-
body: JSON.stringify(body),
|
|
528
|
+
headers,
|
|
347
529
|
signal
|
|
348
530
|
});
|
|
349
531
|
return res.body;
|
|
@@ -361,49 +543,23 @@ var GcpBillingConnector = class _GcpBillingConnector extends BaseConnector {
|
|
|
361
543
|
startDate: window.startDate,
|
|
362
544
|
endDate: window.endDate
|
|
363
545
|
});
|
|
364
|
-
const samples =
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
do {
|
|
369
|
-
if (signal?.aborted) {
|
|
370
|
-
return { done: false };
|
|
371
|
-
}
|
|
372
|
-
const accessToken = await this.getAccessToken(signal);
|
|
373
|
-
let response;
|
|
374
|
-
try {
|
|
375
|
-
response = await this.runQuery(accessToken, sql, pageToken, signal);
|
|
376
|
-
} catch (err) {
|
|
377
|
-
this.logger.warn("fetch page failed", {
|
|
378
|
-
resource: "daily_cost",
|
|
379
|
-
page: page + 1,
|
|
380
|
-
error: err instanceof Error ? err.message : String(err)
|
|
381
|
-
});
|
|
382
|
-
throw err;
|
|
383
|
-
}
|
|
384
|
-
if (response.jobComplete === false) {
|
|
385
|
-
throw new Error(
|
|
386
|
-
`${this.id}: BigQuery query did not complete within the synchronous timeout (jobComplete=false). Narrow the groupBy or lookbackDays so the query finishes faster.`
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
const pageSamples = buildSamplesFromBqResponse(response, groupBy);
|
|
390
|
-
samples.push(...pageSamples);
|
|
391
|
-
pageToken = typeof response.pageToken === "string" && response.pageToken.length > 0 ? response.pageToken : void 0;
|
|
392
|
-
page += 1;
|
|
393
|
-
this.logger.info("fetched page", {
|
|
546
|
+
const { rows: samples, aborted } = await collectBigQueryPages(
|
|
547
|
+
{
|
|
548
|
+
projectId: this.settings.bqProject,
|
|
549
|
+
sql,
|
|
394
550
|
resource: "daily_cost",
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
551
|
+
location: this.settings.bqLocation,
|
|
552
|
+
signal,
|
|
553
|
+
logger: this.logger,
|
|
554
|
+
mapRows: (response) => buildSamplesFromBqResponse(response, groupBy),
|
|
555
|
+
jobIncompleteMessage: `${this.id}: BigQuery query did not complete within the synchronous timeout (jobComplete=false). Narrow the groupBy or lookbackDays so the query finishes faster.`,
|
|
556
|
+
fetchPage: (request, sig) => this.fetchBigQueryPage(request, sig)
|
|
557
|
+
}
|
|
558
|
+
);
|
|
559
|
+
if (aborted) {
|
|
560
|
+
return { done: false };
|
|
561
|
+
}
|
|
400
562
|
await storage.metrics(samples, { names: [COST_METRIC_NAME] });
|
|
401
|
-
this.logger.info("resource done", {
|
|
402
|
-
resource: "daily_cost",
|
|
403
|
-
pages: page,
|
|
404
|
-
items: samples.length,
|
|
405
|
-
duration_ms: Date.now() - phaseStart
|
|
406
|
-
});
|
|
407
563
|
return { done: true };
|
|
408
564
|
}
|
|
409
565
|
};
|
|
@@ -427,23 +583,13 @@ function buildBillingSql(args) {
|
|
|
427
583
|
`ORDER BY date`
|
|
428
584
|
].join("\n");
|
|
429
585
|
}
|
|
430
|
-
function pad2(n) {
|
|
431
|
-
return String(n).padStart(2, "0");
|
|
432
|
-
}
|
|
433
|
-
function toDateStr(ms) {
|
|
434
|
-
const d = new Date(ms);
|
|
435
|
-
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
|
436
|
-
}
|
|
437
|
-
function startOfUtcDay(ms) {
|
|
438
|
-
return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY;
|
|
439
|
-
}
|
|
440
586
|
function getCostWindow(options, lookbackDays, now = Date.now()) {
|
|
441
587
|
const endMs = startOfUtcDay(now) + MS_PER_DAY;
|
|
442
588
|
let days = lookbackDays;
|
|
443
589
|
if (options.mode === "latest") {
|
|
444
590
|
days = INCREMENTAL_LOOKBACK_DAYS;
|
|
445
591
|
} else if (options.since !== void 0) {
|
|
446
|
-
const sinceMs =
|
|
592
|
+
const sinceMs = parseEpoch2(options.since, "iso");
|
|
447
593
|
if (sinceMs !== null) {
|
|
448
594
|
const elapsed = Math.ceil((now - sinceMs) / MS_PER_DAY);
|
|
449
595
|
days = Math.min(
|
|
@@ -458,14 +604,10 @@ function getCostWindow(options, lookbackDays, now = Date.now()) {
|
|
|
458
604
|
};
|
|
459
605
|
}
|
|
460
606
|
function buildSamplesFromBqResponse(response, groupBy) {
|
|
461
|
-
const
|
|
462
|
-
const fieldIndex = {};
|
|
463
|
-
schema.forEach((field, idx) => {
|
|
464
|
-
fieldIndex[field.name] = idx;
|
|
465
|
-
});
|
|
607
|
+
const fieldIndex = indexBqFields(response);
|
|
466
608
|
const samples = [];
|
|
467
609
|
for (const row of response.rows ?? []) {
|
|
468
|
-
const dateValue =
|
|
610
|
+
const dateValue = readBqCell(row.f, fieldIndex, "date");
|
|
469
611
|
if (dateValue === null) {
|
|
470
612
|
continue;
|
|
471
613
|
}
|
|
@@ -473,7 +615,7 @@ function buildSamplesFromBqResponse(response, groupBy) {
|
|
|
473
615
|
if (ts === null) {
|
|
474
616
|
continue;
|
|
475
617
|
}
|
|
476
|
-
const costValue =
|
|
618
|
+
const costValue = readBqCell(row.f, fieldIndex, "cost");
|
|
477
619
|
if (costValue === null) {
|
|
478
620
|
continue;
|
|
479
621
|
}
|
|
@@ -483,10 +625,10 @@ function buildSamplesFromBqResponse(response, groupBy) {
|
|
|
483
625
|
}
|
|
484
626
|
const attributes = {};
|
|
485
627
|
for (const dim of groupBy) {
|
|
486
|
-
const v =
|
|
628
|
+
const v = readBqCell(row.f, fieldIndex, dim);
|
|
487
629
|
attributes[dim] = v ?? null;
|
|
488
630
|
}
|
|
489
|
-
const currency =
|
|
631
|
+
const currency = readBqCell(row.f, fieldIndex, "currency");
|
|
490
632
|
if (currency !== null) {
|
|
491
633
|
attributes["currency"] = currency;
|
|
492
634
|
}
|
|
@@ -494,28 +636,6 @@ function buildSamplesFromBqResponse(response, groupBy) {
|
|
|
494
636
|
}
|
|
495
637
|
return samples;
|
|
496
638
|
}
|
|
497
|
-
function readCell(cells, fieldIndex, name) {
|
|
498
|
-
const idx = fieldIndex[name];
|
|
499
|
-
if (idx === void 0) {
|
|
500
|
-
return null;
|
|
501
|
-
}
|
|
502
|
-
const raw = cells[idx]?.v;
|
|
503
|
-
if (raw === void 0 || raw === null) {
|
|
504
|
-
return null;
|
|
505
|
-
}
|
|
506
|
-
return raw;
|
|
507
|
-
}
|
|
508
|
-
function parseBqDateOrEpoch(value) {
|
|
509
|
-
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
510
|
-
if (dateMatch) {
|
|
511
|
-
return Date.UTC(
|
|
512
|
-
Number(dateMatch[1]),
|
|
513
|
-
Number(dateMatch[2]) - 1,
|
|
514
|
-
Number(dateMatch[3])
|
|
515
|
-
);
|
|
516
|
-
}
|
|
517
|
-
return parseEpoch(value, "iso");
|
|
518
|
-
}
|
|
519
639
|
|
|
520
640
|
// src/index.ts
|
|
521
641
|
var index_default = GcpBillingConnector;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../gcp-shared/src/auth.ts","../../gcp-shared/src/config.ts","../../../connector-shared/src/errors.ts","../../../connector-shared/src/retry.ts","../../../connector-shared/src/version.ts","../../../connector-shared/src/request.ts","../../../connector-shared/src/rate-limit.ts","../../../connector-shared/src/map-concurrent.ts","../../../connector-shared/src/sanitize.ts","../../../connector-shared/src/epoch.ts","../../../connector-shared/src/pagination.ts","../../../connector-shared/src/logger.ts","../src/gcp-billing.ts","../src/index.ts"],"sourcesContent":["import { z } from 'zod';\n\nexport interface ServiceAccountKey {\n client_email: string;\n private_key: string;\n token_uri?: string;\n project_id?: string;\n}\n\nconst serviceAccountKeySchema = z.object({\n client_email: z.string().min(1),\n private_key: z.string().min(1),\n token_uri: z.string().url().optional(),\n project_id: z.string().optional(),\n});\n\nexport interface TokenResponse {\n access_token: string;\n expires_in?: number;\n}\n\nexport const tokenResponseSchema = z.object({\n access_token: z.string().min(1),\n expires_in: z.number().int().positive().optional(),\n});\n\nfunction base64urlFromBytes(bytes: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]!);\n }\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n}\n\nfunction base64urlFromString(str: string): string {\n return base64urlFromBytes(new TextEncoder().encode(str));\n}\n\nasync function signRS256JWT(\n payload: Record<string, unknown>,\n privateKeyPem: string,\n): Promise<string> {\n const header = { alg: 'RS256', typ: 'JWT' };\n const headerB64 = base64urlFromString(JSON.stringify(header));\n const payloadB64 = base64urlFromString(JSON.stringify(payload));\n const signingInput = `${headerB64}.${payloadB64}`;\n\n const pemContent = privateKeyPem\n .replace(/-----BEGIN PRIVATE KEY-----/g, '')\n .replace(/-----END PRIVATE KEY-----/g, '')\n .replace(/\\s/g, '');\n const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));\n\n const key = await globalThis.crypto.subtle.importKey(\n 'pkcs8',\n der,\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n const signature = await globalThis.crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(signingInput),\n );\n\n return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;\n}\n\nexport function parseServiceAccountJson(value: string): ServiceAccountKey {\n const trimmed = value.trim();\n if (trimmed.startsWith('{')) {\n return serviceAccountKeySchema.parse(JSON.parse(trimmed));\n }\n const binary = atob(trimmed);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const decoded = new TextDecoder().decode(bytes);\n return serviceAccountKeySchema.parse(JSON.parse(decoded));\n}\n\nexport async function buildServiceAccountJwt(\n serviceAccountJson: string,\n scope: string,\n): Promise<{ url: string; body: string }> {\n const sa = parseServiceAccountJson(serviceAccountJson);\n const now = Math.floor(Date.now() / 1000);\n const jwt = await signRS256JWT(\n {\n iss: sa.client_email,\n scope,\n aud: sa.token_uri ?? 'https://oauth2.googleapis.com/token',\n exp: now + 3600,\n iat: now,\n },\n sa.private_key,\n );\n\n const body = new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion: jwt,\n }).toString();\n\n return {\n url: sa.token_uri ?? 'https://oauth2.googleapis.com/token',\n body,\n };\n}\n","import { z } from 'zod';\n\nexport const gcpAuthConfigShape = {\n serviceAccountJson: z.object({ $secret: z.string().trim().min(1) }).meta({\n label: 'Service Account JSON',\n description:\n 'Contents of the JSON key file for a Google service account with the role required by this connector. Create one at Google Cloud -> IAM & Admin -> Service Accounts and store the JSON as a secret.',\n secret: true,\n }),\n} as const;\n\nexport interface GcpAuthConfig {\n serviceAccountJson: { $secret: string };\n}\n","import type { HttpResponse } from './types';\n\nexport type HttpErrorKind =\n | 'transient'\n | 'rate_limit'\n | 'auth'\n | 'upstream_bug'\n | 'client_bug';\n\nexport abstract class HttpClientError extends Error {\n abstract readonly kind: HttpErrorKind;\n readonly response?: HttpResponse;\n\n constructor(message: string, response?: HttpResponse) {\n super(message);\n this.name = new.target.name;\n this.response = response;\n }\n}\n\nexport class TransientError extends HttpClientError {\n readonly kind = 'transient' as const;\n}\n\nexport class RateLimitError extends HttpClientError {\n readonly kind = 'rate_limit' as const;\n readonly retryAfter?: Date;\n\n constructor(message: string, response?: HttpResponse, retryAfter?: Date) {\n super(message, response);\n this.retryAfter = retryAfter;\n }\n}\n\nexport class AuthError extends HttpClientError {\n readonly kind = 'auth' as const;\n}\n\nexport class UpstreamBugError extends HttpClientError {\n readonly kind = 'upstream_bug' as const;\n}\n\nexport class ClientBugError extends HttpClientError {\n readonly kind = 'client_bug' as const;\n}\n\nexport function classifyStatus(status: number): HttpErrorKind {\n if (status === 429) {\n return 'rate_limit';\n }\n if (status === 401 || status === 403) {\n return 'auth';\n }\n if (status === 408) {\n return 'transient';\n }\n if (status >= 500) {\n return 'upstream_bug';\n }\n if (status >= 400) {\n return 'client_bug';\n }\n return 'client_bug';\n}\n\nexport function errorForStatus(\n message: string,\n response: HttpResponse,\n retryAfter?: Date,\n): HttpClientError {\n const kind = classifyStatus(response.status);\n switch (kind) {\n case 'rate_limit':\n return new RateLimitError(message, response, retryAfter);\n case 'auth':\n return new AuthError(message, response);\n case 'transient':\n return new TransientError(message, response);\n case 'upstream_bug':\n return new UpstreamBugError(message, response);\n case 'client_bug':\n return new ClientBugError(message, response);\n }\n}\n","import { HttpClientError, RateLimitError, TransientError } from './errors';\n\nexport interface RetryPolicy {\n maxAttempts?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n retryOn?: (status: number | null, err?: Error) => boolean;\n}\n\nexport const defaultRetryOn = (status: number | null, err?: Error): boolean => {\n if (err instanceof RateLimitError) {\n return true;\n }\n if (err instanceof TransientError) {\n return true;\n }\n if (status === null) {\n return err instanceof Error && !(err instanceof HttpClientError);\n }\n if (status === 408 || status === 429) {\n return true;\n }\n if (status >= 500) {\n return true;\n }\n return false;\n};\n\nexport function backoffDelayMs(\n attempt: number,\n policy: Required<Pick<RetryPolicy, 'initialDelayMs' | 'maxDelayMs'>>,\n): number {\n const base = policy.initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, policy.maxDelayMs);\n}\n\nexport function parseRetryAfter(\n headerValue: string | null,\n now: Date = new Date(),\n): Date | undefined {\n if (!headerValue) {\n return undefined;\n }\n const trimmed = headerValue.trim();\n if (/^\\d+$/.test(trimmed)) {\n return new Date(now.getTime() + Number(trimmed) * 1000);\n }\n const parsed = Date.parse(trimmed);\n if (Number.isNaN(parsed)) {\n return undefined;\n }\n return new Date(parsed);\n}\n\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(signal.reason ?? new Error('Aborted'));\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal!.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n","export const HTTP_CLIENT_VERSION = '0.0.0';\n\nexport const DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n\nexport function connectorUserAgent(connectorId: string): string {\n return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n}\n","import {\n AuthError,\n ClientBugError,\n HttpClientError,\n RateLimitError,\n TransientError,\n UpstreamBugError,\n errorForStatus,\n} from './errors';\nimport { defaultRetryOn, parseRetryAfter, sleep } from './retry';\nimport type { FetchLike, HttpMethod, HttpRequest, HttpResponse } from './types';\nimport { DEFAULT_USER_AGENT } from './version';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\nconst DEFAULT_INITIAL_DELAY_MS = 1000;\nconst DEFAULT_MAX_DELAY_MS = 60_000;\nconst OBSERVER_TIMEOUT_MS = 250;\n\nexport interface RequestObservation {\n url: string;\n method: HttpMethod;\n status: number;\n resource: string;\n requestId: string;\n body: unknown;\n}\n\nexport type RequestObserver = (\n event: RequestObservation,\n) => void | Promise<void>;\n\nexport interface RequestOptions {\n fetch?: FetchLike;\n observer?: RequestObserver;\n resource: string;\n requestId?: string;\n}\n\nasync function notifyObserver(\n observer: RequestObserver,\n event: RequestObservation,\n): Promise<void> {\n let result: void | Promise<void>;\n try {\n result = observer(event);\n } catch (err) {\n console.warn('[connector-shared] request observer threw:', err);\n return;\n }\n if (!(result instanceof Promise)) {\n return;\n }\n const guarded = result.catch((err) => {\n console.warn('[connector-shared] request observer rejected:', err);\n });\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, OBSERVER_TIMEOUT_MS);\n });\n try {\n await Promise.race([guarded, timeout]);\n } finally {\n if (timer) {\n clearTimeout(timer);\n }\n }\n}\n\nfunction newRequestId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) {\n return c.randomUUID();\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction mergeHeaders(\n defaults: Record<string, string>,\n overrides: Record<string, string> | undefined,\n): Record<string, string> {\n const merged: Record<string, string> = {};\n for (const [k, v] of Object.entries(defaults)) {\n merged[k.toLowerCase()] = v;\n }\n if (overrides) {\n for (const [k, v] of Object.entries(overrides)) {\n merged[k.toLowerCase()] = v;\n }\n }\n return merged;\n}\n\nfunction linkTimeoutSignal(\n parent: AbortSignal | undefined,\n timeoutMs: number,\n): { signal: AbortSignal; cancel: () => void } {\n const controller = new AbortController();\n const onParentAbort = () => {\n controller.abort(parent?.reason);\n };\n if (parent) {\n if (parent.aborted) {\n controller.abort(parent.reason);\n } else {\n parent.addEventListener('abort', onParentAbort, { once: true });\n }\n }\n const timer = setTimeout(() => {\n controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n return {\n signal: controller.signal,\n cancel: () => {\n clearTimeout(timer);\n if (parent) {\n parent.removeEventListener('abort', onParentAbort);\n }\n },\n };\n}\n\nasync function readBody(res: Response, parseJson: boolean): Promise<unknown> {\n if (res.status === 204 || res.status === 205) {\n return null;\n }\n const contentType = res.headers.get('content-type') ?? '';\n if (parseJson && contentType.includes('application/json')) {\n const text = await res.text();\n if (text.length === 0) {\n return null;\n }\n return JSON.parse(text);\n }\n return res.text();\n}\n\nexport async function request<T = unknown>(\n req: HttpRequest,\n options: RequestOptions,\n): Promise<HttpResponse<T>> {\n const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);\n const retry = req.retry ?? {};\n const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;\n const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const parseJson = req.parseJson ?? true;\n\n const headers = mergeHeaders(\n {\n 'User-Agent': DEFAULT_USER_AGENT,\n Accept: 'application/json',\n },\n req.headers,\n );\n\n let lastErr: Error | undefined;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n req.signal?.throwIfAborted();\n\n const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);\n let res: Response;\n try {\n res = await fetchImpl(req.url, {\n method: req.method ?? 'GET',\n headers,\n body: req.body as RequestInit['body'],\n signal,\n });\n } catch (err) {\n cancel();\n if (req.signal?.aborted) {\n throw req.signal.reason ?? err;\n }\n const error = err instanceof Error ? err : new Error(String(err));\n lastErr = error;\n if (attempt < maxAttempts - 1 && retryOn(null, error)) {\n const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n await sleep(delay, req.signal);\n continue;\n }\n throw new TransientError(error.message);\n }\n cancel();\n\n const body = await readBody(res, parseJson);\n const httpResponse: HttpResponse<T> = {\n status: res.status,\n headers: res.headers,\n body: body as T,\n };\n if (req.rateLimit) {\n const state = req.rateLimit.parse(res.headers);\n if (state) {\n httpResponse.rateLimitState = state;\n }\n }\n\n if (options.observer) {\n await notifyObserver(options.observer, {\n url: req.url,\n method: req.method ?? 'GET',\n status: res.status,\n resource: options.resource,\n requestId: options.requestId ?? newRequestId(),\n body,\n });\n }\n\n if (res.ok) {\n return httpResponse;\n }\n\n const retryAfter = parseRetryAfter(res.headers.get('retry-after'));\n const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? 'GET'} ${req.url}`;\n const err = errorForStatus(message, httpResponse, retryAfter);\n\n if (\n attempt < maxAttempts - 1 &&\n retryOn(res.status, err) &&\n !(err instanceof AuthError) &&\n !(err instanceof ClientBugError)\n ) {\n lastErr = err;\n let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n if (err instanceof RateLimitError && retryAfter) {\n const wait = retryAfter.getTime() - Date.now();\n if (wait > 0) {\n delay = Math.min(wait, maxDelayMs);\n }\n }\n await sleep(delay, req.signal);\n continue;\n }\n\n throw err;\n }\n\n throw lastErr ?? new UpstreamBugError('Exhausted retry attempts');\n}\n\nfunction computeDelay(\n attempt: number,\n initialDelayMs: number,\n maxDelayMs: number,\n): number {\n const base = initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, maxDelayMs);\n}\n\nexport { HttpClientError };\n","export interface RateLimitState {\n remaining: number;\n resetAt: Date;\n}\n\nexport interface RateLimitPolicy {\n parse(headers: Headers): RateLimitState | null;\n}\n\nexport interface StandardRateLimitPolicyConfig {\n remainingHeader: string;\n resetHeader: string;\n resetUnit: 's' | 'ms';\n resetFallbackMs?: number;\n}\n\nexport function standardRateLimitPolicy(\n config: StandardRateLimitPolicyConfig,\n): RateLimitPolicy {\n const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;\n const multiplier = resetUnit === 's' ? 1000 : 1;\n return {\n parse(h) {\n const remainingRaw = h.get(remainingHeader);\n if (remainingRaw === null || remainingRaw.trim() === '') {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n const resetRaw = h.get(resetHeader);\n if (resetRaw === null) {\n if (resetFallbackMs === undefined) {\n return null;\n }\n return {\n remaining,\n resetAt: new Date(Date.now() + resetFallbackMs),\n };\n }\n if (resetRaw.trim() === '') {\n return null;\n }\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n const resetMs = reset * multiplier;\n if (!Number.isFinite(resetMs)) {\n return null;\n }\n return { remaining, resetAt: new Date(resetMs) };\n },\n };\n}\n","export async function mapWithConcurrency<T, R>(\n items: readonly T[],\n concurrency: number,\n fn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length);\n if (items.length === 0) {\n return results;\n }\n const normalized = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;\n const limit = Math.max(1, Math.min(normalized, items.length));\n let next = 0;\n let failed = false;\n\n async function worker(): Promise<void> {\n while (!failed) {\n const i = next++;\n if (i >= items.length) {\n return;\n }\n try {\n results[i] = await fn(items[i]!, i);\n } catch (err) {\n failed = true;\n throw err;\n }\n }\n }\n\n const workers: Promise<void>[] = [];\n for (let w = 0; w < limit; w++) {\n workers.push(worker());\n }\n await Promise.all(workers);\n return results;\n}\n","export interface SanitizeAllowedUrlOptions {\n url: string | null;\n host: string;\n pathname: string;\n protocol?: 'https:' | 'http:';\n}\n\nexport function sanitizeAllowedUrl(\n options: SanitizeAllowedUrlOptions,\n): string | null {\n const { url, host, pathname, protocol = 'https:' } = options;\n if (url === null) {\n return null;\n }\n try {\n const u = new URL(url);\n if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n}\n","export type EpochUnit = 'ms' | 's' | 'iso';\n\nexport function parseEpoch(\n value: number | string | null | undefined,\n unit: EpochUnit,\n): number | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (unit === 'iso') {\n if (typeof value !== 'string') {\n return null;\n }\n const ms = new Date(value).getTime();\n return Number.isFinite(ms) ? ms : null;\n }\n if (typeof value === 'string' && value.trim() === '') {\n return null;\n }\n const n = typeof value === 'number' ? value : Number(value);\n if (!Number.isFinite(n)) {\n return null;\n }\n const result = unit === 's' ? n * 1000 : n;\n return Number.isFinite(result) ? result : null;\n}\n","import { request } from './request';\nimport type { HttpRequest } from './types';\n\nexport function parseLinkHeader(header: string | null): Record<string, string> {\n if (!header) {\n return {};\n }\n const result: Record<string, string> = {};\n for (const part of header.split(',')) {\n const match = part.match(/<([^>]+)>\\s*;\\s*rel=\"([^\"]+)\"/);\n if (match) {\n result[match[2]!] = match[1]!;\n }\n }\n return result;\n}\n\nexport async function* paginateLink<T>(\n initial: HttpRequest,\n parse: (body: unknown) => T[],\n options: { resource: string },\n): AsyncIterable<T> {\n let next: string | null = initial.url;\n while (next) {\n const res: Awaited<ReturnType<typeof request>> = await request(\n {\n ...initial,\n url: next,\n },\n { resource: options.resource },\n );\n for (const item of parse(res.body)) {\n yield item;\n }\n const links = parseLinkHeader(res.headers.get('link'));\n next = links['next'] ?? null;\n }\n}\n\nexport async function* paginateCursor<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; nextCursor: string | null },\n buildNext: (req: HttpRequest, cursor: string) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let req: HttpRequest = initial;\n while (true) {\n const res = await request(req, { resource: options.resource });\n const { items, nextCursor } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!nextCursor) {\n return;\n }\n req = buildNext(req, nextCursor);\n }\n}\n\nexport async function* paginatePage<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; hasMore: boolean },\n buildPage: (req: HttpRequest, page: number) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let page = 1;\n while (true) {\n const req = page === 1 ? initial : buildPage(initial, page);\n const res = await request(req, { resource: options.resource });\n const { items, hasMore } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!hasMore || items.length === 0) {\n return;\n }\n page++;\n }\n}\n","export type LogFields = Record<string, unknown>;\n\nexport interface ConnectorLogger {\n info(event: string, fields?: LogFields): void;\n warn(event: string, fields?: LogFields): void;\n}\n\nexport interface ConnectorLoggerOptions {\n scope: string;\n}\n\nconst MAX_VALUE_LEN = 120;\n\nfunction truncate(s: string, max = MAX_VALUE_LEN): string {\n if (s.length <= max) {\n return s;\n }\n return `${s.slice(0, max - 1)}…`;\n}\n\nfunction formatValue(value: unknown): string {\n if (value === null) {\n return 'null';\n }\n if (value === undefined) {\n return '';\n }\n if (typeof value === 'number' || typeof value === 'boolean') {\n return String(value);\n }\n if (typeof value === 'string') {\n const t = truncate(value);\n if (/[\\s\"=]/.test(t)) {\n return JSON.stringify(t);\n }\n return t;\n }\n if (typeof value === 'bigint') {\n return value.toString();\n }\n let json: string | undefined;\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n return truncate(json ?? String(value));\n}\n\nexport function formatLogFields(fields?: LogFields): string {\n if (!fields) {\n return '';\n }\n const parts: string[] = [];\n for (const [k, v] of Object.entries(fields)) {\n if (v === undefined) {\n continue;\n }\n parts.push(`${k}=${formatValue(v)}`);\n }\n return parts.length > 0 ? ` ${parts.join(' ')}` : '';\n}\n\nexport function formatLogLine(\n scope: string,\n event: string,\n fields?: LogFields,\n): string {\n return `[${scope}] ${event}${formatLogFields(fields)}`;\n}\n\nexport function createDefaultConnectorLogger(\n opts: ConnectorLoggerOptions,\n): ConnectorLogger {\n return {\n info(event, fields) {\n console.info(formatLogLine(opts.scope, event, fields));\n },\n warn(event, fields) {\n console.warn(formatLogLine(opts.scope, event, fields));\n },\n };\n}\n\nconst NOOP_LOGGER: ConnectorLogger = {\n info() {},\n warn() {},\n};\n\nexport function noopConnectorLogger(): ConnectorLogger {\n return NOOP_LOGGER;\n}\n","import {\n buildServiceAccountJwt,\n gcpAuthConfigShape,\n tokenResponseSchema,\n} from '@rawdash/connector-gcp-shared';\nimport {\n AuthError,\n connectorUserAgent,\n parseEpoch,\n} from '@rawdash/connector-shared';\nimport {\n BaseConnector,\n type ConnectorContext,\n type ConnectorCost,\n type ConnectorDoc,\n type CredentialsSchema,\n type JSONValue,\n type MetricSample,\n type StorageHandle,\n type SyncOptions,\n type SyncResult,\n defineConfigFields,\n defineConnectorDoc,\n defineResources,\n schemasFromResources,\n} from '@rawdash/core';\nimport { z } from 'zod';\n\nconst BQ_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;\nconst BQ_DATASET_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;\nconst DIMENSION_VALUES = ['service', 'project', 'sku', 'location'] as const;\ntype Dimension = (typeof DIMENSION_VALUES)[number];\n\nexport const configFields = defineConfigFields(\n z.object({\n ...gcpAuthConfigShape,\n bqProject: z\n .string()\n .regex(BQ_IDENT_RE, 'bqProject must be a valid GCP project id')\n .meta({\n label: 'BigQuery project ID',\n description:\n 'Project that hosts the BigQuery billing-export dataset (also the project used to bill the BigQuery queries this connector runs).',\n placeholder: 'my-billing-project',\n }),\n bqDataset: z\n .string()\n .regex(\n BQ_DATASET_RE,\n 'bqDataset must be a valid BigQuery dataset id (letters, digits, and underscores; must start with a letter or underscore)',\n )\n .meta({\n label: 'BigQuery dataset',\n description:\n 'BigQuery dataset containing the Cloud Billing export tables (gcp_billing_export_v1_*).',\n placeholder: 'billing_export',\n }),\n bqLocation: z.string().min(1).optional().meta({\n label: 'BigQuery location',\n description:\n 'Region or multi-region of the billing dataset (e.g. US, EU, us-central1). Defaults to US.',\n placeholder: 'US',\n }),\n groupBy: z\n .array(z.enum(DIMENSION_VALUES))\n .nonempty()\n .max(\n 3,\n 'groupBy accepts at most three dimensions to keep query cardinality bounded',\n )\n .refine(\n (dims) => new Set(dims).size === dims.length,\n 'groupBy values must be unique',\n )\n .optional()\n .meta({\n label: 'Group by (optional)',\n description:\n 'Dimensions to break daily costs down by. Pick from service, project, sku, location. Defaults to [\"service\"].',\n }),\n lookbackDays: z.number().int().positive().max(720).optional().meta({\n label: 'Backfill window (days)',\n description:\n 'How many days of history to query on a full sync. Defaults to 90.',\n placeholder: '90',\n }),\n }),\n);\n\nexport const doc: ConnectorDoc = defineConnectorDoc({\n displayName: 'Google Cloud Billing',\n category: 'finance',\n brandColor: '#669DF6',\n tagline:\n 'Track Google Cloud spend over time from the Cloud Billing -> BigQuery export, optionally broken down by service, project, SKU, or location.',\n vendor: {\n name: 'Google Cloud',\n apiDocs:\n 'https://cloud.google.com/billing/docs/how-to/export-data-bigquery',\n website: 'https://cloud.google.com/billing',\n },\n auth: {\n summary:\n 'Authenticate against the BigQuery API with a Google service account JSON key. The service account needs the BigQuery Data Viewer role on the billing-export dataset and the BigQuery Job User role on the project that runs the queries.',\n setup: [\n 'Enable the Cloud Billing -> BigQuery export in the GCP console (Billing -> Billing export -> BigQuery export). This is a manual one-time setup; data starts flowing into the configured dataset within a day.',\n 'Create a service account at Google Cloud -> IAM & Admin -> Service Accounts (or grant an existing one access).',\n 'Grant the service account roles/bigquery.dataViewer on the billing dataset (so it can read the export tables) and roles/bigquery.jobUser on the bqProject (so it can run query jobs).',\n 'Generate a JSON key for the service account and store its contents as a secret (e.g. GCP_BILLING_SA_JSON).',\n 'Reference the key from config as serviceAccountJson: secret(\"GCP_BILLING_SA_JSON\") and set bqProject + bqDataset to the export location.',\n ],\n },\n rateLimit:\n 'BigQuery jobs.query is rate-limited per project; standard 429 / RESOURCE_EXHAUSTED responses are retried with backoff. Each connector sync runs one query (or a small number when paginated).',\n limitations: [\n 'Requires the Cloud Billing -> BigQuery export to be configured in the GCP console; that step is manual and one-time, and only days after the configuration date are present in the export.',\n 'Queries the gcp_billing_export_v1_* table family (standard usage cost export). The detailed resource-level export (gcp_billing_export_resource_v1_*) is not used.',\n 'Each BigQuery query is billed against the bqProject; over long windows or wide groupBy axes the cost adds up. Prefer narrow groupBy and reasonable lookbackDays.',\n 'Cost data is back-revised by GCP for several days; an incremental sync refetches the trailing 5 days to pick up corrections.',\n ],\n});\n\nconst BQ_API_BASE = 'https://bigquery.googleapis.com/bigquery/v2';\nconst BQ_SCOPE = 'https://www.googleapis.com/auth/bigquery.readonly';\nconst COST_METRIC_NAME = 'gcp_cost_daily';\nconst DEFAULT_LOOKBACK_DAYS = 90;\nconst INCREMENTAL_LOOKBACK_DAYS = 5;\nconst MS_PER_DAY = 86_400_000;\nconst PAGE_SIZE = 10_000;\nconst DEFAULT_GROUP_BY: readonly Dimension[] = ['service'];\n\nexport interface GcpBillingSettings {\n bqProject: string;\n bqDataset: string;\n bqLocation?: string;\n groupBy?: readonly Dimension[];\n lookbackDays?: number;\n}\n\nconst gcpBillingCredentials = {\n serviceAccountJson: {\n description: 'Google service account JSON key (raw JSON or base64)',\n auth: 'required' as const,\n },\n} satisfies CredentialsSchema;\n\ntype GcpBillingCredentials = typeof gcpBillingCredentials;\n\nconst bqQueryResponseSchema = z.object({\n jobComplete: z.boolean().optional(),\n schema: z\n .object({\n fields: z.array(z.object({ name: z.string(), type: z.string() })),\n })\n .optional(),\n rows: z\n .array(\n z.object({\n f: z.array(z.object({ v: z.string().nullable().optional() })),\n }),\n )\n .optional(),\n pageToken: z.string().optional(),\n jobReference: z\n .object({\n projectId: z.string(),\n jobId: z.string(),\n location: z.string().optional(),\n })\n .optional(),\n});\n\nexport const gcpBillingResources = defineResources({\n [COST_METRIC_NAME]: {\n shape: 'metric',\n description:\n 'Historical GCP cost per day, summed over the dimensions in `groupBy`. One sample per (date, dimension tuple). Pulls from the gcp_billing_export_v1_* tables in BigQuery.',\n endpoint: 'POST /bigquery/v2/projects/{bqProject}/queries',\n unit: 'USD',\n granularity: 'daily',\n notes:\n 'BigQuery charges per query; prefer narrow groupBy and reasonable lookbackDays. The trailing 5 days are always refetched on incremental syncs to pick up back-revisions.',\n dimensions: [\n {\n name: 'service',\n description:\n 'GCP service description (e.g. Compute Engine, BigQuery). Present when groupBy includes service.',\n },\n {\n name: 'project',\n description:\n 'GCP project id the spend is attributed to. Present when groupBy includes project.',\n },\n {\n name: 'sku',\n description:\n 'GCP SKU description (e.g. N1 Predefined Instance Core running in Americas). Present when groupBy includes sku.',\n },\n {\n name: 'location',\n description:\n 'GCP location/region the spend is attributed to. Present when groupBy includes location.',\n },\n { name: 'currency', description: 'Billing currency reported by GCP.' },\n ],\n responses: {\n oauth_token: tokenResponseSchema,\n daily_cost: bqQueryResponseSchema,\n },\n },\n});\n\nexport const id = 'gcp-billing';\n\nexport const cost: ConnectorCost = {\n recommendedInterval: '1 day',\n minInterval: '1 hour',\n perSync: '1 BigQuery query over the gcp_billing_export_v1_* table family',\n warning:\n 'Each BigQuery query is billed against the bqProject. Prefer once-a-day syncs and a focused groupBy.',\n};\n\nexport class GcpBillingConnector extends BaseConnector<\n GcpBillingSettings,\n GcpBillingCredentials\n> {\n static readonly id = id;\n\n static readonly resources = gcpBillingResources;\n\n static readonly schemas = schemasFromResources(gcpBillingResources);\n\n static readonly cost = cost;\n\n static create(input: unknown, ctx?: ConnectorContext): GcpBillingConnector {\n const parsed = configFields.parse(input);\n return new GcpBillingConnector(\n {\n bqProject: parsed.bqProject,\n bqDataset: parsed.bqDataset,\n bqLocation: parsed.bqLocation,\n groupBy: parsed.groupBy,\n lookbackDays: parsed.lookbackDays,\n },\n { serviceAccountJson: parsed.serviceAccountJson },\n ctx,\n );\n }\n\n readonly id = id;\n override readonly credentials = gcpBillingCredentials;\n\n private cachedToken: { token: string; expiresAt: number } | null = null;\n\n private async getAccessToken(signal?: AbortSignal): Promise<string> {\n if (this.cachedToken && Date.now() < this.cachedToken.expiresAt) {\n return this.cachedToken.token;\n }\n const { serviceAccountJson } = this.creds;\n if (!serviceAccountJson) {\n throw new AuthError(`${this.id}: missing serviceAccountJson credential`);\n }\n const { url, body } = await buildServiceAccountJwt(\n serviceAccountJson,\n BQ_SCOPE,\n );\n const res = await this.post<{\n access_token: string;\n expires_in?: number;\n }>(url, {\n resource: 'oauth_token',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body,\n signal,\n });\n const expiresIn = res.body.expires_in ?? 3600;\n this.cachedToken = {\n token: res.body.access_token,\n expiresAt: Date.now() + (expiresIn - 60) * 1000,\n };\n return this.cachedToken.token;\n }\n\n private async runQuery(\n accessToken: string,\n sql: string,\n pageToken: string | undefined,\n signal?: AbortSignal,\n ): Promise<z.infer<typeof bqQueryResponseSchema>> {\n const url = `${BQ_API_BASE}/projects/${encodeURIComponent(\n this.settings.bqProject,\n )}/queries`;\n\n const body: Record<string, unknown> = {\n query: sql,\n useLegacySql: false,\n maxResults: PAGE_SIZE,\n timeoutMs: 30_000,\n };\n if (this.settings.bqLocation !== undefined) {\n body['location'] = this.settings.bqLocation;\n }\n if (pageToken !== undefined) {\n body['pageToken'] = pageToken;\n }\n\n const res = await this.post<z.infer<typeof bqQueryResponseSchema>>(url, {\n resource: 'daily_cost',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'User-Agent': connectorUserAgent(this.id),\n },\n body: JSON.stringify(body),\n signal,\n });\n return res.body;\n }\n\n async sync(\n options: SyncOptions,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<SyncResult> {\n const groupBy = this.settings.groupBy ?? DEFAULT_GROUP_BY;\n const window = getCostWindow(\n options,\n this.settings.lookbackDays ?? DEFAULT_LOOKBACK_DAYS,\n );\n const sql = buildBillingSql({\n bqProject: this.settings.bqProject,\n bqDataset: this.settings.bqDataset,\n groupBy,\n startDate: window.startDate,\n endDate: window.endDate,\n });\n\n const samples: MetricSample[] = [];\n let pageToken: string | undefined;\n let page = 0;\n const phaseStart = Date.now();\n\n do {\n if (signal?.aborted) {\n return { done: false };\n }\n const accessToken = await this.getAccessToken(signal);\n let response: z.infer<typeof bqQueryResponseSchema>;\n try {\n response = await this.runQuery(accessToken, sql, pageToken, signal);\n } catch (err) {\n this.logger.warn('fetch page failed', {\n resource: 'daily_cost',\n page: page + 1,\n error: err instanceof Error ? err.message : String(err),\n });\n throw err;\n }\n if (response.jobComplete === false) {\n throw new Error(\n `${this.id}: BigQuery query did not complete within the synchronous timeout (jobComplete=false). Narrow the groupBy or lookbackDays so the query finishes faster.`,\n );\n }\n const pageSamples = buildSamplesFromBqResponse(response, groupBy);\n samples.push(...pageSamples);\n pageToken =\n typeof response.pageToken === 'string' && response.pageToken.length > 0\n ? response.pageToken\n : undefined;\n page += 1;\n this.logger.info('fetched page', {\n resource: 'daily_cost',\n page,\n items: pageSamples.length,\n next: pageToken ?? null,\n });\n } while (pageToken !== undefined);\n\n await storage.metrics(samples, { names: [COST_METRIC_NAME] });\n this.logger.info('resource done', {\n resource: 'daily_cost',\n pages: page,\n items: samples.length,\n duration_ms: Date.now() - phaseStart,\n });\n return { done: true };\n }\n}\n\ninterface CostWindow {\n startDate: string;\n endDate: string;\n}\n\nconst DIM_TO_SELECT: Record<Dimension, { select: string; alias: string }> = {\n service: { select: 'service.description', alias: 'service' },\n project: { select: 'project.id', alias: 'project' },\n sku: { select: 'sku.description', alias: 'sku' },\n location: { select: 'location.location', alias: 'location' },\n};\n\nexport function buildBillingSql(args: {\n bqProject: string;\n bqDataset: string;\n groupBy: readonly Dimension[];\n startDate: string;\n endDate: string;\n}): string {\n const dims = args.groupBy.map((d) => DIM_TO_SELECT[d]);\n const selectCols = ['DATE(usage_start_time) AS date']\n .concat(dims.map((d) => `${d.select} AS ${d.alias}`))\n .concat(['SUM(cost) AS cost', 'ANY_VALUE(currency) AS currency']);\n const groupCols = ['date'].concat(dims.map((d) => d.alias));\n const table = `\\`${args.bqProject}.${args.bqDataset}.gcp_billing_export_v1_*\\``;\n return [\n `SELECT ${selectCols.join(', ')}`,\n `FROM ${table}`,\n `WHERE DATE(usage_start_time) >= DATE('${args.startDate}')`,\n ` AND DATE(usage_start_time) < DATE('${args.endDate}')`,\n `GROUP BY ${groupCols.join(', ')}`,\n `ORDER BY date`,\n ].join('\\n');\n}\n\nfunction pad2(n: number): string {\n return String(n).padStart(2, '0');\n}\n\nfunction toDateStr(ms: number): string {\n const d = new Date(ms);\n return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;\n}\n\nfunction startOfUtcDay(ms: number): number {\n return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY;\n}\n\nexport function getCostWindow(\n options: SyncOptions,\n lookbackDays: number,\n now: number = Date.now(),\n): CostWindow {\n const endMs = startOfUtcDay(now) + MS_PER_DAY;\n let days = lookbackDays;\n if (options.mode === 'latest') {\n days = INCREMENTAL_LOOKBACK_DAYS;\n } else if (options.since !== undefined) {\n const sinceMs = parseEpoch(options.since, 'iso');\n if (sinceMs !== null) {\n const elapsed = Math.ceil((now - sinceMs) / MS_PER_DAY);\n days = Math.min(\n Math.max(elapsed + INCREMENTAL_LOOKBACK_DAYS, 1),\n lookbackDays,\n );\n }\n }\n return {\n startDate: toDateStr(endMs - days * MS_PER_DAY),\n endDate: toDateStr(endMs),\n };\n}\n\nexport function buildSamplesFromBqResponse(\n response: z.infer<typeof bqQueryResponseSchema>,\n groupBy: readonly Dimension[],\n): MetricSample[] {\n const schema = response.schema?.fields ?? [];\n const fieldIndex: Record<string, number> = {};\n schema.forEach((field, idx) => {\n fieldIndex[field.name] = idx;\n });\n\n const samples: MetricSample[] = [];\n for (const row of response.rows ?? []) {\n const dateValue = readCell(row.f, fieldIndex, 'date');\n if (dateValue === null) {\n continue;\n }\n const ts = parseBqDateOrEpoch(dateValue);\n if (ts === null) {\n continue;\n }\n const costValue = readCell(row.f, fieldIndex, 'cost');\n if (costValue === null) {\n continue;\n }\n const value = Number.parseFloat(costValue);\n if (!Number.isFinite(value)) {\n continue;\n }\n const attributes: Record<string, JSONValue> = {};\n for (const dim of groupBy) {\n const v = readCell(row.f, fieldIndex, dim);\n attributes[dim] = v ?? null;\n }\n const currency = readCell(row.f, fieldIndex, 'currency');\n if (currency !== null) {\n attributes['currency'] = currency;\n }\n samples.push({ name: COST_METRIC_NAME, ts, value, attributes });\n }\n return samples;\n}\n\nfunction readCell(\n cells: ReadonlyArray<{ v?: string | null }>,\n fieldIndex: Record<string, number>,\n name: string,\n): string | null {\n const idx = fieldIndex[name];\n if (idx === undefined) {\n return null;\n }\n const raw = cells[idx]?.v;\n if (raw === undefined || raw === null) {\n return null;\n }\n return raw;\n}\n\nfunction parseBqDateOrEpoch(value: string): number | null {\n const dateMatch = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(value);\n if (dateMatch) {\n return Date.UTC(\n Number(dateMatch[1]),\n Number(dateMatch[2]) - 1,\n Number(dateMatch[3]),\n );\n }\n return parseEpoch(value, 'iso');\n}\n","import { GcpBillingConnector } from './gcp-billing';\n\nexport {\n GcpBillingConnector,\n buildBillingSql,\n buildSamplesFromBqResponse,\n configFields,\n cost,\n doc,\n getCostWindow,\n id,\n gcpBillingResources as resources,\n} from './gcp-billing';\nexport type { GcpBillingSettings } from './gcp-billing';\nexport default GcpBillingConnector;\n"],"mappings":"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","names":["z","z"]}
|
|
1
|
+
{"version":3,"sources":["../../gcp-shared/src/auth.ts","../../gcp-shared/src/config.ts","../../../connector-shared/src/errors.ts","../../../connector-shared/src/retry.ts","../../../connector-shared/src/version.ts","../../../connector-shared/src/request.ts","../../../connector-shared/src/rate-limit.ts","../../../connector-shared/src/map-concurrent.ts","../../../connector-shared/src/sanitize.ts","../../../connector-shared/src/epoch.ts","../../../connector-shared/src/pagination.ts","../../../connector-shared/src/logger.ts","../../gcp-shared/src/bigquery.ts","../../gcp-shared/src/access-token.ts","../../gcp-shared/src/dates.ts","../../../connector-shared/src/errors.ts","../../../connector-shared/src/retry.ts","../../../connector-shared/src/version.ts","../../../connector-shared/src/request.ts","../../../connector-shared/src/rate-limit.ts","../../../connector-shared/src/map-concurrent.ts","../../../connector-shared/src/sanitize.ts","../../../connector-shared/src/epoch.ts","../../../connector-shared/src/pagination.ts","../../../connector-shared/src/logger.ts","../src/gcp-billing.ts","../src/index.ts"],"sourcesContent":["import { z } from 'zod';\n\nexport interface ServiceAccountKey {\n client_email: string;\n private_key: string;\n token_uri?: string;\n project_id?: string;\n}\n\nconst serviceAccountKeySchema = z.object({\n client_email: z.string().min(1),\n private_key: z.string().min(1),\n token_uri: z.string().url().optional(),\n project_id: z.string().optional(),\n});\n\nexport interface TokenResponse {\n access_token: string;\n expires_in?: number;\n}\n\nexport const tokenResponseSchema = z.object({\n access_token: z.string().min(1),\n expires_in: z.number().int().positive().optional(),\n});\n\nfunction base64urlFromBytes(bytes: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]!);\n }\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n}\n\nfunction base64urlFromString(str: string): string {\n return base64urlFromBytes(new TextEncoder().encode(str));\n}\n\nasync function signRS256JWT(\n payload: Record<string, unknown>,\n privateKeyPem: string,\n): Promise<string> {\n const header = { alg: 'RS256', typ: 'JWT' };\n const headerB64 = base64urlFromString(JSON.stringify(header));\n const payloadB64 = base64urlFromString(JSON.stringify(payload));\n const signingInput = `${headerB64}.${payloadB64}`;\n\n const pemContent = privateKeyPem\n .replace(/-----BEGIN PRIVATE KEY-----/g, '')\n .replace(/-----END PRIVATE KEY-----/g, '')\n .replace(/\\s/g, '');\n const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));\n\n const key = await globalThis.crypto.subtle.importKey(\n 'pkcs8',\n der,\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n const signature = await globalThis.crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(signingInput),\n );\n\n return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;\n}\n\nexport function parseServiceAccountJson(value: string): ServiceAccountKey {\n const trimmed = value.trim();\n if (trimmed.startsWith('{')) {\n return serviceAccountKeySchema.parse(JSON.parse(trimmed));\n }\n const binary = atob(trimmed);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const decoded = new TextDecoder().decode(bytes);\n return serviceAccountKeySchema.parse(JSON.parse(decoded));\n}\n\nexport async function buildServiceAccountJwt(\n serviceAccountJson: string,\n scope: string,\n): Promise<{ url: string; body: string }> {\n const sa = parseServiceAccountJson(serviceAccountJson);\n const now = Math.floor(Date.now() / 1000);\n const jwt = await signRS256JWT(\n {\n iss: sa.client_email,\n scope,\n aud: sa.token_uri ?? 'https://oauth2.googleapis.com/token',\n exp: now + 3600,\n iat: now,\n },\n sa.private_key,\n );\n\n const body = new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion: jwt,\n }).toString();\n\n return {\n url: sa.token_uri ?? 'https://oauth2.googleapis.com/token',\n body,\n };\n}\n\nexport interface RefreshTokenCredentials {\n refreshToken: string;\n clientId: string;\n clientSecret: string;\n}\n\nexport function buildRefreshTokenGrant(credentials: RefreshTokenCredentials): {\n url: string;\n body: string;\n} {\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n refresh_token: credentials.refreshToken,\n client_id: credentials.clientId,\n client_secret: credentials.clientSecret,\n }).toString();\n\n return { url: 'https://oauth2.googleapis.com/token', body };\n}\n","import { z } from 'zod';\n\nexport const gcpAuthConfigShape = {\n serviceAccountJson: z.object({ $secret: z.string().trim().min(1) }).meta({\n label: 'Service Account JSON',\n description:\n 'Contents of the JSON key file for a Google service account with the role required by this connector. Create one at Google Cloud -> IAM & Admin -> Service Accounts and store the JSON as a secret.',\n secret: true,\n }),\n} as const;\n\nexport interface GcpAuthConfig {\n serviceAccountJson: { $secret: string };\n}\n","import type { HttpResponse } from './types';\n\nexport type HttpErrorKind =\n | 'transient'\n | 'rate_limit'\n | 'auth'\n | 'upstream_bug'\n | 'client_bug';\n\nexport abstract class HttpClientError extends Error {\n abstract readonly kind: HttpErrorKind;\n readonly response?: HttpResponse;\n\n constructor(message: string, response?: HttpResponse) {\n super(message);\n this.name = new.target.name;\n this.response = response;\n }\n}\n\nexport class TransientError extends HttpClientError {\n readonly kind = 'transient' as const;\n}\n\nexport class RateLimitError extends HttpClientError {\n readonly kind = 'rate_limit' as const;\n readonly retryAfter?: Date;\n\n constructor(message: string, response?: HttpResponse, retryAfter?: Date) {\n super(message, response);\n this.retryAfter = retryAfter;\n }\n}\n\nexport class AuthError extends HttpClientError {\n readonly kind = 'auth' as const;\n}\n\nexport class UpstreamBugError extends HttpClientError {\n readonly kind = 'upstream_bug' as const;\n}\n\nexport class ClientBugError extends HttpClientError {\n readonly kind = 'client_bug' as const;\n}\n\nexport function classifyStatus(status: number): HttpErrorKind {\n if (status === 429) {\n return 'rate_limit';\n }\n if (status === 401 || status === 403) {\n return 'auth';\n }\n if (status === 408) {\n return 'transient';\n }\n if (status >= 500) {\n return 'upstream_bug';\n }\n if (status >= 400) {\n return 'client_bug';\n }\n return 'client_bug';\n}\n\nexport function errorForStatus(\n message: string,\n response: HttpResponse,\n retryAfter?: Date,\n): HttpClientError {\n const kind = classifyStatus(response.status);\n switch (kind) {\n case 'rate_limit':\n return new RateLimitError(message, response, retryAfter);\n case 'auth':\n return new AuthError(message, response);\n case 'transient':\n return new TransientError(message, response);\n case 'upstream_bug':\n return new UpstreamBugError(message, response);\n case 'client_bug':\n return new ClientBugError(message, response);\n }\n}\n","import { HttpClientError, RateLimitError, TransientError } from './errors';\n\nexport interface RetryPolicy {\n maxAttempts?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n retryOn?: (status: number | null, err?: Error) => boolean;\n}\n\nexport const defaultRetryOn = (status: number | null, err?: Error): boolean => {\n if (err instanceof RateLimitError) {\n return true;\n }\n if (err instanceof TransientError) {\n return true;\n }\n if (status === null) {\n return err instanceof Error && !(err instanceof HttpClientError);\n }\n if (status === 408 || status === 429) {\n return true;\n }\n if (status >= 500) {\n return true;\n }\n return false;\n};\n\nexport function backoffDelayMs(\n attempt: number,\n policy: Required<Pick<RetryPolicy, 'initialDelayMs' | 'maxDelayMs'>>,\n): number {\n const base = policy.initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, policy.maxDelayMs);\n}\n\nexport function parseRetryAfter(\n headerValue: string | null,\n now: Date = new Date(),\n): Date | undefined {\n if (!headerValue) {\n return undefined;\n }\n const trimmed = headerValue.trim();\n if (/^\\d+$/.test(trimmed)) {\n return new Date(now.getTime() + Number(trimmed) * 1000);\n }\n const parsed = Date.parse(trimmed);\n if (Number.isNaN(parsed)) {\n return undefined;\n }\n return new Date(parsed);\n}\n\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(signal.reason ?? new Error('Aborted'));\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal!.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n","export const HTTP_CLIENT_VERSION = '0.0.0';\n\nexport const DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n\nexport function connectorUserAgent(connectorId: string): string {\n return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n}\n","import {\n AuthError,\n ClientBugError,\n HttpClientError,\n RateLimitError,\n TransientError,\n UpstreamBugError,\n errorForStatus,\n} from './errors';\nimport { defaultRetryOn, parseRetryAfter, sleep } from './retry';\nimport type { FetchLike, HttpMethod, HttpRequest, HttpResponse } from './types';\nimport { DEFAULT_USER_AGENT } from './version';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\nconst DEFAULT_INITIAL_DELAY_MS = 1000;\nconst DEFAULT_MAX_DELAY_MS = 60_000;\nconst OBSERVER_TIMEOUT_MS = 250;\n\nexport interface RequestObservation {\n url: string;\n method: HttpMethod;\n status: number;\n resource: string;\n requestId: string;\n body: unknown;\n}\n\nexport type RequestObserver = (\n event: RequestObservation,\n) => void | Promise<void>;\n\nexport interface RequestOptions {\n fetch?: FetchLike;\n observer?: RequestObserver;\n resource: string;\n requestId?: string;\n}\n\nasync function notifyObserver(\n observer: RequestObserver,\n event: RequestObservation,\n): Promise<void> {\n let result: void | Promise<void>;\n try {\n result = observer(event);\n } catch (err) {\n console.warn('[connector-shared] request observer threw:', err);\n return;\n }\n if (!(result instanceof Promise)) {\n return;\n }\n const guarded = result.catch((err) => {\n console.warn('[connector-shared] request observer rejected:', err);\n });\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, OBSERVER_TIMEOUT_MS);\n });\n try {\n await Promise.race([guarded, timeout]);\n } finally {\n if (timer) {\n clearTimeout(timer);\n }\n }\n}\n\nfunction newRequestId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) {\n return c.randomUUID();\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction mergeHeaders(\n defaults: Record<string, string>,\n overrides: Record<string, string> | undefined,\n): Record<string, string> {\n const merged: Record<string, string> = {};\n for (const [k, v] of Object.entries(defaults)) {\n merged[k.toLowerCase()] = v;\n }\n if (overrides) {\n for (const [k, v] of Object.entries(overrides)) {\n merged[k.toLowerCase()] = v;\n }\n }\n return merged;\n}\n\nfunction linkTimeoutSignal(\n parent: AbortSignal | undefined,\n timeoutMs: number,\n): { signal: AbortSignal; cancel: () => void } {\n const controller = new AbortController();\n const onParentAbort = () => {\n controller.abort(parent?.reason);\n };\n if (parent) {\n if (parent.aborted) {\n controller.abort(parent.reason);\n } else {\n parent.addEventListener('abort', onParentAbort, { once: true });\n }\n }\n const timer = setTimeout(() => {\n controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n return {\n signal: controller.signal,\n cancel: () => {\n clearTimeout(timer);\n if (parent) {\n parent.removeEventListener('abort', onParentAbort);\n }\n },\n };\n}\n\nasync function readBody(res: Response, parseJson: boolean): Promise<unknown> {\n if (res.status === 204 || res.status === 205) {\n return null;\n }\n const contentType = res.headers.get('content-type') ?? '';\n if (parseJson && contentType.includes('application/json')) {\n const text = await res.text();\n if (text.length === 0) {\n return null;\n }\n return JSON.parse(text);\n }\n return res.text();\n}\n\nexport async function request<T = unknown>(\n req: HttpRequest,\n options: RequestOptions,\n): Promise<HttpResponse<T>> {\n const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);\n const retry = req.retry ?? {};\n const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;\n const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const parseJson = req.parseJson ?? true;\n\n const headers = mergeHeaders(\n {\n 'User-Agent': DEFAULT_USER_AGENT,\n Accept: 'application/json',\n },\n req.headers,\n );\n\n let lastErr: Error | undefined;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n req.signal?.throwIfAborted();\n\n const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);\n let res: Response;\n try {\n res = await fetchImpl(req.url, {\n method: req.method ?? 'GET',\n headers,\n body: req.body as RequestInit['body'],\n signal,\n });\n } catch (err) {\n cancel();\n if (req.signal?.aborted) {\n throw req.signal.reason ?? err;\n }\n const error = err instanceof Error ? err : new Error(String(err));\n lastErr = error;\n if (attempt < maxAttempts - 1 && retryOn(null, error)) {\n const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n await sleep(delay, req.signal);\n continue;\n }\n throw new TransientError(error.message);\n }\n cancel();\n\n const body = await readBody(res, parseJson);\n const httpResponse: HttpResponse<T> = {\n status: res.status,\n headers: res.headers,\n body: body as T,\n };\n if (req.rateLimit) {\n const state = req.rateLimit.parse(res.headers);\n if (state) {\n httpResponse.rateLimitState = state;\n }\n }\n\n if (options.observer) {\n await notifyObserver(options.observer, {\n url: req.url,\n method: req.method ?? 'GET',\n status: res.status,\n resource: options.resource,\n requestId: options.requestId ?? newRequestId(),\n body,\n });\n }\n\n if (res.ok) {\n return httpResponse;\n }\n\n const retryAfter = parseRetryAfter(res.headers.get('retry-after'));\n const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? 'GET'} ${req.url}`;\n const err = errorForStatus(message, httpResponse, retryAfter);\n\n if (\n attempt < maxAttempts - 1 &&\n retryOn(res.status, err) &&\n !(err instanceof AuthError) &&\n !(err instanceof ClientBugError)\n ) {\n lastErr = err;\n let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n if (err instanceof RateLimitError && retryAfter) {\n const wait = retryAfter.getTime() - Date.now();\n if (wait > 0) {\n delay = Math.min(wait, maxDelayMs);\n }\n }\n await sleep(delay, req.signal);\n continue;\n }\n\n throw err;\n }\n\n throw lastErr ?? new UpstreamBugError('Exhausted retry attempts');\n}\n\nfunction computeDelay(\n attempt: number,\n initialDelayMs: number,\n maxDelayMs: number,\n): number {\n const base = initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, maxDelayMs);\n}\n\nexport { HttpClientError };\n","export interface RateLimitState {\n remaining: number;\n resetAt: Date;\n}\n\nexport interface RateLimitPolicy {\n parse(headers: Headers): RateLimitState | null;\n}\n\nexport interface StandardRateLimitPolicyConfig {\n remainingHeader: string;\n resetHeader: string;\n resetUnit: 's' | 'ms';\n resetFallbackMs?: number;\n}\n\nexport function standardRateLimitPolicy(\n config: StandardRateLimitPolicyConfig,\n): RateLimitPolicy {\n const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;\n const multiplier = resetUnit === 's' ? 1000 : 1;\n return {\n parse(h) {\n const remainingRaw = h.get(remainingHeader);\n if (remainingRaw === null || remainingRaw.trim() === '') {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n const resetRaw = h.get(resetHeader);\n if (resetRaw === null) {\n if (resetFallbackMs === undefined) {\n return null;\n }\n return {\n remaining,\n resetAt: new Date(Date.now() + resetFallbackMs),\n };\n }\n if (resetRaw.trim() === '') {\n return null;\n }\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n const resetMs = reset * multiplier;\n if (!Number.isFinite(resetMs)) {\n return null;\n }\n return { remaining, resetAt: new Date(resetMs) };\n },\n };\n}\n","export async function mapWithConcurrency<T, R>(\n items: readonly T[],\n concurrency: number,\n fn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length);\n if (items.length === 0) {\n return results;\n }\n const normalized = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;\n const limit = Math.max(1, Math.min(normalized, items.length));\n let next = 0;\n let failed = false;\n\n async function worker(): Promise<void> {\n while (!failed) {\n const i = next++;\n if (i >= items.length) {\n return;\n }\n try {\n results[i] = await fn(items[i]!, i);\n } catch (err) {\n failed = true;\n throw err;\n }\n }\n }\n\n const workers: Promise<void>[] = [];\n for (let w = 0; w < limit; w++) {\n workers.push(worker());\n }\n await Promise.all(workers);\n return results;\n}\n","export interface SanitizeAllowedUrlOptions {\n url: string | null;\n host: string;\n pathname: string;\n protocol?: 'https:' | 'http:';\n}\n\nexport function sanitizeAllowedUrl(\n options: SanitizeAllowedUrlOptions,\n): string | null {\n const { url, host, pathname, protocol = 'https:' } = options;\n if (url === null) {\n return null;\n }\n try {\n const u = new URL(url);\n if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n}\n","export type EpochUnit = 'ms' | 's' | 'iso';\n\nexport function parseEpoch(\n value: number | string | null | undefined,\n unit: EpochUnit,\n): number | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (unit === 'iso') {\n if (typeof value !== 'string') {\n return null;\n }\n const ms = new Date(value).getTime();\n return Number.isFinite(ms) ? ms : null;\n }\n if (typeof value === 'string' && value.trim() === '') {\n return null;\n }\n const n = typeof value === 'number' ? value : Number(value);\n if (!Number.isFinite(n)) {\n return null;\n }\n const result = unit === 's' ? n * 1000 : n;\n return Number.isFinite(result) ? result : null;\n}\n","import { request } from './request';\nimport type { HttpRequest } from './types';\n\nexport function parseLinkHeader(header: string | null): Record<string, string> {\n if (!header) {\n return {};\n }\n const result: Record<string, string> = {};\n for (const part of header.split(',')) {\n const match = part.match(/<([^>]+)>\\s*;\\s*rel=\"([^\"]+)\"/);\n if (match) {\n result[match[2]!] = match[1]!;\n }\n }\n return result;\n}\n\nexport async function* paginateLink<T>(\n initial: HttpRequest,\n parse: (body: unknown) => T[],\n options: { resource: string },\n): AsyncIterable<T> {\n let next: string | null = initial.url;\n while (next) {\n const res: Awaited<ReturnType<typeof request>> = await request(\n {\n ...initial,\n url: next,\n },\n { resource: options.resource },\n );\n for (const item of parse(res.body)) {\n yield item;\n }\n const links = parseLinkHeader(res.headers.get('link'));\n next = links['next'] ?? null;\n }\n}\n\nexport async function* paginateCursor<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; nextCursor: string | null },\n buildNext: (req: HttpRequest, cursor: string) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let req: HttpRequest = initial;\n while (true) {\n const res = await request(req, { resource: options.resource });\n const { items, nextCursor } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!nextCursor) {\n return;\n }\n req = buildNext(req, nextCursor);\n }\n}\n\nexport async function* paginatePage<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; hasMore: boolean },\n buildPage: (req: HttpRequest, page: number) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let page = 1;\n while (true) {\n const req = page === 1 ? initial : buildPage(initial, page);\n const res = await request(req, { resource: options.resource });\n const { items, hasMore } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!hasMore || items.length === 0) {\n return;\n }\n page++;\n }\n}\n","export type LogFields = Record<string, unknown>;\n\nexport interface ConnectorLogger {\n info(event: string, fields?: LogFields): void;\n warn(event: string, fields?: LogFields): void;\n}\n\nexport interface ConnectorLoggerOptions {\n scope: string;\n}\n\nconst MAX_VALUE_LEN = 120;\n\nfunction truncate(s: string, max = MAX_VALUE_LEN): string {\n if (s.length <= max) {\n return s;\n }\n return `${s.slice(0, max - 1)}…`;\n}\n\nfunction formatValue(value: unknown): string {\n if (value === null) {\n return 'null';\n }\n if (value === undefined) {\n return '';\n }\n if (typeof value === 'number' || typeof value === 'boolean') {\n return String(value);\n }\n if (typeof value === 'string') {\n const t = truncate(value);\n if (/[\\s\"=]/.test(t)) {\n return JSON.stringify(t);\n }\n return t;\n }\n if (typeof value === 'bigint') {\n return value.toString();\n }\n let json: string | undefined;\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n return truncate(json ?? String(value));\n}\n\nexport function formatLogFields(fields?: LogFields): string {\n if (!fields) {\n return '';\n }\n const parts: string[] = [];\n for (const [k, v] of Object.entries(fields)) {\n if (v === undefined) {\n continue;\n }\n parts.push(`${k}=${formatValue(v)}`);\n }\n return parts.length > 0 ? ` ${parts.join(' ')}` : '';\n}\n\nexport function formatLogLine(\n scope: string,\n event: string,\n fields?: LogFields,\n): string {\n return `[${scope}] ${event}${formatLogFields(fields)}`;\n}\n\nexport function createDefaultConnectorLogger(\n opts: ConnectorLoggerOptions,\n): ConnectorLogger {\n return {\n info(event, fields) {\n console.info(formatLogLine(opts.scope, event, fields));\n },\n warn(event, fields) {\n console.warn(formatLogLine(opts.scope, event, fields));\n },\n };\n}\n\nconst NOOP_LOGGER: ConnectorLogger = {\n info() {},\n warn() {},\n};\n\nexport function noopConnectorLogger(): ConnectorLogger {\n return NOOP_LOGGER;\n}\n","import { parseEpoch } from '@rawdash/connector-shared';\nimport { z } from 'zod';\n\nexport const BQ_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;\nexport const BQ_DATASET_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\nexport const BQ_API_BASE = 'https://bigquery.googleapis.com/bigquery/v2';\nexport const BQ_READONLY_SCOPE =\n 'https://www.googleapis.com/auth/bigquery.readonly';\nexport const BQ_PAGE_SIZE = 10_000;\nexport const BQ_QUERY_TIMEOUT_MS = 30_000;\n\nexport const bqQueryResponseSchema = z.object({\n jobComplete: z.boolean().optional(),\n schema: z\n .object({\n fields: z.array(z.object({ name: z.string(), type: z.string() })),\n })\n .optional(),\n rows: z\n .array(\n z.object({\n f: z.array(z.object({ v: z.string().nullable().optional() })),\n }),\n )\n .optional(),\n pageToken: z.string().optional(),\n jobReference: z\n .object({\n projectId: z.string(),\n jobId: z.string(),\n location: z.string().optional(),\n })\n .optional(),\n});\n\nexport type BqQueryResponse = z.infer<typeof bqQueryResponseSchema>;\nexport type BqJobReference = NonNullable<BqQueryResponse['jobReference']>;\n\nexport type BqPageRequest =\n | { method: 'POST'; url: string; body: string }\n | { method: 'GET'; url: string };\n\nexport interface BqPageLogger {\n info(message: string, meta?: Record<string, unknown>): void;\n warn(message: string, meta?: Record<string, unknown>): void;\n}\n\nexport function buildBigQueryPageRequest(opts: {\n projectId: string;\n sql: string;\n pageToken: string | undefined;\n jobReference: BqJobReference | undefined;\n location?: string;\n pageSize?: number;\n timeoutMs?: number;\n}): BqPageRequest {\n const pageSize = opts.pageSize ?? BQ_PAGE_SIZE;\n const timeoutMs = opts.timeoutMs ?? BQ_QUERY_TIMEOUT_MS;\n\n if (opts.pageToken === undefined) {\n const url = `${BQ_API_BASE}/projects/${encodeURIComponent(\n opts.projectId,\n )}/queries`;\n const body: Record<string, unknown> = {\n query: opts.sql,\n useLegacySql: false,\n maxResults: pageSize,\n timeoutMs,\n };\n if (opts.location !== undefined) {\n body['location'] = opts.location;\n }\n return { method: 'POST', url, body: JSON.stringify(body) };\n }\n\n if (opts.jobReference === undefined) {\n throw new Error(\n 'cannot fetch the next page of BigQuery results without a jobReference',\n );\n }\n\n const params = new URLSearchParams({\n pageToken: opts.pageToken,\n maxResults: String(pageSize),\n timeoutMs: String(timeoutMs),\n });\n const location = opts.jobReference.location ?? opts.location;\n if (location !== undefined) {\n params.set('location', location);\n }\n const url = `${BQ_API_BASE}/projects/${encodeURIComponent(\n opts.jobReference.projectId,\n )}/queries/${encodeURIComponent(opts.jobReference.jobId)}?${params.toString()}`;\n return { method: 'GET', url };\n}\n\nexport async function collectBigQueryPages<T>(opts: {\n projectId: string;\n sql: string;\n resource: string;\n fetchPage: (\n request: BqPageRequest,\n signal: AbortSignal | undefined,\n ) => Promise<BqQueryResponse>;\n mapRows: (response: BqQueryResponse) => T[];\n jobIncompleteMessage: string;\n location?: string;\n pageSize?: number;\n signal?: AbortSignal;\n logger?: BqPageLogger;\n}): Promise<{ rows: T[]; aborted: boolean }> {\n const rows: T[] = [];\n let pageToken: string | undefined;\n let jobReference: BqJobReference | undefined;\n let page = 0;\n const phaseStart = Date.now();\n\n do {\n if (opts.signal?.aborted) {\n return { rows, aborted: true };\n }\n const request = buildBigQueryPageRequest({\n projectId: opts.projectId,\n sql: opts.sql,\n pageToken,\n jobReference,\n location: opts.location,\n pageSize: opts.pageSize,\n });\n let response: BqQueryResponse;\n try {\n response = await opts.fetchPage(request, opts.signal);\n } catch (err) {\n opts.logger?.warn('fetch page failed', {\n resource: opts.resource,\n page: page + 1,\n error: err instanceof Error ? err.message : String(err),\n });\n throw err;\n }\n if (response.jobComplete === false) {\n throw new Error(opts.jobIncompleteMessage);\n }\n if (response.jobReference !== undefined) {\n jobReference = response.jobReference;\n }\n const pageRows = opts.mapRows(response);\n rows.push(...pageRows);\n pageToken =\n typeof response.pageToken === 'string' && response.pageToken.length > 0\n ? response.pageToken\n : undefined;\n page += 1;\n opts.logger?.info('fetched page', {\n resource: opts.resource,\n page,\n items: pageRows.length,\n next: pageToken ?? null,\n });\n } while (pageToken !== undefined);\n\n opts.logger?.info('resource done', {\n resource: opts.resource,\n pages: page,\n items: rows.length,\n duration_ms: Date.now() - phaseStart,\n });\n return { rows, aborted: false };\n}\n\nexport function indexBqFields(\n response: BqQueryResponse,\n): Record<string, number> {\n const fieldIndex: Record<string, number> = {};\n (response.schema?.fields ?? []).forEach((field, idx) => {\n fieldIndex[field.name] = idx;\n });\n return fieldIndex;\n}\n\nexport function readBqCell(\n cells: ReadonlyArray<{ v?: string | null }>,\n fieldIndex: Record<string, number>,\n name: string,\n): string | null {\n const idx = fieldIndex[name];\n if (idx === undefined) {\n return null;\n }\n const raw = cells[idx]?.v;\n if (raw === undefined || raw === null) {\n return null;\n }\n return raw;\n}\n\nexport function parseBqDateOrEpoch(value: string): number | null {\n const dateMatch = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(value);\n if (dateMatch) {\n return Date.UTC(\n Number(dateMatch[1]),\n Number(dateMatch[2]) - 1,\n Number(dateMatch[3]),\n );\n }\n return parseEpoch(value, 'iso');\n}\n","import { AuthError } from '@rawdash/connector-shared';\n\nimport {\n type RefreshTokenCredentials,\n buildRefreshTokenGrant,\n buildServiceAccountJwt,\n} from './auth';\n\ninterface GcpTokenResponse {\n access_token: string;\n expires_in?: number;\n}\n\nexport type GcpTokenPoster = (\n url: string,\n opts: {\n resource: string;\n headers: Record<string, string>;\n body: string;\n signal?: AbortSignal;\n },\n) => Promise<{ body: GcpTokenResponse }>;\n\nexport class GcpAccessTokenProvider {\n private cached: { token: string; expiresAt: number } | null = null;\n\n constructor(\n private readonly opts: {\n connectorId: string;\n scope: string;\n getServiceAccountJson: () => string | undefined;\n getRefreshTokenCredentials?: () => RefreshTokenCredentials | undefined;\n post: GcpTokenPoster;\n },\n ) {}\n\n private async resolveGrant(): Promise<{ url: string; body: string }> {\n const serviceAccountJson = this.opts.getServiceAccountJson();\n if (serviceAccountJson) {\n return buildServiceAccountJwt(serviceAccountJson, this.opts.scope);\n }\n const refreshTokenCredentials = this.opts.getRefreshTokenCredentials?.();\n if (refreshTokenCredentials) {\n return buildRefreshTokenGrant(refreshTokenCredentials);\n }\n throw new AuthError(\n `${this.opts.connectorId}: missing serviceAccountJson or refresh-token credentials`,\n );\n }\n\n async getToken(signal?: AbortSignal): Promise<string> {\n if (this.cached && Date.now() < this.cached.expiresAt) {\n return this.cached.token;\n }\n const { url, body } = await this.resolveGrant();\n const res = await this.opts.post(url, {\n resource: 'oauth_token',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body,\n signal,\n });\n const expiresIn = res.body.expires_in ?? 3600;\n this.cached = {\n token: res.body.access_token,\n expiresAt: Date.now() + (expiresIn - 60) * 1000,\n };\n return this.cached.token;\n }\n}\n","export const MS_PER_DAY = 86_400_000;\n\nfunction pad2(n: number): string {\n return String(n).padStart(2, '0');\n}\n\nexport function toDateStr(ms: number): string {\n const d = new Date(ms);\n return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;\n}\n\nexport function startOfUtcDay(ms: number): number {\n return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY;\n}\n","import type { HttpResponse } from './types';\n\nexport type HttpErrorKind =\n | 'transient'\n | 'rate_limit'\n | 'auth'\n | 'upstream_bug'\n | 'client_bug';\n\nexport abstract class HttpClientError extends Error {\n abstract readonly kind: HttpErrorKind;\n readonly response?: HttpResponse;\n\n constructor(message: string, response?: HttpResponse) {\n super(message);\n this.name = new.target.name;\n this.response = response;\n }\n}\n\nexport class TransientError extends HttpClientError {\n readonly kind = 'transient' as const;\n}\n\nexport class RateLimitError extends HttpClientError {\n readonly kind = 'rate_limit' as const;\n readonly retryAfter?: Date;\n\n constructor(message: string, response?: HttpResponse, retryAfter?: Date) {\n super(message, response);\n this.retryAfter = retryAfter;\n }\n}\n\nexport class AuthError extends HttpClientError {\n readonly kind = 'auth' as const;\n}\n\nexport class UpstreamBugError extends HttpClientError {\n readonly kind = 'upstream_bug' as const;\n}\n\nexport class ClientBugError extends HttpClientError {\n readonly kind = 'client_bug' as const;\n}\n\nexport function classifyStatus(status: number): HttpErrorKind {\n if (status === 429) {\n return 'rate_limit';\n }\n if (status === 401 || status === 403) {\n return 'auth';\n }\n if (status === 408) {\n return 'transient';\n }\n if (status >= 500) {\n return 'upstream_bug';\n }\n if (status >= 400) {\n return 'client_bug';\n }\n return 'client_bug';\n}\n\nexport function errorForStatus(\n message: string,\n response: HttpResponse,\n retryAfter?: Date,\n): HttpClientError {\n const kind = classifyStatus(response.status);\n switch (kind) {\n case 'rate_limit':\n return new RateLimitError(message, response, retryAfter);\n case 'auth':\n return new AuthError(message, response);\n case 'transient':\n return new TransientError(message, response);\n case 'upstream_bug':\n return new UpstreamBugError(message, response);\n case 'client_bug':\n return new ClientBugError(message, response);\n }\n}\n","import { HttpClientError, RateLimitError, TransientError } from './errors';\n\nexport interface RetryPolicy {\n maxAttempts?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n retryOn?: (status: number | null, err?: Error) => boolean;\n}\n\nexport const defaultRetryOn = (status: number | null, err?: Error): boolean => {\n if (err instanceof RateLimitError) {\n return true;\n }\n if (err instanceof TransientError) {\n return true;\n }\n if (status === null) {\n return err instanceof Error && !(err instanceof HttpClientError);\n }\n if (status === 408 || status === 429) {\n return true;\n }\n if (status >= 500) {\n return true;\n }\n return false;\n};\n\nexport function backoffDelayMs(\n attempt: number,\n policy: Required<Pick<RetryPolicy, 'initialDelayMs' | 'maxDelayMs'>>,\n): number {\n const base = policy.initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, policy.maxDelayMs);\n}\n\nexport function parseRetryAfter(\n headerValue: string | null,\n now: Date = new Date(),\n): Date | undefined {\n if (!headerValue) {\n return undefined;\n }\n const trimmed = headerValue.trim();\n if (/^\\d+$/.test(trimmed)) {\n return new Date(now.getTime() + Number(trimmed) * 1000);\n }\n const parsed = Date.parse(trimmed);\n if (Number.isNaN(parsed)) {\n return undefined;\n }\n return new Date(parsed);\n}\n\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(signal.reason ?? new Error('Aborted'));\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal!.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n","export const HTTP_CLIENT_VERSION = '0.0.0';\n\nexport const DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n\nexport function connectorUserAgent(connectorId: string): string {\n return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n}\n","import {\n AuthError,\n ClientBugError,\n HttpClientError,\n RateLimitError,\n TransientError,\n UpstreamBugError,\n errorForStatus,\n} from './errors';\nimport { defaultRetryOn, parseRetryAfter, sleep } from './retry';\nimport type { FetchLike, HttpMethod, HttpRequest, HttpResponse } from './types';\nimport { DEFAULT_USER_AGENT } from './version';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\nconst DEFAULT_INITIAL_DELAY_MS = 1000;\nconst DEFAULT_MAX_DELAY_MS = 60_000;\nconst OBSERVER_TIMEOUT_MS = 250;\n\nexport interface RequestObservation {\n url: string;\n method: HttpMethod;\n status: number;\n resource: string;\n requestId: string;\n body: unknown;\n}\n\nexport type RequestObserver = (\n event: RequestObservation,\n) => void | Promise<void>;\n\nexport interface RequestOptions {\n fetch?: FetchLike;\n observer?: RequestObserver;\n resource: string;\n requestId?: string;\n}\n\nasync function notifyObserver(\n observer: RequestObserver,\n event: RequestObservation,\n): Promise<void> {\n let result: void | Promise<void>;\n try {\n result = observer(event);\n } catch (err) {\n console.warn('[connector-shared] request observer threw:', err);\n return;\n }\n if (!(result instanceof Promise)) {\n return;\n }\n const guarded = result.catch((err) => {\n console.warn('[connector-shared] request observer rejected:', err);\n });\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, OBSERVER_TIMEOUT_MS);\n });\n try {\n await Promise.race([guarded, timeout]);\n } finally {\n if (timer) {\n clearTimeout(timer);\n }\n }\n}\n\nfunction newRequestId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) {\n return c.randomUUID();\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction mergeHeaders(\n defaults: Record<string, string>,\n overrides: Record<string, string> | undefined,\n): Record<string, string> {\n const merged: Record<string, string> = {};\n for (const [k, v] of Object.entries(defaults)) {\n merged[k.toLowerCase()] = v;\n }\n if (overrides) {\n for (const [k, v] of Object.entries(overrides)) {\n merged[k.toLowerCase()] = v;\n }\n }\n return merged;\n}\n\nfunction linkTimeoutSignal(\n parent: AbortSignal | undefined,\n timeoutMs: number,\n): { signal: AbortSignal; cancel: () => void } {\n const controller = new AbortController();\n const onParentAbort = () => {\n controller.abort(parent?.reason);\n };\n if (parent) {\n if (parent.aborted) {\n controller.abort(parent.reason);\n } else {\n parent.addEventListener('abort', onParentAbort, { once: true });\n }\n }\n const timer = setTimeout(() => {\n controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n return {\n signal: controller.signal,\n cancel: () => {\n clearTimeout(timer);\n if (parent) {\n parent.removeEventListener('abort', onParentAbort);\n }\n },\n };\n}\n\nasync function readBody(res: Response, parseJson: boolean): Promise<unknown> {\n if (res.status === 204 || res.status === 205) {\n return null;\n }\n const contentType = res.headers.get('content-type') ?? '';\n if (parseJson && contentType.includes('application/json')) {\n const text = await res.text();\n if (text.length === 0) {\n return null;\n }\n return JSON.parse(text);\n }\n return res.text();\n}\n\nexport async function request<T = unknown>(\n req: HttpRequest,\n options: RequestOptions,\n): Promise<HttpResponse<T>> {\n const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);\n const retry = req.retry ?? {};\n const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;\n const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const parseJson = req.parseJson ?? true;\n\n const headers = mergeHeaders(\n {\n 'User-Agent': DEFAULT_USER_AGENT,\n Accept: 'application/json',\n },\n req.headers,\n );\n\n let lastErr: Error | undefined;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n req.signal?.throwIfAborted();\n\n const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);\n let res: Response;\n try {\n res = await fetchImpl(req.url, {\n method: req.method ?? 'GET',\n headers,\n body: req.body as RequestInit['body'],\n signal,\n });\n } catch (err) {\n cancel();\n if (req.signal?.aborted) {\n throw req.signal.reason ?? err;\n }\n const error = err instanceof Error ? err : new Error(String(err));\n lastErr = error;\n if (attempt < maxAttempts - 1 && retryOn(null, error)) {\n const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n await sleep(delay, req.signal);\n continue;\n }\n throw new TransientError(error.message);\n }\n cancel();\n\n const body = await readBody(res, parseJson);\n const httpResponse: HttpResponse<T> = {\n status: res.status,\n headers: res.headers,\n body: body as T,\n };\n if (req.rateLimit) {\n const state = req.rateLimit.parse(res.headers);\n if (state) {\n httpResponse.rateLimitState = state;\n }\n }\n\n if (options.observer) {\n await notifyObserver(options.observer, {\n url: req.url,\n method: req.method ?? 'GET',\n status: res.status,\n resource: options.resource,\n requestId: options.requestId ?? newRequestId(),\n body,\n });\n }\n\n if (res.ok) {\n return httpResponse;\n }\n\n const retryAfter = parseRetryAfter(res.headers.get('retry-after'));\n const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? 'GET'} ${req.url}`;\n const err = errorForStatus(message, httpResponse, retryAfter);\n\n if (\n attempt < maxAttempts - 1 &&\n retryOn(res.status, err) &&\n !(err instanceof AuthError) &&\n !(err instanceof ClientBugError)\n ) {\n lastErr = err;\n let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n if (err instanceof RateLimitError && retryAfter) {\n const wait = retryAfter.getTime() - Date.now();\n if (wait > 0) {\n delay = Math.min(wait, maxDelayMs);\n }\n }\n await sleep(delay, req.signal);\n continue;\n }\n\n throw err;\n }\n\n throw lastErr ?? new UpstreamBugError('Exhausted retry attempts');\n}\n\nfunction computeDelay(\n attempt: number,\n initialDelayMs: number,\n maxDelayMs: number,\n): number {\n const base = initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, maxDelayMs);\n}\n\nexport { HttpClientError };\n","export interface RateLimitState {\n remaining: number;\n resetAt: Date;\n}\n\nexport interface RateLimitPolicy {\n parse(headers: Headers): RateLimitState | null;\n}\n\nexport interface StandardRateLimitPolicyConfig {\n remainingHeader: string;\n resetHeader: string;\n resetUnit: 's' | 'ms';\n resetFallbackMs?: number;\n}\n\nexport function standardRateLimitPolicy(\n config: StandardRateLimitPolicyConfig,\n): RateLimitPolicy {\n const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;\n const multiplier = resetUnit === 's' ? 1000 : 1;\n return {\n parse(h) {\n const remainingRaw = h.get(remainingHeader);\n if (remainingRaw === null || remainingRaw.trim() === '') {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n const resetRaw = h.get(resetHeader);\n if (resetRaw === null) {\n if (resetFallbackMs === undefined) {\n return null;\n }\n return {\n remaining,\n resetAt: new Date(Date.now() + resetFallbackMs),\n };\n }\n if (resetRaw.trim() === '') {\n return null;\n }\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n const resetMs = reset * multiplier;\n if (!Number.isFinite(resetMs)) {\n return null;\n }\n return { remaining, resetAt: new Date(resetMs) };\n },\n };\n}\n","export async function mapWithConcurrency<T, R>(\n items: readonly T[],\n concurrency: number,\n fn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length);\n if (items.length === 0) {\n return results;\n }\n const normalized = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;\n const limit = Math.max(1, Math.min(normalized, items.length));\n let next = 0;\n let failed = false;\n\n async function worker(): Promise<void> {\n while (!failed) {\n const i = next++;\n if (i >= items.length) {\n return;\n }\n try {\n results[i] = await fn(items[i]!, i);\n } catch (err) {\n failed = true;\n throw err;\n }\n }\n }\n\n const workers: Promise<void>[] = [];\n for (let w = 0; w < limit; w++) {\n workers.push(worker());\n }\n await Promise.all(workers);\n return results;\n}\n","export interface SanitizeAllowedUrlOptions {\n url: string | null;\n host: string;\n pathname: string;\n protocol?: 'https:' | 'http:';\n}\n\nexport function sanitizeAllowedUrl(\n options: SanitizeAllowedUrlOptions,\n): string | null {\n const { url, host, pathname, protocol = 'https:' } = options;\n if (url === null) {\n return null;\n }\n try {\n const u = new URL(url);\n if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n}\n","export type EpochUnit = 'ms' | 's' | 'iso';\n\nexport function parseEpoch(\n value: number | string | null | undefined,\n unit: EpochUnit,\n): number | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (unit === 'iso') {\n if (typeof value !== 'string') {\n return null;\n }\n const ms = new Date(value).getTime();\n return Number.isFinite(ms) ? ms : null;\n }\n if (typeof value === 'string' && value.trim() === '') {\n return null;\n }\n const n = typeof value === 'number' ? value : Number(value);\n if (!Number.isFinite(n)) {\n return null;\n }\n const result = unit === 's' ? n * 1000 : n;\n return Number.isFinite(result) ? result : null;\n}\n","import { request } from './request';\nimport type { HttpRequest } from './types';\n\nexport function parseLinkHeader(header: string | null): Record<string, string> {\n if (!header) {\n return {};\n }\n const result: Record<string, string> = {};\n for (const part of header.split(',')) {\n const match = part.match(/<([^>]+)>\\s*;\\s*rel=\"([^\"]+)\"/);\n if (match) {\n result[match[2]!] = match[1]!;\n }\n }\n return result;\n}\n\nexport async function* paginateLink<T>(\n initial: HttpRequest,\n parse: (body: unknown) => T[],\n options: { resource: string },\n): AsyncIterable<T> {\n let next: string | null = initial.url;\n while (next) {\n const res: Awaited<ReturnType<typeof request>> = await request(\n {\n ...initial,\n url: next,\n },\n { resource: options.resource },\n );\n for (const item of parse(res.body)) {\n yield item;\n }\n const links = parseLinkHeader(res.headers.get('link'));\n next = links['next'] ?? null;\n }\n}\n\nexport async function* paginateCursor<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; nextCursor: string | null },\n buildNext: (req: HttpRequest, cursor: string) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let req: HttpRequest = initial;\n while (true) {\n const res = await request(req, { resource: options.resource });\n const { items, nextCursor } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!nextCursor) {\n return;\n }\n req = buildNext(req, nextCursor);\n }\n}\n\nexport async function* paginatePage<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; hasMore: boolean },\n buildPage: (req: HttpRequest, page: number) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let page = 1;\n while (true) {\n const req = page === 1 ? initial : buildPage(initial, page);\n const res = await request(req, { resource: options.resource });\n const { items, hasMore } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!hasMore || items.length === 0) {\n return;\n }\n page++;\n }\n}\n","export type LogFields = Record<string, unknown>;\n\nexport interface ConnectorLogger {\n info(event: string, fields?: LogFields): void;\n warn(event: string, fields?: LogFields): void;\n}\n\nexport interface ConnectorLoggerOptions {\n scope: string;\n}\n\nconst MAX_VALUE_LEN = 120;\n\nfunction truncate(s: string, max = MAX_VALUE_LEN): string {\n if (s.length <= max) {\n return s;\n }\n return `${s.slice(0, max - 1)}…`;\n}\n\nfunction formatValue(value: unknown): string {\n if (value === null) {\n return 'null';\n }\n if (value === undefined) {\n return '';\n }\n if (typeof value === 'number' || typeof value === 'boolean') {\n return String(value);\n }\n if (typeof value === 'string') {\n const t = truncate(value);\n if (/[\\s\"=]/.test(t)) {\n return JSON.stringify(t);\n }\n return t;\n }\n if (typeof value === 'bigint') {\n return value.toString();\n }\n let json: string | undefined;\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n return truncate(json ?? String(value));\n}\n\nexport function formatLogFields(fields?: LogFields): string {\n if (!fields) {\n return '';\n }\n const parts: string[] = [];\n for (const [k, v] of Object.entries(fields)) {\n if (v === undefined) {\n continue;\n }\n parts.push(`${k}=${formatValue(v)}`);\n }\n return parts.length > 0 ? ` ${parts.join(' ')}` : '';\n}\n\nexport function formatLogLine(\n scope: string,\n event: string,\n fields?: LogFields,\n): string {\n return `[${scope}] ${event}${formatLogFields(fields)}`;\n}\n\nexport function createDefaultConnectorLogger(\n opts: ConnectorLoggerOptions,\n): ConnectorLogger {\n return {\n info(event, fields) {\n console.info(formatLogLine(opts.scope, event, fields));\n },\n warn(event, fields) {\n console.warn(formatLogLine(opts.scope, event, fields));\n },\n };\n}\n\nconst NOOP_LOGGER: ConnectorLogger = {\n info() {},\n warn() {},\n};\n\nexport function noopConnectorLogger(): ConnectorLogger {\n return NOOP_LOGGER;\n}\n","import {\n BQ_DATASET_RE,\n BQ_IDENT_RE,\n BQ_READONLY_SCOPE,\n type BqPageRequest,\n type BqQueryResponse,\n GcpAccessTokenProvider,\n MS_PER_DAY,\n bqQueryResponseSchema,\n collectBigQueryPages,\n gcpAuthConfigShape,\n indexBqFields,\n parseBqDateOrEpoch,\n readBqCell as readCell,\n startOfUtcDay,\n toDateStr,\n tokenResponseSchema,\n} from '@rawdash/connector-gcp-shared';\nimport { connectorUserAgent, parseEpoch } from '@rawdash/connector-shared';\nimport {\n BaseConnector,\n type ConnectorContext,\n type ConnectorCost,\n type ConnectorDoc,\n type CredentialsSchema,\n type JSONValue,\n type MetricSample,\n type StorageHandle,\n type SyncOptions,\n type SyncResult,\n defineConfigFields,\n defineConnectorDoc,\n defineResources,\n schemasFromResources,\n} from '@rawdash/core';\nimport { z } from 'zod';\n\nconst DIMENSION_VALUES = ['service', 'project', 'sku', 'location'] as const;\ntype Dimension = (typeof DIMENSION_VALUES)[number];\n\nexport const configFields = defineConfigFields(\n z.object({\n ...gcpAuthConfigShape,\n bqProject: z\n .string()\n .regex(BQ_IDENT_RE, 'bqProject must be a valid GCP project id')\n .meta({\n label: 'BigQuery project ID',\n description:\n 'Project that hosts the BigQuery billing-export dataset (also the project used to bill the BigQuery queries this connector runs).',\n placeholder: 'my-billing-project',\n }),\n bqDataset: z\n .string()\n .regex(\n BQ_DATASET_RE,\n 'bqDataset must be a valid BigQuery dataset id (letters, digits, and underscores; must start with a letter or underscore)',\n )\n .meta({\n label: 'BigQuery dataset',\n description:\n 'BigQuery dataset containing the Cloud Billing export tables (gcp_billing_export_v1_*).',\n placeholder: 'billing_export',\n }),\n bqLocation: z.string().min(1).optional().meta({\n label: 'BigQuery location',\n description:\n 'Region or multi-region of the billing dataset (e.g. US, EU, us-central1). Defaults to US.',\n placeholder: 'US',\n }),\n groupBy: z\n .array(z.enum(DIMENSION_VALUES))\n .nonempty()\n .max(\n 3,\n 'groupBy accepts at most three dimensions to keep query cardinality bounded',\n )\n .refine(\n (dims) => new Set(dims).size === dims.length,\n 'groupBy values must be unique',\n )\n .optional()\n .meta({\n label: 'Group by (optional)',\n description:\n 'Dimensions to break daily costs down by. Pick from service, project, sku, location. Defaults to [\"service\"].',\n }),\n lookbackDays: z.number().int().positive().max(720).optional().meta({\n label: 'Backfill window (days)',\n description:\n 'How many days of history to query on a full sync. Defaults to 90.',\n placeholder: '90',\n }),\n }),\n);\n\nexport const doc: ConnectorDoc = defineConnectorDoc({\n displayName: 'Google Cloud Billing',\n category: 'finance',\n brandColor: '#669DF6',\n tagline:\n 'Track Google Cloud spend over time from the Cloud Billing -> BigQuery export, optionally broken down by service, project, SKU, or location.',\n vendor: {\n name: 'Google Cloud',\n domain: 'cloud.google.com',\n apiDocs:\n 'https://cloud.google.com/billing/docs/how-to/export-data-bigquery',\n website: 'https://cloud.google.com/billing',\n },\n auth: {\n summary:\n 'Authenticate against the BigQuery API with a Google service account JSON key. The service account needs the BigQuery Data Viewer role on the billing-export dataset and the BigQuery Job User role on the project that runs the queries.',\n setup: [\n 'Enable the Cloud Billing -> BigQuery export in the GCP console (Billing -> Billing export -> BigQuery export). This is a manual one-time setup; data starts flowing into the configured dataset within a day.',\n 'Create a service account at Google Cloud -> IAM & Admin -> Service Accounts (or grant an existing one access).',\n 'Grant the service account roles/bigquery.dataViewer on the billing dataset (so it can read the export tables) and roles/bigquery.jobUser on the bqProject (so it can run query jobs).',\n 'Generate a JSON key for the service account and store its contents as a secret (e.g. GCP_BILLING_SA_JSON).',\n 'Reference the key from config as serviceAccountJson: secret(\"GCP_BILLING_SA_JSON\") and set bqProject + bqDataset to the export location.',\n ],\n },\n rateLimit:\n 'BigQuery jobs.query is rate-limited per project; standard 429 / RESOURCE_EXHAUSTED responses are retried with backoff. Each connector sync runs one query (or a small number when paginated).',\n limitations: [\n 'Requires the Cloud Billing -> BigQuery export to be configured in the GCP console; that step is manual and one-time, and only days after the configuration date are present in the export.',\n 'Queries the gcp_billing_export_v1_* table family (standard usage cost export). The detailed resource-level export (gcp_billing_export_resource_v1_*) is not used.',\n 'Each BigQuery query is billed against the bqProject; over long windows or wide groupBy axes the cost adds up. Prefer narrow groupBy and reasonable lookbackDays.',\n 'Cost data is back-revised by GCP for several days; an incremental sync refetches the trailing 5 days to pick up corrections.',\n ],\n});\n\nconst COST_METRIC_NAME = 'gcp_cost_daily';\nconst DEFAULT_LOOKBACK_DAYS = 90;\nconst INCREMENTAL_LOOKBACK_DAYS = 5;\nconst DEFAULT_GROUP_BY: readonly Dimension[] = ['service'];\n\nexport interface GcpBillingSettings {\n bqProject: string;\n bqDataset: string;\n bqLocation?: string;\n groupBy?: readonly Dimension[];\n lookbackDays?: number;\n}\n\nconst gcpBillingCredentials = {\n serviceAccountJson: {\n description: 'Google service account JSON key (raw JSON or base64)',\n auth: 'required' as const,\n },\n} satisfies CredentialsSchema;\n\ntype GcpBillingCredentials = typeof gcpBillingCredentials;\n\nexport const gcpBillingResources = defineResources({\n [COST_METRIC_NAME]: {\n shape: 'metric',\n description:\n 'Historical GCP cost per day, summed over the dimensions in `groupBy`. One sample per (date, dimension tuple). Pulls from the gcp_billing_export_v1_* tables in BigQuery.',\n endpoint: 'POST /bigquery/v2/projects/{bqProject}/queries',\n unit: 'USD',\n granularity: 'daily',\n notes:\n 'BigQuery charges per query; prefer narrow groupBy and reasonable lookbackDays. The trailing 5 days are always refetched on incremental syncs to pick up back-revisions.',\n dimensions: [\n {\n name: 'service',\n description:\n 'GCP service description (e.g. Compute Engine, BigQuery). Present when groupBy includes service.',\n },\n {\n name: 'project',\n description:\n 'GCP project id the spend is attributed to. Present when groupBy includes project.',\n },\n {\n name: 'sku',\n description:\n 'GCP SKU description (e.g. N1 Predefined Instance Core running in Americas). Present when groupBy includes sku.',\n },\n {\n name: 'location',\n description:\n 'GCP location/region the spend is attributed to. Present when groupBy includes location.',\n },\n { name: 'currency', description: 'Billing currency reported by GCP.' },\n ],\n responses: {\n oauth_token: tokenResponseSchema,\n daily_cost: bqQueryResponseSchema,\n },\n },\n});\n\nexport const id = 'gcp-billing';\n\nexport const cost: ConnectorCost = {\n recommendedInterval: '1 day',\n minInterval: '1 hour',\n perSync: '1 BigQuery query over the gcp_billing_export_v1_* table family',\n warning:\n 'Each BigQuery query is billed against the bqProject. Prefer once-a-day syncs and a focused groupBy.',\n};\n\nexport class GcpBillingConnector extends BaseConnector<\n GcpBillingSettings,\n GcpBillingCredentials\n> {\n static readonly id = id;\n\n static readonly resources = gcpBillingResources;\n\n static readonly schemas = schemasFromResources(gcpBillingResources);\n\n static readonly cost = cost;\n\n static create(input: unknown, ctx?: ConnectorContext): GcpBillingConnector {\n const parsed = configFields.parse(input);\n return new GcpBillingConnector(\n {\n bqProject: parsed.bqProject,\n bqDataset: parsed.bqDataset,\n bqLocation: parsed.bqLocation,\n groupBy: parsed.groupBy,\n lookbackDays: parsed.lookbackDays,\n },\n { serviceAccountJson: parsed.serviceAccountJson },\n ctx,\n );\n }\n\n readonly id = id;\n override readonly credentials = gcpBillingCredentials;\n\n private tokenProvider?: GcpAccessTokenProvider;\n\n private getAccessToken(signal?: AbortSignal): Promise<string> {\n this.tokenProvider ??= new GcpAccessTokenProvider({\n connectorId: this.id,\n scope: BQ_READONLY_SCOPE,\n getServiceAccountJson: () => this.creds.serviceAccountJson,\n post: (url, opts) =>\n this.post<{ access_token: string; expires_in?: number }>(url, opts),\n });\n return this.tokenProvider.getToken(signal);\n }\n\n private async fetchBigQueryPage(\n request: BqPageRequest,\n signal: AbortSignal | undefined,\n ): Promise<BqQueryResponse> {\n const accessToken = await this.getAccessToken(signal);\n const headers = {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'User-Agent': connectorUserAgent(this.id),\n };\n if (request.method === 'POST') {\n const res = await this.post<BqQueryResponse>(request.url, {\n resource: 'daily_cost',\n headers,\n body: request.body,\n signal,\n });\n return res.body;\n }\n const res = await this.get<BqQueryResponse>(request.url, {\n resource: 'daily_cost',\n headers,\n signal,\n });\n return res.body;\n }\n\n async sync(\n options: SyncOptions,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<SyncResult> {\n const groupBy = this.settings.groupBy ?? DEFAULT_GROUP_BY;\n const window = getCostWindow(\n options,\n this.settings.lookbackDays ?? DEFAULT_LOOKBACK_DAYS,\n );\n const sql = buildBillingSql({\n bqProject: this.settings.bqProject,\n bqDataset: this.settings.bqDataset,\n groupBy,\n startDate: window.startDate,\n endDate: window.endDate,\n });\n\n const { rows: samples, aborted } = await collectBigQueryPages<MetricSample>(\n {\n projectId: this.settings.bqProject,\n sql,\n resource: 'daily_cost',\n location: this.settings.bqLocation,\n signal,\n logger: this.logger,\n mapRows: (response) => buildSamplesFromBqResponse(response, groupBy),\n jobIncompleteMessage: `${this.id}: BigQuery query did not complete within the synchronous timeout (jobComplete=false). Narrow the groupBy or lookbackDays so the query finishes faster.`,\n fetchPage: (request, sig) => this.fetchBigQueryPage(request, sig),\n },\n );\n if (aborted) {\n return { done: false };\n }\n await storage.metrics(samples, { names: [COST_METRIC_NAME] });\n return { done: true };\n }\n}\n\ninterface CostWindow {\n startDate: string;\n endDate: string;\n}\n\nconst DIM_TO_SELECT: Record<Dimension, { select: string; alias: string }> = {\n service: { select: 'service.description', alias: 'service' },\n project: { select: 'project.id', alias: 'project' },\n sku: { select: 'sku.description', alias: 'sku' },\n location: { select: 'location.location', alias: 'location' },\n};\n\nexport function buildBillingSql(args: {\n bqProject: string;\n bqDataset: string;\n groupBy: readonly Dimension[];\n startDate: string;\n endDate: string;\n}): string {\n const dims = args.groupBy.map((d) => DIM_TO_SELECT[d]);\n const selectCols = ['DATE(usage_start_time) AS date']\n .concat(dims.map((d) => `${d.select} AS ${d.alias}`))\n .concat(['SUM(cost) AS cost', 'ANY_VALUE(currency) AS currency']);\n const groupCols = ['date'].concat(dims.map((d) => d.alias));\n const table = `\\`${args.bqProject}.${args.bqDataset}.gcp_billing_export_v1_*\\``;\n return [\n `SELECT ${selectCols.join(', ')}`,\n `FROM ${table}`,\n `WHERE DATE(usage_start_time) >= DATE('${args.startDate}')`,\n ` AND DATE(usage_start_time) < DATE('${args.endDate}')`,\n `GROUP BY ${groupCols.join(', ')}`,\n `ORDER BY date`,\n ].join('\\n');\n}\n\nexport function getCostWindow(\n options: SyncOptions,\n lookbackDays: number,\n now: number = Date.now(),\n): CostWindow {\n const endMs = startOfUtcDay(now) + MS_PER_DAY;\n let days = lookbackDays;\n if (options.mode === 'latest') {\n days = INCREMENTAL_LOOKBACK_DAYS;\n } else if (options.since !== undefined) {\n const sinceMs = parseEpoch(options.since, 'iso');\n if (sinceMs !== null) {\n const elapsed = Math.ceil((now - sinceMs) / MS_PER_DAY);\n days = Math.min(\n Math.max(elapsed + INCREMENTAL_LOOKBACK_DAYS, 1),\n lookbackDays,\n );\n }\n }\n return {\n startDate: toDateStr(endMs - days * MS_PER_DAY),\n endDate: toDateStr(endMs),\n };\n}\n\nexport function buildSamplesFromBqResponse(\n response: z.infer<typeof bqQueryResponseSchema>,\n groupBy: readonly Dimension[],\n): MetricSample[] {\n const fieldIndex = indexBqFields(response);\n\n const samples: MetricSample[] = [];\n for (const row of response.rows ?? []) {\n const dateValue = readCell(row.f, fieldIndex, 'date');\n if (dateValue === null) {\n continue;\n }\n const ts = parseBqDateOrEpoch(dateValue);\n if (ts === null) {\n continue;\n }\n const costValue = readCell(row.f, fieldIndex, 'cost');\n if (costValue === null) {\n continue;\n }\n const value = Number.parseFloat(costValue);\n if (!Number.isFinite(value)) {\n continue;\n }\n const attributes: Record<string, JSONValue> = {};\n for (const dim of groupBy) {\n const v = readCell(row.f, fieldIndex, dim);\n attributes[dim] = v ?? null;\n }\n const currency = readCell(row.f, fieldIndex, 'currency');\n if (currency !== null) {\n attributes['currency'] = currency;\n }\n samples.push({ name: COST_METRIC_NAME, ts, value, attributes });\n }\n return samples;\n}\n","import { GcpBillingConnector } from './gcp-billing';\n\nexport {\n GcpBillingConnector,\n buildBillingSql,\n buildSamplesFromBqResponse,\n configFields,\n cost,\n doc,\n getCostWindow,\n id,\n gcpBillingResources as resources,\n} from './gcp-billing';\nexport type { GcpBillingSettings } from './gcp-billing';\nexport default GcpBillingConnector;\n"],"mappings":"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","names":["z","url","HTTP_CLIENT_VERSION","DEFAULT_USER_AGENT","parseEpoch","z","res","parseEpoch"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rawdash/connector-gcp-billing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Rawdash connector for Google Cloud Billing — syncs daily spend from a Cloud Billing -> BigQuery export, broken down by service and project",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"zod": "^4.4.3",
|
|
27
|
-
"@rawdash/core": "0.
|
|
27
|
+
"@rawdash/core": "0.23.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"fast-check": "^4.8.0",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"vitest": "^4.1.4",
|
|
34
34
|
"@rawdash/connector-gcp-shared": "0.1.0",
|
|
35
35
|
"@rawdash/connector-shared": "0.3.1",
|
|
36
|
-
"@rawdash/connector-test-utils": "0.0.
|
|
36
|
+
"@rawdash/connector-test-utils": "0.0.10"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "tsup",
|