mozilla_schema_generator.glean_ping

  1# -*- coding: utf-8 -*-
  2
  3# This Source Code Form is subject to the terms of the Mozilla Public
  4# License, v. 2.0. If a copy of the MPL was not distributed with this
  5# file, You can obtain one at http://mozilla.org/MPL/2.0/.
  6
  7import copy
  8import logging
  9from collections import defaultdict
 10from datetime import datetime
 11from functools import cache
 12from pathlib import Path
 13from typing import Any, Dict, List, Set
 14
 15import yaml
 16from requests import HTTPError
 17
 18from .config import Config
 19from .generic_ping import GenericPing
 20from .probes import GleanProbe
 21from .schema import Schema
 22
 23ROOT_DIR = Path(__file__).parent
 24BUG_1737656_TXT = ROOT_DIR / "configs" / "bug_1737656_affected.txt"
 25METRIC_BLOCKLIST = ROOT_DIR / "configs" / "metric_blocklist.yaml"
 26
 27logger = logging.getLogger(__name__)
 28
 29SCHEMA_URL_TEMPLATE = (
 30    "https://raw.githubusercontent.com"
 31    "/mozilla-services/mozilla-pipeline-schemas"
 32    "/{branch}/schemas/glean/glean/"
 33)
 34
 35SCHEMA_VERSION_TEMPLATE = "{schema_type}.{version}.schema.json"
 36
 37DEFAULT_SCHEMA_URL = SCHEMA_URL_TEMPLATE + SCHEMA_VERSION_TEMPLATE.format(
 38    schema_type="glean", version=1
 39)
 40
 41# App ids with v2 schemas already deployed. Applying the metric blocklist to these would drop
 42# columns that already exist in their tables, which isn't allowed, so they keep every metric.
 43METRIC_BLOCKLIST_EXEMPT_APP_IDS = frozenset(
 44    {
 45        "org-mozilla-fenix-nightly",
 46        "org-mozilla-fennec-aurora",
 47    }
 48)
 49
 50
 51class GleanPing(GenericPing):
 52    probes_url_template = GenericPing.probe_info_base_url + "/glean/{}/metrics"
 53    ping_url_template = GenericPing.probe_info_base_url + "/glean/{}/pings"
 54    repos_url = GenericPing.probe_info_base_url + "/glean/repositories"
 55    dependencies_url_template = (
 56        GenericPing.probe_info_base_url + "/glean/{}/dependencies"
 57    )
 58    app_listings_url = GenericPing.probe_info_base_url + "/v2/glean/app-listings"
 59
 60    default_dependencies = ["glean-core"]
 61
 62    with open(BUG_1737656_TXT, "r") as f:
 63        bug_1737656_affected_tables = [
 64            line.strip() for line in f.readlines() if line.strip()
 65        ]
 66
 67    def __init__(
 68        self, repo, version=1, use_metrics_blocklist=False, **kwargs
 69    ):  # TODO: Make env-url optional
 70        self.repo = repo
 71        self.repo_name = repo["name"]
 72        self.app_id = repo["app_id"]
 73        self.version = version
 74
 75        if use_metrics_blocklist and self.app_id not in METRIC_BLOCKLIST_EXEMPT_APP_IDS:
 76            self.metric_blocklist = self.get_metric_blocklist()
 77        else:
 78            self.metric_blocklist = {}
 79
 80        super().__init__(
 81            DEFAULT_SCHEMA_URL,
 82            DEFAULT_SCHEMA_URL,
 83            self.probes_url_template.format(self.repo_name),
 84            **kwargs,
 85        )
 86
 87    def get_schema(self, generic_schema=False) -> Schema:
 88        """
 89        Fetch schema via URL.
 90
 91        Unless *generic_schema* is set to true, this function makes some modifications
 92        to allow some workarounds for proper injection of metrics.
 93        """
 94        schema = super().get_schema()
 95        if generic_schema:
 96            return schema
 97
 98        # We need to inject placeholders for the url2, text2, etc. types as part
 99        # of mitigation for https://bugzilla.mozilla.org/show_bug.cgi?id=1737656
