@rawdash/connector-firebase-crashlytics 0.21.1

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 ADDED
@@ -0,0 +1,744 @@
1
+ // ../gcp-shared/dist/index.js
2
+ import { z } from "zod";
3
+ import { z as z2 } from "zod";
4
+ var serviceAccountKeySchema = z.object({
5
+ client_email: z.string().min(1),
6
+ private_key: z.string().min(1),
7
+ token_uri: z.string().url().optional(),
8
+ project_id: z.string().optional()
9
+ });
10
+ var tokenResponseSchema = z.object({
11
+ access_token: z.string().min(1),
12
+ expires_in: z.number().int().positive().optional()
13
+ });
14
+ function base64urlFromBytes(bytes) {
15
+ let binary = "";
16
+ for (let i = 0; i < bytes.length; i++) {
17
+ binary += String.fromCharCode(bytes[i]);
18
+ }
19
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
20
+ }
21
+ function base64urlFromString(str) {
22
+ return base64urlFromBytes(new TextEncoder().encode(str));
23
+ }
24
+ async function signRS256JWT(payload, privateKeyPem) {
25
+ const header = { alg: "RS256", typ: "JWT" };
26
+ const headerB64 = base64urlFromString(JSON.stringify(header));
27
+ const payloadB64 = base64urlFromString(JSON.stringify(payload));
28
+ const signingInput = `${headerB64}.${payloadB64}`;
29
+ const pemContent = privateKeyPem.replace(/-----BEGIN PRIVATE KEY-----/g, "").replace(/-----END PRIVATE KEY-----/g, "").replace(/\s/g, "");
30
+ const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));
31
+ const key = await globalThis.crypto.subtle.importKey(
32
+ "pkcs8",
33
+ der,
34
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
35
+ false,
36
+ ["sign"]
37
+ );
38
+ const signature = await globalThis.crypto.subtle.sign(
39
+ "RSASSA-PKCS1-v1_5",
40
+ key,
41
+ new TextEncoder().encode(signingInput)
42
+ );
43
+ return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;
44
+ }
45
+ function parseServiceAccountJson(value) {
46
+ const trimmed = value.trim();
47
+ if (trimmed.startsWith("{")) {
48
+ return serviceAccountKeySchema.parse(JSON.parse(trimmed));
49
+ }
50
+ const binary = atob(trimmed);
51
+ const bytes = new Uint8Array(binary.length);
52
+ for (let i = 0; i < binary.length; i++) {
53
+ bytes[i] = binary.charCodeAt(i);
54
+ }
55
+ const decoded = new TextDecoder().decode(bytes);
56
+ return serviceAccountKeySchema.parse(JSON.parse(decoded));
57
+ }
58
+ async function buildServiceAccountJwt(serviceAccountJson, scope) {
59
+ const sa = parseServiceAccountJson(serviceAccountJson);
60
+ const now = Math.floor(Date.now() / 1e3);
61
+ const jwt = await signRS256JWT(
62
+ {
63
+ iss: sa.client_email,
64
+ scope,
65
+ aud: sa.token_uri ?? "https://oauth2.googleapis.com/token",
66
+ exp: now + 3600,
67
+ iat: now
68
+ },
69
+ sa.private_key
70
+ );
71
+ const body = new URLSearchParams({
72
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
73
+ assertion: jwt
74
+ }).toString();
75
+ return {
76
+ url: sa.token_uri ?? "https://oauth2.googleapis.com/token",
77
+ body
78
+ };
79
+ }
80
+ var gcpAuthConfigShape = {
81
+ serviceAccountJson: z2.object({ $secret: z2.string().trim().min(1) }).meta({
82
+ label: "Service Account JSON",
83
+ description: "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.",
84
+ secret: true
85
+ })
86
+ };
87
+
88
+ // ../../connector-shared/dist/index.js
89
+ var HttpClientError = class extends Error {
90
+ response;
91
+ constructor(message, response) {
92
+ super(message);
93
+ this.name = new.target.name;
94
+ this.response = response;
95
+ }
96
+ };
97
+ var AuthError = class extends HttpClientError {
98
+ kind = "auth";
99
+ };
100
+ var HTTP_CLIENT_VERSION = "0.0.0";
101
+ var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
102
+ function connectorUserAgent(connectorId) {
103
+ return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
104
+ }
105
+ function parseEpoch(value, unit) {
106
+ if (value === null || value === void 0) {
107
+ return null;
108
+ }
109
+ if (unit === "iso") {
110
+ if (typeof value !== "string") {
111
+ return null;
112
+ }
113
+ const ms = new Date(value).getTime();
114
+ return Number.isFinite(ms) ? ms : null;
115
+ }
116
+ if (typeof value === "string" && value.trim() === "") {
117
+ return null;
118
+ }
119
+ const n = typeof value === "number" ? value : Number(value);
120
+ if (!Number.isFinite(n)) {
121
+ return null;
122
+ }
123
+ const result = unit === "s" ? n * 1e3 : n;
124
+ return Number.isFinite(result) ? result : null;
125
+ }
126
+
127
+ // src/firebase-crashlytics.ts
128
+ import {
129
+ BaseConnector,
130
+ defineConfigFields,
131
+ defineConnectorDoc,
132
+ defineResources,
133
+ schemasFromResources
134
+ } 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_]*$/;
138
+ var configFields = defineConfigFields(
139
+ z3.object({
140
+ ...gcpAuthConfigShape,
141
+ projectId: z3.string().regex(BQ_IDENT_RE, "projectId must be a valid GCP project id").meta({
142
+ label: "GCP project ID",
143
+ description: "Project that hosts the Firebase Crashlytics -> BigQuery export (also the project used to bill the BigQuery queries this connector runs).",
144
+ placeholder: "my-firebase-project"
145
+ }),
146
+ bqDataset: z3.string().regex(
147
+ BQ_DATASET_RE,
148
+ "bqDataset must be a valid BigQuery dataset id (letters, digits, and underscores; must start with a letter or underscore)"
149
+ ).optional().meta({
150
+ label: "BigQuery dataset",
151
+ description: "BigQuery dataset containing the Crashlytics export tables. Defaults to firebase_crashlytics (the default name Firebase uses when you enable the export).",
152
+ placeholder: "firebase_crashlytics"
153
+ }),
154
+ bqLocation: z3.string().min(1).optional().meta({
155
+ label: "BigQuery location",
156
+ description: "Region or multi-region of the Crashlytics dataset (e.g. US, EU, us-central1). Defaults to US.",
157
+ placeholder: "US"
158
+ }),
159
+ lookbackDays: z3.number().int().positive().max(720).optional().meta({
160
+ label: "Backfill window (days)",
161
+ description: "How many days of history to query on a full sync. Defaults to 90.",
162
+ placeholder: "90"
163
+ }),
164
+ topIssuesLimit: z3.number().int().positive().max(500).optional().meta({
165
+ label: "Top issues limit",
166
+ description: "How many top issues to retain per sync, ranked by event count over the backfill window. Defaults to 50.",
167
+ placeholder: "50"
168
+ })
169
+ })
170
+ );
171
+ var doc = defineConnectorDoc({
172
+ displayName: "Firebase Crashlytics",
173
+ category: "engineering",
174
+ brandColor: "#FFA000",
175
+ 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
+ vendor: {
177
+ name: "Firebase",
178
+ apiDocs: "https://firebase.google.com/docs/crashlytics/bigquery-export",
179
+ website: "https://firebase.google.com/products/crashlytics"
180
+ },
181
+ auth: {
182
+ summary: "Authenticate against the BigQuery API with a Google service account JSON key. The service account needs the BigQuery Data Viewer role on the Crashlytics export dataset and the BigQuery Job User role on the project that runs the queries.",
183
+ setup: [
184
+ "Enable the Firebase Crashlytics -> BigQuery export in the Firebase console (Project Settings -> Integrations -> BigQuery). This is a manual one-time setup per project; data starts flowing into the firebase_crashlytics dataset within a day.",
185
+ "Create a service account at Google Cloud -> IAM & Admin -> Service Accounts in the same project (or grant an existing one access).",
186
+ "Grant the service account roles/bigquery.dataViewer on the Crashlytics dataset (so it can read the export tables) and roles/bigquery.jobUser on the project (so it can run query jobs).",
187
+ "Generate a JSON key for the service account and store its contents as a secret (e.g. FIREBASE_SA_JSON).",
188
+ 'Reference the key from config as serviceAccountJson: secret("FIREBASE_SA_JSON") and set projectId to the Firebase project that owns the export.'
189
+ ]
190
+ },
191
+ rateLimit: "BigQuery jobs.query is rate-limited per project; standard 429 / RESOURCE_EXHAUSTED responses are retried with backoff. Each connector sync runs one query per resource.",
192
+ limitations: [
193
+ "Requires the Firebase Crashlytics -> BigQuery export to be configured in the Firebase console; that step is manual and one-time per project, and only days after the configuration date are present in the export.",
194
+ "Reads the firebase_crashlytics.<bundle>_<platform> tables via a wildcard; one row in storage covers one app/version/platform tuple per day.",
195
+ "Crash-free user rate is approximated from the daily ratio of unique crashing users to total event users observed in the export; matching the Firebase console number exactly requires the full Crashlytics signal, not just the BigQuery export.",
196
+ "Each BigQuery query is billed against the configured projectId; over long lookback windows the cost adds up. Prefer once-a-day syncs and reasonable lookbackDays.",
197
+ "The Crashlytics BigQuery export is streamed; the trailing 2 days are always refetched on incremental syncs to pick up late-arriving rows."
198
+ ]
199
+ });
200
+ var BQ_API_BASE = "https://bigquery.googleapis.com/bigquery/v2";
201
+ var BQ_SCOPE = "https://www.googleapis.com/auth/bigquery.readonly";
202
+ var CRASHES_METRIC_NAME = "crashes_per_day";
203
+ var TOP_ISSUES_ENTITY_TYPE = "firebase_crashlytics_issue";
204
+ var DEFAULT_LOOKBACK_DAYS = 90;
205
+ var DEFAULT_TOP_ISSUES_LIMIT = 50;
206
+ var DEFAULT_BQ_DATASET = "firebase_crashlytics";
207
+ var INCREMENTAL_LOOKBACK_DAYS = 2;
208
+ var MS_PER_DAY = 864e5;
209
+ var PAGE_SIZE = 1e4;
210
+ var firebaseCrashlyticsCredentials = {
211
+ serviceAccountJson: {
212
+ description: "Google service account JSON key (raw JSON or base64)",
213
+ auth: "required"
214
+ }
215
+ };
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
+ var firebaseCrashlyticsResources = defineResources({
234
+ [CRASHES_METRIC_NAME]: {
235
+ shape: "metric",
236
+ description: "Daily crash counts and approximate crash-free user rate per (date, application version, platform). One sample per day per app/version/platform combination present in the Crashlytics BigQuery export.",
237
+ endpoint: "POST /bigquery/v2/projects/{projectId}/queries",
238
+ unit: "crashes",
239
+ granularity: "daily",
240
+ notes: "Reads from firebase_crashlytics.<bundle>_<platform>_* via a wildcard. The trailing 2 days are always refetched on incremental syncs to pick up streamed rows.",
241
+ dimensions: [
242
+ {
243
+ name: "app_id",
244
+ description: "Bundle identifier (iOS) or package name (Android) of the app the crash was recorded against."
245
+ },
246
+ {
247
+ name: "platform",
248
+ description: "Application platform (ios, android, or unknown)."
249
+ },
250
+ {
251
+ name: "version",
252
+ description: "Application display version (e.g. 2.4.1)."
253
+ },
254
+ {
255
+ name: "crash_free_user_rate",
256
+ description: "Approximate share of users on this app/version/day that did not see a crash (0..1). null if no user signal was captured."
257
+ },
258
+ {
259
+ name: "crashing_users",
260
+ description: "Count of distinct users that experienced at least one crash on this app/version/day."
261
+ }
262
+ ],
263
+ responses: {
264
+ oauth_token: tokenResponseSchema,
265
+ crashes_per_day: bqQueryResponseSchema
266
+ }
267
+ },
268
+ top_issues: {
269
+ shape: "entity",
270
+ 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
+ endpoint: "POST /bigquery/v2/projects/{projectId}/queries",
272
+ notes: "topIssuesLimit caps how many issues are retained per sync (default 50). Rows are sorted by descending event count over the backfill window.",
273
+ fields: [
274
+ {
275
+ name: "issue_id",
276
+ description: "Stable Crashlytics issue identifier."
277
+ },
278
+ {
279
+ name: "title",
280
+ description: "Issue title (most recent value seen for this issue id within the window)."
281
+ },
282
+ {
283
+ name: "subtitle",
284
+ description: "Issue subtitle (most recent value seen for this issue id within the window)."
285
+ },
286
+ {
287
+ name: "app_id",
288
+ description: "Bundle identifier (iOS) or package name (Android) most recently seen for this issue."
289
+ },
290
+ {
291
+ name: "platform",
292
+ description: "Application platform (ios, android, or unknown)."
293
+ },
294
+ {
295
+ name: "event_count",
296
+ description: "Total crash events attributed to this issue within the backfill window."
297
+ },
298
+ {
299
+ name: "user_count",
300
+ description: "Distinct users that experienced this issue within the backfill window."
301
+ },
302
+ {
303
+ name: "last_seen",
304
+ description: "ISO timestamp of the most recent event for this issue within the window."
305
+ }
306
+ ],
307
+ responses: {
308
+ top_issues: bqQueryResponseSchema
309
+ }
310
+ }
311
+ });
312
+ var id = "firebase-crashlytics";
313
+ var FirebaseCrashlyticsConnector = class _FirebaseCrashlyticsConnector extends BaseConnector {
314
+ static id = id;
315
+ static resources = firebaseCrashlyticsResources;
316
+ static schemas = schemasFromResources(firebaseCrashlyticsResources);
317
+ static create(input, ctx) {
318
+ const parsed = configFields.parse(input);
319
+ return new _FirebaseCrashlyticsConnector(
320
+ {
321
+ projectId: parsed.projectId,
322
+ bqDataset: parsed.bqDataset,
323
+ bqLocation: parsed.bqLocation,
324
+ lookbackDays: parsed.lookbackDays,
325
+ topIssuesLimit: parsed.topIssuesLimit
326
+ },
327
+ { serviceAccountJson: parsed.serviceAccountJson },
328
+ ctx
329
+ );
330
+ }
331
+ id = id;
332
+ 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
351
+ });
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;
358
+ }
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
368
+ };
369
+ if (this.settings.bqLocation !== void 0) {
370
+ body["location"] = this.settings.bqLocation;
371
+ }
372
+ if (pageToken !== void 0) {
373
+ body["pageToken"] = pageToken;
374
+ }
375
+ const res = await this.post(url, {
376
+ resource,
377
+ headers: {
378
+ Authorization: `Bearer ${accessToken}`,
379
+ "Content-Type": "application/json",
380
+ "User-Agent": connectorUserAgent(this.id)
381
+ },
382
+ body: JSON.stringify(body),
383
+ signal
384
+ });
385
+ return res.body;
386
+ }
387
+ isResourceActive(resource, options) {
388
+ if (!options.resources) {
389
+ return true;
390
+ }
391
+ return options.resources.has(resource);
392
+ }
393
+ async sync(options, storage, signal) {
394
+ const dataset = this.settings.bqDataset ?? DEFAULT_BQ_DATASET;
395
+ const window = getCrashlyticsWindow(
396
+ options,
397
+ this.settings.lookbackDays ?? DEFAULT_LOOKBACK_DAYS
398
+ );
399
+ const topIssuesLimit = this.settings.topIssuesLimit ?? DEFAULT_TOP_ISSUES_LIMIT;
400
+ if (this.isResourceActive(CRASHES_METRIC_NAME, options)) {
401
+ if (signal?.aborted) {
402
+ return { done: false };
403
+ }
404
+ const sql = buildCrashesPerDaySql({
405
+ projectId: this.settings.projectId,
406
+ bqDataset: dataset,
407
+ startDate: window.startDate,
408
+ endDate: window.endDate
409
+ });
410
+ const samples = await this.collectSamples(sql, signal);
411
+ if (signal?.aborted) {
412
+ return { done: false };
413
+ }
414
+ await storage.metrics(samples, { names: [CRASHES_METRIC_NAME] });
415
+ }
416
+ if (this.isResourceActive("top_issues", options)) {
417
+ if (signal?.aborted) {
418
+ return { done: false };
419
+ }
420
+ const sql = buildTopIssuesSql({
421
+ projectId: this.settings.projectId,
422
+ bqDataset: dataset,
423
+ startDate: window.startDate,
424
+ endDate: window.endDate,
425
+ limit: topIssuesLimit
426
+ });
427
+ const entities = await this.collectIssues(sql, signal);
428
+ if (signal?.aborted) {
429
+ return { done: false };
430
+ }
431
+ await storage.entities(entities, { types: [TOP_ISSUES_ENTITY_TYPE] });
432
+ }
433
+ return { done: true };
434
+ }
435
+ 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", {
479
+ resource: CRASHES_METRIC_NAME,
480
+ pages: page,
481
+ items: samples.length,
482
+ duration_ms: Date.now() - phaseStart
483
+ });
484
+ return samples;
485
+ }
486
+ 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", {
530
+ resource: "top_issues",
531
+ pages: page,
532
+ items: entities.length,
533
+ duration_ms: Date.now() - phaseStart
534
+ });
535
+ return entities;
536
+ }
537
+ };
538
+ function buildCrashesPerDaySql(args) {
539
+ const table = `\`${args.projectId}.${args.bqDataset}.*\``;
540
+ return [
541
+ "WITH events AS (",
542
+ " SELECT",
543
+ " DATE(event_timestamp) AS date,",
544
+ " application.bundle_id AS app_id,",
545
+ " LOWER(IFNULL(application.platform, 'unknown')) AS platform,",
546
+ " application.app_display_version AS version,",
547
+ " user.id AS user_id,",
548
+ " is_fatal",
549
+ ` FROM ${table}`,
550
+ ` WHERE DATE(event_timestamp) >= DATE('${args.startDate}')`,
551
+ ` AND DATE(event_timestamp) < DATE('${args.endDate}')`,
552
+ ")",
553
+ "SELECT",
554
+ " date,",
555
+ " app_id,",
556
+ " platform,",
557
+ " version,",
558
+ " COUNTIF(is_fatal) AS crashes,",
559
+ " COUNT(DISTINCT IF(is_fatal, user_id, NULL)) AS crashing_users,",
560
+ " COUNT(DISTINCT user_id) AS total_users",
561
+ "FROM events",
562
+ "GROUP BY date, app_id, platform, version",
563
+ "ORDER BY date"
564
+ ].join("\n");
565
+ }
566
+ function buildTopIssuesSql(args) {
567
+ const table = `\`${args.projectId}.${args.bqDataset}.*\``;
568
+ return [
569
+ "SELECT",
570
+ " issue_id,",
571
+ " ANY_VALUE(issue_title HAVING MAX event_timestamp) AS title,",
572
+ " ANY_VALUE(issue_subtitle HAVING MAX event_timestamp) AS subtitle,",
573
+ " ANY_VALUE(application.bundle_id HAVING MAX event_timestamp) AS app_id,",
574
+ " LOWER(IFNULL(ANY_VALUE(application.platform HAVING MAX event_timestamp), 'unknown')) AS platform,",
575
+ " COUNT(*) AS event_count,",
576
+ " COUNT(DISTINCT user.id) AS user_count,",
577
+ " MAX(event_timestamp) AS last_seen",
578
+ `FROM ${table}`,
579
+ `WHERE DATE(event_timestamp) >= DATE('${args.startDate}')`,
580
+ ` AND DATE(event_timestamp) < DATE('${args.endDate}')`,
581
+ " AND issue_id IS NOT NULL",
582
+ "GROUP BY issue_id",
583
+ "ORDER BY event_count DESC",
584
+ `LIMIT ${args.limit}`
585
+ ].join("\n");
586
+ }
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
+ function getCrashlyticsWindow(options, lookbackDays, now = Date.now()) {
598
+ const endMs = startOfUtcDay(now) + MS_PER_DAY;
599
+ let days = lookbackDays;
600
+ if (options.mode === "latest") {
601
+ days = INCREMENTAL_LOOKBACK_DAYS;
602
+ } else if (options.since !== void 0) {
603
+ const sinceMs = parseEpoch(options.since, "iso");
604
+ if (sinceMs !== null) {
605
+ const elapsed = Math.ceil((now - sinceMs) / MS_PER_DAY);
606
+ days = Math.min(
607
+ Math.max(elapsed + INCREMENTAL_LOOKBACK_DAYS, 1),
608
+ lookbackDays
609
+ );
610
+ }
611
+ }
612
+ return {
613
+ startDate: toDateStr(endMs - days * MS_PER_DAY),
614
+ endDate: toDateStr(endMs)
615
+ };
616
+ }
617
+ function buildCrashesSamplesFromBqResponse(response) {
618
+ const schema = response.schema?.fields ?? [];
619
+ const fieldIndex = {};
620
+ schema.forEach((field, idx) => {
621
+ fieldIndex[field.name] = idx;
622
+ });
623
+ const samples = [];
624
+ for (const row of response.rows ?? []) {
625
+ const dateValue = readCell(row.f, fieldIndex, "date");
626
+ if (dateValue === null) {
627
+ continue;
628
+ }
629
+ const ts = parseBqDateOrEpoch(dateValue);
630
+ if (ts === null) {
631
+ continue;
632
+ }
633
+ const crashesRaw = readCell(row.f, fieldIndex, "crashes");
634
+ if (crashesRaw === null) {
635
+ continue;
636
+ }
637
+ const crashes = Number.parseFloat(crashesRaw);
638
+ if (!Number.isFinite(crashes)) {
639
+ continue;
640
+ }
641
+ const crashingUsersRaw = readCell(row.f, fieldIndex, "crashing_users");
642
+ const totalUsersRaw = readCell(row.f, fieldIndex, "total_users");
643
+ const crashingUsers = crashingUsersRaw !== null ? Number.parseFloat(crashingUsersRaw) : NaN;
644
+ const totalUsers = totalUsersRaw !== null ? Number.parseFloat(totalUsersRaw) : NaN;
645
+ let crashFreeRate = null;
646
+ if (Number.isFinite(totalUsers) && totalUsers > 0 && Number.isFinite(crashingUsers)) {
647
+ const rate = 1 - crashingUsers / totalUsers;
648
+ crashFreeRate = Math.max(0, Math.min(1, rate));
649
+ }
650
+ 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");
654
+ attributes["app_id"] = appId;
655
+ attributes["platform"] = platform;
656
+ attributes["version"] = version;
657
+ attributes["crash_free_user_rate"] = crashFreeRate;
658
+ attributes["crashing_users"] = Number.isFinite(crashingUsers) ? crashingUsers : null;
659
+ samples.push({
660
+ name: CRASHES_METRIC_NAME,
661
+ ts,
662
+ value: crashes,
663
+ attributes
664
+ });
665
+ }
666
+ return samples;
667
+ }
668
+ function buildTopIssuesEntitiesFromBqResponse(response) {
669
+ const schema = response.schema?.fields ?? [];
670
+ const fieldIndex = {};
671
+ schema.forEach((field, idx) => {
672
+ fieldIndex[field.name] = idx;
673
+ });
674
+ const entities = [];
675
+ for (const row of response.rows ?? []) {
676
+ const issueId = readCell(row.f, fieldIndex, "issue_id");
677
+ if (issueId === null || issueId.length === 0) {
678
+ continue;
679
+ }
680
+ const eventCountRaw = readCell(row.f, fieldIndex, "event_count");
681
+ const userCountRaw = readCell(row.f, fieldIndex, "user_count");
682
+ const eventCount = eventCountRaw !== null ? Number.parseFloat(eventCountRaw) : NaN;
683
+ const userCount = userCountRaw !== null ? Number.parseFloat(userCountRaw) : NaN;
684
+ const lastSeenRaw = readCell(row.f, fieldIndex, "last_seen");
685
+ const lastSeenMs = lastSeenRaw !== null ? parseBqDateOrEpoch(lastSeenRaw) : null;
686
+ const updatedAt = lastSeenMs ?? Date.now();
687
+ const attributes = {
688
+ 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"),
693
+ event_count: Number.isFinite(eventCount) ? eventCount : 0,
694
+ user_count: Number.isFinite(userCount) ? userCount : 0,
695
+ last_seen: lastSeenMs !== null ? new Date(lastSeenMs).toISOString() : null
696
+ };
697
+ entities.push({
698
+ type: TOP_ISSUES_ENTITY_TYPE,
699
+ id: issueId,
700
+ attributes,
701
+ updated_at: updatedAt
702
+ });
703
+ }
704
+ return entities;
705
+ }
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
+
729
+ // src/index.ts
730
+ var index_default = FirebaseCrashlyticsConnector;
731
+ export {
732
+ FirebaseCrashlyticsConnector,
733
+ buildCrashesPerDaySql,
734
+ buildCrashesSamplesFromBqResponse,
735
+ buildTopIssuesEntitiesFromBqResponse,
736
+ buildTopIssuesSql,
737
+ configFields,
738
+ index_default as default,
739
+ doc,
740
+ getCrashlyticsWindow,
741
+ id,
742
+ firebaseCrashlyticsResources as resources
743
+ };
744
+ //# sourceMappingURL=index.js.map