@rawdash/connector-firebase-crashlytics 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.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}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
328
+ return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION2} (+https://rawdash.dev)`;
104
329
  }
105
- function parseEpoch(value, unit) {
330
+ function parseEpoch2(value, unit) {
106
331
  if (value === null || value === void 0) {
107
332
  return null;
108
333
  }
@@ -132,18 +357,16 @@ import {
132
357
  defineResources,
133
358
  schemasFromResources
134
359
  } from "@rawdash/core";
135
- import { z as z3 } from "zod";
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 configFields = defineConfigFields(
139
- z3.object({
362
+ z4.object({
140
363
  ...gcpAuthConfigShape,
141
- projectId: z3.string().regex(BQ_IDENT_RE, "projectId must be a valid GCP project id").meta({
364
+ projectId: z4.string().regex(BQ_IDENT_RE, "projectId must be a valid GCP project id").meta({
142
365
  label: "GCP project ID",
143
366
  description: "Project that hosts the Firebase Crashlytics -> BigQuery export (also the project used to bill the BigQuery queries this connector runs).",
144
367
  placeholder: "my-firebase-project"
145
368
  }),
146
- bqDataset: z3.string().regex(
369
+ bqDataset: z4.string().regex(
147
370
  BQ_DATASET_RE,
148
371
  "bqDataset must be a valid BigQuery dataset id (letters, digits, and underscores; must start with a letter or underscore)"
149
372
  ).optional().meta({
@@ -151,17 +374,17 @@ var configFields = defineConfigFields(
151
374
  description: "BigQuery dataset containing the Crashlytics export tables. Defaults to firebase_crashlytics (the default name Firebase uses when you enable the export).",
152
375
  placeholder: "firebase_crashlytics"
153
376
  }),
154
- bqLocation: z3.string().min(1).optional().meta({
377
+ bqLocation: z4.string().min(1).optional().meta({
155
378
  label: "BigQuery location",
156
379
  description: "Region or multi-region of the Crashlytics dataset (e.g. US, EU, us-central1). Defaults to US.",
157
380
  placeholder: "US"
158
381
  }),
159
- lookbackDays: z3.number().int().positive().max(720).optional().meta({
382
+ lookbackDays: z4.number().int().positive().max(720).optional().meta({
160
383
  label: "Backfill window (days)",
161
384
  description: "How many days of history to query on a full sync. Defaults to 90.",
162
385
  placeholder: "90"
163
386
  }),
164
- topIssuesLimit: z3.number().int().positive().max(500).optional().meta({
387
+ topIssuesLimit: z4.number().int().positive().max(500).optional().meta({
165
388
  label: "Top issues limit",
166
389
  description: "How many top issues to retain per sync, ranked by event count over the backfill window. Defaults to 50.",
167
390
  placeholder: "50"
@@ -175,6 +398,7 @@ var doc = defineConnectorDoc({
175
398
  tagline: "Track mobile app reliability over time from the Firebase Crashlytics -> BigQuery export: daily crashes, crash-free user rate, and top issues by impact.",
176
399
  vendor: {
177
400
  name: "Firebase",
401
+ domain: "firebase.google.com",
178
402
  apiDocs: "https://firebase.google.com/docs/crashlytics/bigquery-export",
179
403
  website: "https://firebase.google.com/products/crashlytics"
180
404
  },
@@ -197,39 +421,18 @@ var doc = defineConnectorDoc({
197
421
  "The Crashlytics BigQuery export is streamed; the trailing 2 days are always refetched on incremental syncs to pick up late-arriving rows."
198
422
  ]
199
423
  });
200
- var BQ_API_BASE = "https://bigquery.googleapis.com/bigquery/v2";
201
- var BQ_SCOPE = "https://www.googleapis.com/auth/bigquery.readonly";
202
424
  var CRASHES_METRIC_NAME = "crashes_per_day";
203
425
  var TOP_ISSUES_ENTITY_TYPE = "firebase_crashlytics_issue";
204
426
  var DEFAULT_LOOKBACK_DAYS = 90;
205
427
  var DEFAULT_TOP_ISSUES_LIMIT = 50;
206
428
  var DEFAULT_BQ_DATASET = "firebase_crashlytics";
207
429
  var INCREMENTAL_LOOKBACK_DAYS = 2;
208
- var MS_PER_DAY = 864e5;
209
- var PAGE_SIZE = 1e4;
210
430
  var firebaseCrashlyticsCredentials = {
211
431
  serviceAccountJson: {
212
432
  description: "Google service account JSON key (raw JSON or base64)",
213
433
  auth: "required"
214
434
  }
215
435
  };
216
- var bqQueryResponseSchema = z3.object({
217
- jobComplete: z3.boolean().optional(),
218
- schema: z3.object({
219
- fields: z3.array(z3.object({ name: z3.string(), type: z3.string() }))
220
- }).optional(),
221
- rows: z3.array(
222
- z3.object({
223
- f: z3.array(z3.object({ v: z3.string().nullable().optional() }))
224
- })
225
- ).optional(),
226
- pageToken: z3.string().optional(),
227
- jobReference: z3.object({
228
- projectId: z3.string(),
229
- jobId: z3.string(),
230
- location: z3.string().optional()
231
- }).optional()
232
- });
233
436
  var firebaseCrashlyticsResources = defineResources({
234
437
  [CRASHES_METRIC_NAME]: {
235
438
  shape: "metric",
@@ -267,6 +470,7 @@ var firebaseCrashlyticsResources = defineResources({
267
470
  },
268
471
  top_issues: {
269
472
  shape: "entity",
473
+ filterable: [],
270
474
  description: "Top crash issues by event count over the backfill window, ranked across all apps and versions present in the export. One entity per Crashlytics issue id.",
271
475
  endpoint: "POST /bigquery/v2/projects/{projectId}/queries",
272
476
  notes: "topIssuesLimit caps how many issues are retained per sync (default 50). Rows are sorted by descending event count over the backfill window.",
@@ -330,56 +534,35 @@ var FirebaseCrashlyticsConnector = class _FirebaseCrashlyticsConnector extends B
330
534
  }
331
535
  id = id;
332
536
  credentials = firebaseCrashlyticsCredentials;
333
- cachedToken = null;
334
- async getAccessToken(signal) {
335
- if (this.cachedToken && Date.now() < this.cachedToken.expiresAt) {
336
- return this.cachedToken.token;
337
- }
338
- const { serviceAccountJson } = this.creds;
339
- if (!serviceAccountJson) {
340
- throw new AuthError(`${this.id}: missing serviceAccountJson credential`);
341
- }
342
- const { url, body } = await buildServiceAccountJwt(
343
- serviceAccountJson,
344
- BQ_SCOPE
345
- );
346
- const res = await this.post(url, {
347
- resource: "oauth_token",
348
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
349
- body,
350
- signal
537
+ tokenProvider;
538
+ getAccessToken(signal) {
539
+ this.tokenProvider ??= new GcpAccessTokenProvider({
540
+ connectorId: this.id,
541
+ scope: BQ_READONLY_SCOPE,
542
+ getServiceAccountJson: () => this.creds.serviceAccountJson,
543
+ post: (url, opts) => this.post(url, opts)
351
544
  });
352
- const expiresIn = res.body.expires_in ?? 3600;
353
- this.cachedToken = {
354
- token: res.body.access_token,
355
- expiresAt: Date.now() + (expiresIn - 60) * 1e3
356
- };
357
- return this.cachedToken.token;
545
+ return this.tokenProvider.getToken(signal);
358
546
  }
359
- async runQuery(accessToken, resource, sql, pageToken, signal) {
360
- const url = `${BQ_API_BASE}/projects/${encodeURIComponent(
361
- this.settings.projectId
362
- )}/queries`;
363
- const body = {
364
- query: sql,
365
- useLegacySql: false,
366
- maxResults: PAGE_SIZE,
367
- timeoutMs: 3e4
547
+ async fetchBigQueryPage(resource, request, signal) {
548
+ const accessToken = await this.getAccessToken(signal);
549
+ const headers = {
550
+ Authorization: `Bearer ${accessToken}`,
551
+ "Content-Type": "application/json",
552
+ "User-Agent": connectorUserAgent(this.id)
368
553
  };
369
- if (this.settings.bqLocation !== void 0) {
370
- body["location"] = this.settings.bqLocation;
371
- }
372
- if (pageToken !== void 0) {
373
- body["pageToken"] = pageToken;
554
+ if (request.method === "POST") {
555
+ const res2 = await this.post(request.url, {
556
+ resource,
557
+ headers,
558
+ body: request.body,
559
+ signal
560
+ });
561
+ return res2.body;
374
562
  }
375
- const res = await this.post(url, {
563
+ const res = await this.get(request.url, {
376
564
  resource,
377
- headers: {
378
- Authorization: `Bearer ${accessToken}`,
379
- "Content-Type": "application/json",
380
- "User-Agent": connectorUserAgent(this.id)
381
- },
382
- body: JSON.stringify(body),
565
+ headers,
383
566
  signal
384
567
  });
385
568
  return res.body;
@@ -432,107 +615,36 @@ var FirebaseCrashlyticsConnector = class _FirebaseCrashlyticsConnector extends B
432
615
  }
433
616
  return { done: true };
434
617
  }
618
+ jobIncompleteMessage() {
619
+ return `${this.id}: BigQuery query did not complete within the synchronous timeout (jobComplete=false). Narrow the lookbackDays so the query finishes faster.`;
620
+ }
435
621
  async collectSamples(sql, signal) {
436
- const samples = [];
437
- let pageToken;
438
- let page = 0;
439
- const phaseStart = Date.now();
440
- do {
441
- if (signal?.aborted) {
442
- break;
443
- }
444
- const accessToken = await this.getAccessToken(signal);
445
- let response;
446
- try {
447
- response = await this.runQuery(
448
- accessToken,
449
- CRASHES_METRIC_NAME,
450
- sql,
451
- pageToken,
452
- signal
453
- );
454
- } catch (err) {
455
- this.logger.warn("fetch page failed", {
456
- resource: CRASHES_METRIC_NAME,
457
- page: page + 1,
458
- error: err instanceof Error ? err.message : String(err)
459
- });
460
- throw err;
461
- }
462
- if (response.jobComplete === false) {
463
- throw new Error(
464
- `${this.id}: BigQuery query did not complete within the synchronous timeout (jobComplete=false). Narrow the lookbackDays so the query finishes faster.`
465
- );
466
- }
467
- const pageSamples = buildCrashesSamplesFromBqResponse(response);
468
- samples.push(...pageSamples);
469
- pageToken = typeof response.pageToken === "string" && response.pageToken.length > 0 ? response.pageToken : void 0;
470
- page += 1;
471
- this.logger.info("fetched page", {
472
- resource: CRASHES_METRIC_NAME,
473
- page,
474
- items: pageSamples.length,
475
- next: pageToken ?? null
476
- });
477
- } while (pageToken !== void 0);
478
- this.logger.info("resource done", {
622
+ const { rows } = await collectBigQueryPages({
623
+ projectId: this.settings.projectId,
624
+ sql,
479
625
  resource: CRASHES_METRIC_NAME,
480
- pages: page,
481
- items: samples.length,
482
- duration_ms: Date.now() - phaseStart
626
+ location: this.settings.bqLocation,
627
+ signal,
628
+ logger: this.logger,
629
+ mapRows: buildCrashesSamplesFromBqResponse,
630
+ jobIncompleteMessage: this.jobIncompleteMessage(),
631
+ fetchPage: (request, sig) => this.fetchBigQueryPage(CRASHES_METRIC_NAME, request, sig)
483
632
  });
484
- return samples;
633
+ return rows;
485
634
  }
486
635
  async collectIssues(sql, signal) {
487
- const entities = [];
488
- let pageToken;
489
- let page = 0;
490
- const phaseStart = Date.now();
491
- do {
492
- if (signal?.aborted) {
493
- break;
494
- }
495
- const accessToken = await this.getAccessToken(signal);
496
- let response;
497
- try {
498
- response = await this.runQuery(
499
- accessToken,
500
- "top_issues",
501
- sql,
502
- pageToken,
503
- signal
504
- );
505
- } catch (err) {
506
- this.logger.warn("fetch page failed", {
507
- resource: "top_issues",
508
- page: page + 1,
509
- error: err instanceof Error ? err.message : String(err)
510
- });
511
- throw err;
512
- }
513
- if (response.jobComplete === false) {
514
- throw new Error(
515
- `${this.id}: BigQuery query did not complete within the synchronous timeout (jobComplete=false). Narrow the lookbackDays so the query finishes faster.`
516
- );
517
- }
518
- const pageEntities = buildTopIssuesEntitiesFromBqResponse(response);
519
- entities.push(...pageEntities);
520
- pageToken = typeof response.pageToken === "string" && response.pageToken.length > 0 ? response.pageToken : void 0;
521
- page += 1;
522
- this.logger.info("fetched page", {
523
- resource: "top_issues",
524
- page,
525
- items: pageEntities.length,
526
- next: pageToken ?? null
527
- });
528
- } while (pageToken !== void 0);
529
- this.logger.info("resource done", {
636
+ const { rows } = await collectBigQueryPages({
637
+ projectId: this.settings.projectId,
638
+ sql,
530
639
  resource: "top_issues",
531
- pages: page,
532
- items: entities.length,
533
- duration_ms: Date.now() - phaseStart
640
+ location: this.settings.bqLocation,
641
+ signal,
642
+ logger: this.logger,
643
+ mapRows: buildTopIssuesEntitiesFromBqResponse,
644
+ jobIncompleteMessage: this.jobIncompleteMessage(),
645
+ fetchPage: (request, sig) => this.fetchBigQueryPage("top_issues", request, sig)
534
646
  });
535
- return entities;
647
+ return rows;
536
648
  }
537
649
  };
538
650
  function buildCrashesPerDaySql(args) {
@@ -580,27 +692,17 @@ function buildTopIssuesSql(args) {
580
692
  ` AND DATE(event_timestamp) < DATE('${args.endDate}')`,
581
693
  " AND issue_id IS NOT NULL",
582
694
  "GROUP BY issue_id",
583
- "ORDER BY event_count DESC",
695
+ "ORDER BY event_count DESC, last_seen DESC, issue_id ASC",
584
696
  `LIMIT ${args.limit}`
585
697
  ].join("\n");
586
698
  }
587
- function pad2(n) {
588
- return String(n).padStart(2, "0");
589
- }
590
- function toDateStr(ms) {
591
- const d = new Date(ms);
592
- return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
593
- }
594
- function startOfUtcDay(ms) {
595
- return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY;
596
- }
597
699
  function getCrashlyticsWindow(options, lookbackDays, now = Date.now()) {
598
700
  const endMs = startOfUtcDay(now) + MS_PER_DAY;
599
701
  let days = lookbackDays;
600
702
  if (options.mode === "latest") {
601
703
  days = INCREMENTAL_LOOKBACK_DAYS;
602
704
  } else if (options.since !== void 0) {
603
- const sinceMs = parseEpoch(options.since, "iso");
705
+ const sinceMs = parseEpoch2(options.since, "iso");
604
706
  if (sinceMs !== null) {
605
707
  const elapsed = Math.ceil((now - sinceMs) / MS_PER_DAY);
606
708
  days = Math.min(
@@ -615,14 +717,10 @@ function getCrashlyticsWindow(options, lookbackDays, now = Date.now()) {
615
717
  };
616
718
  }
617
719
  function buildCrashesSamplesFromBqResponse(response) {
618
- const schema = response.schema?.fields ?? [];
619
- const fieldIndex = {};
620
- schema.forEach((field, idx) => {
621
- fieldIndex[field.name] = idx;
622
- });
720
+ const fieldIndex = indexBqFields(response);
623
721
  const samples = [];
624
722
  for (const row of response.rows ?? []) {
625
- const dateValue = readCell(row.f, fieldIndex, "date");
723
+ const dateValue = readBqCell(row.f, fieldIndex, "date");
626
724
  if (dateValue === null) {
627
725
  continue;
628
726
  }
@@ -630,7 +728,7 @@ function buildCrashesSamplesFromBqResponse(response) {
630
728
  if (ts === null) {
631
729
  continue;
632
730
  }
633
- const crashesRaw = readCell(row.f, fieldIndex, "crashes");
731
+ const crashesRaw = readBqCell(row.f, fieldIndex, "crashes");
634
732
  if (crashesRaw === null) {
635
733
  continue;
636
734
  }
@@ -638,8 +736,8 @@ function buildCrashesSamplesFromBqResponse(response) {
638
736
  if (!Number.isFinite(crashes)) {
639
737
  continue;
640
738
  }
641
- const crashingUsersRaw = readCell(row.f, fieldIndex, "crashing_users");
642
- const totalUsersRaw = readCell(row.f, fieldIndex, "total_users");
739
+ const crashingUsersRaw = readBqCell(row.f, fieldIndex, "crashing_users");
740
+ const totalUsersRaw = readBqCell(row.f, fieldIndex, "total_users");
643
741
  const crashingUsers = crashingUsersRaw !== null ? Number.parseFloat(crashingUsersRaw) : NaN;
644
742
  const totalUsers = totalUsersRaw !== null ? Number.parseFloat(totalUsersRaw) : NaN;
645
743
  let crashFreeRate = null;
@@ -648,9 +746,9 @@ function buildCrashesSamplesFromBqResponse(response) {
648
746
  crashFreeRate = Math.max(0, Math.min(1, rate));
649
747
  }
650
748
  const attributes = {};
651
- const appId = readCell(row.f, fieldIndex, "app_id");
652
- const platform = readCell(row.f, fieldIndex, "platform");
653
- const version = readCell(row.f, fieldIndex, "version");
749
+ const appId = readBqCell(row.f, fieldIndex, "app_id");
750
+ const platform = readBqCell(row.f, fieldIndex, "platform");
751
+ const version = readBqCell(row.f, fieldIndex, "version");
654
752
  attributes["app_id"] = appId;
655
753
  attributes["platform"] = platform;
656
754
  attributes["version"] = version;
@@ -666,30 +764,26 @@ function buildCrashesSamplesFromBqResponse(response) {
666
764
  return samples;
667
765
  }
668
766
  function buildTopIssuesEntitiesFromBqResponse(response) {
669
- const schema = response.schema?.fields ?? [];
670
- const fieldIndex = {};
671
- schema.forEach((field, idx) => {
672
- fieldIndex[field.name] = idx;
673
- });
767
+ const fieldIndex = indexBqFields(response);
674
768
  const entities = [];
675
769
  for (const row of response.rows ?? []) {
676
- const issueId = readCell(row.f, fieldIndex, "issue_id");
770
+ const issueId = readBqCell(row.f, fieldIndex, "issue_id");
677
771
  if (issueId === null || issueId.length === 0) {
678
772
  continue;
679
773
  }
680
- const eventCountRaw = readCell(row.f, fieldIndex, "event_count");
681
- const userCountRaw = readCell(row.f, fieldIndex, "user_count");
774
+ const eventCountRaw = readBqCell(row.f, fieldIndex, "event_count");
775
+ const userCountRaw = readBqCell(row.f, fieldIndex, "user_count");
682
776
  const eventCount = eventCountRaw !== null ? Number.parseFloat(eventCountRaw) : NaN;
683
777
  const userCount = userCountRaw !== null ? Number.parseFloat(userCountRaw) : NaN;
684
- const lastSeenRaw = readCell(row.f, fieldIndex, "last_seen");
778
+ const lastSeenRaw = readBqCell(row.f, fieldIndex, "last_seen");
685
779
  const lastSeenMs = lastSeenRaw !== null ? parseBqDateOrEpoch(lastSeenRaw) : null;
686
780
  const updatedAt = lastSeenMs ?? Date.now();
687
781
  const attributes = {
688
782
  issue_id: issueId,
689
- title: readCell(row.f, fieldIndex, "title"),
690
- subtitle: readCell(row.f, fieldIndex, "subtitle"),
691
- app_id: readCell(row.f, fieldIndex, "app_id"),
692
- platform: readCell(row.f, fieldIndex, "platform"),
783
+ title: readBqCell(row.f, fieldIndex, "title"),
784
+ subtitle: readBqCell(row.f, fieldIndex, "subtitle"),
785
+ app_id: readBqCell(row.f, fieldIndex, "app_id"),
786
+ platform: readBqCell(row.f, fieldIndex, "platform"),
693
787
  event_count: Number.isFinite(eventCount) ? eventCount : 0,
694
788
  user_count: Number.isFinite(userCount) ? userCount : 0,
695
789
  last_seen: lastSeenMs !== null ? new Date(lastSeenMs).toISOString() : null
@@ -703,28 +797,6 @@ function buildTopIssuesEntitiesFromBqResponse(response) {
703
797
  }
704
798
  return entities;
705
799
  }
706
- function readCell(cells, fieldIndex, name) {
707
- const idx = fieldIndex[name];
708
- if (idx === void 0) {
709
- return null;
710
- }
711
- const raw = cells[idx]?.v;
712
- if (raw === void 0 || raw === null) {
713
- return null;
714
- }
715
- return raw;
716
- }
717
- function parseBqDateOrEpoch(value) {
718
- const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
719
- if (dateMatch) {
720
- return Date.UTC(
721
- Number(dateMatch[1]),
722
- Number(dateMatch[2]) - 1,
723
- Number(dateMatch[3])
724
- );
725
- }
726
- return parseEpoch(value, "iso");
727
- }
728
800
 
729
801
  // src/index.ts
730
802
  var index_default = FirebaseCrashlyticsConnector;