100        for metric_name in ["labeled_rate", "jwe", "url", "text"]:
101            metric1 = schema.get(
102                ("properties", "metrics", "properties", metric_name)
103            ).copy()
104            metric1 = schema.set_schema_elem(
105                ("properties", "metrics", "properties", metric_name + "2"),
106                metric1,
107            )
108
109        return schema
110
111    @cache
112    def get_dependencies(self):
113        # Get all of the library dependencies for the application that
114        # are also known about in the repositories file.
115
116        # The dependencies are specified using library names, but we need to
117        # map those back to the name of the repository in the repository file.
118        try:
119            dependencies = self._get_json(
120                self.dependencies_url_template.format(self.repo_name)
121            )
122        except HTTPError:
123            logging.info(f"For {self.repo_name}, using default Glean dependencies")
124            return self.default_dependencies
125
126        dependency_library_names = list(dependencies.keys())
127
128        repos = GleanPing._get_json(GleanPing.repos_url)
129        repos_by_dependency_name = {}
130        for repo in repos:
131            for library_name in repo.get("library_names", []):
132                repos_by_dependency_name[library_name] = repo["name"]
133
134        dependencies = []
135        for name in dependency_library_names:
136            if name in repos_by_dependency_name:
137                dependencies.append(repos_by_dependency_name[name])
138
139        if len(dependencies) == 0:
140            logging.info(f"For {self.repo_name}, using default Glean dependencies")
141            return self.default_dependencies
142
143        logging.info(f"For {self.repo_name}, found Glean dependencies: {dependencies}")
144        return dependencies
145
146    @staticmethod
147    def remove_pings_from_metric(
148        metric: Dict[str, Any], blocked_pings: List[str]
149    ) -> Dict[str, Any]:
150        """Remove the given pings from the metric's `send_in_pings` history.
151
152        Only removes if the given metric has been removed from the source since a fixed date
153        (2025-01-01). This allows metrics to be added back to the schema.
154        """
155        if (
156            metric["in-source"]
157            or len(blocked_pings) == 0
158            or datetime.fromisoformat(metric["history"][-1]["dates"]["last"])
159            >= datetime(year=2025, month=8, day=1)
160        ):
161            return metric
162
163        for history_entry in metric["history"]:
164            history_entry["send_in_pings"] = [
165                p for p in history_entry["send_in_pings"] if p not in blocked_pings
166            ]
167
168        return metric
169
170    def get_probes(self) -> List[GleanProbe]:
171        data = self._get_json(self.probes_url)
172
173        # blocklist needs to be applied here instead of generate_schema because it needs to be
174        # dependency-aware; metrics can move between app and library and still be in the schema
175        # turn blocklist into metric_name -> ping_types map
176        blocklist = defaultdict(list)
177        for ping_type, metric_names in self.metric_blocklist.get(
178            self.get_app_name(), {}
179        ).items():
180            for metric_name in metric_names:
181                blocklist[metric_name].append(ping_type)
182
183        probes = [
184            (name, self.remove_pings_from_metric(defn, blocklist.get(name, [])))
185            for name, defn in data.items()
186        ]
187
188        for dependency in self.get_dependencies():
189            dependency_probes = self._get_json(
190                self.probes_url_template.format(dependency)
191            )
192
193            dependency_blocklist = defaultdict(list)
194            for ping_type, metric_names in self.metric_blocklist.get(
195                dependency, {}
196            ).items():
197                for metric_name in metric_names:
198                    dependency_blocklist[metric_name].append(ping_type)
199
200            probes += [
201                (
202                    name,
203                    self.remove_pings_from_metric(
204                        defn, dependency_blocklist.get(name, [])
205                    ),
206                )
207                for name, defn in dependency_probes.items()
208            ]
209
210        # A metric can be moved between an app and its dependencies or between dependencies while
211        # probe scraper keeps the history in each location, so both definitions are returned
212        # Merge the history per probe to take the latest definition while still being able to
213        # find metric type changes below
214        # Metrics are not merged if they are not sent in the same pings as they are disjoint
215
216        # Metrics are grouped by their normalized BigQuery column name (from jsonschema-transpiler)
217        # rather than their raw name. e.g. "media.audio.init_failure" and "media.audio_init_failure"
218        # normalize to "media_audio_init_failure". The transpiler picks one of the colliding
219        # descriptions non-deterministically.
220        def _normalize_name(name):
221            return name.replace(".", "_").replace("-", "_")
222
223        def _pings_in_history(defn):
224            return {
225                p
226                for h in defn[GleanProbe.history_key]
227                for p in h.get("send_in_pings", ["metrics"])
228            }
229
230        def _latest_history_date(defn):
231            return max(
232                datetime.fromisoformat(h["dates"]["last"])
233                for h in defn[GleanProbe.history_key]
234            )
235
236        def _dedupe_sort_key(defn):
237            """Prefer the most recent definition, breaking ties by choosing the in-source metric."""
238            return (
239                _latest_history_date(defn),
240                defn.get(GleanProbe.in_source_key, False),
241                defn["name"],
242            )
243
244        # Group probes that share a normalized name and whose pings intersect to combine
245        # moved metrics and metrics that only differ by "." vs "_"
246        grouped_by_name: Dict[str, List[List[dict]]] = defaultdict(list)
247        for name, defn in probes:
248            defn_pings = _pings_in_history(defn)
249            existing_groups = grouped_by_name[_normalize_name(name)]
250            matches = [
251                group
252                for group in existing_groups
253                if any(
254                    _pings_in_history(other_defn) & defn_pings for other_defn in group
255                )
256            ]
257            if not matches:
258                existing_groups.append([defn])
259            else:
260                merged_group = [defn]
261                for g in matches:
262                    merged_group.extend(g)
263                    existing_groups.remove(g)
264                existing_groups.append(merged_group)
265
266        # Take latest definition per group
267        deduped_probes: List[Any] = []
268        for groups in grouped_by_name.values():
269            for group in groups:
270                latest_defn = max(group, key=_dedupe_sort_key)
271                if len(group) > 1:
272                    latest_defn = latest_defn.copy()
273                    latest_defn[GleanProbe.history_key] = sorted(
274                        (h for d in group for h in d[GleanProbe.history_key]),
275                        key=lambda h: datetime.fromisoformat(h["dates"]["first"]),
276                    )
277                deduped_probes.append((latest_defn["name"], latest_defn))
278        probes = deduped_probes
279
280        pings = self.get_pings()
281
282        processed = []
283        for _id, defn in probes:
284            probe = GleanProbe(_id, defn, pings=pings)
285            processed.append(probe)
286
287            # Handling probe type changes (Bug 1870317)
288            probe_types = {hist["type"] for hist in defn[probe.history_key]}
289            if len(probe_types) > 1:
290                # The probe type changed at some point in history.
291                # Create schema entry for each type.
292                hist_defn = defn.copy()
293
294                # No new entry needs to be created for the current probe type
295                probe_types.remove(defn["type"])
296
297                for hist in hist_defn[probe.history_key]:
298                    # Create a new entry for a historic type
299                    if hist["type"] in probe_types:
300                        hist_defn["type"] = hist["type"]
301                        probe = GleanProbe(_id, hist_defn, pings=pings)
302                        processed.append(probe)
303
304                        # Keep track of the types entries were already created for
305                        probe_types.remove(hist["type"])
306
307        return processed
308
309    def _get_ping_data(self) -> Dict[str, Dict]:
310        url = self.ping_url_template.format(self.repo_name)
311        ping_data = GleanPing._get_json(url)
312        for dependency in self.get_dependencies():
313            dependency_pings = self._get_json(self.ping_url_template.format(dependency))
314            ping_data.update(dependency_pings)
315        return ping_data
316
317    def _get_ping_data_without_dependencies(self) -> Dict[str, Dict]:
318        url = self.ping_url_template.format(self.repo_name)
319        ping_data = GleanPing._get_json(url)
320        return ping_data
321
322    def _get_dependency_pings(self, dependency):
323        return self._get_json(self.ping_url_template.format(dependency))
324
325    def get_pings(self) -> Set[str]:
326        return self._get_ping_data().keys()
327
328    @staticmethod
329    def apply_default_metadata(ping_metadata, default_metadata):
330        """apply_default_metadata recurses down into dicts nested
331        to an arbitrary depth, updating keys. The ``default_metadata`` is merged into
332        ``ping_metadata``.
333        :param ping_metadata: dict onto which the merge is executed
334        :param default_metadata: dct merged into ping_metadata
335        :return: None
336        """
337        for k, v in default_metadata.items():
338            if (
339                k in ping_metadata
340                and isinstance(ping_metadata[k], dict)
341                and isinstance(default_metadata[k], dict)
342            ):
343                GleanPing.apply_default_metadata(ping_metadata[k], default_metadata[k])
344            else:
345                ping_metadata[k] = default_metadata[k]
346
347    def _get_ping_data_and_dependencies_with_default_metadata(self) -> Dict[str, Dict]:
348        # Get the ping data with the pipeline metadata
349        ping_data = self._get_ping_data_without_dependencies()
350
351        # The ping endpoint for the dependency pings does not include any repo defined
352        # moz_pipeline_metadata_defaults so they need to be applied here.
353
354        # 1.  Get repo and pipeline default metadata.
355        repos = self.get_repos()
356        current_repo = next((x for x in repos if x.get("app_id") == self.app_id), {})
357        default_metadata = current_repo.get("moz_pipeline_metadata_defaults", {})
358
359        # 2.  Apply the default metadata to each dependency defined ping.
360
361        # Apply app-level metadata to pings defined in dependencies
362        app_metadata = current_repo.get("moz_pipeline_metadata", {})
363
364        for dependency in self.get_dependencies():
365            dependency_pings = self._get_dependency_pings(dependency)
366            for dependency_ping in dependency_pings.values():
367                # Although it is counter intuitive to apply the default metadata on top of the
368                # existing dependency ping metadata it does set the repo specific value for
369                # bq_dataset_family instead of using the dependency id for the bq_dataset_family
370                # value.
371                GleanPing.apply_default_metadata(
372                    dependency_ping.get("moz_pipeline_metadata"),
373                    copy.deepcopy(default_metadata),
374                )
375                # app-level ping properties take priority over the app defaults
376                metadata_override = app_metadata.get(dependency_ping["name"])
377                if metadata_override is not None:
378                    GleanPing.apply_default_metadata(
379                        dependency_ping.get("moz_pipeline_metadata"), metadata_override
380                    )
381            ping_data.update(dependency_pings)
382
383        return ping_data
384
385    @staticmethod
386    def reorder_metadata(metadata):
387        desired_order_list = [
388            "bq_dataset_family",
389            "bq_table",
390            "bq_metadata_format",
391            "include_info_sections",
392            "uploader_capabilities",
393            "submission_timestamp_granularity",
394            "expiration_policy",
395            "override_attributes",
396            "jwe_mappings",
397        ]
398        reordered_metadata = {
399            k: metadata[k] for k in desired_order_list if k in metadata
400        }
401
402        # re-order jwe-mappings
403        desired_order_list = ["source_field_path", "decrypted_field_path"]
404        jwe_mapping_metadata = reordered_metadata.get("jwe_mappings")
405        if jwe_mapping_metadata:
406            reordered_jwe_mapping_metadata = []
407            for mapping in jwe_mapping_metadata:
408                reordered_jwe_mapping_metadata.append(
409                    {k: mapping[k] for k in desired_order_list if k in mapping}
410                )
411            reordered_metadata["jwe_mappings"] = reordered_jwe_mapping_metadata
412
413        # future proofing, in case there are other fields added at the ping top level
414        # add them to the end.
415        leftovers = {k: metadata[k] for k in set(metadata) - set(reordered_metadata)}
416        reordered_metadata = {**reordered_metadata, **leftovers}
417        return reordered_metadata
418
419    def get_pings_and_pipeline_metadata(self) -> Dict[str, Dict]:
420        pings = self._get_ping_data_and_dependencies_with_default_metadata()
421        for ping_name, ping_data in pings.items():
422            metadata = ping_data.get("moz_pipeline_metadata")
423            if not metadata:
424                continue
425            metadata["include_info_sections"] = self._is_field_included(
426                ping_data, "include_info_sections", consider_all_history=False
427            )
428            metadata["include_client_id"] = self._is_field_included(
429                ping_data, "include_client_id"
430            )
431            # Omitted when empty to avoid touching the schemas of every ping without
432            # uploader capabilities.
433            uploader_capabilities = self._get_uploader_capabilities(ping_data)
434            if uploader_capabilities:
435                metadata["uploader_capabilities"] = uploader_capabilities
436
437            # While technically unnecessary, the dictionary elements are re-ordered to match the
438            # currently deployed order and used to verify no difference in output.
439            pings[ping_name] = GleanPing.reorder_metadata(metadata)
440        return pings
441
442    @staticmethod
443    def _get_uploader_capabilities(ping_data) -> List[str]:
444        """Return the uploader capabilities from the latest history entry, e.g. ["ohttp"]."""
445        history = ping_data.get("history") or [{}]
446        return history[-1].get("uploader_capabilities") or []
447
448    def get_ohttp_pings_with_info_sections(self) -> List[str]:
449        """Return pings that declare the `ohttp` uploader capability but still
450        include info sections.
451        """
452        pings = self._get_ping_data_and_dependencies_with_default_metadata()
453        return sorted(
454            ping_name
455            for ping_name, ping_data in pings.items()
456            # pings without pipeline metadata don't get a schema
457            if ping_data.get("moz_pipeline_metadata")
458            and "ohttp" in self._get_uploader_capabilities(ping_data)
459            and self._is_field_included(
460                ping_data, "include_info_sections", consider_all_history=False
461            )
462        )
463
464    def get_ping_descriptions(self) -> Dict[str, str]:
465        return {
466            k: v["history"][-1]["description"] for k, v in self._get_ping_data().items()
467        }
468
469    @staticmethod
470    def _is_field_included(ping_data, field_name, consider_all_history=True) -> bool:
471        """Return false if the field exists and is false.
472
473        If `consider_all_history` is False, then only check the latest value in the ping history.
474
475        Otherwise, if the field is not found or true in one or more history entries,
476        true is returned.
477        """
478
479        # Default to true if not specified.
480        if "history" not in ping_data or len(ping_data["history"]) == 0:
481            return True
482
483        # Check if at some point in the past the field has already been deployed.
484        # And if the caller of this method wants to consider this history of the field.
485        # Keep them in the schema, even if the field has changed as
486        # removing fields is currently not supported.
487        # See https://bugzilla.mozilla.org/show_bug.cgi?id=1898105
488        # and https://bugzilla.mozilla.org/show_bug.cgi?id=1898105#c10
489        ping_history: list
490        if consider_all_history:
491            ping_history = ping_data["history"]
492        else:
493            ping_history = [ping_data["history"][-1]]
494        for history in ping_history:
495            if field_name not in history or history[field_name]:
496                return True
497
498        # The ping was created with include_info_sections = False. The fields can be excluded.
499        return False
500
501    def set_schema_url(self, metadata):
502        """
503        Switch between the glean-min and glean schemas if the ping does not require
504        info sections as specified in the parsed ping info in probe scraper.
505        """
506        if not metadata["include_info_sections"]:
507            self.schema_url = SCHEMA_URL_TEMPLATE.format(
508                branch=self.branch_name
509            ) + SCHEMA_VERSION_TEMPLATE.format(
510                schema_type="glean-min", version=self.version
511            )
512        else:
513            self.schema_url = SCHEMA_URL_TEMPLATE.format(
514                branch=self.branch_name
515            ) + SCHEMA_VERSION_TEMPLATE.format(
516                schema_type="glean", version=self.version
517            )
518
519    def generate_schema(
520        self,
521        config,
522        generic_schema=False,
523    ) -> Dict[str, Schema]:
524        pings = self.get_pings_and_pipeline_metadata()
525        schemas = {}
526
527        for ping, pipeline_meta in pings.items():
528            matchers = {
529                loc: m.clone(new_table_group=ping) for loc, m in config.matchers.items()
530            }
531
532            # Four newly introduced metric types were incorrectly deployed
533            # as repeated key/value structs in all Glean ping tables existing prior
534            # to November 2021. We maintain the incorrect fields for existing tables
535            # by disabling the associated matchers.
536            # Note that each of these types now has a "2" matcher ("text2", "url2", etc.)
537            # defined that will allow metrics of these types to be injected into proper
538            # structs. The gcp-ingestion repository includes logic to rewrite these
539            # metrics under the "2" names.
540            # See https://bugzilla.mozilla.org/show_bug.cgi?id=1737656
541            bq_identifier = "{bq_dataset_family}.{bq_table}".format(**pipeline_meta)
542            if bq_identifier in self.bug_1737656_affected_tables:
543                matchers = {
544                    loc: m
545                    for loc, m in matchers.items()
546                    if not m.matcher.get("bug_1737656_affected")
547                }
548
549            for matcher in matchers.values():
550                matcher.matcher["send_in_pings"]["contains"] = ping
551
552            new_config = Config(ping, matchers=matchers)
553
554            defaults = {"mozPipelineMetadata": pipeline_meta}
555
556            # Adjust the schema path if the ping does not require info sections
557            self.set_schema_url(pipeline_meta)
558            if generic_schema:  # Use the generic glean ping schema
559                schema = self.get_schema(generic_schema=True)
560                schema.schema.update(defaults)
561                schemas[new_config.name] = schema
562            else:
563                generated = super().generate_schema(new_config)
564                for schema in generated.values():
565                    # We want to override each individual key with assembled defaults,
566                    # but keep values _inside_ them if they have been set in the schemas.
567                    for key, value in defaults.items():
568                        if key not in schema.schema:
569                            schema.schema[key] = {}
570                        schema.schema[key].update(value)
571                schemas.update(generated)
572
573        return schemas
574
575    @staticmethod
576    def get_repos():
577        """
578        Retrieve metadata for all non-library Glean repositories
579        """
580        repos = GleanPing._get_json(GleanPing.repos_url)
581        return [repo for repo in repos if "library_names" not in repo]
582
583    def get_app_name(self) -> str:
584        """Get app name associated with the app id.
585
586        e.g. org-mozilla-firefox -> fenix
587        """
588        apps = GleanPing._get_json(GleanPing.app_listings_url)
589        # app id in app-listings has "." instead of "-" so using document_namespace
590        app_name = [
591            app["app_name"] for app in apps if app["document_namespace"] == self.app_id
592        ]
593        return app_name[0] if len(app_name) > 0 else self.app_id
594
595    @staticmethod
596    def get_metric_blocklist():
597        with open(METRIC_BLOCKLIST, "r") as f:
598            return yaml.safe_load(f)
ROOT_DIR = PosixPath('/home/runner/work/mozilla-schema-generator/mozilla-schema-generator/mozilla_schema_generator')
BUG_1737656_TXT = PosixPath('/home/runner/work/mozilla-schema-generator/mozilla-schema-generator/mozilla_schema_generator/configs/bug_1737656_affected.txt')
METRIC_BLOCKLIST = PosixPath('/home/runner/work/mozilla-schema-generator/mozilla-schema-generator/mozilla_schema_generator/configs/metric_blocklist.yaml')
logger = <Logger mozilla_schema_generator.glean_ping (WARNING)>
SCHEMA_URL_TEMPLATE = 'https://raw.githubusercontent.com/mozilla-services/mozilla-pipeline-schemas/{branch}/schemas/glean/glean/'
SCHEMA_VERSION_TEMPLATE = '{schema_type}.{version}.schema.json'
DEFAULT_SCHEMA_URL = 'https://raw.githubusercontent.com/mozilla-services/mozilla-pipeline-schemas/{branch}/schemas/glean/glean/glean.1.schema.json'
METRIC_BLOCKLIST_EXEMPT_APP_IDS = frozenset({'org-mozilla-fennec-aurora', 'org-mozilla-fenix-nightly'})
 52class GleanPing(GenericPing):
 53    probes_url_template = GenericPing.probe_info_base_url + "/glean/{}/metrics"
 54    ping_url_template = GenericPing.probe_info_base_url + "/glean/{}/pings"
 55    repos_url = GenericPing.probe_info_base_url + "/glean/repositories"
 56    dependencies_url_template = (
 57        GenericPing.probe_info_base_url + "/glean/{}/dependencies"
 58    )
 59    app_listings_url = GenericPing.probe_info_base_url + "/v2/glean/app-listings"
 60
 61    default_dependencies = ["glean-core"]
 62
 63    with open(BUG_1737656_TXT, "r") as f:
 64        bug_1737656_affected_tables = [
 65            line.strip() for line in f.readlines() if line.strip()
 66        ]
 67
 68    def __init__(
 69        self, repo, version=1, use_metrics_blocklist=False, **kwargs
 70    ):  # TODO: Make env-url optional
 71        self.repo = repo
 72        self.repo_name = repo["name"]
 73        self.app_id = repo["app_id"]
 74        self.version = version
 75
 76        if use_metrics_blocklist and self.app_id not in METRIC_BLOCKLIST_EXEMPT_APP_IDS:
 77            self.metric_blocklist = self.get_metric_blocklist()
 78        else:
 79            self.metric_blocklist = {}
 80
 81        super().__init__(
 82            DEFAULT_SCHEMA_URL,
 83            DEFAULT_SCHEMA_URL,
 84            self.probes_url_template.format(self.repo_name),
 85            **kwargs,
 86        )
 87
 88    def get_schema(self, generic_schema=False) -> Schema:
 89        """
 90        Fetch schema via URL.
 91
 92        Unless *generic_schema* is set to true, this function makes some modifications
 93        to allow some workarounds for proper injection of metrics.
 94        """
 95        schema = super().get_schema()
 96        if generic_schema:
 97            return schema
 98
 99        # We need to inject placeholders for the url2, text2, etc. types as part
