Home Unraveling Firebase Analytics GA4 calls to app-analytics-services.com and app-measurement.com
Post
Cancel

Unraveling Firebase Analytics GA4 calls to app-analytics-services.com and app-measurement.com

To implement GA4 on mobile apps, developers are directed to use the Firebase Analytics SDK, which is a free analytics solution offered by Google that integrates with GA4. The Firebase Analytics SDK provides a variety of tools and features to help developers track user behavior, including tracking screen views, events, and user properties. However, the way GA4 is implemented with Firebase SDK differs significantly from how it’s done on the web. Even though there are Google Tag Manager versions for both iOS and Android, they are drastically limited and not very convenient to use. The freedom that GTM and custom JavaScript provide on websites is in a league of its own. To pour salt in the wound, testing and debugging your GA4 events with Firebase Analytics SDK — especially on iOS — is one of the biggest pains.

DebugView

Firebase Analytics / GA4 DebugView enables you to see the events and user properties sent by your app on development devices in near real-time. This is actually a great tool and in many ways a big improvement from Universal Analytics. However, using DebugView is only possible with development versions of your app since enabling it requires special command line arguments or settings.

On Android you need to have Android Studio installed and connect the Android device to your PC with a USB cable. Then you need to run an adb command in the terminal (also Android Studio has a “Terminal” tab) to enable DebugView:

1
$ adb shell setprop debug.firebase.analytics.app PACKAGE_NAME

Replace PACKAGE_NAME with the package name of your app. For example, com.my.app.

While on Android the DebugView can be enabled also when the app has been installed from an online source (e.g. Play Store internal tester distribution), on iOS you need to specify a command line argument in Xcode when building the app from source its source code:

1
-FIRDebugEnabled

In other words, if an iOS test app is distributed through TestFlight, which means that the test version may have a larger audience, it may not be feasible to enable DebugView.

Using an HTTP Proxy

With Universal Analytics it was relatively easy to view the HTTP traffic from the app to Google Analytics servers by using an HTTP Proxy. The proxy software — such as Charles — logs all HTTP / HTTPS traffic between your computer and the internet and displays the requests, responses and HTTP headers (including cookies). The Universal Analytics events were sent to ssl.google-analytics.com in a GET or POST request containing all parameters and dimensions in simple query string format.

app-measurement.com and app-analytics-services.com

Firebase SDK, on the other hand, sends GA4 events to its own servers (app-analytics-services.com and/or app-measurement.com) in a format that’s much harder to read.

At first the raw data looks like a bunch of random characters:

GA4 data sent to app-measurement.com app-analytics-services.com in raw format

Protocol Buffers

At first, it looks like the content is heavily compressed and / or encrypted somehow but it’s actually using Protocol buffers (“Protobuf”), which is a mechanism develop by Google for serializing structured data — kind of like XML or JSON. Luckily Charles support Protobuf out of the box. You can just right click on the request and select View Request As / Protocol Buffers....

GA4 data sent to app-measurement.com app-analytics-services.com in Protobuf (raw) format

The data is a lot easier to read now but it’s still lacking a lot of information. Protobuf compresses the data heavily by replacing long key names with numbers. The mapping is defined in a .proto file that is needed both when writing or reading Protocol buffers.

Reverse Engineering the Protobuf Definitions

Unfortunately the .proto definitions are not included in the public source code of Firebase SDK and thus are not available anywhere. After looking at the encoded data for a while and connecting pieces of information from other sources, it’s actually relatively simple to unravel the names of most keys in the data structure.

One of the greatest sources for this information are Android logs. On iOS you can see the individual events and user properties in device logs but not the whole request body uploaded to Firebase servers. On Android, on the other hand, Firebase SDK logs the full request body it sends in a human readable format.

First, you need to enable verbose logging for FA-SVC tag by running an adb command in the terminal (also Android Studio has a “Terminal” tab):

1
$ adb shell setprop log.tag.FA-SVC VERBOSE

