Skip to content

Commit 7598763

Browse files
Merge pull request #8 from google/ga4_mp_user_id
Adds user_id support for GA4 MP. Improve checks for App and Web required parameters.
2 parents f51d296 + 93db436 commit 7598763

File tree

3 files changed

+141
-34
lines changed

3 files changed

+141
-34
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ generate_csv.sh
1010
.terraform
1111
.idea
1212
.venv
13+
.vscode

megalist_dataflow/uploaders/google_analytics/google_analytics_4_measurement_protocol.py

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
# Copyright 2021 Google LLC
22
#
3-
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# Licensed under the Apache License, Version 2.0 (the 'License');
44
# you may not use this file except in compliance with the License.
55
# You may obtain a copy of the License at
66
#
77
# https://www.apache.org/licenses/LICENSE-2.0
88
#
99
# Unless required by applicable law or agreed to in writing, software
10-
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# distributed under the License is distributed on an 'AS IS' BASIS,
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
@@ -28,7 +28,7 @@
2828
class GoogleAnalytics4MeasurementProtocolUploaderDoFn(beam.DoFn):
2929
def __init__(self):
3030
super().__init__()
31-
self.API_URL = "https://www.google-analytics.com/mp/collect"
31+
self.API_URL = 'https://www.google-analytics.com/mp/collect'
3232

3333
def start_bundle(self):
3434
pass
@@ -41,7 +41,7 @@ def _str2bool(s: str) -> bool:
4141
def _exactly_one_of(a: Any, b: Any) -> bool:
4242
return (a and not b) or (not a and b)
4343

44-
@utils.safe_process(logger=logging.getLogger("megalista.GoogleAnalytics4MeasurementProtocolUploader"))
44+
@utils.safe_process(logger=logging.getLogger('megalista.GoogleAnalytics4MeasurementProtocolUploader'))
4545
def process(self, batch: Batch, **kwargs):
4646
execution = batch.execution
4747

@@ -60,52 +60,62 @@ def process(self, batch: Batch, **kwargs):
6060

6161
if not self._exactly_one_of(firebase_app_id, measurement_id):
6262
raise ValueError(
63-
f"GA4 MP should be called either with a firebase_app_id (for apps) or a measurement_id (for web)")
63+
'GA4 MP should be called either with a firebase_app_id (for apps) or a measurement_id (for web)')
6464

6565
if not self._exactly_one_of(is_event, is_user_property):
6666
raise ValueError(
67-
f"GA4 MP should be called either for sending events or a user properties")
67+
'GA4 MP should be called either for sending events or a user properties')
6868

6969
payload: Dict[str, Any] = {
70-
"nonPersonalizedAds": non_personalized_ads
70+
'nonPersonalizedAds': non_personalized_ads
7171
}
7272

7373
accepted_elements = []
7474

7575
for row in batch.elements:
7676
app_instance_id = row.get('app_instance_id')
7777
client_id = row.get('client_id')
78+
user_id = row.get('user_id')
7879

7980
if not self._exactly_one_of(app_instance_id, client_id):
80-
logging.getLogger("megalista.GoogleAnalytics4MeasurementProtocolUploader").error(
81-
f"GA4 MP should be called either with an app_instance_id (for apps) or a client_id (for web)")
81+
raise ValueError(
82+
'GA4 MP should be called either with an app_instance_id (for apps) or a client_id (for web)')
8283

8384
if is_event:
84-
params = {k: v for k, v in row.items() if k not in ("name", "app_instance_id", "client_id", "uuid")}
85-
payload["events"] = [{"name": row["name"], "params": params}]
85+
params = {k: v for k, v in row.items() if k not in ('name', 'app_instance_id', 'client_id', 'uuid', 'user_id')}
86+
payload['events'] = [{'name': row['name'], 'params': params}]
8687

8788
if is_user_property:
88-
payload["userProperties"] = {k: {"value": v} for k, v in row.items() if k not in ("app_instance_id", "client_id", "uuid")}
89-
payload["events"] = {"name": "user_property_addition_event", "params": {}}
89+
payload['userProperties'] = {k: {'value': v} for k, v in row.items() if k not in ('app_instance_id', 'client_id', 'uuid', 'user_id')}
90+
payload['events'] = {'name': 'user_property_addition_event', 'params': {}}
9091

91-
url_container = [f"{self.API_URL}?api_secret={api_secret}"]
92+
url_container = [f'{self.API_URL}?api_secret={api_secret}']
9293