100        # of mitigation for https://bugzilla.mozilla.org/show_bug.cgi?id=1737656
101        for metric_name in ["labeled_rate", "jwe", "url", "text"]:
102            metric1 = schema.get(
103                ("properties", "metrics", "properties", metric_name)
104            ).copy()
105            metric1 = schema.set_schema_elem(
106                ("properties", "metrics", "properties", metric_name + "2"),
107                metric1,
108            )
109
110        return schema
111
112    @cache
113    def get_dependencies(self):
114        # Get all of the library dependencies for the application that
115        # are also known about in the repositories file.
116
117        # The dependencies are specified using library names, but we need to
118        # map those back to the name of the repository in the repository file.
119        try:
120            dependencies = self._get_json(
121                self.dependencies_url_template.format(self.repo_name)
122            )
123        except HTTPError:
124            logging.info(f"For {self.repo_name}, using default Glean dependencies")
125            return self.default_dependencies
126
127        dependency_library_names = list(dependencies.keys())
128
129        repos = GleanPing._get_json(GleanPing.repos_url)
130        repos_by_dependency_name = {}
131        for repo in repos:
132            for library_name in repo.get("library_names", []):
133                repos_by_dependency_name[library_name] = repo["name"]
134
135        dependencies = []
136        for name in dependency_library_names:
137            if name in repos_by_dependency_name:
138                dependencies.append(repos_by_dependency_name[name])
139
140        if len(dependencies) == 0:
141            logging.info(f"For {self.repo_name}, using default Glean dependencies")
142            return self.default_dependencies
143
144        logging.info(f"For {self.repo_name}, found Glean dependencies: {dependencies}")
145        return dependencies
146
147    @staticmethod
148    def remove_pings_from_metric(
149        metric: Dict[str, Any], blocked_pings: List[str]
150    ) -> Dict[str, Any]:
151        """Remove the given pings from the metric's `send_in_pings` history.
152
153        Only removes if the given metric has been removed from the source since a fixed date
154        (2025-01-01). This allows metrics to be added back to the schema.
155        """
156        if (
157            metric["in-source"]
158            or len(blocked_pings) == 0
159            or datetime.fromisoformat(metric["history"][-1]["dates"]["last"])
160            >= datetime(year=2025, month=8, day=1)
161        ):
162            return metric
163
164        for history_entry in metric["history"]:
165            history_entry["send_in_pings"] = [
166                p for p in history_entry["send_in_pings"] if p not in blocked_pings
167            ]
168
169        return metric
170
171    def get_probes(self) -> List[GleanProbe]:
172        data = self._get_json(self.probes_url)
173
174        # blocklist needs to be applied here instead of generate_schema because it needs to be
175        # dependency-aware; metrics can move between app and library and still be in the schema
176        # turn blocklist into metric_name -> ping_types map
177        blocklist = defaultdict(list)
178        for ping_type, metric_names in self.metric_blocklist.get(
179            self.get_app_name(), {}
180        ).items():
181            for metric_name in metric_names:
182                blocklist[metric_name].append(ping_type)
183
184        probes = [
185            (name, self.remove_pings_from_metric(defn, blocklist.get(name, [])))
186            for name, defn in data.items()
187        ]
188
189        for dependency in self.get_dependencies():
190            dependency_probes = self._get_json(
191                self.probes_url_template.format(dependency)
192            )
193
194            dependency_blocklist = defaultdict(list)
195            for ping_type, metric_names in self.metric_blocklist.get(
196                dependency, {}
197            ).items():
198                for metric_name in metric_names:
199                    dependency_blocklist[metric_name].append(ping_type)
200
201            probes += [
202                (
203                    name,
204                    self.remove_pings_from_metric(
205                        defn, dependency_blocklist.get(name, [])
206                    ),
207                )
208                for name, defn in dependency_probes.items()
209            ]
210
211        # A metric can be moved between an app and its dependencies or between dependencies while
212        # probe scraper keeps the history in each location, so both definitions are returned
213        # Merge the history per probe to take the latest definition while still being able to
214        # find metric type changes below
215        # Metrics are not merged if they are not sent in the same pings as they are disjoint
216
217        # Metrics are grouped by their normalized BigQuery column name (from jsonschema-transpiler)
218        # rather than their raw name. e.g. "media.audio.init_failure" and "media.audio_init_failure"
219        # normalize to "media_audio_init_failure". The transpiler picks one of the colliding
220        # descriptions non-deterministically.
221        def _normalize_name(name):
222            return name.replace(".", "_").replace("-", "_")
223
224        def _pings_in_history(defn):
225            return {
226                p
227                for h in defn[GleanProbe.history_key]
228                for p in h.get("send_in_pings", ["metrics"])
229            }
230
231        def _latest_history_date(defn):
232            return max(
233                datetime.fromisoformat(h["dates"]["last"])
234                for h in defn[GleanProbe.history_key]
235            )
236
237        def _dedupe_sort_key(defn):
238            """Prefer the most recent definition, breaking ties by choosing the in-source metric."""
239            return (
240                _latest_history_date(defn),
241                defn.get(GleanProbe.in_source_key, False),
242                defn["name"],
243            )
244
245        # Group probes that share a normalized name and whose pings intersect to combine
246        # moved metrics and metrics that only differ by "." vs "_"
247        grouped_by_name: Dict[str, List[List[dict]]] = defaultdict(list)
248        for name, defn in probes:
249            defn_pings = _pings_in_history(defn)
250            existing_groups = grouped_by_name[_normalize_name(name)]
251            matches = [
252                group
253                for group in existing_groups
254                if any(
255                    _pings_in_history(other_defn) & defn_pings for other_defn in group
256                )
257            ]
258            if not matches:
259                existing_groups.append([defn])
260            else:
261                merged_group = [defn]
262                for g in matches:
263                    merged_group.extend(g)
264                    existing_groups.remove(g)
265                existing_groups.append(merged_group)
266
267        # Take latest definition per group
268        deduped_probes: List[Any] = []
269        for groups in grouped_by_name.values():
270            for group in groups:
271                latest_defn = max(group, key=_dedupe_sort_key)
272                if len(group) > 1:
273                    latest_defn = latest_defn.copy()
274                    latest_defn[GleanProbe.history_key] = sorted(
275                        (h for d in group for h in d[GleanProbe.history_key]),
276                        key=lambda h: datetime.fromisoformat(h["dates"]["first"]),
277                    )
278                deduped_probes.append((latest_defn["name"], latest_defn))
279        probes = deduped_probes
280
281        pings = self.get_pings()
282
283        processed = []
284        for _id, defn in probes:
285            probe = GleanProbe(_id, defn, pings=pings)
286            processed.append(probe)
287
288            # Handling probe type changes (Bug 1870317)
289            probe_types = {hist["type"] for hist in defn[probe.history_key]}
290            if len(probe_types) > 1:
291                # The probe type changed at some point in history.
292                # Create schema entry for each type.
293                hist_defn = defn.copy()
294
295                # No new entry needs to be created for the current probe type
296                probe_types.remove(defn["type"])
297
298                for hist in hist_defn[probe.history_key]:
299                    # Create a new entry for a historic type
300                    if hist["type"] in probe_types:
301                        hist_defn["type"] = hist["type"]
302                        probe = GleanProbe(_id, hist_defn, pings=pings)
303                        processed.append(probe)
304
305                        # Keep track of the types entries were already created for
306                        probe_types.remove(hist["type"])
307
308        return processed
309
310    def _get_ping_data(self) -> Dict[str, Dict]:
311        url = self.ping_url_template.format(self.repo_name)
312        ping_data = GleanPing._get_json(url)
313        for dependency in self.get_dependencies():
314            dependency_pings = self._get_json(self.ping_url_template.format(dependency))
315            ping_data.update(dependency_pings)
316        return ping_data
317
318    def _get_ping_data_without_dependencies(self) -> Dict[str, Dict]:
319        url = self.ping_url_template.format(self.repo_name)
320        ping_data = GleanPing._get_json(url)
321        return ping_data
322
323    def _get_dependency_pings(self, dependency):
324        return self._get_json(self.ping_url_template.format(dependency))
325
326    def get_pings(self) -> Set[str]:
327        return self._get_ping_data().keys()
328
329    @staticmethod
330    def apply_default_metadata(ping_metadata, default_metadata):
331        """apply_default_metadata recurses down into dicts nested
332        to an arbitrary depth, updating keys. The ``default_metadata`` is merged into
333        ``ping_metadata``.
334        :param ping_metadata: dict onto which the merge is executed
335        :param default_metadata: dct merged into ping_metadata
336        :return: None
337        """
338        for k, v in default_metadata.items():
339            if (
340                k in ping_metadata
341                and isinstance(ping_metadata[k], dict)
342                and isinstance(default_metadata[k], dict)
343            ):
344                GleanPing.apply_default_metadata(ping_metadata[k], default_metadata[k])
345            else:
346                ping_metadata[k] = default_metadata[k]
347
348    def _get_ping_data_and_dependencies_with_default_metadata(self) -> Dict[str, Dict]:
349        # Get the ping data with the pipeline metadata
350        ping_data = self._get_ping_data_without_dependencies()
351
352        # The ping endpoint for the dependency pings does not include any repo defined
353        # moz_pipeline_metadata_defaults so they need to be applied here.
354
355        # 1.  Get repo and pipeline default metadata.
356        repos = self.get_repos()
357        current_repo = next((x for x in repos if x.get("app_id") == self.app_id), {})
358        default_metadata = current_repo.get("moz_pipeline_metadata_defaults", {})
359
360        # 2.  Apply the default metadata to each dependency defined ping.
361
362        # Apply app-level metadata to pings defined in dependencies
363        app_metadata = current_repo.get("moz_pipeline_metadata", {})
364
365        for dependency in self.get_dependencies():
366            dependency_pings = self._get_dependency_pings(dependency)
367            for dependency_ping in dependency_pings.values():
368                # Although it is counter intuitive to apply the default metadata on top of the
369                # existing dependency ping metadata it does set the repo specific value for
370                # bq_dataset_family instead of using the dependency id for the bq_dataset_family
371                # value.
372                GleanPing.apply_default_metadata(
373                    dependency_ping.get("moz_pipeline_metadata"),
374                    copy.deepcopy(default_metadata),
375                )
376                # app-level ping properties take priority over the app defaults
377                metadata_override = app_metadata.get(dependency_ping["name"])
378                if metadata_override is not None:
379                    GleanPing.apply_default_metadata(
380                        dependency_ping.get("moz_pipeline_metadata"), metadata_override
381                    )
382            ping_data.update(dependency_pings)
383
384        return ping_data
385
386    @staticmethod
387    def reorder_metadata(metadata):
388        desired_order_list = [
389            "bq_dataset_family",
390            "bq_table",
391            "bq_metadata_format",
392            "include_info_sections",
393            "uploader_capabilities",
394            "submission_timestamp_granularity",
395            "expiration_policy",
396            "override_attributes",
397            "jwe_mappings",
398        ]
399        reordered_metadata = {
400            k: metadata[k] for k in desired_order_list if k in metadata
401        }
402
403        # re-order jwe-mappings
404        desired_order_list = ["source_field_path", "decrypted_field_path"]
405        jwe_mapping_metadata = reordered_metadata.get("jwe_mappings")
406        if jwe_mapping_metadata:
407            reordered_jwe_mapping_metadata = []
408            for mapping in jwe_mapping_metadata:
409                reordered_jwe_mapping_metadata.append(
410                    {k: mapping[k] for k in desired_order_list if k in mapping}
411                )
412            reordered_metadata["jwe_mappings"] = reordered_jwe_mapping_metadata
413
414        # future proofing, in case there are other fields added at the ping top level
415        # add them to the end.
416        leftovers = {k: metadata[k] for k in set(metadata) - set(reordered_metadata)}
417        reordered_metadata = {**reordered_metadata, **leftovers}
418        return reordered_metadata
419
420    def get_pings_and_pipeline_metadata(self) -> Dict[str, Dict]:
421        pings = self._get_ping_data_and_dependencies_with_default_metadata()
422        for ping_name, ping_data in pings.items():
423            metadata = ping_data.get("moz_pipeline_metadata")
424            if not metadata:
425                continue
426            metadata["include_info_sections"] = self._is_field_included(
427                ping_data, "include_info_sections", consider_all_history=False
428            )
429            metadata["include_client_id"] = self._is_field_included(
430                ping_data, "include_client_id"
431            )
432            # Omitted when empty to avoid touching the schemas of every ping without
433            # uploader capabilities.
434            uploader_capabilities = self._get_uploader_capabilities(ping_data)
435            if uploader_capabilities:
436                metadata["uploader_capabilities"] = uploader_capabilities
437
438            # While technically unnecessary, the dictionary elements are re-ordered to match the
439            # currently deployed order and used to verify no difference in output.
440            pings[ping_name] = GleanPing.reorder_metadata(metadata)
441        return pings
442
443    @staticmethod
444    def _get_uploader_capabilities(ping_data) -> List[str]:
445        """Return the uploader capabilities from the latest history entry, e.g. ["ohttp"]."""
446        history = ping_data.get("history") or [{}]
447        return history[-1].get("uploader_capabilities") or []
448
449    def get_ohttp_pings_with_info_sections(self) -> List[str]:
450        """Return pings that declare the `ohttp` uploader capability but still
451        include info sections.
452        """
453        pings = self._get_ping_data_and_dependencies_with_default_metadata()
454        return sorted(
455            ping_name
456            for ping_name, ping_data in pings.items()
457            # pings without pipeline metadata don't get a schema
458            if ping_data.get("moz_pipeline_metadata")
459            and "ohttp" in self._get_uploader_capabilities(ping_data)
460            and self._is_field_included(
461                ping_data, "include_info_sections", consider_all_history=False
462            )
463        )
464
465    def get_ping_descriptions(self) -> Dict[str, str]:
466        return {
467            k: v["history"][-1]["description"] for k, v in self._get_ping_data().items()
468        }
469
470    @staticmethod
471    def _is_field_included(ping_data, field_name, consider_all_history=True) -> bool:
472        """Return false if the field exists and is false.
473
474        If `consider_all_history` is False, then only check the latest value in the ping history.
475
476        Otherwise, if the field is not found or true in one or more history entries,
477        true is returned.
478        """
479
480        # Default to true if not specified.
481        if "history" not in ping_data or len(ping_data["history"]) == 0:
482            return True
483
484        # Check if at some point in the past the field has already been deployed.
485        # And if the caller of this method wants to consider this history of the field.
486        # Keep them in the schema, even if the field has changed as
487        # removing fields is currently not supported.
488        # See https://bugzilla.mozilla.org/show_bug.cgi?id=1898105
489        # and https://bugzilla.mozilla.org/show_bug.cgi?id=1898105#c10
490        ping_history: list
491        if consider_all_history:
492            ping_history = ping_data["history"]
493        else:
494            ping_history = [ping_data["history"][-1]]
495        for history in ping_history:
496            if field_name not in history or history[field_name]:
497                return True
498
499        # The ping was created with include_info_sections = False. The fields can be excluded.
500        return False
501
502    def set_schema_url(self, metadata):
503        """
504        Switch between the glean-min and glean schemas if the ping does not require
505        info sections as specified in the parsed ping info in probe scraper.
506        """
507        if not metadata["include_info_sections"]:
508            self.schema_url = SCHEMA_URL_TEMPLATE.format(
509                branch=self.branch_name
510            ) + SCHEMA_VERSION_TEMPLATE.format(
511                schema_type="glean-min", version=self.version
512            )
513        else:
514            self.schema_url = SCHEMA_URL_TEMPLATE.format(
515                branch=self.branch_name
516            ) + SCHEMA_VERSION_TEMPLATE.format(
517                schema_type="glean", version=self.version
518            )
519
520    def generate_schema(
521        self,
522        config,
523        generic_schema=False,
524    ) -> Dict[str, Schema]:
525        pings = self.get_pings_and_pipeline_metadata()
526        schemas = {}
527
528        for ping, pipeline_meta in pings.items():
529            matchers = {
530                loc: m.clone(new_table_group=ping) for loc, m in config.matchers.items()
531            }
532
533            # Four newly introduced metric types were incorrectly deployed
534            # as repeated key/value structs in all Glean ping tables existing prior
535            # to November 2021. We maintain the incorrect fields for existing tables
536            # by disabling the associated matchers.
537            # Note that each of these types now has a "2" matcher ("text2", "url2", etc.)
538            # defined that will allow metrics of these types to be injected into proper
539            # structs. The gcp-ingestion repository includes logic to rewrite these
540            # metrics under the "2" names.
541            # See https://bugzilla.mozilla.org/show_bug.cgi?id=1737656
542            bq_identifier = "{bq_dataset_family}.{bq_table}".format(**pipeline_meta)
543            if bq_identifier in self.bug_1737656_affected_tables:
544                matchers = {
545                    loc: m
546                    for loc, m in matchers.items()
547                    if not m.matcher.get("bug_1737656_affected")
548                }
549
550            for matcher in matchers.values():
551                matcher.matcher["send_in_pings"]["contains"] = ping
552
553            new_config = Config(ping, matchers=matchers)
554
555            defaults = {"mozPipelineMetadata": pipeline_meta}
556
557            # Adjust the schema path if the ping does not require info sections
558            self.set_schema_url(pipeline_meta)
559            if generic_schema:  # Use the generic glean ping schema
560                schema = self.get_schema(generic_schema=True)
561                schema.schema.update(defaults)
562                schemas[new_config.name] = schema
563            else:
564                generated = super().generate_schema(new_config)
565                for schema in generated.values():
566                    # We want to override each individual key with assembled defaults,
567                    # but keep values _inside_ them if they have been set in the schemas.
568                    for key, value in defaults.items():
569                        if key not in schema.schema:
570                            schema.schema[key] = {}
571                        schema.schema[key].update(value)
572                schemas.update(generated)
573
574        return schemas
575
576    @staticmethod
577    def get_repos():
578        """
579        Retrieve metadata for all non-library Glean repositories
580        """
581        repos = GleanPing._get_json(GleanPing.repos_url)
582        return [repo for repo in repos if "library_names" not in repo]
583
584    def get_app_name(self) -> str:
585        """Get app name associated with the app id.
586
587        e.g. org-mozilla-firefox -> fenix
588        """
589        apps = GleanPing._get_json(GleanPing.app_listings_url)
590        # app id in app-listings has "." instead of "-" so using document_namespace
591        app_name = [
592            app["app_name"] for app in apps if app["document_namespace"] == self.app_id
593        ]
594        return app_name[0] if len(app_name) > 0 else self.app_id
595
596    @staticmethod
597    def get_metric_blocklist():
598        with open(METRIC_BLOCKLIST, "r") as f:
599            return yaml.safe_load(f)
GleanPing(repo, version=1, use_metrics_blocklist=False, **kwargs)
68    def __init__(
69        self, repo, version=1, use_metrics_blocklist=False, **kwargs
70    ):  # TODO: Make env-url optional
71        self.repo = repo
72        self.repo_name = repo["name"]
73        self.app_id = repo["app_id"]
74        self.version = version
75
76        if use_metrics_blocklist and self.app_id not in METRIC_BLOCKLIST_EXEMPT_APP_IDS:
77            self.metric_blocklist = self.get_metric_blocklist()
78        else:
79            self.metric_blocklist = {}
80
81        super().__init__(
82            DEFAULT_SCHEMA_URL,
83            DEFAULT_SCHEMA_URL,
84            self.probes_url_template.format(self.repo_name),
85            **kwargs,
86        )
probes_url_template = 'https://probeinfo.telemetry.mozilla.org/glean/{}/metrics'
ping_url_template = 'https://probeinfo.telemetry.mozilla.org/glean/{}/pings'
repos_url = 'https://probeinfo.telemetry.mozilla.org/glean/repositories'
dependencies_url_template = 'https://probeinfo.telemetry.mozilla.org/glean/{}/dependencies'
app_listings_url = 'https://probeinfo.telemetry.mozilla.org/v2/glean/app-listings'
default_dependencies = ['glean-core']
repo
repo_name
app_id
version
def get_schema(self, generic_schema=False) -> mozilla_schema_generator.schema.Schema:
 88    def get_schema(self, generic_schema=False) -> Schema:
 89        """
 90        Fetch schema via URL.
 91
 92        Unless *generic_schema* is set to true, this function makes some modifications
 93        to allow some workarounds for proper injection of metrics.
 94        """
 95        schema = super().get_schema()
 96        if generic_schema:
 97            return schema
 98
 99        # We need to inject placeholders for the url2, text2, etc. types as part