After generating some events in your app and waiting for a while the app will upload the data to Firebase and log everything. Here’s a what it looks like (I’ve masked some of the data):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
FA-SVC com.google.android.gms V Uploading data. app, uncompressed size, data: com.my.app, 9332, 
batch {
  bundle {
    protocol_version: 1
    platform: android
    gmp_version: 46000
    uploading_gmp_version: 19056
    dynamite_version: 55
    config_version: 1679644809123456
    gmp_app_id: 1:123456789:android:aaaaaaaaaa
    admob_app_id: 
    app_id: com.my.app
    app_version: 1.0.0
    app_version_major: 100
    firebase_instance_id: xx_xxxx_xx
    dev_cert_hash: -12313123123
    app_store: 
    upload_timestamp_millis: 1681470819289
    start_timestamp_millis: 1681468977430
    end_timestamp_millis: 1681469192488
    previous_bundle_start_timestamp_millis: 1681468790498
    previous_bundle_end_timestamp_millis: 1681468884140
    app_instance_id: f8s9fa09vsa4a4lk2983fsdf
    resettable_device_id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
    device_id: 
    ds_id: 
    limited_ad_tracking: false
    os_version: 9
    device_model: SM-J530F
    user_default_language: fi-fi
    time_zone_offset_minutes: 180
    bundle_sequential_index: 31
    service_upload: true
    health_monitor: 
    user_property {
      set_timestamp_millis: 1631520687985
      name: first_open_time(_fot)
      string_value: 
      int_value: 1631523600000
    }
    user_property {
      set_timestamp_millis: 1631520687985
      name: first_open_after_install(_fi)
      string_value: 
      int_value: 1
    }
    user_property {
      set_timestamp_millis: 1681468712345
      name: ga_session_id(_sid)
      string_value: 
      int_value: 1681468788
    }
    event {
      name: user_engagement(_e)
      timestamp_millis: 1681468977430
      previous_timestamp_millis: 1681468884057
      param {
        name: ga_event_origin(_o)
        string_value: auto
      }
      param {
        name: engagement_time_msec(_et)
        string_value: 
        int_value: 90654
      }
      param {
        name: ga_screen_class(_sc)
        string_value: MyViewController
      }
      param {
        name: ga_screen_id(_si)
        string_value: 
        int_value: -13918239812398123
      }
    }
  }
}

The data is clearly in the same format but just decoded into the original keys. Thus, we can use this to translate many of the numbers in the encoded format to their human readable names.

E.g. at the first level we have “batch” (root message type), and number 1 inside batch is “bundle”. Number 2 in bundle is clearly “event” and so on. Here are some of the clear matches that can be identified:

1
2
3
4
5
6
7
8
9
10
11
12
13
root:    batch
1:       bundle
1.2:     event
1.2.1:   param
1.2.1.1: name (param)
1.2.1.2: string_value
1.2.1.3: int_value
1.2.2:   name (event)
1.3:     user_property
1.3.1:   set_timestamp_millis
1.3.2:   name (user_property)
1.3.3:   string_value
1.3.4:   int_value

You can probably see the pattern?

Now we just need to create a matching .proto file and add it to Charles in order to see the decoded values. Here’s what I’ve managed to figure out by connecting the dots from device logs and BigQuery Exports:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// For the most up-to-date version of this Protocol buffers definition,
// check out the GitHub repository:
// https://github.com/lari/firebase-ga4-app-measurement-protobuf

syntax = "proto3";

package app_measurement;

message Bundle {
    int32 protocol_version = 1;
    message Event {
        message EventParameter {
            string name = 1;
            string string_value = 2;
            int64 int_value = 3;
        }
        repeated EventParameter param = 1;
        string name = 2;
        int64 timestamp_millis = 3;
        int64 previous_timestamp_millis = 4;

    }
    repeated Event event = 2;

    message UserProperty {
        int64 set_timestamp_millis = 1;
        string name = 2;
        string string_value = 3;
        int64 int_value = 4;
    }
    repeated UserProperty user_property = 3;

    int64 upload_timestamp_millis = 4;
    int64 start_timestamp_millis = 5;
    int64 end_timestamp_millis = 6;
    int64 previous_bundle_end_timestamp_millis = 7;

    string platform = 8;
    string operating_system_version = 9;
    string device_model = 10;

    string user_default_language = 11;

    int32 time_zone_offset_minutes = 12;
    string install_source = 13;
    string app_id = 14;

    string app_version = 16;

    int32 gmp_version = 17;
    int32 uploading_gmp_version = 18;

    // 20 unknown, example values: "1"

    string app_instance_id = 21;

    int32 bundle_sequential_index = 23;

    string gmp_app_id = 25;

    int64 previous_bundle_start_timestamp_millis = 26;

    string resettable_device_id = 27; // Android ID or IDFV

    string firebase_instance_id = 30;

    int32 app_version_major = 31;

    int64 config_version = 35;

    // 52 unknown, example values: "G1--"
    // 53 unknown, example values: "2"
    // 64 unknown, example values: "google_signals"
    
}