9394
if firebase_app_id:
94-
url_container.append(f"&firebase_app_id={firebase_app_id}")
95-
payload["app_instance_id"] = app_instance_id
96-
95+
url_container.append(f'&firebase_app_id={firebase_app_id}')
96+
if not app_instance_id:
97+
raise ValueError(
98+
'GA4 MP needs an app_instance_id parameter when used for an App Stream.')
99+
payload['app_instance_id'] = app_instance_id
100+
97101
if measurement_id:
98-
url_container.append(f"&measurement_id={measurement_id}")
99-
payload["client_id"] = client_id
102+
url_container.append(f'&measurement_id={measurement_id}')
103+
if not client_id:
104+
raise ValueError(
105+
'GA4 MP needs a client_id parameter when used for a Web Stream.')
106+
payload['client_id'] = client_id
107+
108+
if user_id:
109+
payload['user_id'] = user_id
100110

101111
url = ''.join(url_container)
102112
response = requests.post(url,data=json.dumps(payload))
103113
if response.status_code != 204:
104-
logging.getLogger("megalista.GoogleAnalytics4MeasurementProtocolUploader").error(
105-
f"Error calling GA4 MP {response.status_code}: {response.raw}")
114+
logging.getLogger('megalista.GoogleAnalytics4MeasurementProtocolUploader').error(
115+
f'Error calling GA4 MP {response.status_code}: {response.raw}')
106116
else:
107117
accepted_elements.append(row)
108118

109-
logging.getLogger("megalista.GoogleAnalytics4MeasurementProtocolUploader").info(
110-
f"Successfully uploaded {len(accepted_elements)}/{len(batch.elements)} events.")
119+
logging.getLogger('megalista.GoogleAnalytics4MeasurementProtocolUploader').info(
120+
f'Successfully uploaded {len(accepted_elements)}/{len(batch.elements)} events.')
111121
yield Batch(execution, accepted_elements)

megalist_dataflow/uploaders/google_analytics/google_analytics_4_measurement_protocol_test.py

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
# Copyright 2021 Google LLC
22
#
3-
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# Licensed under the Apache License, Version 2.0 (the 'License');
44
# you may not use this file except in compliance with the License.
55
# You may obtain a copy of the License at
66
#
77
# https://www.apache.org/licenses/LICENSE-2.0
88
#
99
# Unless required by applicable law or agreed to in writing, software
10-
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# distributed under the License is distributed on an 'AS IS' BASIS,
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
@@ -46,7 +46,7 @@ def test_exception_event_and_user_property(uploader, caplog):
4646
])
4747
source = Source('orig1', SourceType.BIG_QUERY, [])
4848
execution = Execution(_account_config, source, destination)
49-
with pytest.raises(ValueError, match="GA4 MP should be called either for sending events"):
49+
with pytest.raises(ValueError, match='GA4 MP should be called either for sending events'):
5050
next(uploader.process(Batch(execution, [])))
5151

5252

@@ -64,7 +64,7 @@ def test_exception_no_event_nor_user_property(uploader, caplog):
6464
])
6565
source = Source('orig1', SourceType.BIG_QUERY, [])
6666
execution = Execution(_account_config, source, destination)
67-
with pytest.raises(ValueError, match="GA4 MP should be called either for sending events"):
67+
with pytest.raises(ValueError, match='GA4 MP should be called either for sending events'):
6868
next(uploader.process(Batch(execution, [])))
6969

7070

@@ -82,7 +82,7 @@ def test_exception_app_and_web(uploader, caplog):
8282
])
8383
source = Source('orig1', SourceType.BIG_QUERY, [])
8484
execution = Execution(_account_config, source, destination)
85-
with pytest.raises(ValueError, match="GA4 MP should be called either with a firebase_app_id"):
85+
with pytest.raises(ValueError, match='GA4 MP should be called either with a firebase_app_id'):
8686
next(uploader.process(Batch(execution, [{
8787
'name': 'event_name',
8888
}])))
@@ -102,12 +102,55 @@ def test_exception_no_id(uploader, caplog):
102102
])
103103
source = Source('orig1', SourceType.BIG_QUERY, [])
104104
execution = Execution(_account_config, source, destination)
105-
with pytest.raises(ValueError, match="GA4 MP should be called either with a firebase_app_id"):
105+
with pytest.raises(ValueError, match='GA4 MP should be called either with a firebase_app_id'):
106106
next(uploader.process(Batch(execution, [{
107107
'name': 'event_name',
108108
'value': '123'
109109
}])))
110110