100        # of mitigation for https://bugzilla.mozilla.org/show_bug.cgi?id=1737656
101        for metric_name in ["labeled_rate", "jwe", "url", "text"]:
102            metric1 = schema.get(
103                ("properties", "metrics", "properties", metric_name)
104            ).copy()
105            metric1 = schema.set_schema_elem(
106                ("properties", "metrics", "properties", metric_name + "2"),
107                metric1,
108            )
109
110        return schema

Fetch schema via URL.

Unless generic_schema is set to true, this function makes some modifications to allow some workarounds for proper injection of metrics.

@cache
def get_dependencies(self):
112    @cache
113    def get_dependencies(self):
114        # Get all of the library dependencies for the application that
115        # are also known about in the repositories file.
116
117        # The dependencies are specified using library names, but we need to
118        # map those back to the name of the repository in the repository file.
119        try:
120            dependencies = self._get_json(
121                self.dependencies_url_template.format(self.repo_name)
122            )
123        except HTTPError:
124            logging.info(f"For {self.repo_name}, using default Glean dependencies")
125            return self.default_dependencies
126
127        dependency_library_names = list(dependencies.keys())
128
129        repos = GleanPing._get_json(GleanPing.repos_url)
130        repos_by_dependency_name = {}
131        for repo in repos:
132            for library_name in repo.get("library_names", []):
133                repos_by_dependency_name[library_name] = repo["name"]
134
135        dependencies = []
136        for name in dependency_library_names:
137            if name in repos_by_dependency_name:
138                dependencies.append(repos_by_dependency_name[name])
139
140        if len(dependencies) == 0:
141            logging.info(f"For {self.repo_name}, using default Glean dependencies")
142            return self.default_dependencies
143
144        logging.info(f"For {self.repo_name}, found Glean dependencies: {dependencies}")
145        return dependencies
@staticmethod
def remove_pings_from_metric(metric: Dict[str, Any], blocked_pings: List[str]) -> Dict[str, Any]:
147    @staticmethod
148    def remove_pings_from_metric(
149        metric: Dict[str, Any], blocked_pings: List[str]
150    ) -> Dict[str, Any]:
151        """Remove the given pings from the metric's `send_in_pings` history.
152
153        Only removes if the given metric has been removed from the source since a fixed date
154        (2025-01-01). This allows metrics to be added back to the schema.
155        """
156        if (
157            metric["in-source"]
158            or len(blocked_pings) == 0
159            or datetime.fromisoformat(metric["history"][-1]["dates"]["last"])
160            >= datetime(year=2025, month=8, day=1)
161        ):
162            return metric
163
164        for history_entry in metric["history"]:
165            history_entry["send_in_pings"] = [
166                p for p in history_entry["send_in_pings"] if p not in blocked_pings
167            ]
168
169        return metric