message Batch {
    repeated Bundle bundle = 1;
}

Next we need to convert this into a “descriptor” that can be added to Charles. If our Protobuf definition file is called app-measurement.proto, we can run the following command to generate a descriptor file called app-measurement.desc. You of course need to have the Protocol compiler installed.

1
$ protoc --descriptor_set_out=app-measurement.desc app-measurement.proto

In case you only need the descriptor file, I’ve shared the .proto and .desc files in a GitHub repo where you can downloaded them.

Viewing Decoded app-measurement.com / app-analytics-services.com Requests in Charles

Now that we have a descriptor file for the Protocol buffers, we can add it to Charles and set up a viewer mapping for app-analytics-services.com and/or app-measurement.com requests. Viewer mappings are a way to tell Charles how to decode the data in a request so that you don’t have to select the “View Request As” menu every time you want to see the decoded data.

From view menu open Viewer mappings... and add a new mapping. You need to add the app-measurement.desc to the Descriptor Registry and then add a new mapping for app-analytics-services.com and/or app-measurement.com requests with the following settings:

  • Request type: Protocol Buffers
  • Message type: app_measurement.Batch
  • Payload encoding: Binary & Unknown

Add viewer mapping

If you view the app-analytics-services.com / app-measurement.com requests now you should start to see values that make a lot more sense.

Decoded app-analytics-services.com app-measurement.com request

Many of the default event, event parameter and user property names are still shortened but luckily their actual values can be also seen in the Android and iOS device logs and most of them seem to be quite obvious abbreviation that are easy to learn even from memory.

Event names:

1
2
3
4
5
_s = session_start
_e = user_engagement
_vs = screen_view
_ab = app background ?
_au = app update ?

Event parameter names:

1
2
3
4
5
6
7
8
9
10
11
12
_si  = ga_screen_id
_et  = engagement_time_msec
_sc  = ga_screen_class
_sn  = screen name
_o   = ga_event_origin
_pc  = previous view controller
_pv  = previous app version
_err = error
_ev  = error parameter
_el  = error code?
_r   = realtime
_dbg = ga_debug

User property names:

1
2
3
4
5
6
_fi  = first_open_after_install
_fot = first_open_time
_sid = ga_session_id
_sno = ga_session_number
_lte = lifetime_user_engagement (time in ms)
_se  = session_user_engagement (time in ms)

I have added these to the same GitHub repo and in case you come accross values that are missing, feel free to open an issue in GitHub or, even better, create a pull request with the values that you figured out yourself.

Conclusions

In today’s privacy landscape, it’s essential that an app’s publisher knows what data the app collects and how it’s processed. The way GA4 is implemented with Firebase SDK does not meet this regulatory requirement. Even though the DebugView is a welcome addition to the GA4 toolbox, it unfortunately does not reveal all of the parameters collected by Firebase Analytics. The lack of documentation and proper tooling for testing and debugging makes GA4 a less attractive choice when implementing analytics in native mobile apps. The fact that there might even be a distant need to “reverse engineer” an SDK added to the app, is an indication that openness, transparency and developer experience are not a priority for Google. At a minimum, Google should publish the full protocol buffers definition used by Firebase Analytics. In the meanwhile, I hope this blog post and the GitHub repository shed some light on the inner workings of Firebase Analytics and GA4, and bridge the gap in our knowledge on the subject.

This post is licensed under CC BY-SA 4.0 by the author.