@rawdash/connector-firebase-analytics 0.1.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/README.md +117 -0
- package/dist/index.d.ts +298 -0
- package/dist/index.js +548 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<!-- This file is generated from connector metadata by scripts/generate-connector-docs.ts. Do not edit by hand. -->
|
|
2
|
+
|
|
3
|
+
# @rawdash/connector-firebase-analytics
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@rawdash/connector-firebase-analytics)
|
|
6
|
+
[](https://github.com/rawdash/rawdash/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
Sync DAU/WAU/MAU, per-event activity, and cohort retention from a Firebase project via the GA4 Data API.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @rawdash/connector-firebase-analytics
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Authentication
|
|
17
|
+
|
|
18
|
+
Firebase Analytics data is exposed through the linked GA4 property. Authenticate against the GA4 Data API with either a Google service account JSON key (recommended) or an OAuth 2.0 refresh-token tuple. The identity must have at least the Analytics Viewer role on the property.
|
|
19
|
+
|
|
20
|
+
1. In Firebase Console -> Project settings -> Integrations -> Google Analytics, note the linked GA4 property and copy its numeric Property ID from Google Analytics -> Admin -> Property settings.
|
|
21
|
+
2. In Firebase Console -> Project settings -> General -> Your apps, copy the Firebase App ID for the app whose analytics you want to sync.
|
|
22
|
+
3. Recommended: create a service account at Google Cloud -> IAM & Admin -> Service Accounts, generate a JSON key, and grant it the Analytics Viewer role on the GA4 property. Store the JSON as a secret and reference it as serviceAccountJson: secret("FIREBASE_ANALYTICS_SA_JSON").
|
|
23
|
+
4. Alternative: provide an OAuth 2.0 refresh token with the analytics.readonly scope together with its clientId and clientSecret from the Google Cloud Console.
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
| Field | Type | Required | Description |
|
|
28
|
+
| -------------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
|
+
| `propertyId` | string | Yes | Numeric ID of the GA4 property linked to your Firebase project (e.g. 123456789). Find it in Google Analytics -> Admin -> Property settings. |
|
|
30
|
+
| `firebaseAppId` | string | Yes | Firebase App ID for the app whose analytics you are syncing (e.g. 1:1234567890:web:abcdef). Find it in Firebase Console -> Project settings -> General -> Your apps. Used to label samples with the source app. |
|
|
31
|
+
| `serviceAccountJson` | secret | No | Contents of the JSON key file for a Google service account with the Firebase Viewer + Analytics Viewer roles. Create one at Google Cloud -> IAM & Admin -> Service Accounts. |
|
|
32
|
+
| `refreshToken` | secret | No | Google OAuth 2.0 refresh token with the analytics.readonly scope. Required if not using serviceAccountJson. |
|
|
33
|
+
| `clientId` | string | No | OAuth 2.0 client ID from Google Cloud Console. Required when using refreshToken auth. |
|
|
34
|
+
| `clientSecret` | secret | No | OAuth 2.0 client secret from Google Cloud Console. Required when using refreshToken auth. |
|
|
35
|
+
| `lookbackDays` | number | No | How many calendar days to fetch on a full sync. Defaults to 90. |
|
|
36
|
+
|
|
37
|
+
## Resources
|
|
38
|
+
|
|
39
|
+
- **`firebase_dau_wau_mau`** _(metric)_ - Daily active, weekly active, and monthly active user counts for the linked GA4 property.
|
|
40
|
+
- Endpoint: `POST /v1beta/properties/{propertyId}:runReport`
|
|
41
|
+
- Unit: users
|
|
42
|
+
- Granularity: day
|
|
43
|
+
- Dimensions: `date`
|
|
44
|
+
- **`firebase_events_per_day`** _(metric)_ - Daily event counts and the active users that triggered them, bucketed by event name.
|
|
45
|
+
- Endpoint: `POST /v1beta/properties/{propertyId}:runReport`
|
|
46
|
+
- Unit: events
|
|
47
|
+
- Granularity: day
|
|
48
|
+
- Dimensions: `date`, `eventName`
|
|
49
|
+
- **`firebase_retention`** _(metric)_ - Active users on each day grouped by the date of their first session (cohort retention).
|
|
50
|
+
- Endpoint: `POST /v1beta/properties/{propertyId}:runReport`
|
|
51
|
+
- Unit: users
|
|
52
|
+
- Granularity: day
|
|
53
|
+
- Dimensions: `firstSessionDate`, `date`
|
|
54
|
+
- Each sample also carries a `period` attribute equal to (date - firstSessionDate) in days, so retention curves can be built by grouping on it.
|
|
55
|
+
|
|
56
|
+
## Example
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import {
|
|
60
|
+
defineConfig,
|
|
61
|
+
defineDashboard,
|
|
62
|
+
defineMetric,
|
|
63
|
+
secret,
|
|
64
|
+
} from '@rawdash/core';
|
|
65
|
+
|
|
66
|
+
const firebaseAnalytics = {
|
|
67
|
+
name: 'firebaseAnalytics',
|
|
68
|
+
connectorId: 'firebase-analytics',
|
|
69
|
+
config: {
|
|
70
|
+
propertyId: '123456789',
|
|
71
|
+
firebaseAppId: '1:1234567890:web:abcdef1234567890',
|
|
72
|
+
serviceAccountJson: secret('FIREBASE_ANALYTICS_SA_JSON'),
|
|
73
|
+
lookbackDays: 90,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export default defineConfig({
|
|
78
|
+
connectors: [firebaseAnalytics],
|
|
79
|
+
dashboards: {
|
|
80
|
+
engagement: defineDashboard({
|
|
81
|
+
widgets: {
|
|
82
|
+
dau: {
|
|
83
|
+
kind: 'timeseries',
|
|
84
|
+
title: 'Daily active users',
|
|
85
|
+
window: '30d',
|
|
86
|
+
metric: defineMetric({
|
|
87
|
+
connector: firebaseAnalytics,
|
|
88
|
+
shape: 'metric',
|
|
89
|
+
name: 'firebase_dau_wau_mau',
|
|
90
|
+
fn: 'sum',
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Rate limits
|
|
100
|
+
|
|
101
|
+
GA4 Data API quota is 200,000 tokens/day per property (default); 429 responses are retried automatically with exponential backoff.
|
|
102
|
+
|
|
103
|
+
## Limitations
|
|
104
|
+
|
|
105
|
+
- Incremental syncs use a 30-day window because GA4 can attribute events up to 3 days after they occur.
|
|
106
|
+
- Report pagination is 10,000 rows per page.
|
|
107
|
+
- The firebaseAppId is recorded on every sample but does not filter the report; ensure your GA4 property only contains the app you intend to sync.
|
|
108
|
+
|
|
109
|
+
## Links
|
|
110
|
+
|
|
111
|
+
- [Rawdash docs](https://rawdash.dev/docs/connectors/)
|
|
112
|
+
- [Firebase Analytics API docs](https://developers.google.com/analytics/devguides/reporting/data/v1)
|
|
113
|
+
- [GitHub](https://github.com/rawdash/rawdash)
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
Apache-2.0
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { BaseConnector, ConnectorContext, SyncOptions, StorageHandle, SyncResult, ConnectorDoc } from '@rawdash/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
declare const configFields: z.ZodObject<{
|
|
5
|
+
propertyId: z.ZodString;
|
|
6
|
+
firebaseAppId: z.ZodString;
|
|
7
|
+
serviceAccountJson: z.ZodOptional<z.ZodObject<{
|
|
8
|
+
$secret: z.ZodString;
|
|
9
|
+
}, z.core.$strip>>;
|
|
10
|
+
refreshToken: z.ZodOptional<z.ZodObject<{
|
|
11
|
+
$secret: z.ZodString;
|
|
12
|
+
}, z.core.$strip>>;
|
|
13
|
+
clientId: z.ZodOptional<z.ZodString>;
|
|
14
|
+
clientSecret: z.ZodOptional<z.ZodObject<{
|
|
15
|
+
$secret: z.ZodString;
|
|
16
|
+
}, z.core.$strip>>;
|
|
17
|
+
lookbackDays: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
declare const doc: ConnectorDoc;
|
|
20
|
+
interface FirebaseAnalyticsSettings {
|
|
21
|
+
propertyId: string;
|
|
22
|
+
firebaseAppId: string;
|
|
23
|
+
lookbackDays?: number;
|
|
24
|
+
}
|
|
25
|
+
declare const firebaseAnalyticsCredentials: {
|
|
26
|
+
serviceAccountJson: {
|
|
27
|
+
description: string;
|
|
28
|
+
auth: "optional";
|
|
29
|
+
};
|
|
30
|
+
refreshToken: {
|
|
31
|
+
description: string;
|
|
32
|
+
auth: "optional";
|
|
33
|
+
};
|
|
34
|
+
clientId: {
|
|
35
|
+
description: string;
|
|
36
|
+
auth: "optional";
|
|
37
|
+
};
|
|
38
|
+
clientSecret: {
|
|
39
|
+
description: string;
|
|
40
|
+
auth: "optional";
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
type FirebaseAnalyticsCredentials = typeof firebaseAnalyticsCredentials;
|
|
44
|
+
interface FAReportDimensionValue {
|
|
45
|
+
value: string;
|
|
46
|
+
}
|
|
47
|
+
interface FAReportMetricValue {
|
|
48
|
+
value: string;
|
|
49
|
+
}
|
|
50
|
+
interface FAReportRow {
|
|
51
|
+
dimensionValues: FAReportDimensionValue[];
|
|
52
|
+
metricValues: FAReportMetricValue[];
|
|
53
|
+
}
|
|
54
|
+
declare function rowToMetricSample(row: FAReportRow, dimensionHeaders: string[], metricHeaders: string[], metricName: string, firebaseAppId: string): {
|
|
55
|
+
name: string;
|
|
56
|
+
ts: number;
|
|
57
|
+
value: number;
|
|
58
|
+
attributes: Record<string, string | number>;
|
|
59
|
+
};
|
|
60
|
+
declare const firebaseAnalyticsResources: {
|
|
61
|
+
readonly firebase_dau_wau_mau: {
|
|
62
|
+
readonly shape: "metric";
|
|
63
|
+
readonly description: "Daily active, weekly active, and monthly active user counts for the linked GA4 property.";
|
|
64
|
+
readonly unit: "users";
|
|
65
|
+
readonly granularity: "day";
|
|
66
|
+
readonly endpoint: "POST /v1beta/properties/{propertyId}:runReport";
|
|
67
|
+
readonly dimensions: [{
|
|
68
|
+
readonly name: "date";
|
|
69
|
+
readonly description: "Calendar day of the metric sample.";
|
|
70
|
+
}];
|
|
71
|
+
readonly responses: {
|
|
72
|
+
readonly oauth_token: z.ZodObject<{
|
|
73
|
+
access_token: z.ZodString;
|
|
74
|
+
expires_in: z.ZodOptional<z.ZodNumber>;
|
|
75
|
+
}, z.core.$strip>;
|
|
76
|
+
readonly dau_wau_mau: z.ZodObject<{
|
|
77
|
+
rows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
78
|
+
dimensionValues: z.ZodTuple<[z.ZodObject<{
|
|
79
|
+
value: z.ZodString;
|
|
80
|
+
}, z.core.$strip>, ...z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>[]], null>;
|
|
81
|
+
metricValues: z.ZodArray<z.ZodObject<{
|
|
82
|
+
value: z.ZodString;
|
|
83
|
+
}, z.core.$strip>>;
|
|
84
|
+
}, z.core.$strip>>>;
|
|
85
|
+
}, z.core.$strip>;
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
readonly firebase_events_per_day: {
|
|
89
|
+
readonly shape: "metric";
|
|
90
|
+
readonly description: "Daily event counts and the active users that triggered them, bucketed by event name.";
|
|
91
|
+
readonly unit: "events";
|
|
92
|
+
readonly granularity: "day";
|
|
93
|
+
readonly endpoint: "POST /v1beta/properties/{propertyId}:runReport";
|
|
94
|
+
readonly dimensions: [{
|
|
95
|
+
readonly name: "date";
|
|
96
|
+
readonly description: "Calendar day of the metric sample.";
|
|
97
|
+
}, {
|
|
98
|
+
readonly name: "eventName";
|
|
99
|
+
readonly description: "GA4 event name (e.g. session_start, first_open, login).";
|
|
100
|
+
}];
|
|
101
|
+
readonly responses: {
|
|
102
|
+
readonly events_per_day: z.ZodObject<{
|
|
103
|
+
rows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
104
|
+
dimensionValues: z.ZodTuple<[z.ZodObject<{
|
|
105
|
+
value: z.ZodString;
|
|
106
|
+
}, z.core.$strip>, ...z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>[]], null>;
|
|
107
|
+
metricValues: z.ZodArray<z.ZodObject<{
|
|
108
|
+
value: z.ZodString;
|
|
109
|
+
}, z.core.$strip>>;
|
|
110
|
+
}, z.core.$strip>>>;
|
|
111
|
+
}, z.core.$strip>;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
readonly firebase_retention: {
|
|
115
|
+
readonly shape: "metric";
|
|
116
|
+
readonly description: "Active users on each day grouped by the date of their first session (cohort retention).";
|
|
117
|
+
readonly unit: "users";
|
|
118
|
+
readonly granularity: "day";
|
|
119
|
+
readonly endpoint: "POST /v1beta/properties/{propertyId}:runReport";
|
|
120
|
+
readonly dimensions: [{
|
|
121
|
+
readonly name: "firstSessionDate";
|
|
122
|
+
readonly description: "Calendar day on which the user first opened the app.";
|
|
123
|
+
}, {
|
|
124
|
+
readonly name: "date";
|
|
125
|
+
readonly description: "Calendar day on which the user was active.";
|
|
126
|
+
}];
|
|
127
|
+
readonly notes: "Each sample also carries a `period` attribute equal to (date - firstSessionDate) in days, so retention curves can be built by grouping on it.";
|
|
128
|
+
readonly responses: {
|
|
129
|
+
readonly retention: z.ZodObject<{
|
|
130
|
+
rows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
131
|
+
dimensionValues: z.ZodTuple<[z.ZodObject<{
|
|
132
|
+
value: z.ZodString;
|
|
133
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
134
|
+
value: z.ZodString;
|
|
135
|
+
}, z.core.$strip>], null>;
|
|
136
|
+
metricValues: z.ZodArray<z.ZodObject<{
|
|
137
|
+
value: z.ZodString;
|
|
138
|
+
}, z.core.$strip>>;
|
|
139
|
+
}, z.core.$strip>>>;
|
|
140
|
+
}, z.core.$strip>;
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
declare const id = "firebase-analytics";
|
|
145
|
+
declare class FirebaseAnalyticsConnector extends BaseConnector<FirebaseAnalyticsSettings, FirebaseAnalyticsCredentials> {
|
|
146
|
+
static readonly id = "firebase-analytics";
|
|
147
|
+
static readonly resources: {
|
|
148
|
+
readonly firebase_dau_wau_mau: {
|
|
149
|
+
readonly shape: "metric";
|
|
150
|
+
readonly description: "Daily active, weekly active, and monthly active user counts for the linked GA4 property.";
|
|
151
|
+
readonly unit: "users";
|
|
152
|
+
readonly granularity: "day";
|
|
153
|
+
readonly endpoint: "POST /v1beta/properties/{propertyId}:runReport";
|
|
154
|
+
readonly dimensions: [{
|
|
155
|
+
readonly name: "date";
|
|
156
|
+
readonly description: "Calendar day of the metric sample.";
|
|
157
|
+
}];
|
|
158
|
+
readonly responses: {
|
|
159
|
+
readonly oauth_token: z.ZodObject<{
|
|
160
|
+
access_token: z.ZodString;
|
|
161
|
+
expires_in: z.ZodOptional<z.ZodNumber>;
|
|
162
|
+
}, z.core.$strip>;
|
|
163
|
+
readonly dau_wau_mau: z.ZodObject<{
|
|
164
|
+
rows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
165
|
+
dimensionValues: z.ZodTuple<[z.ZodObject<{
|
|
166
|
+
value: z.ZodString;
|
|
167
|
+
}, z.core.$strip>, ...z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>[]], null>;
|
|
168
|
+
metricValues: z.ZodArray<z.ZodObject<{
|
|
169
|
+
value: z.ZodString;
|
|
170
|
+
}, z.core.$strip>>;
|
|
171
|
+
}, z.core.$strip>>>;
|
|
172
|
+
}, z.core.$strip>;
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
readonly firebase_events_per_day: {
|
|
176
|
+
readonly shape: "metric";
|
|
177
|
+
readonly description: "Daily event counts and the active users that triggered them, bucketed by event name.";
|
|
178
|
+
readonly unit: "events";
|
|
179
|
+
readonly granularity: "day";
|
|
180
|
+
readonly endpoint: "POST /v1beta/properties/{propertyId}:runReport";
|
|
181
|
+
readonly dimensions: [{
|
|
182
|
+
readonly name: "date";
|
|
183
|
+
readonly description: "Calendar day of the metric sample.";
|
|
184
|
+
}, {
|
|
185
|
+
readonly name: "eventName";
|
|
186
|
+
readonly description: "GA4 event name (e.g. session_start, first_open, login).";
|
|
187
|
+
}];
|
|
188
|
+
readonly responses: {
|
|
189
|
+
readonly events_per_day: z.ZodObject<{
|
|
190
|
+
rows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
191
|
+
dimensionValues: z.ZodTuple<[z.ZodObject<{
|
|
192
|
+
value: z.ZodString;
|
|
193
|
+
}, z.core.$strip>, ...z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>[]], null>;
|
|
194
|
+
metricValues: z.ZodArray<z.ZodObject<{
|
|
195
|
+
value: z.ZodString;
|
|
196
|
+
}, z.core.$strip>>;
|
|
197
|
+
}, z.core.$strip>>>;
|
|
198
|
+
}, z.core.$strip>;
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
readonly firebase_retention: {
|
|
202
|
+
readonly shape: "metric";
|
|
203
|
+
readonly description: "Active users on each day grouped by the date of their first session (cohort retention).";
|
|
204
|
+
readonly unit: "users";
|
|
205
|
+
readonly granularity: "day";
|
|
206
|
+
readonly endpoint: "POST /v1beta/properties/{propertyId}:runReport";
|
|
207
|
+
readonly dimensions: [{
|
|
208
|
+
readonly name: "firstSessionDate";
|
|
209
|
+
readonly description: "Calendar day on which the user first opened the app.";
|
|
210
|
+
}, {
|
|
211
|
+
readonly name: "date";
|
|
212
|
+
readonly description: "Calendar day on which the user was active.";
|
|
213
|
+
}];
|
|
214
|
+
readonly notes: "Each sample also carries a `period` attribute equal to (date - firstSessionDate) in days, so retention curves can be built by grouping on it.";
|
|
215
|
+
readonly responses: {
|
|
216
|
+
readonly retention: z.ZodObject<{
|
|
217
|
+
rows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
218
|
+
dimensionValues: z.ZodTuple<[z.ZodObject<{
|
|
219
|
+
value: z.ZodString;
|
|
220
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
221
|
+
value: z.ZodString;
|
|
222
|
+
}, z.core.$strip>], null>;
|
|
223
|
+
metricValues: z.ZodArray<z.ZodObject<{
|
|
224
|
+
value: z.ZodString;
|
|
225
|
+
}, z.core.$strip>>;
|
|
226
|
+
}, z.core.$strip>>>;
|
|
227
|
+
}, z.core.$strip>;
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
static readonly schemas: {
|
|
232
|
+
readonly oauth_token: z.ZodObject<{
|
|
233
|
+
access_token: z.ZodString;
|
|
234
|
+
expires_in: z.ZodOptional<z.ZodNumber>;
|
|
235
|
+
}, z.core.$strip>;
|
|
236
|
+
readonly dau_wau_mau: z.ZodObject<{
|
|
237
|
+
rows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
238
|
+
dimensionValues: z.ZodTuple<[z.ZodObject<{
|
|
239
|
+
value: z.ZodString;
|
|
240
|
+
}, z.core.$strip>, ...z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>[]], null>;
|
|
241
|
+
metricValues: z.ZodArray<z.ZodObject<{
|
|
242
|
+
value: z.ZodString;
|
|
243
|
+
}, z.core.$strip>>;
|
|
244
|
+
}, z.core.$strip>>>;
|
|
245
|
+
}, z.core.$strip>;
|
|
246
|
+
} & {
|
|
247
|
+
readonly events_per_day: z.ZodObject<{
|
|
248
|
+
rows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
249
|
+
dimensionValues: z.ZodTuple<[z.ZodObject<{
|
|
250
|
+
value: z.ZodString;
|
|
251
|
+
}, z.core.$strip>, ...z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>[]], null>;
|
|
252
|
+
metricValues: z.ZodArray<z.ZodObject<{
|
|
253
|
+
value: z.ZodString;
|
|
254
|
+
}, z.core.$strip>>;
|
|
255
|
+
}, z.core.$strip>>>;
|
|
256
|
+
}, z.core.$strip>;
|
|
257
|
+
} & {
|
|
258
|
+
readonly retention: z.ZodObject<{
|
|
259
|
+
rows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
260
|
+
dimensionValues: z.ZodTuple<[z.ZodObject<{
|
|
261
|
+
value: z.ZodString;
|
|
262
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
263
|
+
value: z.ZodString;
|
|
264
|
+
}, z.core.$strip>], null>;
|
|
265
|
+
metricValues: z.ZodArray<z.ZodObject<{
|
|
266
|
+
value: z.ZodString;
|
|
267
|
+
}, z.core.$strip>>;
|
|
268
|
+
}, z.core.$strip>>>;
|
|
269
|
+
}, z.core.$strip>;
|
|
270
|
+
} & Readonly<Record<string, z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>>;
|
|
271
|
+
static create(input: unknown, ctx?: ConnectorContext): FirebaseAnalyticsConnector;
|
|
272
|
+
readonly id = "firebase-analytics";
|
|
273
|
+
readonly credentials: {
|
|
274
|
+
serviceAccountJson: {
|
|
275
|
+
description: string;
|
|
276
|
+
auth: "optional";
|
|
277
|
+
};
|
|
278
|
+
refreshToken: {
|
|
279
|
+
description: string;
|
|
280
|
+
auth: "optional";
|
|
281
|
+
};
|
|
282
|
+
clientId: {
|
|
283
|
+
description: string;
|
|
284
|
+
auth: "optional";
|
|
285
|
+
};
|
|
286
|
+
clientSecret: {
|
|
287
|
+
description: string;
|
|
288
|
+
auth: "optional";
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
private cachedToken;
|
|
292
|
+
private fetchOAuthToken;
|
|
293
|
+
private getAccessToken;
|
|
294
|
+
private runReport;
|
|
295
|
+
sync(options: SyncOptions, storage: StorageHandle, signal?: AbortSignal): Promise<SyncResult>;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export { FirebaseAnalyticsConnector, type FirebaseAnalyticsSettings, configFields, FirebaseAnalyticsConnector as default, doc, id, firebaseAnalyticsResources as resources, rowToMetricSample };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
// ../../connector-shared/dist/index.js
|
|
2
|
+
var HTTP_CLIENT_VERSION = "0.0.0";
|
|
3
|
+
var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
|
|
4
|
+
function connectorUserAgent(connectorId) {
|
|
5
|
+
return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// src/firebase-analytics.ts
|
|
9
|
+
import {
|
|
10
|
+
BaseConnector,
|
|
11
|
+
defineConfigFields,
|
|
12
|
+
defineConnectorDoc,
|
|
13
|
+
defineResources,
|
|
14
|
+
schemasFromResources
|
|
15
|
+
} from "@rawdash/core";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
var configFields = defineConfigFields(
|
|
18
|
+
z.object({
|
|
19
|
+
propertyId: z.string().trim().regex(/^\d+$/, "GA4 Property ID must be digits only").meta({
|
|
20
|
+
label: "GA4 Property ID",
|
|
21
|
+
description: "Numeric ID of the GA4 property linked to your Firebase project (e.g. 123456789). Find it in Google Analytics -> Admin -> Property settings.",
|
|
22
|
+
placeholder: "123456789"
|
|
23
|
+
}),
|
|
24
|
+
firebaseAppId: z.string().trim().min(1, "Firebase App ID is required").meta({
|
|
25
|
+
label: "Firebase App ID",
|
|
26
|
+
description: "Firebase App ID for the app whose analytics you are syncing (e.g. 1:1234567890:web:abcdef). Find it in Firebase Console -> Project settings -> General -> Your apps. Used to label samples with the source app.",
|
|
27
|
+
placeholder: "1:1234567890:web:abcdef"
|
|
28
|
+
}),
|
|
29
|
+
serviceAccountJson: z.object({ $secret: z.string() }).optional().meta({
|
|
30
|
+
label: "Service Account JSON (recommended)",
|
|
31
|
+
description: "Contents of the JSON key file for a Google service account with the Firebase Viewer + Analytics Viewer roles. Create one at Google Cloud -> IAM & Admin -> Service Accounts.",
|
|
32
|
+
secret: true
|
|
33
|
+
}),
|
|
34
|
+
refreshToken: z.object({ $secret: z.string() }).optional().meta({
|
|
35
|
+
label: "OAuth Refresh Token",
|
|
36
|
+
description: "Google OAuth 2.0 refresh token with the analytics.readonly scope. Required if not using serviceAccountJson.",
|
|
37
|
+
secret: true
|
|
38
|
+
}),
|
|
39
|
+
clientId: z.string().optional().meta({
|
|
40
|
+
label: "OAuth Client ID",
|
|
41
|
+
description: "OAuth 2.0 client ID from Google Cloud Console. Required when using refreshToken auth.",
|
|
42
|
+
placeholder: "...apps.googleusercontent.com"
|
|
43
|
+
}),
|
|
44
|
+
clientSecret: z.object({ $secret: z.string() }).optional().meta({
|
|
45
|
+
label: "OAuth Client Secret",
|
|
46
|
+
description: "OAuth 2.0 client secret from Google Cloud Console. Required when using refreshToken auth.",
|
|
47
|
+
secret: true
|
|
48
|
+
}),
|
|
49
|
+
lookbackDays: z.number().int().positive().optional().meta({
|
|
50
|
+
label: "Lookback days (full sync)",
|
|
51
|
+
description: "How many calendar days to fetch on a full sync. Defaults to 90.",
|
|
52
|
+
placeholder: "90"
|
|
53
|
+
})
|
|
54
|
+
}).refine(
|
|
55
|
+
(val) => val.serviceAccountJson !== void 0 || val.refreshToken !== void 0 && val.clientId !== void 0 && val.clientSecret !== void 0,
|
|
56
|
+
{
|
|
57
|
+
message: "Provide either serviceAccountJson or the full OAuth tuple (refreshToken + clientId + clientSecret)"
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
var doc = defineConnectorDoc({
|
|
62
|
+
displayName: "Firebase Analytics",
|
|
63
|
+
category: "product",
|
|
64
|
+
brandColor: "#DD2C00",
|
|
65
|
+
tagline: "Sync DAU/WAU/MAU, per-event activity, and cohort retention from a Firebase project via the GA4 Data API.",
|
|
66
|
+
vendor: {
|
|
67
|
+
name: "Firebase Analytics",
|
|
68
|
+
domain: "firebase.google.com",
|
|
69
|
+
apiDocs: "https://developers.google.com/analytics/devguides/reporting/data/v1",
|
|
70
|
+
website: "https://firebase.google.com/products/analytics"
|
|
71
|
+
},
|
|
72
|
+
auth: {
|
|
73
|
+
summary: "Firebase Analytics data is exposed through the linked GA4 property. Authenticate against the GA4 Data API with either a Google service account JSON key (recommended) or an OAuth 2.0 refresh-token tuple. The identity must have at least the Analytics Viewer role on the property.",
|
|
74
|
+
setup: [
|
|
75
|
+
"In Firebase Console -> Project settings -> Integrations -> Google Analytics, note the linked GA4 property and copy its numeric Property ID from Google Analytics -> Admin -> Property settings.",
|
|
76
|
+
"In Firebase Console -> Project settings -> General -> Your apps, copy the Firebase App ID for the app whose analytics you want to sync.",
|
|
77
|
+
'Recommended: create a service account at Google Cloud -> IAM & Admin -> Service Accounts, generate a JSON key, and grant it the Analytics Viewer role on the GA4 property. Store the JSON as a secret and reference it as serviceAccountJson: secret("FIREBASE_ANALYTICS_SA_JSON").',
|
|
78
|
+
"Alternative: provide an OAuth 2.0 refresh token with the analytics.readonly scope together with its clientId and clientSecret from the Google Cloud Console."
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
rateLimit: "GA4 Data API quota is 200,000 tokens/day per property (default); 429 responses are retried automatically with exponential backoff.",
|
|
82
|
+
limitations: [
|
|
83
|
+
"Incremental syncs use a 30-day window because GA4 can attribute events up to 3 days after they occur.",
|
|
84
|
+
"Report pagination is 10,000 rows per page.",
|
|
85
|
+
"The firebaseAppId is recorded on every sample but does not filter the report; ensure your GA4 property only contains the app you intend to sync."
|
|
86
|
+
]
|
|
87
|
+
});
|
|
88
|
+
var firebaseAnalyticsCredentials = {
|
|
89
|
+
serviceAccountJson: {
|
|
90
|
+
description: "Google service account JSON key (base64 or raw JSON)",
|
|
91
|
+
auth: "optional"
|
|
92
|
+
},
|
|
93
|
+
refreshToken: {
|
|
94
|
+
description: "Google OAuth 2.0 refresh token",
|
|
95
|
+
auth: "optional"
|
|
96
|
+
},
|
|
97
|
+
clientId: {
|
|
98
|
+
description: "Google OAuth 2.0 client ID",
|
|
99
|
+
auth: "optional"
|
|
100
|
+
},
|
|
101
|
+
clientSecret: {
|
|
102
|
+
description: "Google OAuth 2.0 client secret",
|
|
103
|
+
auth: "optional"
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
var PHASE_ORDER = ["dau_wau_mau", "events_per_day", "retention"];
|
|
107
|
+
var FA_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
108
|
+
function isFADateString(value) {
|
|
109
|
+
return typeof value === "string" && FA_DATE_RE.test(value);
|
|
110
|
+
}
|
|
111
|
+
function isFADateRange(value) {
|
|
112
|
+
if (typeof value !== "object" || value === null) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const v = value;
|
|
116
|
+
return isFADateString(v.startDate) && isFADateString(v.endDate);
|
|
117
|
+
}
|
|
118
|
+
function isFASyncCursor(value) {
|
|
119
|
+
if (typeof value !== "object" || value === null) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const v = value;
|
|
123
|
+
if (typeof v.phase !== "string") {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (!PHASE_ORDER.includes(v.phase)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return isFADateRange(v.dateRange);
|
|
130
|
+
}
|
|
131
|
+
var PHASE_CONFIGS = {
|
|
132
|
+
dau_wau_mau: {
|
|
133
|
+
dimensions: ["date"],
|
|
134
|
+
metrics: ["active1DayUsers", "active7DayUsers", "active28DayUsers"],
|
|
135
|
+
metricName: "firebase_dau_wau_mau"
|
|
136
|
+
},
|
|
137
|
+
events_per_day: {
|
|
138
|
+
dimensions: ["date", "eventName"],
|
|
139
|
+
metrics: ["eventCount", "totalUsers"],
|
|
140
|
+
metricName: "firebase_events_per_day"
|
|
141
|
+
},
|
|
142
|
+
retention: {
|
|
143
|
+
dimensions: ["firstSessionDate", "date"],
|
|
144
|
+
metrics: ["activeUsers"],
|
|
145
|
+
metricName: "firebase_retention"
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
var ROWS_PER_PAGE = 1e4;
|
|
149
|
+
function base64urlFromBytes(bytes) {
|
|
150
|
+
let binary = "";
|
|
151
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
152
|
+
binary += String.fromCharCode(bytes[i]);
|
|
153
|
+
}
|
|
154
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
155
|
+
}
|
|
156
|
+
function base64urlFromString(str) {
|
|
157
|
+
return base64urlFromBytes(new TextEncoder().encode(str));
|
|
158
|
+
}
|
|
159
|
+
async function signRS256JWT(payload, privateKeyPem) {
|
|
160
|
+
const header = { alg: "RS256", typ: "JWT" };
|
|
161
|
+
const headerB64 = base64urlFromString(JSON.stringify(header));
|
|
162
|
+
const payloadB64 = base64urlFromString(JSON.stringify(payload));
|
|
163
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
164
|
+
const pemContent = privateKeyPem.replace(/-----BEGIN PRIVATE KEY-----/g, "").replace(/-----END PRIVATE KEY-----/g, "").replace(/\s/g, "");
|
|
165
|
+
const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));
|
|
166
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
167
|
+
"pkcs8",
|
|
168
|
+
der,
|
|
169
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
170
|
+
false,
|
|
171
|
+
["sign"]
|
|
172
|
+
);
|
|
173
|
+
const signature = await globalThis.crypto.subtle.sign(
|
|
174
|
+
"RSASSA-PKCS1-v1_5",
|
|
175
|
+
key,
|
|
176
|
+
new TextEncoder().encode(signingInput)
|
|
177
|
+
);
|
|
178
|
+
return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;
|
|
179
|
+
}
|
|
180
|
+
function parseServiceAccountJson(value) {
|
|
181
|
+
const trimmed = value.trim();
|
|
182
|
+
if (trimmed.startsWith("{")) {
|
|
183
|
+
return JSON.parse(trimmed);
|
|
184
|
+
}
|
|
185
|
+
const binary = atob(trimmed);
|
|
186
|
+
const bytes = new Uint8Array(binary.length);
|
|
187
|
+
for (let i = 0; i < binary.length; i++) {
|
|
188
|
+
bytes[i] = binary.charCodeAt(i);
|
|
189
|
+
}
|
|
190
|
+
const decoded = new TextDecoder().decode(bytes);
|
|
191
|
+
return JSON.parse(decoded);
|
|
192
|
+
}
|
|
193
|
+
async function buildServiceAccountJwt(serviceAccountJson) {
|
|
194
|
+
const sa = parseServiceAccountJson(serviceAccountJson);
|
|
195
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
196
|
+
const jwt = await signRS256JWT(
|
|
197
|
+
{
|
|
198
|
+
iss: sa.client_email,
|
|
199
|
+
scope: "https://www.googleapis.com/auth/analytics.readonly",
|
|
200
|
+
aud: sa.token_uri ?? "https://oauth2.googleapis.com/token",
|
|
201
|
+
exp: now + 3600,
|
|
202
|
+
iat: now
|
|
203
|
+
},
|
|
204
|
+
sa.private_key
|
|
205
|
+
);
|
|
206
|
+
const body = new URLSearchParams({
|
|
207
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
208
|
+
assertion: jwt
|
|
209
|
+
}).toString();
|
|
210
|
+
return {
|
|
211
|
+
url: sa.token_uri ?? "https://oauth2.googleapis.com/token",
|
|
212
|
+
body
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function toGA4Date(date) {
|
|
216
|
+
const y = date.getUTCFullYear();
|
|
217
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
218
|
+
const d = String(date.getUTCDate()).padStart(2, "0");
|
|
219
|
+
return `${y}-${m}-${d}`;
|
|
220
|
+
}
|
|
221
|
+
function ga4DateToMs(ga4Date) {
|
|
222
|
+
const y = ga4Date.slice(0, 4);
|
|
223
|
+
const m = ga4Date.slice(4, 6);
|
|
224
|
+
const d = ga4Date.slice(6, 8);
|
|
225
|
+
return Date.UTC(Number(y), Number(m) - 1, Number(d));
|
|
226
|
+
}
|
|
227
|
+
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
228
|
+
var INCREMENTAL_LOOKBACK_DAYS = 30;
|
|
229
|
+
function getDateRange(options, lookbackDays) {
|
|
230
|
+
const now = Date.now();
|
|
231
|
+
const endDate = toGA4Date(new Date(now));
|
|
232
|
+
if (options.mode === "latest" && options.since) {
|
|
233
|
+
const startMs2 = now - (INCREMENTAL_LOOKBACK_DAYS - 1) * MS_PER_DAY;
|
|
234
|
+
return { startDate: toGA4Date(new Date(startMs2)), endDate };
|
|
235
|
+
}
|
|
236
|
+
if (options.since) {
|
|
237
|
+
const sinceMs = new Date(options.since).getTime();
|
|
238
|
+
if (Number.isFinite(sinceMs)) {
|
|
239
|
+
const days = Math.max(1, Math.ceil((now - sinceMs) / MS_PER_DAY));
|
|
240
|
+
const cappedDays = Math.min(days, lookbackDays);
|
|
241
|
+
const startMs2 = now - (cappedDays - 1) * MS_PER_DAY;
|
|
242
|
+
return { startDate: toGA4Date(new Date(startMs2)), endDate };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const startMs = now - (lookbackDays - 1) * MS_PER_DAY;
|
|
246
|
+
return { startDate: toGA4Date(new Date(startMs)), endDate };
|
|
247
|
+
}
|
|
248
|
+
function rowToMetricSample(row, dimensionHeaders, metricHeaders, metricName, firebaseAppId) {
|
|
249
|
+
const dims = {};
|
|
250
|
+
for (let i = 0; i < dimensionHeaders.length; i++) {
|
|
251
|
+
dims[dimensionHeaders[i]] = row.dimensionValues[i]?.value ?? "";
|
|
252
|
+
}
|
|
253
|
+
const mets = {};
|
|
254
|
+
for (let i = 0; i < metricHeaders.length; i++) {
|
|
255
|
+
mets[metricHeaders[i]] = parseFloat(row.metricValues[i]?.value ?? "0") || 0;
|
|
256
|
+
}
|
|
257
|
+
const dateStr = dims["date"] ?? "19700101";
|
|
258
|
+
const ts = ga4DateToMs(dateStr);
|
|
259
|
+
const primaryValue = mets[metricHeaders[0]] ?? 0;
|
|
260
|
+
const attributes = {
|
|
261
|
+
firebaseAppId,
|
|
262
|
+
...dims,
|
|
263
|
+
...mets
|
|
264
|
+
};
|
|
265
|
+
if (dims["firstSessionDate"] && dims["date"]) {
|
|
266
|
+
const firstMs = ga4DateToMs(dims["firstSessionDate"]);
|
|
267
|
+
const dateMs = ga4DateToMs(dims["date"]);
|
|
268
|
+
const period = Math.max(0, Math.round((dateMs - firstMs) / MS_PER_DAY));
|
|
269
|
+
attributes["period"] = period;
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
name: metricName,
|
|
273
|
+
ts,
|
|
274
|
+
value: primaryValue,
|
|
275
|
+
attributes
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
var dateDimensionValue = z.object({
|
|
279
|
+
value: z.string().regex(/^(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])$/)
|
|
280
|
+
});
|
|
281
|
+
var stringDimensionValue = z.object({ value: z.string() });
|
|
282
|
+
var numericMetricValue = z.object({
|
|
283
|
+
value: z.string().regex(/^-?\d+(\.\d+)?$/)
|
|
284
|
+
});
|
|
285
|
+
function reportSchema(dimensionCount, firstIsDate = true) {
|
|
286
|
+
const first = firstIsDate ? dateDimensionValue : stringDimensionValue;
|
|
287
|
+
const dims = dimensionCount === 1 ? z.tuple([first]) : z.tuple([
|
|
288
|
+
first,
|
|
289
|
+
...Array(dimensionCount - 1).fill(stringDimensionValue)
|
|
290
|
+
]);
|
|
291
|
+
return z.object({
|
|
292
|
+
rows: z.array(
|
|
293
|
+
z.object({
|
|
294
|
+
dimensionValues: dims,
|
|
295
|
+
metricValues: z.array(numericMetricValue).nonempty()
|
|
296
|
+
})
|
|
297
|
+
).optional()
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
var retentionReportSchema = z.object({
|
|
301
|
+
rows: z.array(
|
|
302
|
+
z.object({
|
|
303
|
+
dimensionValues: z.tuple([dateDimensionValue, dateDimensionValue]),
|
|
304
|
+
metricValues: z.array(numericMetricValue).nonempty()
|
|
305
|
+
})
|
|
306
|
+
).optional()
|
|
307
|
+
});
|
|
308
|
+
var tokenResponseSchema = z.object({
|
|
309
|
+
access_token: z.string().min(1),
|
|
310
|
+
expires_in: z.number().int().positive().optional()
|
|
311
|
+
});
|
|
312
|
+
var firebaseAnalyticsResources = defineResources({
|
|
313
|
+
firebase_dau_wau_mau: {
|
|
314
|
+
shape: "metric",
|
|
315
|
+
description: "Daily active, weekly active, and monthly active user counts for the linked GA4 property.",
|
|
316
|
+
unit: "users",
|
|
317
|
+
granularity: "day",
|
|
318
|
+
endpoint: "POST /v1beta/properties/{propertyId}:runReport",
|
|
319
|
+
dimensions: [
|
|
320
|
+
{ name: "date", description: "Calendar day of the metric sample." }
|
|
321
|
+
],
|
|
322
|
+
responses: {
|
|
323
|
+
oauth_token: tokenResponseSchema,
|
|
324
|
+
dau_wau_mau: reportSchema(1)
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
firebase_events_per_day: {
|
|
328
|
+
shape: "metric",
|
|
329
|
+
description: "Daily event counts and the active users that triggered them, bucketed by event name.",
|
|
330
|
+
unit: "events",
|
|
331
|
+
granularity: "day",
|
|
332
|
+
endpoint: "POST /v1beta/properties/{propertyId}:runReport",
|
|
333
|
+
dimensions: [
|
|
334
|
+
{ name: "date", description: "Calendar day of the metric sample." },
|
|
335
|
+
{
|
|
336
|
+
name: "eventName",
|
|
337
|
+
description: "GA4 event name (e.g. session_start, first_open, login)."
|
|
338
|
+
}
|
|
339
|
+
],
|
|
340
|
+
responses: { events_per_day: reportSchema(2) }
|
|
341
|
+
},
|
|
342
|
+
firebase_retention: {
|
|
343
|
+
shape: "metric",
|
|
344
|
+
description: "Active users on each day grouped by the date of their first session (cohort retention).",
|
|
345
|
+
unit: "users",
|
|
346
|
+
granularity: "day",
|
|
347
|
+
endpoint: "POST /v1beta/properties/{propertyId}:runReport",
|
|
348
|
+
dimensions: [
|
|
349
|
+
{
|
|
350
|
+
name: "firstSessionDate",
|
|
351
|
+
description: "Calendar day on which the user first opened the app."
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: "date",
|
|
355
|
+
description: "Calendar day on which the user was active."
|
|
356
|
+
}
|
|
357
|
+
],
|
|
358
|
+
notes: "Each sample also carries a `period` attribute equal to (date - firstSessionDate) in days, so retention curves can be built by grouping on it.",
|
|
359
|
+
responses: { retention: retentionReportSchema }
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
var id = "firebase-analytics";
|
|
363
|
+
var FirebaseAnalyticsConnector = class _FirebaseAnalyticsConnector extends BaseConnector {
|
|
364
|
+
static id = id;
|
|
365
|
+
static resources = firebaseAnalyticsResources;
|
|
366
|
+
static schemas = schemasFromResources(firebaseAnalyticsResources);
|
|
367
|
+
static create(input, ctx) {
|
|
368
|
+
const parsed = configFields.parse(input);
|
|
369
|
+
return new _FirebaseAnalyticsConnector(
|
|
370
|
+
{
|
|
371
|
+
propertyId: parsed.propertyId,
|
|
372
|
+
firebaseAppId: parsed.firebaseAppId,
|
|
373
|
+
lookbackDays: parsed.lookbackDays
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
serviceAccountJson: parsed.serviceAccountJson,
|
|
377
|
+
refreshToken: parsed.refreshToken,
|
|
378
|
+
clientId: parsed.clientId,
|
|
379
|
+
clientSecret: parsed.clientSecret
|
|
380
|
+
},
|
|
381
|
+
ctx
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
id = id;
|
|
385
|
+
credentials = firebaseAnalyticsCredentials;
|
|
386
|
+
cachedToken = null;
|
|
387
|
+
async fetchOAuthToken(url, body, signal) {
|
|
388
|
+
const res = await this.post(url, {
|
|
389
|
+
resource: "oauth_token",
|
|
390
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
391
|
+
body,
|
|
392
|
+
signal
|
|
393
|
+
});
|
|
394
|
+
const expiresIn = res.body.expires_in ?? 3600;
|
|
395
|
+
return {
|
|
396
|
+
token: res.body.access_token,
|
|
397
|
+
expiresAt: Date.now() + (expiresIn - 60) * 1e3
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
async getAccessToken(signal) {
|
|
401
|
+
if (this.cachedToken && Date.now() < this.cachedToken.expiresAt) {
|
|
402
|
+
return this.cachedToken.token;
|
|
403
|
+
}
|
|
404
|
+
const { serviceAccountJson, refreshToken, clientId, clientSecret } = this.creds;
|
|
405
|
+
if (serviceAccountJson) {
|
|
406
|
+
const { url, body } = await buildServiceAccountJwt(serviceAccountJson);
|
|
407
|
+
this.cachedToken = await this.fetchOAuthToken(url, body, signal);
|
|
408
|
+
return this.cachedToken.token;
|
|
409
|
+
}
|
|
410
|
+
if (refreshToken && clientId && clientSecret) {
|
|
411
|
+
const body = new URLSearchParams({
|
|
412
|
+
grant_type: "refresh_token",
|
|
413
|
+
refresh_token: refreshToken,
|
|
414
|
+
client_id: clientId,
|
|
415
|
+
client_secret: clientSecret
|
|
416
|
+
}).toString();
|
|
417
|
+
this.cachedToken = await this.fetchOAuthToken(
|
|
418
|
+
"https://oauth2.googleapis.com/token",
|
|
419
|
+
body,
|
|
420
|
+
signal
|
|
421
|
+
);
|
|
422
|
+
return this.cachedToken.token;
|
|
423
|
+
}
|
|
424
|
+
throw new Error(
|
|
425
|
+
"Firebase Analytics connector: provide either serviceAccountJson or (refreshToken + clientId + clientSecret)"
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
async runReport(accessToken, phase, dateRange, offset, signal) {
|
|
429
|
+
const { dimensions, metrics } = PHASE_CONFIGS[phase];
|
|
430
|
+
const url = `https://analyticsdata.googleapis.com/v1beta/properties/${this.settings.propertyId}:runReport`;
|
|
431
|
+
const body = {
|
|
432
|
+
dimensions: dimensions.map((name) => ({ name })),
|
|
433
|
+
metrics: metrics.map((name) => ({ name })),
|
|
434
|
+
dateRanges: [
|
|
435
|
+
{ startDate: dateRange.startDate, endDate: dateRange.endDate }
|
|
436
|
+
],
|
|
437
|
+
limit: ROWS_PER_PAGE,
|
|
438
|
+
offset
|
|
439
|
+
};
|
|
440
|
+
const res = await this.post(url, {
|
|
441
|
+
resource: phase,
|
|
442
|
+
headers: {
|
|
443
|
+
Authorization: `Bearer ${accessToken}`,
|
|
444
|
+
"Content-Type": "application/json",
|
|
445
|
+
"User-Agent": connectorUserAgent("firebase-analytics")
|
|
446
|
+
},
|
|
447
|
+
body: JSON.stringify(body),
|
|
448
|
+
signal
|
|
449
|
+
});
|
|
450
|
+
return res.body;
|
|
451
|
+
}
|
|
452
|
+
async sync(options, storage, signal) {
|
|
453
|
+
const lookbackDays = this.settings.lookbackDays ?? 90;
|
|
454
|
+
const cursor = isFASyncCursor(options.cursor) ? options.cursor : void 0;
|
|
455
|
+
const dateRange = cursor?.dateRange ?? getDateRange(options, lookbackDays);
|
|
456
|
+
let accessToken = null;
|
|
457
|
+
const getToken = async (sig) => {
|
|
458
|
+
if (!accessToken) {
|
|
459
|
+
accessToken = await this.getAccessToken(sig);
|
|
460
|
+
}
|
|
461
|
+
return accessToken;
|
|
462
|
+
};
|
|
463
|
+
const runReportWithRetry = async (phase, offset, sig) => {
|
|
464
|
+
const token = await getToken(sig);
|
|
465
|
+
try {
|
|
466
|
+
return await this.runReport(token, phase, dateRange, offset, sig);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
this.logger.warn("runReport failed, refreshing token and retrying", {
|
|
469
|
+
err: String(err),
|
|
470
|
+
phase
|
|
471
|
+
});
|
|
472
|
+
accessToken = null;
|
|
473
|
+
const freshToken = await getToken(sig);
|
|
474
|
+
return this.runReport(freshToken, phase, dateRange, offset, sig);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const drainPhase = async (phase) => {
|
|
478
|
+
const allRows = [];
|
|
479
|
+
let offset = 0;
|
|
480
|
+
for (; ; ) {
|
|
481
|
+
const response = await runReportWithRetry(phase, offset, signal);
|
|
482
|
+
const rows = response.rows ?? [];
|
|
483
|
+
allRows.push(...rows);
|
|
484
|
+
offset += rows.length;
|
|
485
|
+
if (rows.length === 0) {
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
const done = typeof response.rowCount === "number" ? offset >= response.rowCount : rows.length < ROWS_PER_PAGE;
|
|
489
|
+
if (done) {
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return allRows;
|
|
494
|
+
};
|
|
495
|
+
const enabled = options.resources;
|
|
496
|
+
const isPhaseEnabled = (phase) => {
|
|
497
|
+
if (!enabled || enabled.size === 0) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
return enabled.has(PHASE_CONFIGS[phase].metricName);
|
|
501
|
+
};
|
|
502
|
+
const resumeIdx = cursor ? PHASE_ORDER.indexOf(cursor.phase) : -1;
|
|
503
|
+
const startIdx = resumeIdx >= 0 ? resumeIdx : 0;
|
|
504
|
+
for (let i = startIdx; i < PHASE_ORDER.length; i++) {
|
|
505
|
+
const phase = PHASE_ORDER[i];
|
|
506
|
+
if (signal?.aborted) {
|
|
507
|
+
return { done: false, cursor: { phase, dateRange } };
|
|
508
|
+
}
|
|
509
|
+
if (!isPhaseEnabled(phase)) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
let rows;
|
|
513
|
+
try {
|
|
514
|
+
rows = await drainPhase(phase);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
if (signal?.aborted) {
|
|
517
|
+
return { done: false, cursor: { phase, dateRange } };
|
|
518
|
+
}
|
|
519
|
+
throw err;
|
|
520
|
+
}
|
|
521
|
+
const cfg = PHASE_CONFIGS[phase];
|
|
522
|
+
const samples = rows.map(
|
|
523
|
+
(row) => rowToMetricSample(
|
|
524
|
+
row,
|
|
525
|
+
cfg.dimensions,
|
|
526
|
+
cfg.metrics,
|
|
527
|
+
cfg.metricName,
|
|
528
|
+
this.settings.firebaseAppId
|
|
529
|
+
)
|
|
530
|
+
);
|
|
531
|
+
await storage.metrics(samples, { names: [cfg.metricName] });
|
|
532
|
+
}
|
|
533
|
+
return { done: true };
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// src/index.ts
|
|
538
|
+
var index_default = FirebaseAnalyticsConnector;
|
|
539
|
+
export {
|
|
540
|
+
FirebaseAnalyticsConnector,
|
|
541
|
+
configFields,
|
|
542
|
+
index_default as default,
|
|
543
|
+
doc,
|
|
544
|
+
id,
|
|
545
|
+
firebaseAnalyticsResources as resources,
|
|
546
|
+
rowToMetricSample
|
|
547
|
+
};
|
|
548
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../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/firebase-analytics.ts","../src/index.ts"],"sourcesContent":["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 { connectorUserAgent } from '@rawdash/connector-shared';\nimport {\n BaseConnector,\n type ConnectorContext,\n type ConnectorDoc,\n type CredentialsSchema,\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\nexport const configFields = defineConfigFields(\n z\n .object({\n propertyId: z\n .string()\n .trim()\n .regex(/^\\d+$/, 'GA4 Property ID must be digits only')\n .meta({\n label: 'GA4 Property ID',\n description:\n 'Numeric ID of the GA4 property linked to your Firebase project (e.g. 123456789). Find it in Google Analytics -> Admin -> Property settings.',\n placeholder: '123456789',\n }),\n firebaseAppId: z\n .string()\n .trim()\n .min(1, 'Firebase App ID is required')\n .meta({\n label: 'Firebase App ID',\n description:\n 'Firebase App ID for the app whose analytics you are syncing (e.g. 1:1234567890:web:abcdef). Find it in Firebase Console -> Project settings -> General -> Your apps. Used to label samples with the source app.',\n placeholder: '1:1234567890:web:abcdef',\n }),\n serviceAccountJson: z.object({ $secret: z.string() }).optional().meta({\n label: 'Service Account JSON (recommended)',\n description:\n 'Contents of the JSON key file for a Google service account with the Firebase Viewer + Analytics Viewer roles. Create one at Google Cloud -> IAM & Admin -> Service Accounts.',\n secret: true,\n }),\n refreshToken: z.object({ $secret: z.string() }).optional().meta({\n label: 'OAuth Refresh Token',\n description:\n 'Google OAuth 2.0 refresh token with the analytics.readonly scope. Required if not using serviceAccountJson.',\n secret: true,\n }),\n clientId: z.string().optional().meta({\n label: 'OAuth Client ID',\n description:\n 'OAuth 2.0 client ID from Google Cloud Console. Required when using refreshToken auth.',\n placeholder: '...apps.googleusercontent.com',\n }),\n clientSecret: z.object({ $secret: z.string() }).optional().meta({\n label: 'OAuth Client Secret',\n description:\n 'OAuth 2.0 client secret from Google Cloud Console. Required when using refreshToken auth.',\n secret: true,\n }),\n lookbackDays: z.number().int().positive().optional().meta({\n label: 'Lookback days (full sync)',\n description:\n 'How many calendar days to fetch on a full sync. Defaults to 90.',\n placeholder: '90',\n }),\n })\n .refine(\n (val) =>\n val.serviceAccountJson !== undefined ||\n (val.refreshToken !== undefined &&\n val.clientId !== undefined &&\n val.clientSecret !== undefined),\n {\n message:\n 'Provide either serviceAccountJson or the full OAuth tuple (refreshToken + clientId + clientSecret)',\n },\n ),\n);\n\nexport const doc: ConnectorDoc = defineConnectorDoc({\n displayName: 'Firebase Analytics',\n category: 'product',\n brandColor: '#DD2C00',\n tagline:\n 'Sync DAU/WAU/MAU, per-event activity, and cohort retention from a Firebase project via the GA4 Data API.',\n vendor: {\n name: 'Firebase Analytics',\n domain: 'firebase.google.com',\n apiDocs:\n 'https://developers.google.com/analytics/devguides/reporting/data/v1',\n website: 'https://firebase.google.com/products/analytics',\n },\n auth: {\n summary:\n 'Firebase Analytics data is exposed through the linked GA4 property. Authenticate against the GA4 Data API with either a Google service account JSON key (recommended) or an OAuth 2.0 refresh-token tuple. The identity must have at least the Analytics Viewer role on the property.',\n setup: [\n 'In Firebase Console -> Project settings -> Integrations -> Google Analytics, note the linked GA4 property and copy its numeric Property ID from Google Analytics -> Admin -> Property settings.',\n 'In Firebase Console -> Project settings -> General -> Your apps, copy the Firebase App ID for the app whose analytics you want to sync.',\n 'Recommended: create a service account at Google Cloud -> IAM & Admin -> Service Accounts, generate a JSON key, and grant it the Analytics Viewer role on the GA4 property. Store the JSON as a secret and reference it as serviceAccountJson: secret(\"FIREBASE_ANALYTICS_SA_JSON\").',\n 'Alternative: provide an OAuth 2.0 refresh token with the analytics.readonly scope together with its clientId and clientSecret from the Google Cloud Console.',\n ],\n },\n rateLimit:\n 'GA4 Data API quota is 200,000 tokens/day per property (default); 429 responses are retried automatically with exponential backoff.',\n limitations: [\n 'Incremental syncs use a 30-day window because GA4 can attribute events up to 3 days after they occur.',\n 'Report pagination is 10,000 rows per page.',\n 'The firebaseAppId is recorded on every sample but does not filter the report; ensure your GA4 property only contains the app you intend to sync.',\n ],\n});\n\nexport interface FirebaseAnalyticsSettings {\n propertyId: string;\n firebaseAppId: string;\n lookbackDays?: number;\n}\n\nconst firebaseAnalyticsCredentials = {\n serviceAccountJson: {\n description: 'Google service account JSON key (base64 or raw JSON)',\n auth: 'optional' as const,\n },\n refreshToken: {\n description: 'Google OAuth 2.0 refresh token',\n auth: 'optional' as const,\n },\n clientId: {\n description: 'Google OAuth 2.0 client ID',\n auth: 'optional' as const,\n },\n clientSecret: {\n description: 'Google OAuth 2.0 client secret',\n auth: 'optional' as const,\n },\n} satisfies CredentialsSchema;\n\ntype FirebaseAnalyticsCredentials = typeof firebaseAnalyticsCredentials;\n\nconst PHASE_ORDER = ['dau_wau_mau', 'events_per_day', 'retention'] as const;\n\ntype FirebaseAnalyticsPhase = (typeof PHASE_ORDER)[number];\n\ninterface FirebaseAnalyticsDateRange {\n startDate: string;\n endDate: string;\n}\n\ninterface FirebaseAnalyticsSyncCursor {\n phase: FirebaseAnalyticsPhase;\n dateRange: FirebaseAnalyticsDateRange;\n}\n\nconst FA_DATE_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\nfunction isFADateString(value: unknown): value is string {\n return typeof value === 'string' && FA_DATE_RE.test(value);\n}\n\nfunction isFADateRange(value: unknown): value is FirebaseAnalyticsDateRange {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n const v = value as { startDate?: unknown; endDate?: unknown };\n return isFADateString(v.startDate) && isFADateString(v.endDate);\n}\n\nfunction isFASyncCursor(value: unknown): value is FirebaseAnalyticsSyncCursor {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n const v = value as { phase?: unknown; dateRange?: unknown };\n if (typeof v.phase !== 'string') {\n return false;\n }\n if (!(PHASE_ORDER as readonly string[]).includes(v.phase)) {\n return false;\n }\n return isFADateRange(v.dateRange);\n}\n\ninterface PhaseConfig {\n dimensions: string[];\n metrics: string[];\n metricName: string;\n}\n\nconst PHASE_CONFIGS: Record<FirebaseAnalyticsPhase, PhaseConfig> = {\n dau_wau_mau: {\n dimensions: ['date'],\n metrics: ['active1DayUsers', 'active7DayUsers', 'active28DayUsers'],\n metricName: 'firebase_dau_wau_mau',\n },\n events_per_day: {\n dimensions: ['date', 'eventName'],\n metrics: ['eventCount', 'totalUsers'],\n metricName: 'firebase_events_per_day',\n },\n retention: {\n dimensions: ['firstSessionDate', 'date'],\n metrics: ['activeUsers'],\n metricName: 'firebase_retention',\n },\n};\n\nconst ROWS_PER_PAGE = 10_000;\n\nexport interface FAReportDimensionValue {\n value: string;\n}\n\nexport interface FAReportMetricValue {\n value: string;\n}\n\nexport interface FAReportRow {\n dimensionValues: FAReportDimensionValue[];\n metricValues: FAReportMetricValue[];\n}\n\ninterface FAReportResponse {\n rows?: FAReportRow[];\n rowCount?: number;\n dimensionHeaders?: Array<{ name: string }>;\n metricHeaders?: Array<{ name: string; type: string }>;\n}\n\ninterface ServiceAccountKey {\n client_email: string;\n private_key: string;\n token_uri?: string;\n}\n\ninterface TokenResponse {\n access_token: string;\n expires_in?: number;\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\nfunction parseServiceAccountJson(value: string): ServiceAccountKey {\n const trimmed = value.trim();\n if (trimmed.startsWith('{')) {\n return JSON.parse(trimmed) as ServiceAccountKey;\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 JSON.parse(decoded) as ServiceAccountKey;\n}\n\nasync function buildServiceAccountJwt(\n serviceAccountJson: 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: 'https://www.googleapis.com/auth/analytics.readonly',\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\nfunction toGA4Date(date: Date): string {\n const y = date.getUTCFullYear();\n const m = String(date.getUTCMonth() + 1).padStart(2, '0');\n const d = String(date.getUTCDate()).padStart(2, '0');\n return `${y}-${m}-${d}`;\n}\n\nfunction ga4DateToMs(ga4Date: string): number {\n const y = ga4Date.slice(0, 4);\n const m = ga4Date.slice(4, 6);\n const d = ga4Date.slice(6, 8);\n return Date.UTC(Number(y), Number(m) - 1, Number(d));\n}\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nconst INCREMENTAL_LOOKBACK_DAYS = 30;\n\nfunction getDateRange(\n options: SyncOptions,\n lookbackDays: number,\n): FirebaseAnalyticsDateRange {\n const now = Date.now();\n const endDate = toGA4Date(new Date(now));\n if (options.mode === 'latest' && options.since) {\n const startMs = now - (INCREMENTAL_LOOKBACK_DAYS - 1) * MS_PER_DAY;\n return { startDate: toGA4Date(new Date(startMs)), endDate };\n }\n if (options.since) {\n const sinceMs = new Date(options.since).getTime();\n if (Number.isFinite(sinceMs)) {\n const days = Math.max(1, Math.ceil((now - sinceMs) / MS_PER_DAY));\n const cappedDays = Math.min(days, lookbackDays);\n const startMs = now - (cappedDays - 1) * MS_PER_DAY;\n return { startDate: toGA4Date(new Date(startMs)), endDate };\n }\n }\n const startMs = now - (lookbackDays - 1) * MS_PER_DAY;\n return { startDate: toGA4Date(new Date(startMs)), endDate };\n}\n\nexport function rowToMetricSample(\n row: FAReportRow,\n dimensionHeaders: string[],\n metricHeaders: string[],\n metricName: string,\n firebaseAppId: string,\n): {\n name: string;\n ts: number;\n value: number;\n attributes: Record<string, string | number>;\n} {\n const dims: Record<string, string> = {};\n for (let i = 0; i < dimensionHeaders.length; i++) {\n dims[dimensionHeaders[i]!] = row.dimensionValues[i]?.value ?? '';\n }\n\n const mets: Record<string, number> = {};\n for (let i = 0; i < metricHeaders.length; i++) {\n mets[metricHeaders[i]!] =\n parseFloat(row.metricValues[i]?.value ?? '0') || 0;\n }\n\n const dateStr = dims['date'] ?? '19700101';\n const ts = ga4DateToMs(dateStr);\n const primaryValue = mets[metricHeaders[0]!] ?? 0;\n\n const attributes: Record<string, string | number> = {\n firebaseAppId,\n ...dims,\n ...mets,\n };\n\n if (dims['firstSessionDate'] && dims['date']) {\n const firstMs = ga4DateToMs(dims['firstSessionDate']);\n const dateMs = ga4DateToMs(dims['date']);\n const period = Math.max(0, Math.round((dateMs - firstMs) / MS_PER_DAY));\n attributes['period'] = period;\n }\n\n return {\n name: metricName,\n ts,\n value: primaryValue,\n attributes,\n };\n}\n\nconst dateDimensionValue = z.object({\n value: z.string().regex(/^(19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])$/),\n});\n\nconst stringDimensionValue = z.object({ value: z.string() });\nconst numericMetricValue = z.object({\n value: z.string().regex(/^-?\\d+(\\.\\d+)?$/),\n});\n\nfunction reportSchema(dimensionCount: number, firstIsDate = true) {\n const first = firstIsDate ? dateDimensionValue : stringDimensionValue;\n const dims =\n dimensionCount === 1\n ? z.tuple([first])\n : z.tuple([\n first,\n ...Array(dimensionCount - 1).fill(stringDimensionValue),\n ] as [typeof first, ...z.ZodType[]]);\n return z.object({\n rows: z\n .array(\n z.object({\n dimensionValues: dims,\n metricValues: z.array(numericMetricValue).nonempty(),\n }),\n )\n .optional(),\n });\n}\n\nconst retentionReportSchema = z.object({\n rows: z\n .array(\n z.object({\n dimensionValues: z.tuple([dateDimensionValue, dateDimensionValue]),\n metricValues: z.array(numericMetricValue).nonempty(),\n }),\n )\n .optional(),\n});\n\nconst tokenResponseSchema = z.object({\n access_token: z.string().min(1),\n expires_in: z.number().int().positive().optional(),\n});\n\nexport const firebaseAnalyticsResources = defineResources({\n firebase_dau_wau_mau: {\n shape: 'metric',\n description:\n 'Daily active, weekly active, and monthly active user counts for the linked GA4 property.',\n unit: 'users',\n granularity: 'day',\n endpoint: 'POST /v1beta/properties/{propertyId}:runReport',\n dimensions: [\n { name: 'date', description: 'Calendar day of the metric sample.' },\n ],\n responses: {\n oauth_token: tokenResponseSchema,\n dau_wau_mau: reportSchema(1),\n },\n },\n firebase_events_per_day: {\n shape: 'metric',\n description:\n 'Daily event counts and the active users that triggered them, bucketed by event name.',\n unit: 'events',\n granularity: 'day',\n endpoint: 'POST /v1beta/properties/{propertyId}:runReport',\n dimensions: [\n { name: 'date', description: 'Calendar day of the metric sample.' },\n {\n name: 'eventName',\n description: 'GA4 event name (e.g. session_start, first_open, login).',\n },\n ],\n responses: { events_per_day: reportSchema(2) },\n },\n firebase_retention: {\n shape: 'metric',\n description:\n 'Active users on each day grouped by the date of their first session (cohort retention).',\n unit: 'users',\n granularity: 'day',\n endpoint: 'POST /v1beta/properties/{propertyId}:runReport',\n dimensions: [\n {\n name: 'firstSessionDate',\n description: 'Calendar day on which the user first opened the app.',\n },\n {\n name: 'date',\n description: 'Calendar day on which the user was active.',\n },\n ],\n notes:\n 'Each sample also carries a `period` attribute equal to (date - firstSessionDate) in days, so retention curves can be built by grouping on it.',\n responses: { retention: retentionReportSchema },\n },\n});\n\nexport const id = 'firebase-analytics';\n\nexport class FirebaseAnalyticsConnector extends BaseConnector<\n FirebaseAnalyticsSettings,\n FirebaseAnalyticsCredentials\n> {\n static readonly id = id;\n\n static readonly resources = firebaseAnalyticsResources;\n\n static readonly schemas = schemasFromResources(firebaseAnalyticsResources);\n\n static create(\n input: unknown,\n ctx?: ConnectorContext,\n ): FirebaseAnalyticsConnector {\n const parsed = configFields.parse(input);\n return new FirebaseAnalyticsConnector(\n {\n propertyId: parsed.propertyId,\n firebaseAppId: parsed.firebaseAppId,\n lookbackDays: parsed.lookbackDays,\n },\n {\n serviceAccountJson: parsed.serviceAccountJson,\n refreshToken: parsed.refreshToken,\n clientId: parsed.clientId,\n clientSecret: parsed.clientSecret,\n },\n ctx,\n );\n }\n\n readonly id = id;\n override readonly credentials = firebaseAnalyticsCredentials;\n\n private cachedToken: { token: string; expiresAt: number } | null = null;\n\n private async fetchOAuthToken(\n url: string,\n body: string,\n signal: AbortSignal | undefined,\n ): Promise<{ token: string; expiresAt: number }> {\n const res = await this.post<TokenResponse>(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 return {\n token: res.body.access_token,\n expiresAt: Date.now() + (expiresIn - 60) * 1000,\n };\n }\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\n const { serviceAccountJson, refreshToken, clientId, clientSecret } =\n this.creds;\n\n if (serviceAccountJson) {\n const { url, body } = await buildServiceAccountJwt(serviceAccountJson);\n this.cachedToken = await this.fetchOAuthToken(url, body, signal);\n return this.cachedToken.token;\n }\n\n if (refreshToken && clientId && clientSecret) {\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n refresh_token: refreshToken,\n client_id: clientId,\n client_secret: clientSecret,\n }).toString();\n this.cachedToken = await this.fetchOAuthToken(\n 'https://oauth2.googleapis.com/token',\n body,\n signal,\n );\n return this.cachedToken.token;\n }\n\n throw new Error(\n 'Firebase Analytics connector: provide either serviceAccountJson or (refreshToken + clientId + clientSecret)',\n );\n }\n\n private async runReport(\n accessToken: string,\n phase: FirebaseAnalyticsPhase,\n dateRange: { startDate: string; endDate: string },\n offset: number,\n signal?: AbortSignal,\n ): Promise<FAReportResponse> {\n const { dimensions, metrics } = PHASE_CONFIGS[phase];\n const url = `https://analyticsdata.googleapis.com/v1beta/properties/${this.settings.propertyId}:runReport`;\n\n const body: Record<string, unknown> = {\n dimensions: dimensions.map((name) => ({ name })),\n metrics: metrics.map((name) => ({ name })),\n dateRanges: [\n { startDate: dateRange.startDate, endDate: dateRange.endDate },\n ],\n limit: ROWS_PER_PAGE,\n offset,\n };\n\n const res = await this.post<FAReportResponse>(url, {\n resource: phase,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'User-Agent': connectorUserAgent('firebase-analytics'),\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 lookbackDays = this.settings.lookbackDays ?? 90;\n\n const cursor = isFASyncCursor(options.cursor) ? options.cursor : undefined;\n const dateRange = cursor?.dateRange ?? getDateRange(options, lookbackDays);\n\n let accessToken: string | null = null;\n const getToken = async (sig?: AbortSignal): Promise<string> => {\n if (!accessToken) {\n accessToken = await this.getAccessToken(sig);\n }\n return accessToken;\n };\n\n const runReportWithRetry = async (\n phase: FirebaseAnalyticsPhase,\n offset: number,\n sig: AbortSignal | undefined,\n ): Promise<FAReportResponse> => {\n const token = await getToken(sig);\n try {\n return await this.runReport(token, phase, dateRange, offset, sig);\n } catch (err) {\n this.logger.warn('runReport failed, refreshing token and retrying', {\n err: String(err),\n phase,\n });\n accessToken = null;\n const freshToken = await getToken(sig);\n return this.runReport(freshToken, phase, dateRange, offset, sig);\n }\n };\n\n const drainPhase = async (\n phase: FirebaseAnalyticsPhase,\n ): Promise<FAReportRow[]> => {\n const allRows: FAReportRow[] = [];\n let offset = 0;\n for (;;) {\n const response = await runReportWithRetry(phase, offset, signal);\n const rows = response.rows ?? [];\n allRows.push(...rows);\n offset += rows.length;\n if (rows.length === 0) {\n break;\n }\n const done =\n typeof response.rowCount === 'number'\n ? offset >= response.rowCount\n : rows.length < ROWS_PER_PAGE;\n if (done) {\n break;\n }\n }\n return allRows;\n };\n\n const enabled = options.resources;\n const isPhaseEnabled = (phase: FirebaseAnalyticsPhase): boolean => {\n if (!enabled || enabled.size === 0) {\n return true;\n }\n return enabled.has(PHASE_CONFIGS[phase].metricName);\n };\n\n const resumeIdx = cursor ? PHASE_ORDER.indexOf(cursor.phase) : -1;\n const startIdx = resumeIdx >= 0 ? resumeIdx : 0;\n\n for (let i = startIdx; i < PHASE_ORDER.length; i++) {\n const phase = PHASE_ORDER[i]!;\n if (signal?.aborted) {\n return { done: false, cursor: { phase, dateRange } };\n }\n if (!isPhaseEnabled(phase)) {\n continue;\n }\n\n let rows: FAReportRow[];\n try {\n rows = await drainPhase(phase);\n } catch (err) {\n if (signal?.aborted) {\n return { done: false, cursor: { phase, dateRange } };\n }\n throw err;\n }\n const cfg = PHASE_CONFIGS[phase];\n const samples = rows.map((row) =>\n rowToMetricSample(\n row,\n cfg.dimensions,\n cfg.metrics,\n cfg.metricName,\n this.settings.firebaseAppId,\n ),\n );\n await storage.metrics(samples, { names: [cfg.metricName] });\n }\n\n return { done: true };\n }\n}\n","import { FirebaseAnalyticsConnector } from './firebase-analytics';\n\nexport {\n configFields,\n doc,\n FirebaseAnalyticsConnector,\n firebaseAnalyticsResources as resources,\n id,\n rowToMetricSample,\n} from './firebase-analytics';\nexport type { FirebaseAnalyticsSettings } from './firebase-analytics';\nexport default FirebaseAnalyticsConnector;\n"],"mappings":";AEAO,IAAM,sBAAsB;AAE5B,IAAM,qBAAqB,qBAAqB,mBAAmB;AAEnE,SAAS,mBAAmB,aAA6B;AAC9D,SAAO,qBAAqB,WAAW,IAAI,mBAAmB;AAChE;;;AQLA;AAAA,EACE;AAAA,EAOA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,SAAS;AAEX,IAAM,eAAe;AAAA,EAC1B,EACG,OAAO;AAAA,IACN,YAAY,EACT,OAAO,EACP,KAAK,EACL,MAAM,SAAS,qCAAqC,EACpD,KAAK;AAAA,MACJ,OAAO;AAAA,MACP,aACE;AAAA,MACF,aAAa;AAAA,IACf,CAAC;AAAA,IACH,eAAe,EACZ,OAAO,EACP,KAAK,EACL,IAAI,GAAG,6BAA6B,EACpC,KAAK;AAAA,MACJ,OAAO;AAAA,MACP,aACE;AAAA,MACF,aAAa;AAAA,IACf,CAAC;AAAA,IACH,oBAAoB,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE,SAAS,EAAE,KAAK;AAAA,MACpE,OAAO;AAAA,MACP,aACE;AAAA,MACF,QAAQ;AAAA,IACV,CAAC;AAAA,IACD,cAAc,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE,SAAS,EAAE,KAAK;AAAA,MAC9D,OAAO;AAAA,MACP,aACE;AAAA,MACF,QAAQ;AAAA,IACV,CAAC;AAAA,IACD,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK;AAAA,MACnC,OAAO;AAAA,MACP,aACE;AAAA,MACF,aAAa;AAAA,IACf,CAAC;AAAA,IACD,cAAc,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE,SAAS,EAAE,KAAK;AAAA,MAC9D,OAAO;AAAA,MACP,aACE;AAAA,MACF,QAAQ;AAAA,IACV,CAAC;AAAA,IACD,cAAc,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK;AAAA,MACxD,OAAO;AAAA,MACP,aACE;AAAA,MACF,aAAa;AAAA,IACf,CAAC;AAAA,EACH,CAAC,EACA;AAAA,IACC,CAAC,QACC,IAAI,uBAAuB,UAC1B,IAAI,iBAAiB,UACpB,IAAI,aAAa,UACjB,IAAI,iBAAiB;AAAA,IACzB;AAAA,MACE,SACE;AAAA,IACJ;AAAA,EACF;AACJ;AAEO,IAAM,MAAoB,mBAAmB;AAAA,EAClD,aAAa;AAAA,EACb,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,SACE;AAAA,EACF,QAAQ;AAAA,IACN,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,SACE;AAAA,IACF,SAAS;AAAA,EACX;AAAA,EACA,MAAM;AAAA,IACJ,SACE;AAAA,IACF,OAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAAA,EACA,WACE;AAAA,EACF,aAAa;AAAA,IACX;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF,CAAC;AAQD,IAAM,+BAA+B;AAAA,EACnC,oBAAoB;AAAA,IAClB,aAAa;AAAA,IACb,MAAM;AAAA,EACR;AAAA,EACA,cAAc;AAAA,IACZ,aAAa;AAAA,IACb,MAAM;AAAA,EACR;AAAA,EACA,UAAU;AAAA,IACR,aAAa;AAAA,IACb,MAAM;AAAA,EACR;AAAA,EACA,cAAc;AAAA,IACZ,aAAa;AAAA,IACb,MAAM;AAAA,EACR;AACF;AAIA,IAAM,cAAc,CAAC,eAAe,kBAAkB,WAAW;AAcjE,IAAM,aAAa;AAEnB,SAAS,eAAe,OAAiC;AACvD,SAAO,OAAO,UAAU,YAAY,WAAW,KAAK,KAAK;AAC3D;AAEA,SAAS,cAAc,OAAqD;AAC1E,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,WAAO;AAAA,EACT;AACA,QAAM,IAAI;AACV,SAAO,eAAe,EAAE,SAAS,KAAK,eAAe,EAAE,OAAO;AAChE;AAEA,SAAS,eAAe,OAAsD;AAC5E,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,WAAO;AAAA,EACT;AACA,QAAM,IAAI;AACV,MAAI,OAAO,EAAE,UAAU,UAAU;AAC/B,WAAO;AAAA,EACT;AACA,MAAI,CAAE,YAAkC,SAAS,EAAE,KAAK,GAAG;AACzD,WAAO;AAAA,EACT;AACA,SAAO,cAAc,EAAE,SAAS;AAClC;AAQA,IAAM,gBAA6D;AAAA,EACjE,aAAa;AAAA,IACX,YAAY,CAAC,MAAM;AAAA,IACnB,SAAS,CAAC,mBAAmB,mBAAmB,kBAAkB;AAAA,IAClE,YAAY;AAAA,EACd;AAAA,EACA,gBAAgB;AAAA,IACd,YAAY,CAAC,QAAQ,WAAW;AAAA,IAChC,SAAS,CAAC,cAAc,YAAY;AAAA,IACpC,YAAY;AAAA,EACd;AAAA,EACA,WAAW;AAAA,IACT,YAAY,CAAC,oBAAoB,MAAM;AAAA,IACvC,SAAS,CAAC,aAAa;AAAA,IACvB,YAAY;AAAA,EACd;AACF;AAEA,IAAM,gBAAgB;AAiCtB,SAAS,mBAAmB,OAA2B;AACrD,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,cAAU,OAAO,aAAa,MAAM,CAAC,CAAE;AAAA,EACzC;AACA,SAAO,KAAK,MAAM,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,MAAM,EAAE;AAC9E;AAEA,SAAS,oBAAoB,KAAqB;AAChD,SAAO,mBAAmB,IAAI,YAAY,EAAE,OAAO,GAAG,CAAC;AACzD;AAEA,eAAe,aACb,SACA,eACiB;AACjB,QAAM,SAAS,EAAE,KAAK,SAAS,KAAK,MAAM;AAC1C,QAAM,YAAY,oBAAoB,KAAK,UAAU,MAAM,CAAC;AAC5D,QAAM,aAAa,oBAAoB,KAAK,UAAU,OAAO,CAAC;AAC9D,QAAM,eAAe,GAAG,SAAS,IAAI,UAAU;AAE/C,QAAM,aAAa,cAChB,QAAQ,gCAAgC,EAAE,EAC1C,QAAQ,8BAA8B,EAAE,EACxC,QAAQ,OAAO,EAAE;AACpB,QAAM,MAAM,WAAW,KAAK,KAAK,UAAU,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAEpE,QAAM,MAAM,MAAM,WAAW,OAAO,OAAO;AAAA,IACzC;AAAA,IACA;AAAA,IACA,EAAE,MAAM,qBAAqB,MAAM,UAAU;AAAA,IAC7C;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AAEA,QAAM,YAAY,MAAM,WAAW,OAAO,OAAO;AAAA,IAC/C;AAAA,IACA;AAAA,IACA,IAAI,YAAY,EAAE,OAAO,YAAY;AAAA,EACvC;AAEA,SAAO,GAAG,YAAY,IAAI,mBAAmB,IAAI,WAAW,SAAS,CAAC,CAAC;AACzE;AAEA,SAAS,wBAAwB,OAAkC;AACjE,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,QAAQ,WAAW,GAAG,GAAG;AAC3B,WAAO,KAAK,MAAM,OAAO;AAAA,EAC3B;AACA,QAAM,SAAS,KAAK,OAAO;AAC3B,QAAM,QAAQ,IAAI,WAAW,OAAO,MAAM;AAC1C,WAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK;AACtC,UAAM,CAAC,IAAI,OAAO,WAAW,CAAC;AAAA,EAChC;AACA,QAAM,UAAU,IAAI,YAAY,EAAE,OAAO,KAAK;AAC9C,SAAO,KAAK,MAAM,OAAO;AAC3B;AAEA,eAAe,uBACb,oBACwC;AACxC,QAAM,KAAK,wBAAwB,kBAAkB;AACrD,QAAM,MAAM,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACxC,QAAM,MAAM,MAAM;AAAA,IAChB;AAAA,MACE,KAAK,GAAG;AAAA,MACR,OAAO;AAAA,MACP,KAAK,GAAG,aAAa;AAAA,MACrB,KAAK,MAAM;AAAA,MACX,KAAK;AAAA,IACP;AAAA,IACA,GAAG;AAAA,EACL;AAEA,QAAM,OAAO,IAAI,gBAAgB;AAAA,IAC/B,YAAY;AAAA,IACZ,WAAW;AAAA,EACb,CAAC,EAAE,SAAS;AAEZ,SAAO;AAAA,IACL,KAAK,GAAG,aAAa;AAAA,IACrB;AAAA,EACF;AACF;AAEA,SAAS,UAAU,MAAoB;AACrC,QAAM,IAAI,KAAK,eAAe;AAC9B,QAAM,IAAI,OAAO,KAAK,YAAY,IAAI,CAAC,EAAE,SAAS,GAAG,GAAG;AACxD,QAAM,IAAI,OAAO,KAAK,WAAW,CAAC,EAAE,SAAS,GAAG,GAAG;AACnD,SAAO,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC;AACvB;AAEA,SAAS,YAAY,SAAyB;AAC5C,QAAM,IAAI,QAAQ,MAAM,GAAG,CAAC;AAC5B,QAAM,IAAI,QAAQ,MAAM,GAAG,CAAC;AAC5B,QAAM,IAAI,QAAQ,MAAM,GAAG,CAAC;AAC5B,SAAO,KAAK,IAAI,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,GAAG,OAAO,CAAC,CAAC;AACrD;AAEA,IAAM,aAAa,KAAK,KAAK,KAAK;AAClC,IAAM,4BAA4B;AAElC,SAAS,aACP,SACA,cAC4B;AAC5B,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,UAAU,UAAU,IAAI,KAAK,GAAG,CAAC;AACvC,MAAI,QAAQ,SAAS,YAAY,QAAQ,OAAO;AAC9C,UAAMA,WAAU,OAAO,4BAA4B,KAAK;AACxD,WAAO,EAAE,WAAW,UAAU,IAAI,KAAKA,QAAO,CAAC,GAAG,QAAQ;AAAA,EAC5D;AACA,MAAI,QAAQ,OAAO;AACjB,UAAM,UAAU,IAAI,KAAK,QAAQ,KAAK,EAAE,QAAQ;AAChD,QAAI,OAAO,SAAS,OAAO,GAAG;AAC5B,YAAM,OAAO,KAAK,IAAI,GAAG,KAAK,MAAM,MAAM,WAAW,UAAU,CAAC;AAChE,YAAM,aAAa,KAAK,IAAI,MAAM,YAAY;AAC9C,YAAMA,WAAU,OAAO,aAAa,KAAK;AACzC,aAAO,EAAE,WAAW,UAAU,IAAI,KAAKA,QAAO,CAAC,GAAG,QAAQ;AAAA,IAC5D;AAAA,EACF;AACA,QAAM,UAAU,OAAO,eAAe,KAAK;AAC3C,SAAO,EAAE,WAAW,UAAU,IAAI,KAAK,OAAO,CAAC,GAAG,QAAQ;AAC5D;AAEO,SAAS,kBACd,KACA,kBACA,eACA,YACA,eAMA;AACA,QAAM,OAA+B,CAAC;AACtC,WAAS,IAAI,GAAG,IAAI,iBAAiB,QAAQ,KAAK;AAChD,SAAK,iBAAiB,CAAC,CAAE,IAAI,IAAI,gBAAgB,CAAC,GAAG,SAAS;AAAA,EAChE;AAEA,QAAM,OAA+B,CAAC;AACtC,WAAS,IAAI,GAAG,IAAI,cAAc,QAAQ,KAAK;AAC7C,SAAK,cAAc,CAAC,CAAE,IACpB,WAAW,IAAI,aAAa,CAAC,GAAG,SAAS,GAAG,KAAK;AAAA,EACrD;AAEA,QAAM,UAAU,KAAK,MAAM,KAAK;AAChC,QAAM,KAAK,YAAY,OAAO;AAC9B,QAAM,eAAe,KAAK,cAAc,CAAC,CAAE,KAAK;AAEhD,QAAM,aAA8C;AAAA,IAClD;AAAA,IACA,GAAG;AAAA,IACH,GAAG;AAAA,EACL;AAEA,MAAI,KAAK,kBAAkB,KAAK,KAAK,MAAM,GAAG;AAC5C,UAAM,UAAU,YAAY,KAAK,kBAAkB,CAAC;AACpD,UAAM,SAAS,YAAY,KAAK,MAAM,CAAC;AACvC,UAAM,SAAS,KAAK,IAAI,GAAG,KAAK,OAAO,SAAS,WAAW,UAAU,CAAC;AACtE,eAAW,QAAQ,IAAI;AAAA,EACzB;AAEA,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IACA,OAAO;AAAA,IACP;AAAA,EACF;AACF;AAEA,IAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,OAAO,EAAE,OAAO,EAAE,MAAM,oDAAoD;AAC9E,CAAC;AAED,IAAM,uBAAuB,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AAC3D,IAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB;AAC3C,CAAC;AAED,SAAS,aAAa,gBAAwB,cAAc,MAAM;AAChE,QAAM,QAAQ,cAAc,qBAAqB;AACjD,QAAM,OACJ,mBAAmB,IACf,EAAE,MAAM,CAAC,KAAK,CAAC,IACf,EAAE,MAAM;AAAA,IACN;AAAA,IACA,GAAG,MAAM,iBAAiB,CAAC,EAAE,KAAK,oBAAoB;AAAA,EACxD,CAAmC;AACzC,SAAO,EAAE,OAAO;AAAA,IACd,MAAM,EACH;AAAA,MACC,EAAE,OAAO;AAAA,QACP,iBAAiB;AAAA,QACjB,cAAc,EAAE,MAAM,kBAAkB,EAAE,SAAS;AAAA,MACrD,CAAC;AAAA,IACH,EACC,SAAS;AAAA,EACd,CAAC;AACH;AAEA,IAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,MAAM,EACH;AAAA,IACC,EAAE,OAAO;AAAA,MACP,iBAAiB,EAAE,MAAM,CAAC,oBAAoB,kBAAkB,CAAC;AAAA,MACjE,cAAc,EAAE,MAAM,kBAAkB,EAAE,SAAS;AAAA,IACrD,CAAC;AAAA,EACH,EACC,SAAS;AACd,CAAC;AAED,IAAM,sBAAsB,EAAE,OAAO;AAAA,EACnC,cAAc,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EAC9B,YAAY,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,SAAS;AACnD,CAAC;AAEM,IAAM,6BAA6B,gBAAgB;AAAA,EACxD,sBAAsB;AAAA,IACpB,OAAO;AAAA,IACP,aACE;AAAA,IACF,MAAM;AAAA,IACN,aAAa;AAAA,IACb,UAAU;AAAA,IACV,YAAY;AAAA,MACV,EAAE,MAAM,QAAQ,aAAa,qCAAqC;AAAA,IACpE;AAAA,IACA,WAAW;AAAA,MACT,aAAa;AAAA,MACb,aAAa,aAAa,CAAC;AAAA,IAC7B;AAAA,EACF;AAAA,EACA,yBAAyB;AAAA,IACvB,OAAO;AAAA,IACP,aACE;AAAA,IACF,MAAM;AAAA,IACN,aAAa;AAAA,IACb,UAAU;AAAA,IACV,YAAY;AAAA,MACV,EAAE,MAAM,QAAQ,aAAa,qCAAqC;AAAA,MAClE;AAAA,QACE,MAAM;AAAA,QACN,aAAa;AAAA,MACf;AAAA,IACF;AAAA,IACA,WAAW,EAAE,gBAAgB,aAAa,CAAC,EAAE;AAAA,EAC/C;AAAA,EACA,oBAAoB;AAAA,IAClB,OAAO;AAAA,IACP,aACE;AAAA,IACF,MAAM;AAAA,IACN,aAAa;AAAA,IACb,UAAU;AAAA,IACV,YAAY;AAAA,MACV;AAAA,QACE,MAAM;AAAA,QACN,aAAa;AAAA,MACf;AAAA,MACA;AAAA,QACE,MAAM;AAAA,QACN,aAAa;AAAA,MACf;AAAA,IACF;AAAA,IACA,OACE;AAAA,IACF,WAAW,EAAE,WAAW,sBAAsB;AAAA,EAChD;AACF,CAAC;AAEM,IAAM,KAAK;AAEX,IAAM,6BAAN,MAAM,oCAAmC,cAG9C;AAAA,EACA,OAAgB,KAAK;AAAA,EAErB,OAAgB,YAAY;AAAA,EAE5B,OAAgB,UAAU,qBAAqB,0BAA0B;AAAA,EAEzE,OAAO,OACL,OACA,KAC4B;AAC5B,UAAM,SAAS,aAAa,MAAM,KAAK;AACvC,WAAO,IAAI;AAAA,MACT;AAAA,QACE,YAAY,OAAO;AAAA,QACnB,eAAe,OAAO;AAAA,QACtB,cAAc,OAAO;AAAA,MACvB;AAAA,MACA;AAAA,QACE,oBAAoB,OAAO;AAAA,QAC3B,cAAc,OAAO;AAAA,QACrB,UAAU,OAAO;AAAA,QACjB,cAAc,OAAO;AAAA,MACvB;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAAA,EAES,KAAK;AAAA,EACI,cAAc;AAAA,EAExB,cAA2D;AAAA,EAEnE,MAAc,gBACZ,KACA,MACA,QAC+C;AAC/C,UAAM,MAAM,MAAM,KAAK,KAAoB,KAAK;AAAA,MAC9C,UAAU;AAAA,MACV,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,MAC/D;AAAA,MACA;AAAA,IACF,CAAC;AACD,UAAM,YAAY,IAAI,KAAK,cAAc;AACzC,WAAO;AAAA,MACL,OAAO,IAAI,KAAK;AAAA,MAChB,WAAW,KAAK,IAAI,KAAK,YAAY,MAAM;AAAA,IAC7C;AAAA,EACF;AAAA,EAEA,MAAc,eAAe,QAAuC;AAClE,QAAI,KAAK,eAAe,KAAK,IAAI,IAAI,KAAK,YAAY,WAAW;AAC/D,aAAO,KAAK,YAAY;AAAA,IAC1B;AAEA,UAAM,EAAE,oBAAoB,cAAc,UAAU,aAAa,IAC/D,KAAK;AAEP,QAAI,oBAAoB;AACtB,YAAM,EAAE,KAAK,KAAK,IAAI,MAAM,uBAAuB,kBAAkB;AACrE,WAAK,cAAc,MAAM,KAAK,gBAAgB,KAAK,MAAM,MAAM;AAC/D,aAAO,KAAK,YAAY;AAAA,IAC1B;AAEA,QAAI,gBAAgB,YAAY,cAAc;AAC5C,YAAM,OAAO,IAAI,gBAAgB;AAAA,QAC/B,YAAY;AAAA,QACZ,eAAe;AAAA,QACf,WAAW;AAAA,QACX,eAAe;AAAA,MACjB,CAAC,EAAE,SAAS;AACZ,WAAK,cAAc,MAAM,KAAK;AAAA,QAC5B;AAAA,QACA;AAAA,QACA;AAAA,MACF;AACA,aAAO,KAAK,YAAY;AAAA,IAC1B;AAEA,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAc,UACZ,aACA,OACA,WACA,QACA,QAC2B;AAC3B,UAAM,EAAE,YAAY,QAAQ,IAAI,cAAc,KAAK;AACnD,UAAM,MAAM,0DAA0D,KAAK,SAAS,UAAU;AAE9F,UAAM,OAAgC;AAAA,MACpC,YAAY,WAAW,IAAI,CAAC,UAAU,EAAE,KAAK,EAAE;AAAA,MAC/C,SAAS,QAAQ,IAAI,CAAC,UAAU,EAAE,KAAK,EAAE;AAAA,MACzC,YAAY;AAAA,QACV,EAAE,WAAW,UAAU,WAAW,SAAS,UAAU,QAAQ;AAAA,MAC/D;AAAA,MACA,OAAO;AAAA,MACP;AAAA,IACF;AAEA,UAAM,MAAM,MAAM,KAAK,KAAuB,KAAK;AAAA,MACjD,UAAU;AAAA,MACV,SAAS;AAAA,QACP,eAAe,UAAU,WAAW;AAAA,QACpC,gBAAgB;AAAA,QAChB,cAAc,mBAAmB,oBAAoB;AAAA,MACvD;AAAA,MACA,MAAM,KAAK,UAAU,IAAI;AAAA,MACzB;AAAA,IACF,CAAC;AACD,WAAO,IAAI;AAAA,EACb;AAAA,EAEA,MAAM,KACJ,SACA,SACA,QACqB;AACrB,UAAM,eAAe,KAAK,SAAS,gBAAgB;AAEnD,UAAM,SAAS,eAAe,QAAQ,MAAM,IAAI,QAAQ,SAAS;AACjE,UAAM,YAAY,QAAQ,aAAa,aAAa,SAAS,YAAY;AAEzE,QAAI,cAA6B;AACjC,UAAM,WAAW,OAAO,QAAuC;AAC7D,UAAI,CAAC,aAAa;AAChB,sBAAc,MAAM,KAAK,eAAe,GAAG;AAAA,MAC7C;AACA,aAAO;AAAA,IACT;AAEA,UAAM,qBAAqB,OACzB,OACA,QACA,QAC8B;AAC9B,YAAM,QAAQ,MAAM,SAAS,GAAG;AAChC,UAAI;AACF,eAAO,MAAM,KAAK,UAAU,OAAO,OAAO,WAAW,QAAQ,GAAG;AAAA,MAClE,SAAS,KAAK;AACZ,aAAK,OAAO,KAAK,mDAAmD;AAAA,UAClE,KAAK,OAAO,GAAG;AAAA,UACf;AAAA,QACF,CAAC;AACD,sBAAc;AACd,cAAM,aAAa,MAAM,SAAS,GAAG;AACrC,eAAO,KAAK,UAAU,YAAY,OAAO,WAAW,QAAQ,GAAG;AAAA,MACjE;AAAA,IACF;AAEA,UAAM,aAAa,OACjB,UAC2B;AAC3B,YAAM,UAAyB,CAAC;AAChC,UAAI,SAAS;AACb,iBAAS;AACP,cAAM,WAAW,MAAM,mBAAmB,OAAO,QAAQ,MAAM;AAC/D,cAAM,OAAO,SAAS,QAAQ,CAAC;AAC/B,gBAAQ,KAAK,GAAG,IAAI;AACpB,kBAAU,KAAK;AACf,YAAI,KAAK,WAAW,GAAG;AACrB;AAAA,QACF;AACA,cAAM,OACJ,OAAO,SAAS,aAAa,WACzB,UAAU,SAAS,WACnB,KAAK,SAAS;AACpB,YAAI,MAAM;AACR;AAAA,QACF;AAAA,MACF;AACA,aAAO;AAAA,IACT;AAEA,UAAM,UAAU,QAAQ;AACxB,UAAM,iBAAiB,CAAC,UAA2C;AACjE,UAAI,CAAC,WAAW,QAAQ,SAAS,GAAG;AAClC,eAAO;AAAA,MACT;AACA,aAAO,QAAQ,IAAI,cAAc,KAAK,EAAE,UAAU;AAAA,IACpD;AAEA,UAAM,YAAY,SAAS,YAAY,QAAQ,OAAO,KAAK,IAAI;AAC/D,UAAM,WAAW,aAAa,IAAI,YAAY;AAE9C,aAAS,IAAI,UAAU,IAAI,YAAY,QAAQ,KAAK;AAClD,YAAM,QAAQ,YAAY,CAAC;AAC3B,UAAI,QAAQ,SAAS;AACnB,eAAO,EAAE,MAAM,OAAO,QAAQ,EAAE,OAAO,UAAU,EAAE;AAAA,MACrD;AACA,UAAI,CAAC,eAAe,KAAK,GAAG;AAC1B;AAAA,MACF;AAEA,UAAI;AACJ,UAAI;AACF,eAAO,MAAM,WAAW,KAAK;AAAA,MAC/B,SAAS,KAAK;AACZ,YAAI,QAAQ,SAAS;AACnB,iBAAO,EAAE,MAAM,OAAO,QAAQ,EAAE,OAAO,UAAU,EAAE;AAAA,QACrD;AACA,cAAM;AAAA,MACR;AACA,YAAM,MAAM,cAAc,KAAK;AAC/B,YAAM,UAAU,KAAK;AAAA,QAAI,CAAC,QACxB;AAAA,UACE;AAAA,UACA,IAAI;AAAA,UACJ,IAAI;AAAA,UACJ,IAAI;AAAA,UACJ,KAAK,SAAS;AAAA,QAChB;AAAA,MACF;AACA,YAAM,QAAQ,QAAQ,SAAS,EAAE,OAAO,CAAC,IAAI,UAAU,EAAE,CAAC;AAAA,IAC5D;AAEA,WAAO,EAAE,MAAM,KAAK;AAAA,EACtB;AACF;;;AC5tBA,IAAO,gBAAQ;","names":["startMs"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rawdash/connector-firebase-analytics",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rawdash connector for Firebase Analytics - syncs DAU/WAU/MAU, per-event volume, and cohort retention from the GA4 Data API into the six-shape storage model",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/rawdash/rawdash.git",
|
|
11
|
+
"directory": "packages/connectors/firebase-analytics"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"@rawdash/source": "./src/index.ts",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"lint": "eslint src",
|
|
29
|
+
"test": "vitest run"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@rawdash/core": "workspace:*",
|
|
33
|
+
"zod": "^4.4.3"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@rawdash/connector-shared": "workspace:*",
|
|
37
|
+
"@rawdash/connector-test-utils": "workspace:*",
|
|
38
|
+
"fast-check": "^4.8.0",
|
|
39
|
+
"tsup": "^8.0.0",
|
|
40
|
+
"typescript": "^5.7.2",
|
|
41
|
+
"vitest": "^4.1.4"
|
|
42
|
+
}
|
|
43
|
+
}
|