Remove the given pings from the metric's send_in_pings history.

Only removes if the given metric has been removed from the source since a fixed date (2025-01-01). This allows metrics to be added back to the schema.

def get_probes(self) -> List[mozilla_schema_generator.probes.GleanProbe]:
171    def get_probes(self) -> List[GleanProbe]:
172        data = self._get_json(self.probes_url)
173
174        # blocklist needs to be applied here instead of generate_schema because it needs to be
175        # dependency-aware; metrics can move between app and library and still be in the schema
176        # turn blocklist into metric_name -> ping_types map
177        blocklist = defaultdict(list)
178        for ping_type, metric_names in self.metric_blocklist.get(
179            self.get_app_name(), {}
180        ).items():
181            for metric_name in metric_names:
182                blocklist[metric_name].append(ping_type)
183
184        probes = [
185            (name, self.remove_pings_from_metric(defn, blocklist.get(name, [])))
186            for name, defn in data.items()
187        ]
188
189        for dependency in self.get_dependencies():
190            dependency_probes = self._get_json(
191                self.probes_url_template.format(dependency)
192            )
193
194            dependency_blocklist = defaultdict(list)
195            for ping_type, metric_names in self.metric_blocklist.get(
196                dependency, {}
197            ).items():
198                for metric_name in metric_names:
199                    dependency_blocklist[metric_name].append(ping_type)
200
201            probes += [
202                (
203                    name,
204                    self.remove_pings_from_metric(
205                        defn, dependency_blocklist.get(name, [])
206                    ),
207                )
208                for name, defn in dependency_probes.items()
209            ]
210
211        # A metric can be moved between an app and its dependencies or between dependencies while
212        # probe scraper keeps the history in each location, so both definitions are returned
213        # Merge the history per probe to take the latest definition while still being able to
214        # find metric type changes below
215        # Metrics are not merged if they are not sent in the same pings as they are disjoint
216
217        # Metrics are grouped by their normalized BigQuery column name (from jsonschema-transpiler)
218        # rather than their raw name. e.g. "media.audio.init_failure" and "media.audio_init_failure"
219        # normalize to "media_audio_init_failure". The transpiler picks one of the colliding
220        # descriptions non-deterministically.
221        def _normalize_name(name):
222            return name.replace(".", "_").replace("-", "_")
223
224        def _pings_in_history(defn):
225            return {
226                p
227                for h in defn[GleanProbe.history_key]
228                for p in h.get("send_in_pings", ["metrics"])
229            }
230
231        def _latest_history_date(defn):
232            return max(
233                datetime.fromisoformat(h["dates"]["last"])
234                for h in defn[GleanProbe.history_key]
235            )
236
237        def _dedupe_sort_key(defn):
238            """Prefer the most recent definition, breaking ties by choosing the in-source metric."""
239            return (
240                _latest_history_date(defn),
241                defn.get(GleanProbe.in_source_key, False),
242                defn["name"],
243            )
244
245        # Group probes that share a normalized name and whose pings intersect to combine
246        # moved metrics and metrics that only differ by "." vs "_"
247        grouped_by_name: Dict[str, List[List[dict]]] = defaultdict(list)
248        for name, defn in probes:
249            defn_pings = _pings_in_history(defn)
250            existing_groups = grouped_by_name[_normalize_name(name)]
251            matches = [
252                group
253                for group in existing_groups
254                if any(
255                    _pings_in_history(other_defn) & defn_pings for other_defn in group
256                )
257            ]
258            if not matches:
259                existing_groups.append([defn])
260            else:
261                merged_group = [defn]
262                for g in matches:
263                    merged_group.extend(g)
264                    existing_groups.remove(g)
265                existing_groups.append(merged_group)
266
267        # Take latest definition per group
268        deduped_probes: List[Any] = []
269        for groups in grouped_by_name.values():
270            for group in groups:
271                latest_defn = max(group, key=_dedupe_sort_key)
272                if len(group) > 1:
273                    latest_defn = latest_defn.copy()
274                    latest_defn[GleanProbe.history_key] = sorted(
275                        (h for d in group for h in d[GleanProbe.history_key]),
276                        key=lambda h: datetime.fromisoformat(h["dates"]["first"]),
277                    )
278                deduped_probes.append((latest_defn["name"], latest_defn))
279        probes = deduped_probes
280
281        pings = self.get_pings()
282
283        processed = []
284        for _id, defn in probes:
285            probe = GleanProbe(_id, defn, pings=pings)
286            processed.append(probe)
287
288            # Handling probe type changes (Bug 1870317)
289            probe_types = {hist["type"] for hist in defn[probe.history_key]}
290            if len(probe_types) > 1:
291                # The probe type changed at some point in history.
292                # Create schema entry for each type.
293                hist_defn = defn.copy()
294
295                # No new entry needs to be created for the current probe type
296                probe_types.remove(defn["type"])
297
298                for hist in hist_defn[probe.history_key]:
299                    # Create a new entry for a historic type
300                    if hist["type"] in probe_types:
301                        hist_defn["type"] = hist["type"]
302                        probe = GleanProbe(_id, hist_defn, pings=pings)
303                        processed.append(probe)
304
305                        # Keep track of the types entries were already created for
306                        probe_types.remove(hist["type"])
307
308        return processed
def get_pings(self) -> Set[str]:
326    def get_pings(self) -> Set[str]:
327        return self._get_ping_data().keys()
@staticmethod
def apply_default_metadata(ping_metadata, default_metadata):
329    @staticmethod
330    def apply_default_metadata(ping_metadata, default_metadata):
331        """apply_default_metadata recurses down into dicts nested
332        to an arbitrary depth, updating keys. The ``default_metadata`` is merged into
333        ``ping_metadata``.
334        :param ping_metadata: dict onto which the merge is executed
335        :param default_metadata: dct merged into ping_metadata
336        :return: None
337        """
338        for k, v in default_metadata.items():
339            if (
340                k in ping_metadata
341                and isinstance(ping_metadata[k], dict)
342                and isinstance(default_metadata[k], dict)
343            ):
344                GleanPing.apply_default_metadata(ping_metadata[k], default_metadata[k])
345            else:
346                ping_metadata[k] = default_metadata[k]

