@memberjunction/connector-ga4 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/GA4Config.d.ts +72 -0
- package/dist/GA4Config.js +103 -0
- package/dist/GA4Config.js.map +1 -0
- package/dist/GA4Connector.d.ts +82 -0
- package/dist/GA4Connector.js +353 -0
- package/dist/GA4Connector.js.map +1 -0
- package/dist/GA4Objects.d.ts +100 -0
- package/dist/GA4Objects.js +191 -0
- package/dist/GA4Objects.js.map +1 -0
- package/dist/GA4Report.d.ts +109 -0
- package/dist/GA4Report.js +77 -0
- package/dist/GA4Report.js.map +1 -0
- package/dist/GA4Rows.d.ts +63 -0
- package/dist/GA4Rows.js +117 -0
- package/dist/GA4Rows.js.map +1 -0
- package/dist/GA4ServiceAccount.d.ts +40 -0
- package/dist/GA4ServiceAccount.js +96 -0
- package/dist/GA4ServiceAccount.js.map +1 -0
- package/dist/GA4Window.d.ts +61 -0
- package/dist/GA4Window.js +83 -0
- package/dist/GA4Window.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared object/field catalog — the single source of truth for this connector's schema.
|
|
3
|
+
*
|
|
4
|
+
* WHY A TS CATALOG. This is a `--base` connector: the Data API has no "list my tables" surface to
|
|
5
|
+
* introspect. A GA4 report is a request you compose, not a resource you discover, so the objects
|
|
6
|
+
* below are *defined* reports — a chosen set of dimensions and metrics with a name — and
|
|
7
|
+
* `DiscoverObjects`/`DiscoverFields` answer from this table. It also has to work BEFORE the
|
|
8
|
+
* integration metadata is seeded, which is the state the connector is in at first setup.
|
|
9
|
+
*
|
|
10
|
+
* `scripts/gen-integration-metadata.mjs` projects this table into
|
|
11
|
+
* `metadata/integration/.ga4.integration.json`, so this file is the authority and that JSON is a
|
|
12
|
+
* build artifact. Maintaining both by hand is what makes the two drift silently: the created columns
|
|
13
|
+
* stop matching the emitted records and the sync lands nulls rather than failing.
|
|
14
|
+
*
|
|
15
|
+
* ── WHY THREE OBJECTS AND NOT ONE ────────────────────────────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* The three differ only in their dimension list, and it is tempting to ship the finest grain alone
|
|
18
|
+
* and let consumers roll up. That would be wrong, and the reason is worth stating once here because
|
|
19
|
+
* it is the single most consequential design fact in this connector:
|
|
20
|
+
*
|
|
21
|
+
* **GA4's user metrics are cardinalities, and cardinalities are not additive.**
|
|
22
|
+
*
|
|
23
|
+
* `totalUsers` and `activeUsers` are counts of DISTINCT users, de-duplicated by GA4 at exactly the
|
|
24
|
+
* grain you requested. One person who arrives twice in a day from two campaigns counts once in a
|
|
25
|
+
* campaign-less report and once in EACH campaign row of a campaign-scoped one. So summing the
|
|
26
|
+
* campaign rows does not give you the campaign-less answer — it gives you a number that is too big by
|
|
27
|
+
* however much your audiences overlap, and nothing in the data says by how much.
|
|
28
|
+
*
|
|
29
|
+
* `sessions`, `engagedSessions`, `screenPageViews`, `keyEvents` and `userEngagementDuration` ARE
|
|
30
|
+
* additive and could be rolled up. The user counts cannot be, ever. Since a rollup that is right for
|
|
31
|
+
* five columns and quietly wrong for two is worse than no rollup at all, each grain that anyone
|
|
32
|
+
* actually wants user counts at is asked of GA4 directly, as its own report.
|
|
33
|
+
*
|
|
34
|
+
* That is also why each object is its own `runReport` call rather than one call re-projected: the
|
|
35
|
+
* de-duplication has to happen inside GA4, at the grain requested.
|
|
36
|
+
*
|
|
37
|
+
* ── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* No ratio metrics (`bounceRate`, `engagementRate`, `sessionKeyEventRate`, …). Every one is a
|
|
40
|
+
* quotient of two columns already present, so landing it adds a number that is correct only at the
|
|
41
|
+
* exact grain it was fetched at and silently wrong the moment anyone groups the table — the same
|
|
42
|
+
* non-additivity trap as above, but without the excuse of being un-derivable. Consumers divide.
|
|
43
|
+
*
|
|
44
|
+
* No `firstUser*` attribution dimensions. They attribute to the first touch EVER rather than to the
|
|
45
|
+
* touch that drove the session, so mixing them into the same row as session-scoped metrics produces
|
|
46
|
+
* a table where the dimensions and the numbers answer different questions.
|
|
47
|
+
*/
|
|
48
|
+
import type { IntegrationObjectInfo } from '@memberjunction/integration-engine';
|
|
49
|
+
/**
|
|
50
|
+
* The complete set of type tokens the schema builder understands.
|
|
51
|
+
*
|
|
52
|
+
* `IntegrationObjectField.Type` is a plain `nvarchar(200)` with no check constraint, and an
|
|
53
|
+
* unrecognized token does not error — it falls through to `NVARCHAR(MAX)`. This union is the schema
|
|
54
|
+
* builder's TYPE_MAP key set, so an unsupported token is a compile error here instead of a silently
|
|
55
|
+
* unbounded column at deploy time.
|
|
56
|
+
*/
|
|
57
|
+
export type CatalogType = 'nvarchar' | 'string' | 'text' | 'integer' | 'bigint' | 'decimal' | 'boolean' | 'datetime' | 'date' | 'uuid' | 'json' | 'float' | 'time';
|
|
58
|
+
/** One IntegrationObjectField, with the five attributes `IntegrationFieldInfo` cannot express. */
|
|
59
|
+
export interface CatalogField {
|
|
60
|
+
Name: string;
|
|
61
|
+
DisplayName: string;
|
|
62
|
+
Description: string;
|
|
63
|
+
Type: CatalogType;
|
|
64
|
+
Length?: number;
|
|
65
|
+
Precision?: number;
|
|
66
|
+
Scale?: number;
|
|
67
|
+
IsPrimaryKey: boolean;
|
|
68
|
+
IsUniqueKey: boolean;
|
|
69
|
+
IsRequired: boolean;
|
|
70
|
+
IsReadOnly: boolean;
|
|
71
|
+
AllowsNull: boolean;
|
|
72
|
+
}
|
|
73
|
+
/** One IntegrationObject — here, one *defined report*. */
|
|
74
|
+
export interface CatalogObject {
|
|
75
|
+
Name: string;
|
|
76
|
+
DisplayName: string;
|
|
77
|
+
Description: string;
|
|
78
|
+
/**
|
|
79
|
+
* `IntegrationObject.APIPath` is NOT NULL. Every object goes to the same `:runReport` endpoint
|
|
80
|
+
* and differs only in its request body, so the path carries the object name as a fragment to
|
|
81
|
+
* stay unique and to name what actually varies.
|
|
82
|
+
*/
|
|
83
|
+
APIPath: string;
|
|
84
|
+
Category: string;
|
|
85
|
+
SupportsIncrementalSync: boolean;
|
|
86
|
+
IncrementalWatermarkField: string | null;
|
|
87
|
+
PaginationType: 'Cursor';
|
|
88
|
+
/** GA4 API dimension names, in request order. Positionally aligned to `dimensionValues`. */
|
|
89
|
+
Dimensions: string[];
|
|
90
|
+
/** GA4 API metric names, in request order. Positionally aligned to `metricValues`. */
|
|
91
|
+
Metrics: string[];
|
|
92
|
+
Fields: CatalogField[];
|
|
93
|
+
}
|
|
94
|
+
export declare const GA4_OBJECTS: CatalogObject[];
|
|
95
|
+
/** Look an object up, or throw naming the ones that exist — the message worth getting. */
|
|
96
|
+
export declare function catalogObject(name: string): CatalogObject;
|
|
97
|
+
/** The key field names of an object, in declared order — the order `ExternalID` joins them in. */
|
|
98
|
+
export declare function primaryKeyFields(o: CatalogObject): string[];
|
|
99
|
+
/** Narrow a catalog object back to the framework type, which cannot carry the extra attributes. */
|
|
100
|
+
export declare function toIntegrationObjectInfo(o: CatalogObject): IntegrationObjectInfo;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// ── Field constructors ────────────────────────────────────────────────────────
|
|
2
|
+
// Everything here is read-only: a GA4 report is a computed answer, not a record you can write back.
|
|
3
|
+
const dimField = (Name, Description, Length, pk) => ({
|
|
4
|
+
Name,
|
|
5
|
+
DisplayName: Name,
|
|
6
|
+
Description,
|
|
7
|
+
Type: 'nvarchar',
|
|
8
|
+
Length,
|
|
9
|
+
IsPrimaryKey: pk,
|
|
10
|
+
IsUniqueKey: false,
|
|
11
|
+
IsRequired: pk,
|
|
12
|
+
IsReadOnly: true,
|
|
13
|
+
// Never null in practice: GA4 substitutes its own sentinels — '(not set)', '(direct)', '(none)'
|
|
14
|
+
// — rather than omitting a dimension value. Declared NOT NULL for the key components so the
|
|
15
|
+
// constraint states that, and nullable elsewhere so a future dimension that CAN be absent does
|
|
16
|
+
// not need a schema change.
|
|
17
|
+
AllowsNull: !pk,
|
|
18
|
+
});
|
|
19
|
+
/** A whole-number metric. GA4 returns every metric as a string; these parse as integers. */
|
|
20
|
+
const countField = (Name, Description) => ({
|
|
21
|
+
Name,
|
|
22
|
+
DisplayName: Name,
|
|
23
|
+
Description,
|
|
24
|
+
Type: 'bigint',
|
|
25
|
+
IsPrimaryKey: false,
|
|
26
|
+
IsUniqueKey: false,
|
|
27
|
+
IsRequired: false,
|
|
28
|
+
IsReadOnly: true,
|
|
29
|
+
AllowsNull: true,
|
|
30
|
+
});
|
|
31
|
+
/** A metric GA4 types as FLOAT. `keyEvents` is one — it is float-typed even though it counts. */
|
|
32
|
+
const rateField = (Name, Description) => ({
|
|
33
|
+
Name,
|
|
34
|
+
DisplayName: Name,
|
|
35
|
+
Description,
|
|
36
|
+
Type: 'decimal',
|
|
37
|
+
Precision: 18,
|
|
38
|
+
Scale: 4,
|
|
39
|
+
IsPrimaryKey: false,
|
|
40
|
+
IsUniqueKey: false,
|
|
41
|
+
IsRequired: false,
|
|
42
|
+
IsReadOnly: true,
|
|
43
|
+
AllowsNull: true,
|
|
44
|
+
});
|
|
45
|
+
// ── Shared field groups ───────────────────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* The reporting day, as a real date rather than GA4's `YYYYMMDD` string.
|
|
48
|
+
*
|
|
49
|
+
* It is the first key component of every object because it is what makes these rows *records* at
|
|
50
|
+
* all. A GA4 report aggregated over a range is a single answer that changes shape every time the
|
|
51
|
+
* range moves; adding `date` to the dimensions turns it into one stable row per day, which is the
|
|
52
|
+
* only form that can be upserted by key across runs.
|
|
53
|
+
*/
|
|
54
|
+
const DATE_FIELD = dimField('date', 'The reporting day in the property\'s configured reporting time zone. GA4 returns this as YYYYMMDD; it is normalized to a date here.', 10, true);
|
|
55
|
+
/**
|
|
56
|
+
* Provenance, stamped by the connector rather than returned by GA4.
|
|
57
|
+
*
|
|
58
|
+
* Not part of the key: one CompanyIntegration row addresses exactly one property, so within a synced
|
|
59
|
+
* table it is constant. It is carried anyway because the table outlives the configuration that
|
|
60
|
+
* produced it, and "which property is this?" is otherwise unanswerable from the data.
|
|
61
|
+
*/
|
|
62
|
+
const PROPERTY_ID_FIELD = {
|
|
63
|
+
Name: 'propertyId',
|
|
64
|
+
DisplayName: 'propertyId',
|
|
65
|
+
Description: 'The numeric GA4 property this row was read from. Stamped by the connector from Configuration.propertyId; GA4 does not return it.',
|
|
66
|
+
Type: 'nvarchar',
|
|
67
|
+
Length: 32,
|
|
68
|
+
IsPrimaryKey: false,
|
|
69
|
+
IsUniqueKey: false,
|
|
70
|
+
IsRequired: false,
|
|
71
|
+
IsReadOnly: true,
|
|
72
|
+
AllowsNull: true,
|
|
73
|
+
};
|
|
74
|
+
/** The additive engagement counts, shared by all three reports. */
|
|
75
|
+
const ENGAGEMENT_METRIC_FIELDS = [
|
|
76
|
+
countField('sessions', 'Sessions in this row\'s grain. Additive across rows.'),
|
|
77
|
+
countField('engagedSessions', 'Sessions that lasted 10+ seconds, had a key event, or had 2+ screen views. Additive across rows.'),
|
|
78
|
+
countField('userEngagementDuration', 'Total time the app or site was in the foreground, in SECONDS, summed over the row. Additive across rows. Divide by sessions or users for an average — the average itself is deliberately not landed, because an average cannot be re-aggregated.'),
|
|
79
|
+
countField('screenPageViews', 'Screen and page views, counting repeat views of the same page. Additive across rows.'),
|
|
80
|
+
rateField('keyEvents', 'Key events (what GA4 called "conversions" before the May 2024 rename). Float-typed by GA4 because a key event can be weighted. Additive across rows.'),
|
|
81
|
+
];
|
|
82
|
+
/**
|
|
83
|
+
* The two user cardinalities.
|
|
84
|
+
*
|
|
85
|
+
* Split out from the additive metrics and described as non-additive on every object, because this is
|
|
86
|
+
* the fact that gets a downstream rollup wrong, and the column description is the only place a
|
|
87
|
+
* consumer reading the landed table will encounter it.
|
|
88
|
+
*/
|
|
89
|
+
const USER_METRIC_FIELDS = [
|
|
90
|
+
countField('totalUsers', 'DISTINCT users in this row\'s grain. NOT ADDITIVE — GA4 de-duplicates users at the grain requested, so summing rows double-counts anyone who appears in more than one. Use the object whose grain you want.'),
|
|
91
|
+
countField('activeUsers', 'DISTINCT users who had an engaged session. NOT ADDITIVE, for the same reason as totalUsers.'),
|
|
92
|
+
];
|
|
93
|
+
/** The UTM dimensions, in GA4 request order. Session-scoped — see the object descriptions. */
|
|
94
|
+
const CAMPAIGN_DIM_FIELDS = [
|
|
95
|
+
dimField('sessionCampaignName', 'utm_campaign for the session. GA4 returns \'(not set)\' / \'(direct)\' / \'(organic)\' verbatim rather than null; those sentinels are preserved.', 255, true),
|
|
96
|
+
dimField('sessionSource', 'utm_source for the session, e.g. google, newsletter, (direct).', 255, true),
|
|
97
|
+
dimField('sessionMedium', 'utm_medium for the session, e.g. cpc, email, organic, (none).', 255, true),
|
|
98
|
+
];
|
|
99
|
+
// ── The catalog ───────────────────────────────────────────────────────────────
|
|
100
|
+
export const GA4_OBJECTS = [
|
|
101
|
+
{
|
|
102
|
+
Name: 'PagePerformance',
|
|
103
|
+
DisplayName: 'Page Performance',
|
|
104
|
+
Description: 'Daily traffic and engagement per page path. One row per (date, pagePath). The page-level report the legacy AIDP provider read, minus its slug-to-Blog join — this lands the raw dimensioned rows and the CRM join happens downstream.',
|
|
105
|
+
APIPath: '/v1beta/properties/{propertyId}:runReport#PagePerformance',
|
|
106
|
+
Category: 'Marketing',
|
|
107
|
+
SupportsIncrementalSync: true,
|
|
108
|
+
IncrementalWatermarkField: 'date',
|
|
109
|
+
PaginationType: 'Cursor',
|
|
110
|
+
Dimensions: ['date', 'pagePath'],
|
|
111
|
+
Metrics: ['screenPageViews', 'totalUsers', 'activeUsers', 'sessions', 'engagedSessions', 'userEngagementDuration', 'keyEvents'],
|
|
112
|
+
Fields: [
|
|
113
|
+
DATE_FIELD,
|
|
114
|
+
dimField('pagePath', 'Page path WITHOUT the query string (GA4 dimension pagePath, not pagePathPlusQueryString) — so /pricing?utm_source=x and /pricing are one row, which is what a page-performance report wants.', 512, true),
|
|
115
|
+
...ENGAGEMENT_METRIC_FIELDS,
|
|
116
|
+
...USER_METRIC_FIELDS,
|
|
117
|
+
PROPERTY_ID_FIELD,
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
Name: 'UtmPerformance',
|
|
122
|
+
DisplayName: 'UTM Performance',
|
|
123
|
+
Description: 'Daily traffic per campaign / source / medium; one row per (date, campaign, source, medium). Session-scoped: a session is credited to the campaign that drove it. Read user counts at THIS grain — they are cardinalities and do not sum up from finer rows.',
|
|
124
|
+
APIPath: '/v1beta/properties/{propertyId}:runReport#UtmPerformance',
|
|
125
|
+
Category: 'Marketing',
|
|
126
|
+
SupportsIncrementalSync: true,
|
|
127
|
+
IncrementalWatermarkField: 'date',
|
|
128
|
+
PaginationType: 'Cursor',
|
|
129
|
+
Dimensions: ['date', 'sessionCampaignName', 'sessionSource', 'sessionMedium'],
|
|
130
|
+
Metrics: ['sessions', 'totalUsers', 'activeUsers', 'engagedSessions', 'userEngagementDuration', 'screenPageViews', 'keyEvents'],
|
|
131
|
+
Fields: [
|
|
132
|
+
DATE_FIELD,
|
|
133
|
+
...CAMPAIGN_DIM_FIELDS,
|
|
134
|
+
...ENGAGEMENT_METRIC_FIELDS,
|
|
135
|
+
...USER_METRIC_FIELDS,
|
|
136
|
+
PROPERTY_ID_FIELD,
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
Name: 'UtmContentPerformance',
|
|
141
|
+
DisplayName: 'UTM Content Performance',
|
|
142
|
+
Description: 'The daily campaign report split one level finer, by utm_content; one row per (date, campaign, source, medium, content). utm_content carries the variant tag, so an A/B comparison lives here. Additive metrics roll up to UtmPerformance; user counts do not.',
|
|
143
|
+
APIPath: '/v1beta/properties/{propertyId}:runReport#UtmContentPerformance',
|
|
144
|
+
Category: 'Marketing',
|
|
145
|
+
SupportsIncrementalSync: true,
|
|
146
|
+
IncrementalWatermarkField: 'date',
|
|
147
|
+
PaginationType: 'Cursor',
|
|
148
|
+
Dimensions: ['date', 'sessionCampaignName', 'sessionSource', 'sessionMedium', 'sessionManualAdContent'],
|
|
149
|
+
Metrics: ['sessions', 'totalUsers', 'activeUsers', 'engagedSessions', 'userEngagementDuration', 'screenPageViews', 'keyEvents'],
|
|
150
|
+
Fields: [
|
|
151
|
+
DATE_FIELD,
|
|
152
|
+
...CAMPAIGN_DIM_FIELDS,
|
|
153
|
+
dimField('sessionManualAdContent', 'utm_content for the session (GA4 UI name "Session manual ad content"). Free text set by whoever built the link; \'(not set)\' when absent. Any variant convention encoded in it belongs to the tagger and is parsed downstream, not here.', 255, true),
|
|
154
|
+
...ENGAGEMENT_METRIC_FIELDS,
|
|
155
|
+
...USER_METRIC_FIELDS,
|
|
156
|
+
PROPERTY_ID_FIELD,
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
const BY_NAME = new Map(GA4_OBJECTS.map((o) => [o.Name, o]));
|
|
161
|
+
/** Look an object up, or throw naming the ones that exist — the message worth getting. */
|
|
162
|
+
export function catalogObject(name) {
|
|
163
|
+
const found = BY_NAME.get(name);
|
|
164
|
+
if (!found) {
|
|
165
|
+
throw new Error(`GA4: unknown object '${name}'. This connector defines: ${GA4_OBJECTS.map((o) => o.Name).join(', ')}.`);
|
|
166
|
+
}
|
|
167
|
+
return found;
|
|
168
|
+
}
|
|
169
|
+
/** The key field names of an object, in declared order — the order `ExternalID` joins them in. */
|
|
170
|
+
export function primaryKeyFields(o) {
|
|
171
|
+
return o.Fields.filter((f) => f.IsPrimaryKey).map((f) => f.Name);
|
|
172
|
+
}
|
|
173
|
+
/** Narrow a catalog object back to the framework type, which cannot carry the extra attributes. */
|
|
174
|
+
export function toIntegrationObjectInfo(o) {
|
|
175
|
+
return {
|
|
176
|
+
Name: o.Name,
|
|
177
|
+
DisplayName: o.DisplayName,
|
|
178
|
+
Description: o.Description,
|
|
179
|
+
SupportsWrite: false,
|
|
180
|
+
Fields: o.Fields.map((f) => ({
|
|
181
|
+
Name: f.Name,
|
|
182
|
+
DisplayName: f.DisplayName,
|
|
183
|
+
Description: f.Description,
|
|
184
|
+
Type: f.Type,
|
|
185
|
+
IsRequired: f.IsRequired,
|
|
186
|
+
IsReadOnly: f.IsReadOnly,
|
|
187
|
+
IsPrimaryKey: f.IsPrimaryKey,
|
|
188
|
+
})),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=GA4Objects.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"GA4Objects.js","sourceRoot":"","sources":["../src/GA4Objects.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between this connector and Google.
|
|
3
|
+
*
|
|
4
|
+
* Everything that talks to the network lives behind {@link GA4ReportPort}, which has exactly one
|
|
5
|
+
* method. That is not indirection for its own sake: it is what lets the whole fetch loop — window
|
|
6
|
+
* arithmetic, cursor advance, `(other)`-row detection, record projection, watermark — be exercised
|
|
7
|
+
* against canned responses with no credential, which is the only verification available to this
|
|
8
|
+
* connector before a live property exists to point it at.
|
|
9
|
+
*
|
|
10
|
+
* The types below are the subset of the v1beta `runReport` request/response this connector uses,
|
|
11
|
+
* declared locally rather than imported from the Google client. Two reasons: the generated protobuf
|
|
12
|
+
* types make everything optional and nullable, which pushes a `?? ''` onto every field access at the
|
|
13
|
+
* call site; and a test fake should not have to construct a protobuf.
|
|
14
|
+
*/
|
|
15
|
+
/** A `runReport` request. Field names match the REST/JSON API exactly. */
|
|
16
|
+
export interface GA4RunReportRequest {
|
|
17
|
+
/** `properties/<numeric id>`. */
|
|
18
|
+
property: string;
|
|
19
|
+
dateRanges: Array<{
|
|
20
|
+
startDate: string;
|
|
21
|
+
endDate: string;
|
|
22
|
+
}>;
|
|
23
|
+
dimensions: Array<{
|
|
24
|
+
name: string;
|
|
25
|
+
}>;
|
|
26
|
+
metrics: Array<{
|
|
27
|
+
name: string;
|
|
28
|
+
}>;
|
|
29
|
+
limit: number;
|
|
30
|
+
offset: number;
|
|
31
|
+
/** Ask GA4 to report remaining quota alongside the data, so exhaustion is visible before it bites. */
|
|
32
|
+
returnPropertyQuota?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface GA4Row {
|
|
35
|
+
dimensionValues?: Array<{
|
|
36
|
+
value?: string | null;
|
|
37
|
+
} | null> | null;
|
|
38
|
+
metricValues?: Array<{
|
|
39
|
+
value?: string | null;
|
|
40
|
+
} | null> | null;
|
|
41
|
+
}
|
|
42
|
+
export interface GA4QuotaBucket {
|
|
43
|
+
consumed?: number | null;
|
|
44
|
+
remaining?: number | null;
|
|
45
|
+
}
|
|
46
|
+
export interface GA4RunReportResponse {
|
|
47
|
+
rows?: GA4Row[] | null;
|
|
48
|
+
/** Total rows matching the query, across all pages. GA4 returns this on every page. */
|
|
49
|
+
rowCount?: number | null;
|
|
50
|
+
dimensionHeaders?: Array<{
|
|
51
|
+
name?: string | null;
|
|
52
|
+
} | null> | null;
|
|
53
|
+
metricHeaders?: Array<{
|
|
54
|
+
name?: string | null;
|
|
55
|
+
} | null> | null;
|
|
56
|
+
metadata?: {
|
|
57
|
+
/** True when cardinality limits forced some rows into an aggregate `(other)` row. */
|
|
58
|
+
dataLossFromOtherRow?: boolean | null;
|
|
59
|
+
/** True when results were withheld or thresholded for privacy (small audiences). */
|
|
60
|
+
subjectToThresholding?: boolean | null;
|
|
61
|
+
timeZone?: string | null;
|
|
62
|
+
currencyCode?: string | null;
|
|
63
|
+
} | null;
|
|
64
|
+
propertyQuota?: {
|
|
65
|
+
tokensPerDay?: GA4QuotaBucket | null;
|
|
66
|
+
tokensPerHour?: GA4QuotaBucket | null;
|
|
67
|
+
concurrentRequests?: GA4QuotaBucket | null;
|
|
68
|
+
potentiallyThresholdedRequestsPerHour?: GA4QuotaBucket | null;
|
|
69
|
+
} | null;
|
|
70
|
+
}
|
|
71
|
+
/** The one operation this connector performs against Google. */
|
|
72
|
+
export interface GA4ReportPort {
|
|
73
|
+
RunReport(request: GA4RunReportRequest): Promise<GA4RunReportResponse>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The live port, over `@google-analytics/data`'s `BetaAnalyticsDataClient`.
|
|
77
|
+
*
|
|
78
|
+
* The client is imported lazily and constructed once per port instance. Lazy because the Google
|
|
79
|
+
* client drags in `google-gax` and its gRPC stack — a substantial import that a test run, a
|
|
80
|
+
* `TestConnection` against a misconfigured credential, or an MJAPI boot that never syncs GA4 should
|
|
81
|
+
* not pay for. Once per instance because the client caches its OAuth token: rebuilding it per request
|
|
82
|
+
* would mint a fresh JWT and round-trip Google's token endpoint on every page of every window.
|
|
83
|
+
*/
|
|
84
|
+
export declare class DefaultGA4ReportPort implements GA4ReportPort {
|
|
85
|
+
private readonly clientEmail;
|
|
86
|
+
private readonly privateKey;
|
|
87
|
+
private client;
|
|
88
|
+
constructor(clientEmail: string, privateKey: string);
|
|
89
|
+
RunReport(request: GA4RunReportRequest): Promise<GA4RunReportResponse>;
|
|
90
|
+
private Client;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Is this error Google's rate limiter?
|
|
94
|
+
*
|
|
95
|
+
* GA4 meters by "analytics tokens" against per-hour and per-day buckets, and a report over a wide
|
|
96
|
+
* date range costs more tokens than a narrow one. Exhaustion arrives as gRPC code 8
|
|
97
|
+
* (RESOURCE_EXHAUSTED) or HTTP 429 depending on the transport, and it is transient — the hourly
|
|
98
|
+
* bucket refills on the hour — so it is worth telling apart from a permission failure, which is not.
|
|
99
|
+
*/
|
|
100
|
+
export declare function isQuotaError(error: unknown): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Is this error Google saying the service account cannot see the property?
|
|
103
|
+
*
|
|
104
|
+
* The single most common GA4 setup failure, and the one whose raw message is least helpful: creating
|
|
105
|
+
* the service account and enabling the Data API are not enough — the service account's email must
|
|
106
|
+
* ALSO be added as a user on the GA4 property itself, which happens in the Analytics UI rather than
|
|
107
|
+
* in Cloud Console. Detected so `TestConnection` can say that instead of "PERMISSION_DENIED".
|
|
108
|
+
*/
|
|
109
|
+
export declare function isPermissionError(error: unknown): boolean;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between this connector and Google.
|
|
3
|
+
*
|
|
4
|
+
* Everything that talks to the network lives behind {@link GA4ReportPort}, which has exactly one
|
|
5
|
+
* method. That is not indirection for its own sake: it is what lets the whole fetch loop — window
|
|
6
|
+
* arithmetic, cursor advance, `(other)`-row detection, record projection, watermark — be exercised
|
|
7
|
+
* against canned responses with no credential, which is the only verification available to this
|
|
8
|
+
* connector before a live property exists to point it at.
|
|
9
|
+
*
|
|
10
|
+
* The types below are the subset of the v1beta `runReport` request/response this connector uses,
|
|
11
|
+
* declared locally rather than imported from the Google client. Two reasons: the generated protobuf
|
|
12
|
+
* types make everything optional and nullable, which pushes a `?? ''` onto every field access at the
|
|
13
|
+
* call site; and a test fake should not have to construct a protobuf.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The live port, over `@google-analytics/data`'s `BetaAnalyticsDataClient`.
|
|
17
|
+
*
|
|
18
|
+
* The client is imported lazily and constructed once per port instance. Lazy because the Google
|
|
19
|
+
* client drags in `google-gax` and its gRPC stack — a substantial import that a test run, a
|
|
20
|
+
* `TestConnection` against a misconfigured credential, or an MJAPI boot that never syncs GA4 should
|
|
21
|
+
* not pay for. Once per instance because the client caches its OAuth token: rebuilding it per request
|
|
22
|
+
* would mint a fresh JWT and round-trip Google's token endpoint on every page of every window.
|
|
23
|
+
*/
|
|
24
|
+
export class DefaultGA4ReportPort {
|
|
25
|
+
constructor(clientEmail, privateKey) {
|
|
26
|
+
this.clientEmail = clientEmail;
|
|
27
|
+
this.privateKey = privateKey;
|
|
28
|
+
this.client = null;
|
|
29
|
+
}
|
|
30
|
+
async RunReport(request) {
|
|
31
|
+
const client = await this.Client();
|
|
32
|
+
const result = await client.runReport(request);
|
|
33
|
+
// The Google client returns [response, request, callOptions]; only the first is data.
|
|
34
|
+
return (result[0] ?? {});
|
|
35
|
+
}
|
|
36
|
+
async Client() {
|
|
37
|
+
if (this.client)
|
|
38
|
+
return this.client;
|
|
39
|
+
const mod = await import('@google-analytics/data');
|
|
40
|
+
const Ctor = mod.BetaAnalyticsDataClient;
|
|
41
|
+
this.client = new Ctor({
|
|
42
|
+
credentials: { client_email: this.clientEmail, private_key: this.privateKey },
|
|
43
|
+
});
|
|
44
|
+
return this.client;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Is this error Google's rate limiter?
|
|
49
|
+
*
|
|
50
|
+
* GA4 meters by "analytics tokens" against per-hour and per-day buckets, and a report over a wide
|
|
51
|
+
* date range costs more tokens than a narrow one. Exhaustion arrives as gRPC code 8
|
|
52
|
+
* (RESOURCE_EXHAUSTED) or HTTP 429 depending on the transport, and it is transient — the hourly
|
|
53
|
+
* bucket refills on the hour — so it is worth telling apart from a permission failure, which is not.
|
|
54
|
+
*/
|
|
55
|
+
export function isQuotaError(error) {
|
|
56
|
+
const code = error?.code;
|
|
57
|
+
if (code === 8 || code === 429)
|
|
58
|
+
return true;
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
return /RESOURCE_EXHAUSTED|quota|rate limit|429/i.test(message);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Is this error Google saying the service account cannot see the property?
|
|
64
|
+
*
|
|
65
|
+
* The single most common GA4 setup failure, and the one whose raw message is least helpful: creating
|
|
66
|
+
* the service account and enabling the Data API are not enough — the service account's email must
|
|
67
|
+
* ALSO be added as a user on the GA4 property itself, which happens in the Analytics UI rather than
|
|
68
|
+
* in Cloud Console. Detected so `TestConnection` can say that instead of "PERMISSION_DENIED".
|
|
69
|
+
*/
|
|
70
|
+
export function isPermissionError(error) {
|
|
71
|
+
const code = error?.code;
|
|
72
|
+
if (code === 7 || code === 403)
|
|
73
|
+
return true;
|
|
74
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
75
|
+
return /PERMISSION_DENIED|403|caller does not have permission|User does not have sufficient permissions/i.test(message);
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=GA4Report.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"GA4Report.js","sourceRoot":"","sources":["../src/GA4Report.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAoDH;;;;;;;;GAQG;AACH,MAAM,OAAO,oBAAoB;IAG7B,YACqB,WAAmB,EACnB,UAAkB;QADlB,gBAAW,GAAX,WAAW,CAAQ;QACnB,eAAU,GAAV,UAAU,CAAQ;QAJ/B,WAAM,GAA+D,IAAI,CAAC;IAK/E,CAAC;IAEG,KAAK,CAAC,SAAS,CAAC,OAA4B;QAC/C,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,EAAE,CAAC;QACnC,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QAC/C,sFAAsF;QACtF,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAyB,CAAC;IACrD,CAAC;IAEO,KAAK,CAAC,MAAM;QAChB,IAAI,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC,MAAM,CAAC;QACpC,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,wBAAwB,CAAC,CAAC;QACnD,MAAM,IAAI,GAAG,GAAG,CAAC,uBAAuB,CAAC;QACzC,IAAI,CAAC,MAAM,GAAG,IAAI,IAAI,CAAC;YACnB,WAAW,EAAE,EAAE,YAAY,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,EAAE,IAAI,CAAC,UAAU,EAAE;SAChF,CAAmE,CAAC;QACrE,OAAO,IAAI,CAAC,MAAM,CAAC;IACvB,CAAC;CACJ;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,KAAc;IACvC,MAAM,IAAI,GAAI,KAA4B,EAAE,IAAI,CAAC;IACjD,IAAI,IAAI,KAAK,CAAC,IAAI,IAAI,KAAK,GAAG;QAAE,OAAO,IAAI,CAAC;IAC5C,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACvE,OAAO,0CAA0C,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACpE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAc;IAC5C,MAAM,IAAI,GAAI,KAA4B,EAAE,IAAI,CAAC;IACjD,IAAI,IAAI,KAAK,CAAC,IAAI,IAAI,KAAK,GAAG;QAAE,OAAO,IAAI,CAAC;IAC5C,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACvE,OAAO,kGAAkG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AAC5H,CAAC"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ExternalRecord } from '@memberjunction/integration-engine';
|
|
2
|
+
import type { CatalogObject } from './GA4Objects.js';
|
|
3
|
+
import type { GA4Row } from './GA4Report.js';
|
|
4
|
+
/**
|
|
5
|
+
* Upper bound for `ExternalID`.
|
|
6
|
+
*
|
|
7
|
+
* `CompanyIntegrationRecordMap.ExternalSystemRecordID` is `nvarchar(750)`. A key that overflows it
|
|
8
|
+
* does not corrupt anything — the insert fails and the record is dead-lettered — but the row never
|
|
9
|
+
* lands, silently, for as long as the offending value keeps appearing. Below this length the key is
|
|
10
|
+
* the readable join; above it, {@link buildExternalID} substitutes a digest. The margin under 750 is
|
|
11
|
+
* for the fallback prefix.
|
|
12
|
+
*/
|
|
13
|
+
export declare const MAX_EXTERNAL_ID_LENGTH = 700;
|
|
14
|
+
/** GA4's sentinel for rows collapsed together once a dimension exceeds its cardinality limit. */
|
|
15
|
+
export declare const OTHER_ROW_SENTINEL = "(other)";
|
|
16
|
+
/**
|
|
17
|
+
* Build the `ExternalID` from the declared key values.
|
|
18
|
+
*
|
|
19
|
+
* It must equal the key fields joined on '|' — that is what the engine's REST base class produces
|
|
20
|
+
* and what a `--base` connector therefore has to reproduce by hand. A mismatch does not throw:
|
|
21
|
+
* identity silently falls back to a content hash, and every run re-inserts every row.
|
|
22
|
+
*
|
|
23
|
+
* The digest fallback exists because two key components here are free text set by whoever built a
|
|
24
|
+
* marketing link. `utm_campaign` and `utm_content` have no length limit, and a tracking template
|
|
25
|
+
* that stuffs a few hundred characters into one is not exotic. Truncating would be the worse answer:
|
|
26
|
+
* two distinct campaigns sharing a prefix would collapse into one row and silently merge their
|
|
27
|
+
* numbers, which is a wrong answer rather than a missing one. The digest is deterministic, so the
|
|
28
|
+
* same row keeps the same identity across runs, and the `ga4:` prefix makes an audit of the record
|
|
29
|
+
* map able to tell the two forms apart.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildExternalID(keyValues: string[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* GA4's `date` dimension is `YYYYMMDD` with no separators. Returns null for anything else — including
|
|
34
|
+
* `(other)`, which GA4 can in principle substitute for any dimension.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseGA4Date(value: string): string | null;
|
|
37
|
+
/**
|
|
38
|
+
* Parse a GA4 metric value.
|
|
39
|
+
*
|
|
40
|
+
* Every metric arrives as a string, including integers, and a metric that is genuinely absent for a
|
|
41
|
+
* row arrives as `''` rather than being omitted. Absent stays null (the column is nullable and "no
|
|
42
|
+
* data" is not the same claim as "zero"); anything present but unparseable becomes null too, rather
|
|
43
|
+
* than a 0 that would read as a measured value.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseMetric(value: string | null | undefined): number | null;
|
|
46
|
+
export interface ProjectedRow {
|
|
47
|
+
Record: ExternalRecord;
|
|
48
|
+
/** True when any dimension of this row is GA4's `(other)` aggregate. */
|
|
49
|
+
IsOtherRow: boolean;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Project one report row.
|
|
53
|
+
*
|
|
54
|
+
* Returns null when the row cannot be keyed — in practice only when `date` is not a real date, which
|
|
55
|
+
* means GA4 substituted a sentinel for it. A row with no usable key cannot be upserted, and emitting
|
|
56
|
+
* it with a made-up key would create a row that is re-inserted on every run forever.
|
|
57
|
+
*
|
|
58
|
+
* `dimensionValues` and `metricValues` are aligned by POSITION to the request's `dimensions` and
|
|
59
|
+
* `metrics`, not by name — GA4 sends the names once in the headers and never again. The catalog's
|
|
60
|
+
* `Dimensions`/`Metrics` arrays are the request order, so they are also the read order, and that
|
|
61
|
+
* single fact is why those arrays live next to the field list rather than being derived from it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function projectRow(obj: CatalogObject, row: GA4Row, propertyId: string): ProjectedRow | null;
|