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            "submission_timestamp_granularity",
393            "expiration_policy",
394            "override_attributes",
395            "jwe_mappings",
396        ]
397        reordered_metadata = {
398            k: metadata[k] for k in desired_order_list if k in metadata
399        }
400
401        # re-order jwe-mappings
402        desired_order_list = ["source_field_path", "decrypted_field_path"]
403        jwe_mapping_metadata = reordered_metadata.get("jwe_mappings")
404        if jwe_mapping_metadata:
405            reordered_jwe_mapping_metadata = []
406            for mapping in jwe_mapping_metadata:
407                reordered_jwe_mapping_metadata.append(
408                    {k: mapping[k] for k in desired_order_list if k in mapping}
409                )
410            reordered_metadata["jwe_mappings"] = reordered_jwe_mapping_metadata
411
412        # future proofing, in case there are other fields added at the ping top level
413        # add them to the end.
414        leftovers = {k: metadata[k] for k in set(metadata) - set(reordered_metadata)}
415        reordered_metadata = {**reordered_metadata, **leftovers}
416        return reordered_metadata
417
418    def get_pings_and_pipeline_metadata(self) -> Dict[str, Dict]:
419        pings = self._get_ping_data_and_dependencies_with_default_metadata()
420        for ping_name, ping_data in pings.items():
421            metadata = ping_data.get("moz_pipeline_metadata")
422            if not metadata:
423                continue
424            metadata["include_info_sections"] = self._is_field_included(
425                ping_data, "include_info_sections", consider_all_history=False
426            )
427            metadata["include_client_id"] = self._is_field_included(
428                ping_data, "include_client_id"
429            )
430
431            # While technically unnecessary, the dictionary elements are re-ordered to match the
432            # currently deployed order and used to verify no difference in output.
433            pings[ping_name] = GleanPing.reorder_metadata(metadata)
434        return pings
435
436    def get_ping_descriptions(self) -> Dict[str, str]:
437        return {
438            k: v["history"][-1]["description"] for k, v in self._get_ping_data().items()
439        }
440
441    @staticmethod
442    def _is_field_included(ping_data, field_name, consider_all_history=True) -> bool:
443        """Return false if the field exists and is false.
444
445        If `consider_all_history` is False, then only check the latest value in the ping history.
446
447        Otherwise, if the field is not found or true in one or more history entries,
448        true is returned.
449        """
450
451        # Default to true if not specified.
452        if "history" not in ping_data or len(ping_data["history"]) == 0:
453            return True
454
455        # Check if at some point in the past the field has already been deployed.
456        # And if the caller of this method wants to consider this history of the field.
457        # Keep them in the schema, even if the field has changed as
458        # removing fields is currently not supported.
459        # See https://bugzilla.mozilla.org/show_bug.cgi?id=1898105
460        # and https://bugzilla.mozilla.org/show_bug.cgi?id=1898105#c10
461        ping_history: list
462        if consider_all_history:
463            ping_history = ping_data["history"]
464        else:
465            ping_history = [ping_data["history"][-1]]
466        for history in ping_history:
467            if field_name not in history or history[field_name]:
468                return True
469
470        # The ping was created with include_info_sections = False. The fields can be excluded.
471        return False
472
473    def set_schema_url(self, metadata):
474        """
475        Switch between the glean-min and glean schemas if the ping does not require
476        info sections as specified in the parsed ping info in probe scraper.
477        """
478        if not metadata["include_info_sections"]:
479            self.schema_url = SCHEMA_URL_TEMPLATE.format(
480                branch=self.branch_name
481            ) + SCHEMA_VERSION_TEMPLATE.format(
482                schema_type="glean-min", version=self.version
483            )
484        else:
485            self.schema_url = SCHEMA_URL_TEMPLATE.format(
486                branch=self.branch_name
487            ) + SCHEMA_VERSION_TEMPLATE.format(
488                schema_type="glean", version=self.version
489            )
490
491    def generate_schema(
492        self,
493        config,
494        generic_schema=False,
495    ) -> Dict[str, Schema]:
496        pings = self.get_pings_and_pipeline_metadata()
497        schemas = {}
498
499        for ping, pipeline_meta in pings.items():
500            matchers = {
501                loc: m.clone(new_table_group=ping) for loc, m in config.matchers.items()
502            }
503
504            # Four newly introduced metric types were incorrectly deployed
505            # as repeated key/value structs in all Glean ping tables existing prior
506            # to November 2021. We maintain the incorrect fields for existing tables
507            # by disabling the associated matchers.
508            # Note that each of these types now has a "2" matcher ("text2", "url2", etc.)
509            # defined that will allow metrics of these types to be injected into proper
510            # structs. The gcp-ingestion repository includes logic to rewrite these
511            # metrics under the "2" names.
512            # See https://bugzilla.mozilla.org/show_bug.cgi?id=1737656
513            bq_identifier = "{bq_dataset_family}.{bq_table}".format(**pipeline_meta)
514            if bq_identifier in self.bug_1737656_affected_tables:
515                matchers = {
516                    loc: m
517                    for loc, m in matchers.items()
518                    if not m.matcher.get("bug_1737656_affected")
519                }
520
521            for matcher in matchers.values():
522                matcher.matcher["send_in_pings"]["contains"] = ping
523
524            new_config = Config(ping, matchers=matchers)
525
526            defaults = {"mozPipelineMetadata": pipeline_meta}
527
528            # Adjust the schema path if the ping does not require info sections
529            self.set_schema_url(pipeline_meta)
530            if generic_schema:  # Use the generic glean ping schema
531                schema = self.get_schema(generic_schema=True)
532                schema.schema.update(defaults)
533                schemas[new_config.name] = schema
534            else:
535                generated = super().generate_schema(new_config)
536                for schema in generated.values():
537                    # We want to override each individual key with assembled defaults,
538                    # but keep values _inside_ them if they have been set in the schemas.
539                    for key, value in defaults.items():
540                        if key not in schema.schema:
541                            schema.schema[key] = {}
542                        schema.schema[key].update(value)
543                schemas.update(generated)
544
545        return schemas
546
547    @staticmethod
548    def get_repos():
549        """
550        Retrieve metadata for all non-library Glean repositories
551        """
552        repos = GleanPing._get_json(GleanPing.repos_url)
553        return [repo for repo in repos if "library_names" not in repo]
554
555    def get_app_name(self) -> str:
556        """Get app name associated with the app id.
557
558        e.g. org-mozilla-firefox -> fenix
559        """
560        apps = GleanPing._get_json(GleanPing.app_listings_url)
561        # app id in app-listings has "." instead of "-" so using document_namespace
562        app_name = [
563            app["app_name"] for app in apps if app["document_namespace"] == self.app_id
564        ]
565        return app_name[0] if len(app_name) > 0 else self.app_id
566
567    @staticmethod
568    def get_metric_blocklist():
569        with open(METRIC_BLOCKLIST, "r") as f:
570            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            "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
432            # While technically unnecessary, the dictionary elements are re-ordered to match the
433            # currently deployed order and used to verify no difference in output.
434            pings[ping_name] = GleanPing.reorder_metadata(metadata)
435        return pings
436
437    def get_ping_descriptions(self) -> Dict[str, str]:
438        return {
439            k: v["history"][-1]["description"] for k, v in self._get_ping_data().items()
440        }
441
442    @staticmethod
443    def _is_field_included(ping_data, field_name, consider_all_history=True) -> bool:
444        """Return false if the field exists and is false.
445
446        If `consider_all_history` is False, then only check the latest value in the ping history.
447
448        Otherwise, if the field is not found or true in one or more history entries,
449        true is returned.
450        """
451
452        # Default to true if not specified.
453        if "history" not in ping_data or len(ping_data["history"]) == 0:
454            return True
455
456        # Check if at some point in the past the field has already been deployed.
457        # And if the caller of this method wants to consider this history of the field.
458        # Keep them in the schema, even if the field has changed as
459        # removing fields is currently not supported.
460        # See https://bugzilla.mozilla.org/show_bug.cgi?id=1898105
461        # and https://bugzilla.mozilla.org/show_bug.cgi?id=1898105#c10
462        ping_history: list
463        if consider_all_history:
464            ping_history = ping_data["history"]
465        else:
466            ping_history = [ping_data["history"][-1]]
467        for history in ping_history:
468            if field_name not in history or history[field_name]:
469                return True
470
471        # The ping was created with include_info_sections = False. The fields can be excluded.
472        return False
473
474    def set_schema_url(self, metadata):
475        """
476        Switch between the glean-min and glean schemas if the ping does not require
477        info sections as specified in the parsed ping info in probe scraper.
478        """
479        if not metadata["include_info_sections"]:
480            self.schema_url = SCHEMA_URL_TEMPLATE.format(
481                branch=self.branch_name
482            ) + SCHEMA_VERSION_TEMPLATE.format(
483                schema_type="glean-min", version=self.version
484            )
485        else:
486            self.schema_url = SCHEMA_URL_TEMPLATE.format(
487                branch=self.branch_name
488            ) + SCHEMA_VERSION_TEMPLATE.format(
489                schema_type="glean", version=self.version
490            )
491
492    def generate_schema(
493        self,
494        config,
495        generic_schema=False,
496    ) -> Dict[str, Schema]:
497        pings = self.get_pings_and_pipeline_metadata()
498        schemas = {}
499
500        for ping, pipeline_meta in pings.items():
501            matchers = {
502                loc: m.clone(new_table_group=ping) for loc, m in config.matchers.items()
503            }
504
505            # Four newly introduced metric types were incorrectly deployed
506            # as repeated key/value structs in all Glean ping tables existing prior
507            # to November 2021. We maintain the incorrect fields for existing tables
508            # by disabling the associated matchers.
509            # Note that each of these types now has a "2" matcher ("text2", "url2", etc.)
510            # defined that will allow metrics of these types to be injected into proper
511            # structs. The gcp-ingestion repository includes logic to rewrite these
512            # metrics under the "2" names.
513            # See https://bugzilla.mozilla.org/show_bug.cgi?id=1737656
514            bq_identifier = "{bq_dataset_family}.{bq_table}".format(**pipeline_meta)
515            if bq_identifier in self.bug_1737656_affected_tables:
516                matchers = {
517                    loc: m
518                    for loc, m in matchers.items()
519                    if not m.matcher.get("bug_1737656_affected")
520                }
521
522            for matcher in matchers.values():
523                matcher.matcher["send_in_pings"]["contains"] = ping
524
525            new_config = Config(ping, matchers=matchers)
526
527            defaults = {"mozPipelineMetadata": pipeline_meta}
528
529            # Adjust the schema path if the ping does not require info sections
530            self.set_schema_url(pipeline_meta)
531            if generic_schema:  # Use the generic glean ping schema
532                schema = self.get_schema(generic_schema=True)
533                schema.schema.update(defaults)
534                schemas[new_config.name] = schema
535            else:
536                generated = super().generate_schema(new_config)
537                for schema in generated.values():
538                    # We want to override each individual key with assembled defaults,
539                    # but keep values _inside_ them if they have been set in the schemas.
540                    for key, value in defaults.items():
541                        if key not in schema.schema:
542                            schema.schema[key] = {}
543                        schema.schema[key].update(value)
544                schemas.update(generated)
545
546        return schemas
547
548    @staticmethod
549    def get_repos():
550        """
551        Retrieve metadata for all non-library Glean repositories
552        """
553        repos = GleanPing._get_json(GleanPing.repos_url)
554        return [repo for repo in repos if "library_names" not in repo]
555
556    def get_app_name(self) -> str:
557        """Get app name associated with the app id.
558
559        e.g. org-mozilla-firefox -> fenix
560        """
561        apps = GleanPing._get_json(GleanPing.app_listings_url)
562        # app id in app-listings has "." instead of "-" so using document_namespace
563        app_name = [
564            app["app_name"] for app in apps if app["document_namespace"] == self.app_id
565        ]
566        return app_name[0] if len(app_name) > 0 else self.app_id
567
568    @staticmethod
569    def get_metric_blocklist():
570        with open(METRIC_BLOCKLIST, "r") as f:
571            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            "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
def get_pings_and_pipeline_metadata(self) -> Dict[str, Dict]:
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
432            # While technically unnecessary, the dictionary elements are re-ordered to match the
433            # currently deployed order and used to verify no difference in output.
434            pings[ping_name] = GleanPing.reorder_metadata(metadata)
435        return pings
def get_ping_descriptions(self) -> Dict[str, str]:
437    def get_ping_descriptions(self) -> Dict[str, str]:
438        return {
439            k: v["history"][-1]["description"] for k, v in self._get_ping_data().items()
440        }
def set_schema_url(self, metadata):
474    def set_schema_url(self, metadata):
475        """
476        Switch between the glean-min and glean schemas if the ping does not require
477        info sections as specified in the parsed ping info in probe scraper.
478        """
479        if not metadata["include_info_sections"]:
480            self.schema_url = SCHEMA_URL_TEMPLATE.format(
481                branch=self.branch_name
482            ) + SCHEMA_VERSION_TEMPLATE.format(
483                schema_type="glean-min", version=self.version
484            )
485        else:
486            self.schema_url = SCHEMA_URL_TEMPLATE.format(
487                branch=self.branch_name
488            ) + SCHEMA_VERSION_TEMPLATE.format(
489                schema_type="glean", version=self.version
490            )

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]:
492    def generate_schema(
493        self,
494        config,
495        generic_schema=False,
496    ) -> Dict[str, Schema]:
497        pings = self.get_pings_and_pipeline_metadata()
498        schemas = {}
499
500        for ping, pipeline_meta in pings.items():
501            matchers = {
502                loc: m.clone(new_table_group=ping) for loc, m in config.matchers.items()
503            }
504
505            # Four newly introduced metric types were incorrectly deployed
506            # as repeated key/value structs in all Glean ping tables existing prior
507            # to November 2021. We maintain the incorrect fields for existing tables
508            # by disabling the associated matchers.
509            # Note that each of these types now has a "2" matcher ("text2", "url2", etc.)
510            # defined that will allow metrics of these types to be injected into proper
511            # structs. The gcp-ingestion repository includes logic to rewrite these
512            # metrics under the "2" names.
513            # See https://bugzilla.mozilla.org/show_bug.cgi?id=1737656
514            bq_identifier = "{bq_dataset_family}.{bq_table}".format(**pipeline_meta)
515            if bq_identifier in self.bug_1737656_affected_tables:
516                matchers = {
517                    loc: m
518                    for loc, m in matchers.items()
519                    if not m.matcher.get("bug_1737656_affected")
520                }
521
522            for matcher in matchers.values():
523                matcher.matcher["send_in_pings"]["contains"] = ping
524
525            new_config = Config(ping, matchers=matchers)
526
527            defaults = {"mozPipelineMetadata": pipeline_meta}
528
529            # Adjust the schema path if the ping does not require info sections
530            self.set_schema_url(pipeline_meta)
531            if generic_schema:  # Use the generic glean ping schema
532                schema = self.get_schema(generic_schema=True)
533                schema.schema.update(defaults)
534                schemas[new_config.name] = schema
535            else:
536                generated = super().generate_schema(new_config)
537                for schema in generated.values():
538                    # We want to override each individual key with assembled defaults,
539                    # but keep values _inside_ them if they have been set in the schemas.
540                    for key, value in defaults.items():
541                        if key not in schema.schema:
542                            schema.schema[key] = {}
543                        schema.schema[key].update(value)
544                schemas.update(generated)
545
546        return schemas
@staticmethod
def get_repos():
548    @staticmethod
549    def get_repos():
550        """
551        Retrieve metadata for all non-library Glean repositories
552        """
553        repos = GleanPing._get_json(GleanPing.repos_url)
554        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:
556    def get_app_name(self) -> str:
557        """Get app name associated with the app id.
558
559        e.g. org-mozilla-firefox -> fenix
560        """
561        apps = GleanPing._get_json(GleanPing.app_listings_url)
562        # app id in app-listings has "." instead of "-" so using document_namespace
563        app_name = [
564            app["app_name"] for app in apps if app["document_namespace"] == self.app_id
565        ]
566        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():
568    @staticmethod
569    def get_metric_blocklist():
570        with open(METRIC_BLOCKLIST, "r") as f:
571            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']