apply_default_metadata recurses down into dicts nested to an arbitrary depth, updating keys. The default_metadata is merged into ping_metadata.

Parameters
  • ping_metadata: dict onto which the merge is executed
  • default_metadata: dct merged into ping_metadata
Returns

None

@staticmethod
def reorder_metadata(metadata):
386    @staticmethod
387    def reorder_metadata(metadata):
388        desired_order_list = [
389            "bq_dataset_family",
390            "bq_table",
391            "bq_metadata_format",
392            "include_info_sections",
393            "uploader_capabilities",
394            "submission_timestamp_granularity",
395            "expiration_policy",
396            "override_attributes",
397            "jwe_mappings",
398        ]
399        reordered_metadata = {
400            k: metadata[k] for k in desired_order_list if k in metadata
401        }
402
403        # re-order jwe-mappings
404        desired_order_list = ["source_field_path", "decrypted_field_path"]
405        jwe_mapping_metadata = reordered_metadata.get("jwe_mappings")
406        if jwe_mapping_metadata:
407            reordered_jwe_mapping_metadata = []
408            for mapping in jwe_mapping_metadata:
409                reordered_jwe_mapping_metadata.append(
410                    {k: mapping[k] for k in desired_order_list if k in mapping}
411                )
412            reordered_metadata["jwe_mappings"] = reordered_jwe_mapping_metadata
413
414        # future proofing, in case there are other fields added at the ping top level
415        # add them to the end.
416        leftovers = {k: metadata[k] for k in set(metadata) - set(reordered_metadata)}
417        reordered_metadata = {**reordered_metadata, **leftovers}
418        return reordered_metadata
def get_pings_and_pipeline_metadata(self) -> Dict[str, Dict]:
420    def get_pings_and_pipeline_metadata(self) -> Dict[str, Dict]:
421        pings = self._get_ping_data_and_dependencies_with_default_metadata()
422        for ping_name, ping_data in pings.items():
423            metadata = ping_data.get("moz_pipeline_metadata")
424            if not metadata:
425                continue
426            metadata["include_info_sections"] = self._is_field_included(
427                ping_data, "include_info_sections", consider_all_history=False
428            )
429            metadata["include_client_id"] = self._is_field_included(
430                ping_data, "include_client_id"
431            )
432            # Omitted when empty to avoid touching the schemas of every ping without
433            # uploader capabilities.
434            uploader_capabilities = self._get_uploader_capabilities(ping_data)
435            if uploader_capabilities:
436                metadata["uploader_capabilities"] = uploader_capabilities
437
438            # While technically unnecessary, the dictionary elements are re-ordered to match the
439            # currently deployed order and used to verify no difference in output.
440            pings[ping_name] = GleanPing.reorder_metadata(metadata)
441        return pings
def get_ohttp_pings_with_info_sections(self) -> List[str]:
449    def get_ohttp_pings_with_info_sections(self) -> List[str]:
450        """Return pings that declare the `ohttp` uploader capability but still
451        include info sections.
452        """
453        pings = self._get_ping_data_and_dependencies_with_default_metadata()
454        return sorted(
455            ping_name
456            for ping_name, ping_data in pings.items()
457            # pings without pipeline metadata don't get a schema
458            if ping_data.get("moz_pipeline_metadata")
459            and "ohttp" in self._get_uploader_capabilities(ping_data)
460            and self._is_field_included(
461                ping_data, "include_info_sections", consider_all_history=False
462            )
463        )

