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)
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)
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 )
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.
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
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.
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
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
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
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
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.
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.
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
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
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