111+
def test_exception_app_event_without_app_instance_id(uploader, caplog):
112+
with requests_mock.Mocker() as m:
113+
m.post(requests_mock.ANY, status_code=204)
114+
destination = Destination(
115+
'dest1', DestinationType.GA_4_MEASUREMENT_PROTOCOL, [
116+
'api_secret',
117+
'True',
118+
'False',
119+
'',
120+
'some_id',
121+
''
122+
])
123+
source = Source('orig1', SourceType.BIG_QUERY, [])
124+
execution = Execution(_account_config, source, destination)
125+
with pytest.raises(ValueError, match='GA4 MP needs an app_instance_id parameter when used for an App Stream.'):
126+
next(uploader.process(Batch(execution, [{
127+
'client_id': '123',
128+
'name': 'event_name',
129+
'value': '42',
130+
'important_event': 'False'
131+
}])))
132+
133+
def test_exception_web_event_without_client_id(uploader, caplog):
134+
with requests_mock.Mocker() as m:
135+
m.post(requests_mock.ANY, status_code=204)
136+
destination = Destination(
137+
'dest1', DestinationType.GA_4_MEASUREMENT_PROTOCOL, [
138+
'api_secret',
139+
'True',
140+
'False',
141+
'',
142+
'',
143+
'some_id'
144+
])
145+
source = Source('orig1', SourceType.BIG_QUERY, [])
146+
execution = Execution(_account_config, source, destination)
147+
with pytest.raises(ValueError, match='GA4 MP needs a client_id parameter when used for a Web Stream.'):
148+
next(uploader.process(Batch(execution, [{
149+
'app_instance_id': '123',
150+
'name': 'event_name',
151+
'value': '42',
152+
'important_event': 'False'
153+
}])))
111154

112155
def test_succesful_app_event_call(uploader, caplog):
113156
with requests_mock.Mocker() as m:
@@ -124,14 +167,39 @@ def test_succesful_app_event_call(uploader, caplog):
124167
source = Source('orig1', SourceType.BIG_QUERY, [])
125168
execution = Execution(_account_config, source, destination)
126169
next(uploader.process(Batch(execution, [{
127-
'firebase_app_id': '123',
170+
'app_instance_id': '123',
128171
'name': 'event_name',
129172
'value': '42',
130173
'important_event': 'False'
131174
}])))
132175

133176
assert m.call_count == 1
134-
assert m.last_request.json()["events"][0]["params"]["value"] == '42'
177+
assert m.last_request.json()['events'][0]['params']['value'] == '42'
178+
179+
180+
def test_succesful_app_event_call_with_user_id(uploader, caplog):
181+
with requests_mock.Mocker() as m:
182+
m.post(requests_mock.ANY, status_code=204)
183+
destination = Destination(
184+
'dest1', DestinationType.GA_4_MEASUREMENT_PROTOCOL, [
185+
'api_secret',
186+
'True',
187+
'False',
188+
'',
189+
'some_id',
190+
''
191+
])
192+
source = Source('orig1', SourceType.BIG_QUERY, [])
193+
execution = Execution(_account_config, source, destination)
194+
next(uploader.process(Batch(execution, [{
195+
'app_instance_id': '123',
196+
'name': 'event_name',
197+
'value': '42',
198+
'user_id': 'Id42'
199+
}])))
200+
201+
assert m.call_count == 1
202+
assert m.last_request.json()['user_id'] == 'Id42'
135203

136204

137205
def test_succesful_web_user_property_call(uploader, caplog):
@@ -149,13 +217,41 @@ def test_succesful_web_user_property_call(uploader, caplog):
149217
source = Source('orig1', SourceType.BIG_QUERY, [])
150218
execution = Execution(_account_config, source, destination)
151219
next(uploader.process(Batch(execution, [{
152-
'user_ltv': '42'
220+
'user_ltv': '42',
221+
'client_id': 'some_id'
153222
},
154223
{
155-
'user_will_churn': 'Maybe'
224+
'user_will_churn': 'Maybe',
225+
'client_id': 'some_id'
156226
}
157227
])))
158228

159229
assert m.call_count == 2
160230
assert m.last_request.json(
161-
)["userProperties"]["user_will_churn"]["value"] == 'Maybe'
231+
)['userProperties']['user_will_churn']['value'] == 'Maybe'
232+
233+
def test_succesful_web_user_property_call_with_user_id(uploader, caplog):
234+
with requests_mock.Mocker() as m:
235+
m.post(requests_mock.ANY, status_code=204)
236+
destination = Destination(
237+
'dest1', DestinationType.GA_4_MEASUREMENT_PROTOCOL, [
238+
'api_secret',
239+
'False',
240+
'True',
241+
'',
242+
'',
243+
'some_id'
244+
])
245+
source = Source('orig1', SourceType.BIG_QUERY, [])
246+
execution = Execution(_account_config, source, destination)
247+
next(uploader.process(Batch(execution, [{
248+
'user_ltv': '42',
249+
'user_id': 'Id42',
250+
'client_id': 'someId'
251+
}
252+
])))
253+
254+
assert m.call_count == 1
255+
assert m.last_request.json(
256+
)['user_id'] == 'Id42'
257+

0 commit comments

Comments
 (0)