Return pings that declare the ohttp uploader capability but still include info sections.

def get_ping_descriptions(self) -> Dict[str, str]:
465    def get_ping_descriptions(self) -> Dict[str, str]:
466        return {
467            k: v["history"][-1]["description"] for k, v in self._get_ping_data().items()
468        }
def set_schema_url(self, metadata):
502    def set_schema_url(self, metadata):
503        """
504        Switch between the glean-min and glean schemas if the ping does not require
505        info sections as specified in the parsed ping info in probe scraper.
506        """
507        if not metadata["include_info_sections"]:
508            self.schema_url = SCHEMA_URL_TEMPLATE.format(
509                branch=self.branch_name
510            ) + SCHEMA_VERSION_TEMPLATE.format(
511                schema_type="glean-min", version=self.version
512            )
513        else:
514            self.schema_url = SCHEMA_URL_TEMPLATE.format(
515                branch=self.branch_name
516            ) + SCHEMA_VERSION_TEMPLATE.format(
517                schema_type="glean", version=self.version
518            )

Switch between the glean-min and glean schemas if the ping does not require info sections as specified in the parsed ping info in probe scraper.

def generate_schema( self, config, generic_schema=False) -> Dict[str, mozilla_schema_generator.schema.Schema]:
520    def generate_schema(
521        self,
522        config,
523        generic_schema=False,
524    ) -> Dict[str, Schema]:
525        pings = self.get_pings_and_pipeline_metadata()
526        schemas = {}
527
528        for ping, pipeline_meta in pings.items():
529            matchers = {
530                loc: m.clone(new_table_group=ping) for loc, m in config.matchers.items()
531            }
532
533            # Four newly introduced metric types were incorrectly deployed
534            # as repeated key/value structs in all Glean ping tables existing prior
535            # to November 2021. We maintain the incorrect fields for existing tables
536            # by disabling the associated matchers.
537            # Note that each of these types now has a "2" matcher ("text2", "url2", etc.)
538            # defined that will allow metrics of these types to be injected into proper
539            # structs. The gcp-ingestion repository includes logic to rewrite these
540            # metrics under the "2" names.
541            # See https://bugzilla.mozilla.org/show_bug.cgi?id=1737656
542            bq_identifier = "{bq_dataset_family}.{bq_table}".format(**pipeline_meta)
543            if bq_identifier in self.bug_1737656_affected_tables:
544                matchers = {
545                    loc: m
546                    for loc, m in matchers.items()
547                    if not m.matcher.get("bug_1737656_affected")
548                }
549
550            for matcher in matchers.values():
551                matcher.matcher["send_in_pings"]["contains"] = ping
552
553            new_config = Config(ping, matchers=matchers)
554
555            defaults = {"mozPipelineMetadata": pipeline_meta}
556
557            # Adjust the schema path if the ping does not require info sections
558            self.set_schema_url(pipeline_meta)
559            if generic_schema:  # Use the generic glean ping schema
560                schema = self.get_schema(generic_schema=True)
561                schema.schema.update(defaults)
562                schemas[new_config.name] = schema
563            else:
564                generated = super().generate_schema(new_config)
565                for schema in generated.values():
566                    # We want to override each individual key with assembled defaults,
567                    # but keep values _inside_ them if they have been set in the schemas.
568                    for key, value in defaults.items():
569                        if key not in schema.schema:
570                            schema.schema[key] = {}
571                        schema.schema[key].update(value)
572                schemas.update(generated)
573
574        return schemas
@staticmethod
def get_repos():
576    @staticmethod
577    def get_repos():
578        """
579        Retrieve metadata for all non-library Glean repositories
580        """
581        repos = GleanPing._get_json(GleanPing.repos_url)
582        return [repo for repo in repos if "library_names" not in repo]

Retrieve metadata for all non-library Glean repositories

def get_app_name(self) -> str:
584    def get_app_name(self) -> str:
585        """Get app name associated with the app id.
586
587        e.g. org-mozilla-firefox -> fenix
588        """
589        apps = GleanPing._get_json(GleanPing.app_listings_url)
590        # app id in app-listings has "." instead of "-" so using document_namespace
591        app_name = [
592            app["app_name"] for app in apps if app["document_namespace"] == self.app_id
593        ]
594        return app_name[0] if len(app_name) > 0 else self.app_id

Get app name associated with the app id.

e.g. org-mozilla-firefox -> fenix

