@happyvertical/analytics 0.74.8

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.
@@ -0,0 +1,863 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { google } from "googleapis";
3
+ import { AuthenticationError, AnalyticsError, QuotaExceededError, RateLimitError, PropertyNotFoundError } from "../index.js";
4
+ const MEASUREMENT_PROTOCOL_URL = "https://www.google-analytics.com/mp/collect";
5
+ const PROPERTY_HYDRATION_CONCURRENCY = 5;
6
+ class GA4Provider {
7
+ adminClient = null;
8
+ dataClient = null;
9
+ options;
10
+ credentials = null;
11
+ constructor(options) {
12
+ this.options = {
13
+ timeout: 3e4,
14
+ maxRetries: 3,
15
+ cacheTTL: 36e5,
16
+ // 1 hour
17
+ ...options
18
+ };
19
+ }
20
+ /**
21
+ * Initialize Google API clients with service account credentials
22
+ */
23
+ async ensureClients() {
24
+ if (this.adminClient && this.dataClient) {
25
+ return;
26
+ }
27
+ if (!this.credentials && this.options.serviceAccountKey) {
28
+ if (typeof this.options.serviceAccountKey === "string") {
29
+ const content = await readFile(this.options.serviceAccountKey, "utf-8");
30
+ this.credentials = JSON.parse(content);
31
+ } else {
32
+ this.credentials = this.options.serviceAccountKey;
33
+ }
34
+ }
35
+ if (!this.credentials) {
36
+ throw new AuthenticationError("ga4");
37
+ }
38
+ const auth = new google.auth.GoogleAuth({
39
+ credentials: this.credentials,
40
+ scopes: [
41
+ "https://www.googleapis.com/auth/analytics.readonly",
42
+ "https://www.googleapis.com/auth/analytics.edit"
43
+ ]
44
+ });
45
+ this.adminClient = google.analyticsadmin({
46
+ version: "v1beta",
47
+ auth
48
+ });
49
+ this.dataClient = google.analyticsdata({
50
+ version: "v1beta",
51
+ auth
52
+ });
53
+ }
54
+ /**
55
+ * Normalize property ID to numeric format
56
+ */
57
+ normalizePropertyId(propertyId) {
58
+ return propertyId.replace(/^properties\//, "");
59
+ }
60
+ /**
61
+ * Get full property resource name
62
+ */
63
+ getPropertyName(propertyId) {
64
+ const id = this.normalizePropertyId(propertyId);
65
+ return `properties/${id}`;
66
+ }
67
+ /**
68
+ * Map a Google Analytics property resource to the public Property shape
69
+ */
70
+ mapProperty(property) {
71
+ return {
72
+ id: property.name?.replace("properties/", "") || "",
73
+ name: property.name || "",
74
+ displayName: property.displayName || "",
75
+ createTime: property.createTime || "",
76
+ updateTime: property.updateTime ?? void 0,
77
+ timeZone: property.timeZone ?? void 0,
78
+ currencyCode: property.currencyCode ?? void 0,
79
+ industryCategory: property.industryCategory ?? void 0,
80
+ serviceLevel: property.serviceLevel
81
+ };
82
+ }
83
+ /**
84
+ * Hydrate discovered properties without firing unbounded concurrent requests
85
+ */
86
+ async hydrateProperties(properties) {
87
+ const hydrated = [];
88
+ for (let index = 0; index < properties.length; index += PROPERTY_HYDRATION_CONCURRENCY) {
89
+ const batch = properties.slice(
90
+ index,
91
+ index + PROPERTY_HYDRATION_CONCURRENCY
92
+ );
93
+ const hydratedBatch = await Promise.all(
94
+ batch.map(async (property) => {
95
+ const response = await this.adminClient.properties.get({
96
+ name: property.name
97
+ });
98
+ return this.mapProperty(response.data);
99
+ })
100
+ );
101
+ hydrated.push(...hydratedBatch);
102
+ }
103
+ return hydrated;
104
+ }
105
+ /**
106
+ * Map Google API errors to our error types
107
+ */
108
+ mapError(error) {
109
+ if (error instanceof AnalyticsError) {
110
+ return error;
111
+ }
112
+ const err = error;
113
+ const status = err.code || err.status;
114
+ const message = err.message || "Unknown error";
115
+ switch (status) {
116
+ case 401:
117
+ case 403:
118
+ return new AuthenticationError("ga4");
119
+ case 404:
120
+ return new PropertyNotFoundError(message, "ga4");
121
+ case 429:
122
+ return new RateLimitError("ga4");
123
+ case 402:
124
+ return new QuotaExceededError("ga4");
125
+ default:
126
+ return new AnalyticsError(message, "API_ERROR", "ga4");
127
+ }
128
+ }
129
+ // ===========================================================================
130
+ // Property Management
131
+ // ===========================================================================
132
+ async createProperty(options) {
133
+ await this.ensureClients();
134
+ if (!options.parent) {
135
+ throw new AnalyticsError(
136
+ "Parent account is required for creating GA4 properties",
137
+ "MISSING_PARENT",
138
+ "ga4"
139
+ );
140
+ }
141
+ try {
142
+ const response = await this.adminClient.properties.create({
143
+ requestBody: {
144
+ displayName: options.displayName,
145
+ timeZone: options.timeZone || "America/Los_Angeles",
146
+ currencyCode: options.currencyCode || "USD",
147
+ industryCategory: options.industryCategory,
148
+ parent: options.parent
149
+ }
150
+ });
151
+ const property = this.mapProperty(response.data);
152
+ if (!property.createTime) {
153
+ property.createTime = (/* @__PURE__ */ new Date()).toISOString();
154
+ }
155
+ return property;
156
+ } catch (error) {
157
+ throw this.mapError(error);
158
+ }
159
+ }
160
+ async listProperties(options) {
161
+ await this.ensureClients();
162
+ try {
163
+ const properties = /* @__PURE__ */ new Map();
164
+ let pageToken;
165
+ do {
166
+ const response = await this.adminClient.accountSummaries.list({
167
+ pageSize: 200,
168
+ pageToken
169
+ });
170
+ for (const accountSummary of response.data.accountSummaries || []) {
171
+ for (const propertySummary of accountSummary.propertySummaries || []) {
172
+ const propertyName = propertySummary.property || "";
173
+ if (!propertyName || properties.has(propertyName)) {
174
+ continue;
175
+ }
176
+ properties.set(propertyName, {
177
+ id: propertyName.replace("properties/", ""),
178
+ name: propertyName,
179
+ displayName: propertySummary.displayName || "",
180
+ createTime: ""
181
+ });
182
+ }
183
+ }
184
+ pageToken = response.data.nextPageToken ?? void 0;
185
+ } while (pageToken);
186
+ const listedProperties = [...properties.values()];
187
+ if (options?.hydrate === false) {
188
+ return listedProperties;
189
+ }
190
+ return this.hydrateProperties(listedProperties);
191
+ } catch (error) {
192
+ throw this.mapError(error);
193
+ }
194
+ }
195
+ async getProperty(propertyId) {
196
+ await this.ensureClients();
197
+ try {
198
+ const response = await this.adminClient.properties.get({
199
+ name: this.getPropertyName(propertyId)
200
+ });
201
+ return this.mapProperty(response.data);
202
+ } catch (error) {
203
+ throw this.mapError(error);
204
+ }
205
+ }
206
+ async updateProperty(propertyId, data) {
207
+ await this.ensureClients();
208
+ const updateMask = [];
209
+ if (data.displayName) updateMask.push("displayName");
210
+ if (data.timeZone) updateMask.push("timeZone");
211
+ if (data.currencyCode) updateMask.push("currencyCode");
212
+ if (data.industryCategory) updateMask.push("industryCategory");
213
+ try {
214
+ const response = await this.adminClient.properties.patch({
215
+ name: this.getPropertyName(propertyId),
216
+ updateMask: updateMask.join(","),
217
+ requestBody: {
218
+ displayName: data.displayName,
219
+ timeZone: data.timeZone,
220
+ currencyCode: data.currencyCode,
221
+ industryCategory: data.industryCategory
222
+ }
223
+ });
224
+ return this.mapProperty(response.data);
225
+ } catch (error) {
226
+ throw this.mapError(error);
227
+ }
228
+ }
229
+ async deleteProperty(propertyId) {
230
+ await this.ensureClients();
231
+ try {
232
+ await this.adminClient.properties.delete({
233
+ name: this.getPropertyName(propertyId)
234
+ });
235
+ } catch (error) {
236
+ throw this.mapError(error);
237
+ }
238
+ }
239
+ // ===========================================================================
240
+ // Data Streams
241
+ // ===========================================================================
242
+ async getDataStreams(propertyId) {
243
+ await this.ensureClients();
244
+ try {
245
+ const response = await this.adminClient.properties.dataStreams.list({
246
+ parent: this.getPropertyName(propertyId)
247
+ });
248
+ return (response.data.dataStreams || []).map(
249
+ (ds) => ({
250
+ id: ds.name?.split("/").pop() || "",
251
+ type: ds.type,
252
+ displayName: ds.displayName || "",
253
+ measurementId: ds.webStreamData?.measurementId ?? void 0,
254
+ firebaseAppId: ds.androidAppStreamData?.firebaseAppId ?? ds.iosAppStreamData?.firebaseAppId ?? void 0,
255
+ defaultUri: ds.webStreamData?.defaultUri ?? void 0,
256
+ createTime: ds.createTime || "",
257
+ updateTime: ds.updateTime ?? void 0
258
+ })
259
+ );
260
+ } catch (error) {
261
+ throw this.mapError(error);
262
+ }
263
+ }
264
+ async createDataStream(propertyId, options) {
265
+ await this.ensureClients();
266
+ try {
267
+ const requestBody = {
268
+ type: options.type,
269
+ displayName: options.displayName
270
+ };
271
+ if (options.type === "WEB_DATA_STREAM") {
272
+ requestBody.webStreamData = {
273
+ defaultUri: options.defaultUri
274
+ };
275
+ } else if (options.type === "ANDROID_APP_DATA_STREAM") {
276
+ requestBody.androidAppStreamData = {
277
+ packageName: options.packageName
278
+ };
279
+ } else if (options.type === "IOS_APP_DATA_STREAM") {
280
+ requestBody.iosAppStreamData = {
281
+ bundleId: options.bundleId
282
+ };
283
+ }
284
+ const response = await this.adminClient.properties.dataStreams.create({
285
+ parent: this.getPropertyName(propertyId),
286
+ requestBody
287
+ });
288
+ const ds = response.data;
289
+ return {
290
+ id: ds.name?.split("/").pop() || "",
291
+ type: ds.type,
292
+ displayName: ds.displayName || "",
293
+ measurementId: ds.webStreamData?.measurementId ?? void 0,
294
+ firebaseAppId: ds.androidAppStreamData?.firebaseAppId ?? ds.iosAppStreamData?.firebaseAppId ?? void 0,
295
+ defaultUri: ds.webStreamData?.defaultUri ?? void 0,
296
+ createTime: ds.createTime || "",
297
+ updateTime: ds.updateTime ?? void 0
298
+ };
299
+ } catch (error) {
300
+ throw this.mapError(error);
301
+ }
302
+ }
303
+ async deleteDataStream(propertyId, streamId) {
304
+ await this.ensureClients();
305
+ try {
306
+ await this.adminClient.properties.dataStreams.delete({
307
+ name: `${this.getPropertyName(propertyId)}/dataStreams/${streamId}`
308
+ });
309
+ } catch (error) {
310
+ throw this.mapError(error);
311
+ }
312
+ }
313
+ // ===========================================================================
314
+ // Custom Definitions
315
+ // ===========================================================================
316
+ async getCustomDimensions(propertyId) {
317
+ await this.ensureClients();
318
+ try {
319
+ const response = await this.adminClient.properties.customDimensions.list(
320
+ {
321
+ parent: this.getPropertyName(propertyId)
322
+ }
323
+ );
324
+ return (response.data.customDimensions || []).map(
325
+ (cd) => ({
326
+ id: cd.name?.split("/").pop() || "",
327
+ name: cd.name || "",
328
+ parameterName: cd.parameterName || "",
329
+ displayName: cd.displayName || "",
330
+ description: cd.description ?? void 0,
331
+ scope: cd.scope,
332
+ disallowAdsPersonalization: cd.disallowAdsPersonalization ?? void 0
333
+ })
334
+ );
335
+ } catch (error) {
336
+ throw this.mapError(error);
337
+ }
338
+ }
339
+ async createCustomDimension(propertyId, options) {
340
+ await this.ensureClients();
341
+ try {
342
+ const response = await this.adminClient.properties.customDimensions.create({
343
+ parent: this.getPropertyName(propertyId),
344
+ requestBody: {
345
+ parameterName: options.parameterName,
346
+ displayName: options.displayName,
347
+ description: options.description,
348
+ scope: options.scope,
349
+ disallowAdsPersonalization: options.disallowAdsPersonalization
350
+ }
351
+ });
352
+ const cd = response.data;
353
+ return {
354
+ id: cd.name?.split("/").pop() || "",
355
+ name: cd.name || "",
356
+ parameterName: cd.parameterName || "",
357
+ displayName: cd.displayName || "",
358
+ description: cd.description ?? void 0,
359
+ scope: cd.scope,
360
+ disallowAdsPersonalization: cd.disallowAdsPersonalization ?? void 0
361
+ };
362
+ } catch (error) {
363
+ throw this.mapError(error);
364
+ }
365
+ }
366
+ async archiveCustomDimension(propertyId, dimensionId) {
367
+ await this.ensureClients();
368
+ try {
369
+ await this.adminClient.properties.customDimensions.archive({
370
+ name: `${this.getPropertyName(propertyId)}/customDimensions/${dimensionId}`
371
+ });
372
+ } catch (error) {
373
+ throw this.mapError(error);
374
+ }
375
+ }
376
+ async getCustomMetrics(propertyId) {
377
+ await this.ensureClients();
378
+ try {
379
+ const response = await this.adminClient.properties.customMetrics.list({
380
+ parent: this.getPropertyName(propertyId)
381
+ });
382
+ return (response.data.customMetrics || []).map(
383
+ (cm) => ({
384
+ id: cm.name?.split("/").pop() || "",
385
+ name: cm.name || "",
386
+ parameterName: cm.parameterName || "",
387
+ displayName: cm.displayName || "",
388
+ description: cm.description ?? void 0,
389
+ scope: "EVENT",
390
+ measurementUnit: cm.measurementUnit,
391
+ restrictedMetricType: cm.restrictedMetricType?.[0] ?? void 0
392
+ })
393
+ );
394
+ } catch (error) {
395
+ throw this.mapError(error);
396
+ }
397
+ }
398
+ async createCustomMetric(propertyId, options) {
399
+ await this.ensureClients();
400
+ try {
401
+ const response = await this.adminClient.properties.customMetrics.create({
402
+ parent: this.getPropertyName(propertyId),
403
+ requestBody: {
404
+ parameterName: options.parameterName,
405
+ displayName: options.displayName,
406
+ description: options.description,
407
+ measurementUnit: options.measurementUnit,
408
+ restrictedMetricType: options.restrictedMetricType ? [options.restrictedMetricType] : void 0,
409
+ scope: "EVENT"
410
+ }
411
+ });
412
+ const cm = response.data;
413
+ return {
414
+ id: cm.name?.split("/").pop() || "",
415
+ name: cm.name || "",
416
+ parameterName: cm.parameterName || "",
417
+ displayName: cm.displayName || "",
418
+ description: cm.description ?? void 0,
419
+ scope: "EVENT",
420
+ measurementUnit: cm.measurementUnit,
421
+ restrictedMetricType: cm.restrictedMetricType?.[0] ?? void 0
422
+ };
423
+ } catch (error) {
424
+ throw this.mapError(error);
425
+ }
426
+ }
427
+ async archiveCustomMetric(propertyId, metricId) {
428
+ await this.ensureClients();
429
+ try {
430
+ await this.adminClient.properties.customMetrics.archive({
431
+ name: `${this.getPropertyName(propertyId)}/customMetrics/${metricId}`
432
+ });
433
+ } catch (error) {
434
+ throw this.mapError(error);
435
+ }
436
+ }
437
+ // ===========================================================================
438
+ // Key Events (Conversions)
439
+ // ===========================================================================
440
+ async getKeyEvents(propertyId) {
441
+ await this.ensureClients();
442
+ try {
443
+ const response = await this.adminClient.properties.keyEvents.list({
444
+ parent: this.getPropertyName(propertyId)
445
+ });
446
+ return (response.data.keyEvents || []).map(
447
+ (ke) => ({
448
+ id: ke.name?.split("/").pop() || "",
449
+ name: ke.name || "",
450
+ eventName: ke.eventName || "",
451
+ createTime: ke.createTime || "",
452
+ countingMethod: ke.countingMethod,
453
+ defaultValue: ke.defaultValue ? {
454
+ numericValue: ke.defaultValue.numericValue ?? void 0,
455
+ currencyCode: ke.defaultValue.currencyCode ?? void 0
456
+ } : void 0
457
+ })
458
+ );
459
+ } catch (error) {
460
+ throw this.mapError(error);
461
+ }
462
+ }
463
+ async createKeyEvent(propertyId, options) {
464
+ await this.ensureClients();
465
+ try {
466
+ const response = await this.adminClient.properties.keyEvents.create({
467
+ parent: this.getPropertyName(propertyId),
468
+ requestBody: {
469
+ eventName: options.eventName,
470
+ countingMethod: options.countingMethod,
471
+ defaultValue: options.defaultValue
472
+ }
473
+ });
474
+ const ke = response.data;
475
+ return {
476
+ id: ke.name?.split("/").pop() || "",
477
+ name: ke.name || "",
478
+ eventName: ke.eventName || "",
479
+ createTime: ke.createTime || "",
480
+ countingMethod: ke.countingMethod,
481
+ defaultValue: ke.defaultValue ? {
482
+ numericValue: ke.defaultValue.numericValue ?? void 0,
483
+ currencyCode: ke.defaultValue.currencyCode ?? void 0
484
+ } : void 0
485
+ };
486
+ } catch (error) {
487
+ throw this.mapError(error);
488
+ }
489
+ }
490
+ async deleteKeyEvent(propertyId, eventId) {
491
+ await this.ensureClients();
492
+ try {
493
+ await this.adminClient.properties.keyEvents.delete({
494
+ name: `${this.getPropertyName(propertyId)}/keyEvents/${eventId}`
495
+ });
496
+ } catch (error) {
497
+ throw this.mapError(error);
498
+ }
499
+ }
500
+ // ===========================================================================
501
+ // Reporting
502
+ // ===========================================================================
503
+ async runReport(propertyId, options) {
504
+ await this.ensureClients();
505
+ try {
506
+ const response = await this.dataClient.properties.runReport({
507
+ property: this.getPropertyName(propertyId),
508
+ requestBody: {
509
+ dateRanges: options.dateRanges,
510
+ dimensions: options.dimensions,
511
+ metrics: options.metrics,
512
+ dimensionFilter: options.dimensionFilter,
513
+ metricFilter: options.metricFilter,
514
+ offset: options.offset ? String(options.offset) : void 0,
515
+ limit: options.limit ? String(options.limit) : void 0,
516
+ orderBys: options.orderBys,
517
+ keepEmptyRows: options.keepEmptyRows,
518
+ returnPropertyQuota: options.returnPropertyQuota
519
+ }
520
+ });
521
+ const data = response.data;
522
+ return {
523
+ dimensionHeaders: (data.dimensionHeaders || []).map(
524
+ (h) => ({
525
+ name: h.name || ""
526
+ })
527
+ ),
528
+ metricHeaders: (data.metricHeaders || []).map(
529
+ (h) => ({
530
+ name: h.name || "",
531
+ type: h.type || ""
532
+ })
533
+ ),
534
+ rows: (data.rows || []).map((r) => ({
535
+ dimensionValues: (r.dimensionValues || []).map(
536
+ (v) => ({
537
+ value: v.value || ""
538
+ })
539
+ ),
540
+ metricValues: (r.metricValues || []).map(
541
+ (v) => ({
542
+ value: v.value || ""
543
+ })
544
+ )
545
+ })),
546
+ rowCount: data.rowCount ?? void 0,
547
+ metadata: data.metadata ? {
548
+ currencyCode: data.metadata.currencyCode ?? void 0,
549
+ timeZone: data.metadata.timeZone ?? void 0,
550
+ dataLossFromOtherRow: data.metadata.dataLossFromOtherRow ?? void 0,
551
+ emptyReason: data.metadata.emptyReason ?? void 0
552
+ } : void 0,
553
+ propertyQuota: data.propertyQuota ? {
554
+ tokensPerDay: {
555
+ consumed: data.propertyQuota.tokensPerDay?.consumed || 0,
556
+ remaining: data.propertyQuota.tokensPerDay?.remaining || 0
557
+ },
558
+ tokensPerHour: {
559
+ consumed: data.propertyQuota.tokensPerHour?.consumed || 0,
560
+ remaining: data.propertyQuota.tokensPerHour?.remaining || 0
561
+ },
562
+ concurrentRequests: {
563
+ consumed: data.propertyQuota.concurrentRequests?.consumed || 0,
564
+ remaining: data.propertyQuota.concurrentRequests?.remaining || 0
565
+ }
566
+ } : void 0
567
+ };
568
+ } catch (error) {
569
+ throw this.mapError(error);
570
+ }
571
+ }
572
+ async runRealtimeReport(propertyId, options) {
573
+ await this.ensureClients();
574
+ try {
575
+ const response = await this.dataClient.properties.runRealtimeReport({
576
+ property: this.getPropertyName(propertyId),
577
+ requestBody: {
578
+ dimensions: options?.dimensions,
579
+ metrics: options?.metrics || [{ name: "activeUsers" }],
580
+ dimensionFilter: options?.dimensionFilter,
581
+ metricFilter: options?.metricFilter,
582
+ limit: options?.limit ? String(options.limit) : void 0,
583
+ minuteRanges: options?.minuteRanges
584
+ }
585
+ });
586
+ const data = response.data;
587
+ return {
588
+ dimensionHeaders: (data.dimensionHeaders || []).map(
589
+ (h) => ({
590
+ name: h.name || ""
591
+ })
592
+ ),
593
+ metricHeaders: (data.metricHeaders || []).map(
594
+ (h) => ({
595
+ name: h.name || "",
596
+ type: h.type || ""
597
+ })
598
+ ),
599
+ rows: (data.rows || []).map((r) => ({
600
+ dimensionValues: (r.dimensionValues || []).map(
601
+ (v) => ({
602
+ value: v.value || ""
603
+ })
604
+ ),
605
+ metricValues: (r.metricValues || []).map(
606
+ (v) => ({
607
+ value: v.value || ""
608
+ })
609
+ )
610
+ })),
611
+ rowCount: data.rowCount ?? void 0
612
+ };
613
+ } catch (error) {
614
+ throw this.mapError(error);
615
+ }
616
+ }
617
+ async getMetrics(propertyId) {
618
+ await this.ensureClients();
619
+ try {
620
+ const response = await this.dataClient.properties.getMetadata({
621
+ name: `${this.getPropertyName(propertyId)}/metadata`
622
+ });
623
+ return (response.data.metrics || []).map(
624
+ (m) => ({
625
+ apiName: m.apiName || "",
626
+ uiName: m.uiName || "",
627
+ description: m.description || "",
628
+ deprecatedApiNames: m.deprecatedApiNames ?? void 0,
629
+ type: m.type,
630
+ expression: m.expression ?? void 0,
631
+ customDefinition: m.customDefinition ?? void 0,
632
+ blockedReasons: m.blockedReasons ?? void 0,
633
+ category: m.category ?? void 0
634
+ })
635
+ );
636
+ } catch (error) {
637
+ throw this.mapError(error);
638
+ }
639
+ }
640
+ async getDimensions(propertyId) {
641
+ await this.ensureClients();
642
+ try {
643
+ const response = await this.dataClient.properties.getMetadata({
644
+ name: `${this.getPropertyName(propertyId)}/metadata`
645
+ });
646
+ return (response.data.dimensions || []).map(
647
+ (d) => ({
648
+ apiName: d.apiName || "",
649
+ uiName: d.uiName || "",
650
+ description: d.description || "",
651
+ deprecatedApiNames: d.deprecatedApiNames ?? void 0,
652
+ customDefinition: d.customDefinition ?? void 0,
653
+ category: d.category ?? void 0
654
+ })
655
+ );
656
+ } catch (error) {
657
+ throw this.mapError(error);
658
+ }
659
+ }
660
+ // ===========================================================================
661
+ // Event Tracking (Measurement Protocol)
662
+ // ===========================================================================
663
+ async track(event) {
664
+ if (!this.options.measurementId || !this.options.apiSecret) {
665
+ throw new AnalyticsError(
666
+ "Measurement ID and API Secret are required for server-side tracking",
667
+ "MISSING_CREDENTIALS",
668
+ "ga4"
669
+ );
670
+ }
671
+ const clientId = event.clientId || this.generateClientId();
672
+ const url = `${MEASUREMENT_PROTOCOL_URL}?measurement_id=${this.options.measurementId}&api_secret=${this.options.apiSecret}`;
673
+ const payload = {
674
+ client_id: clientId,
675
+ user_id: event.userId,
676
+ timestamp_micros: event.timestamp,
677
+ non_personalized_ads: event.nonPersonalizedAds,
678
+ events: [
679
+ {
680
+ name: event.name,
681
+ params: event.params
682
+ }
683
+ ]
684
+ };
685
+ try {
686
+ const response = await fetch(url, {
687
+ method: "POST",
688
+ headers: {
689
+ "Content-Type": "application/json"
690
+ },
691
+ body: JSON.stringify(payload)
692
+ });
693
+ if (!response.ok) {
694
+ throw new AnalyticsError(
695
+ `Measurement Protocol error: ${response.status}`,
696
+ "TRACKING_ERROR",
697
+ "ga4"
698
+ );
699
+ }
700
+ } catch (error) {
701
+ if (error instanceof AnalyticsError) {
702
+ throw error;
703
+ }
704
+ throw this.mapError(error);
705
+ }
706
+ }
707
+ async trackPageview(pageview) {
708
+ await this.track({
709
+ name: "page_view",
710
+ params: {
711
+ page_path: pageview.pagePath,
712
+ page_title: pageview.pageTitle || "",
713
+ page_location: pageview.pageLocation || "",
714
+ ...pageview.params
715
+ },
716
+ clientId: pageview.clientId,
717
+ userId: pageview.userId
718
+ });
719
+ }
720
+ async trackBatch(events) {
721
+ if (!this.options.measurementId || !this.options.apiSecret) {
722
+ throw new AnalyticsError(
723
+ "Measurement ID and API Secret are required for server-side tracking",
724
+ "MISSING_CREDENTIALS",
725
+ "ga4"
726
+ );
727
+ }
728
+ const batchSize = 25;
729
+ for (let i = 0; i < events.length; i += batchSize) {
730
+ const batch = events.slice(i, i + batchSize);
731
+ const byClient = /* @__PURE__ */ new Map();
732
+ for (const event of batch) {
733
+ const clientId = event.clientId || this.generateClientId();
734
+ const existing = byClient.get(clientId) || [];
735
+ existing.push(event);
736
+ byClient.set(clientId, existing);
737
+ }
738
+ for (const [clientId, clientEvents] of byClient) {
739
+ const url = `${MEASUREMENT_PROTOCOL_URL}?measurement_id=${this.options.measurementId}&api_secret=${this.options.apiSecret}`;
740
+ const payload = {
741
+ client_id: clientId,
742
+ events: clientEvents.map((e) => ({
743
+ name: e.name,
744
+ params: e.params
745
+ }))
746
+ };
747
+ try {
748
+ const response = await fetch(url, {
749
+ method: "POST",
750
+ headers: {
751
+ "Content-Type": "application/json"
752
+ },
753
+ body: JSON.stringify(payload)
754
+ });
755
+ if (!response.ok) {
756
+ throw new AnalyticsError(
757
+ `Measurement Protocol error: ${response.status}`,
758
+ "TRACKING_ERROR",
759
+ "ga4"
760
+ );
761
+ }
762
+ } catch (error) {
763
+ if (error instanceof AnalyticsError) {
764
+ throw error;
765
+ }
766
+ throw this.mapError(error);
767
+ }
768
+ }
769
+ }
770
+ }
771
+ async identify(userId, traits) {
772
+ await this.track({
773
+ name: "user_engagement",
774
+ userId,
775
+ params: traits
776
+ });
777
+ }
778
+ /**
779
+ * Generate a random client ID for Measurement Protocol
780
+ */
781
+ generateClientId() {
782
+ return `${Math.floor(Math.random() * 2147483647)}.${Math.floor(Date.now() / 1e3)}`;
783
+ }
784
+ // ===========================================================================
785
+ // Client-Side Helpers
786
+ // ===========================================================================
787
+ generateTrackingSnippet(propertyId, options) {
788
+ const measurementId = this.options.measurementId || `G-${propertyId}`;
789
+ const configOptions = {};
790
+ if (options?.anonymizeIp) {
791
+ configOptions.anonymize_ip = true;
792
+ }
793
+ if (options?.sendPageView === false) {
794
+ configOptions.send_page_view = false;
795
+ }
796
+ if (options?.cookieFlags) {
797
+ configOptions.cookie_flags = options.cookieFlags;
798
+ }
799
+ if (options?.customConfig) {
800
+ Object.assign(configOptions, options.customConfig);
801
+ }
802
+ const configStr = Object.keys(configOptions).length > 0 ? `, ${JSON.stringify(configOptions)}` : "";
803
+ const html = `<!-- Google Analytics -->
804
+ <script async src="https://www.googletagmanager.com/gtag/js?id=${measurementId}"><\/script>
805
+ <script>
806
+ window.dataLayer = window.dataLayer || [];
807
+ function gtag(){dataLayer.push(arguments);}
808
+ gtag('js', new Date());
809
+ gtag('config', '${measurementId}'${configStr});
810
+ <\/script>`;
811
+ return {
812
+ html,
813
+ config: {
814
+ measurementId,
815
+ ...configOptions
816
+ },
817
+ scripts: [`https://www.googletagmanager.com/gtag/js?id=${measurementId}`]
818
+ };
819
+ }
820
+ generateConfig(propertyId, options) {
821
+ const measurementId = this.options.measurementId || `G-${propertyId}`;
822
+ const config = {
823
+ measurement_id: measurementId
824
+ };
825
+ if (options?.anonymizeIp) {
826
+ config.anonymize_ip = true;
827
+ }
828
+ if (options?.sendPageView === false) {
829
+ config.send_page_view = false;
830
+ }
831
+ if (options?.userId) {
832
+ config.user_id = options.userId;
833
+ }
834
+ if (options?.customDimensions) {
835
+ Object.entries(options.customDimensions).forEach(([key, value]) => {
836
+ config[key] = value;
837
+ });
838
+ }
839
+ return config;
840
+ }
841
+ // ===========================================================================
842
+ // Provider Info
843
+ // ===========================================================================
844
+ async getCapabilities() {
845
+ return {
846
+ propertyManagement: true,
847
+ dataStreams: true,
848
+ customDimensions: true,
849
+ customMetrics: true,
850
+ keyEvents: true,
851
+ reporting: true,
852
+ realtimeReporting: true,
853
+ serverSideTracking: true,
854
+ clientSideSnippet: true,
855
+ userIdentification: true,
856
+ batchTracking: true
857
+ };
858
+ }
859
+ }
860
+ export {
861
+ GA4Provider
862
+ };
863
+ //# sourceMappingURL=ga4-6gyDPZRn.js.map