@staticmethod
def get_metric_blocklist():
596    @staticmethod
597    def get_metric_blocklist():
598        with open(METRIC_BLOCKLIST, "r") as f:
599            return yaml.safe_load(f)
f = <_io.TextIOWrapper name='/home/runner/work/mozilla-schema-generator/mozilla-schema-generator/mozilla_schema_generator/configs/bug_1737656_affected.txt' mode='r' encoding='UTF-8'>
bug_1737656_affected_tables = ['burnham.baseline_v1', 'burnham.deletion_request_v1', 'burnham.discovery_v1', 'burnham.events_v1', 'burnham.metrics_v1', 'burnham.space_ship_ready_v1', 'burnham.starbase46_v1', 'firefox_desktop_background_update.background_update_v1', 'firefox_desktop_background_update.baseline_v1', 'firefox_desktop_background_update.deletion_request_v1', 'firefox_desktop_background_update.events_v1', 'firefox_desktop_background_update.metrics_v1', 'firefox_desktop.baseline_v1', 'firefox_desktop.deletion_request_v1', 'firefox_desktop.events_v1', 'firefox_desktop.fog_validation_v1', 'firefox_desktop.metrics_v1', 'firefox_installer.install_v1', 'firefox_launcher_process.launcher_process_failure_v1', 'messaging_system.cfr_v1', 'messaging_system.infobar_v1', 'messaging_system.moments_v1', 'messaging_system.onboarding_v1', 'messaging_system.personalization_experiment_v1', 'messaging_system.snippets_v1', 'messaging_system.spotlight_v1', 'messaging_system.undesired_events_v1', 'messaging_system.whats_new_panel_v1', 'mlhackweek_search.action_v1', 'mlhackweek_search.baseline_v1', 'mlhackweek_search.custom_v1', 'mlhackweek_search.deletion_request_v1', 'mlhackweek_search.events_v1', 'mlhackweek_search.metrics_v1', 'mozilla_lockbox.addresses_sync_v1', 'mozilla_lockbox.baseline_v1', 'mozilla_lockbox.bookmarks_sync_v1', 'mozilla_lockbox.creditcards_sync_v1', 'mozilla_lockbox.deletion_request_v1', 'mozilla_lockbox.events_v1', 'mozilla_lockbox.history_sync_v1', 'mozilla_lockbox.logins_sync_v1', 'mozilla_lockbox.metrics_v1', 'mozilla_lockbox.sync_v1', 'mozilla_lockbox.tabs_sync_v1', 'mozilla_mach.baseline_v1', 'mozilla_mach.deletion_request_v1', 'mozilla_mach.events_v1', 'mozilla_mach.metrics_v1', 'mozilla_mach.usage_v1', 'mozillavpn.deletion_request_v1', 'mozillavpn.main_v1', 'mozphab.baseline_v1', 'mozphab.deletion_request_v1', 'mozphab.events_v1', 'mozphab.metrics_v1', 'mozphab.usage_v1', 'org_mozilla_bergamot.custom_v1', 'org_mozilla_bergamot.deletion_request_v1', 'org_mozilla_connect_firefox.baseline_v1', 'org_mozilla_connect_firefox.deletion_request_v1', 'org_mozilla_connect_firefox.events_v1', 'org_mozilla_connect_firefox.metrics_v1', 'org_mozilla_fenix.activation_v1', 'org_mozilla_fenix.addresses_sync_v1', 'org_mozilla_fenix.baseline_v1', 'org_mozilla_fenix.bookmarks_sync_v1', 'org_mozilla_fenix.creditcards_sync_v1', 'org_mozilla_fenix.deletion_request_v1', 'org_mozilla_fenix.events_v1', 'org_mozilla_fenix.first_session_v1', 'org_mozilla_fenix.fog_validation_v1', 'org_mozilla_fenix.history_sync_v1', 'org_mozilla_fenix.installation_v1', 'org_mozilla_fenix.logins_sync_v1', 'org_mozilla_fenix.metrics_v1', 'org_mozilla_fenix.migration_v1', 'org_mozilla_fenix.startup_timeline_v1', 'org_mozilla_fenix.sync_v1', 'org_mozilla_fenix.tabs_sync_v1', 'org_mozilla_fenix_nightly.activation_v1', 'org_mozilla_fenix_nightly.addresses_sync_v1', 'org_mozilla_fenix_nightly.baseline_v1', 'org_mozilla_fenix_nightly.bookmarks_sync_v1', 'org_mozilla_fenix_nightly.creditcards_sync_v1', 'org_mozilla_fenix_nightly.deletion_request_v1', 'org_mozilla_fenix_nightly.events_v1', 'org_mozilla_fenix_nightly.first_session_v1', 'org_mozilla_fenix_nightly.fog_validation_v1', 'org_mozilla_fenix_nightly.history_sync_v1', 'org_mozilla_fenix_nightly.installation_v1', 'org_mozilla_fenix_nightly.logins_sync_v1', 'org_mozilla_fenix_nightly.metrics_v1', 'org_mozilla_fenix_nightly.migration_v1', 'org_mozilla_fenix_nightly.startup_timeline_v1', 'org_mozilla_fenix_nightly.sync_v1', 'org_mozilla_fenix_nightly.tabs_sync_v1', 'org_mozilla_fennec_aurora.activation_v1', 'org_mozilla_fennec_aurora.addresses_sync_v1', 'org_mozilla_fennec_aurora.baseline_v1', 'org_mozilla_fennec_aurora.bookmarks_sync_v1', 'org_mozilla_fennec_aurora.creditcards_sync_v1', 'org_mozilla_fennec_aurora.deletion_request_v1', 'org_mozilla_fennec_aurora.events_v1', 'org_mozilla_fennec_aurora.first_session_v1', 'org_mozilla_fennec_aurora.fog_validation_v1', 'org_mozilla_fennec_aurora.history_sync_v1', 'org_mozilla_fennec_aurora.installation_v1', 'org_mozilla_fennec_aurora.logins_sync_v1', 'org_mozilla_fennec_aurora.metrics_v1', 'org_mozilla_fennec_aurora.migration_v1', 'org_mozilla_fennec_aurora.startup_timeline_v1', 'org_mozilla_fennec_aurora.sync_v1', 'org_mozilla_fennec_aurora.tabs_sync_v1', 'org_mozilla_firefox_beta.activation_v1', 'org_mozilla_firefox_beta.addresses_sync_v1', 'org_mozilla_firefox_beta.baseline_v1', 'org_mozilla_firefox_beta.bookmarks_sync_v1', 'org_mozilla_firefox_beta.creditcards_sync_v1', 'org_mozilla_firefox_beta.deletion_request_v1', 'org_mozilla_firefox_beta.events_v1', 'org_mozilla_firefox_beta.first_session_v1', 'org_mozilla_firefox_beta.fog_validation_v1', 'org_mozilla_firefox_beta.history_sync_v1', 'org_mozilla_firefox_beta.installation_v1', 'org_mozilla_firefox_beta.logins_sync_v1', 'org_mozilla_firefox_beta.metrics_v1', 'org_mozilla_firefox_beta.migration_v1', 'org_mozilla_firefox_beta.startup_timeline_v1', 'org_mozilla_firefox_beta.sync_v1', 'org_mozilla_firefox_beta.tabs_sync_v1', 'org_mozilla_firefox.activation_v1', 'org_mozilla_firefox.addresses_sync_v1', 'org_mozilla_firefox.baseline_v1', 'org_mozilla_firefox.bookmarks_sync_v1', 'org_mozilla_firefox.creditcards_sync_v1', 'org_mozilla_firefox.deletion_request_v1', 'org_mozilla_firefox.events_v1', 'org_mozilla_firefox.first_session_v1', 'org_mozilla_firefox.fog_validation_v1', 'org_mozilla_firefox.history_sync_v1', 'org_mozilla_firefox.installation_v1', 'org_mozilla_firefox.logins_sync_v1', 'org_mozilla_firefox.metrics_v1', 'org_mozilla_firefox.migration_v1', 'org_mozilla_firefox.startup_timeline_v1', 'org_mozilla_firefox.sync_v1', 'org_mozilla_firefox.tabs_sync_v1', 'org_mozilla_firefoxreality.baseline_v1', 'org_mozilla_firefoxreality.deletion_request_v1', 'org_mozilla_firefoxreality.events_v1', 'org_mozilla_firefoxreality.launch_v1', 'org_mozilla_firefoxreality.metrics_v1', 'org_mozilla_focus_beta.activation_v1', 'org_mozilla_focus_beta.baseline_v1', 'org_mozilla_focus_beta.deletion_request_v1', 'org_mozilla_focus_beta.events_v1', 'org_mozilla_focus_beta.metrics_v1', 'org_mozilla_focus.activation_v1', 'org_mozilla_focus.baseline_v1', 'org_mozilla_focus.deletion_request_v1', 'org_mozilla_focus.events_v1', 'org_mozilla_focus.metrics_v1', 'org_mozilla_focus_nightly.activation_v1', 'org_mozilla_focus_nightly.baseline_v1', 'org_mozilla_focus_nightly.deletion_request_v1', 'org_mozilla_focus_nightly.events_v1', 'org_mozilla_focus_nightly.metrics_v1', 'org_mozilla_ios_fennec.baseline_v1', 'org_mozilla_ios_fennec.deletion_request_v1', 'org_mozilla_ios_fennec.events_v1', 'org_mozilla_ios_fennec.metrics_v1', 'org_mozilla_ios_firefox.baseline_v1', 'org_mozilla_ios_firefox.deletion_request_v1', 'org_mozilla_ios_firefox.events_v1', 'org_mozilla_ios_firefox.metrics_v1', 'org_mozilla_ios_firefoxbeta.baseline_v1', 'org_mozilla_ios_firefoxbeta.deletion_request_v1', 'org_mozilla_ios_firefoxbeta.events_v1', 'org_mozilla_ios_firefoxbeta.metrics_v1', 'org_mozilla_ios_focus.baseline_v1', 'org_mozilla_ios_focus.deletion_request_v1', 'org_mozilla_ios_focus.events_v1', 'org_mozilla_ios_focus.metrics_v1', 'org_mozilla_ios_klar.baseline_v1', 'org_mozilla_ios_klar.deletion_request_v1', 'org_mozilla_ios_klar.events_v1', 'org_mozilla_ios_klar.metrics_v1', 'org_mozilla_ios_lockbox.baseline_v1', 'org_mozilla_ios_lockbox.deletion_request_v1', 'org_mozilla_ios_lockbox.events_v1', 'org_mozilla_ios_lockbox.metrics_v1', 'org_mozilla_klar.activation_v1', 'org_mozilla_klar.baseline_v1', 'org_mozilla_klar.deletion_request_v1', 'org_mozilla_klar.events_v1', 'org_mozilla_klar.metrics_v1', 'org_mozilla_mozregression.baseline_v1', 'org_mozilla_mozregression.deletion_request_v1', 'org_mozilla_mozregression.events_v1', 'org_mozilla_mozregression.metrics_v1', 'org_mozilla_mozregression.usage_v1', 'org_mozilla_reference_browser.baseline_v1', 'org_mozilla_reference_browser.deletion_request_v1', 'org_mozilla_reference_browser.events_v1', 'org_mozilla_reference_browser.metrics_v1', 'org_mozilla_tv_firefox.baseline_v1', 'org_mozilla_tv_firefox.deletion_request_v1', 'org_mozilla_tv_firefox.events_v1', 'org_mozilla_tv_firefox.metrics_v1', 'org_mozilla_vrbrowser.addresses_sync_v1', 'org_mozilla_vrbrowser.baseline_v1', 'org_mozilla_vrbrowser.bookmarks_sync_v1', 'org_mozilla_vrbrowser.creditcards_sync_v1', 'org_mozilla_vrbrowser.deletion_request_v1', 'org_mozilla_vrbrowser.events_v1', 'org_mozilla_vrbrowser.history_sync_v1', 'org_mozilla_vrbrowser.logins_sync_v1', 'org_mozilla_vrbrowser.metrics_v1', 'org_mozilla_vrbrowser.session_end_v1', 'org_mozilla_vrbrowser.sync_v1', 'org_mozilla_vrbrowser.tabs_sync_v1', 'rally_core.deletion_request_v1', 'rally_core.demographics_v1', 'rally_core.enrollment_v1', 'rally_core.study_enrollment_v1', 'rally_core.study_unenrollment_v1', 'rally_core.uninstall_deletion_v1', 'rally_debug.deletion_request_v1', 'rally_debug.demographics_v1', 'rally_debug.enrollment_v1', 'rally_debug.study_enrollment_v1', 'rally_debug.study_unenrollment_v1', 'rally_debug.uninstall_deletion_v1', 'rally_study_zero_one.deletion_request_v1', 'rally_study_zero_one.rs01_event_v1', 'rally_study_zero_one.study_enrollment_v1', 'rally_zero_one.deletion_request_v1', 'rally_zero_one.measurements_v1', 'rally_zero_one.pioneer_enrollment_v1']