• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / relman-auto-nag / #4675

pending completion
#4675

push

coveralls-python

suhaibmujahid
[file_crash_bug] Show details about the summary data

716 of 3564 branches covered (20.09%)

4 of 4 new or added lines in 1 file covered. (100.0%)

1924 of 8717 relevant lines covered (22.07%)

0.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/bugbot/crash/analyzer.py
1
# This Source Code Form is subject to the terms of the Mozilla Public
2
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
3
# You can obtain one at http://mozilla.org/MPL/2.0/.
4

5
import itertools
×
6
import re
×
7
from collections import defaultdict
×
8
from datetime import date, timedelta
×
9
from functools import cached_property
×
10
from typing import Iterable, Iterator
×
11

12
from libmozdata import bugzilla, clouseau, connection, socorro
×
13
from libmozdata import utils as lmdutils
×
14
from libmozdata.bugzilla import Bugzilla
×
15
from libmozdata.connection import Connection
×
16

17
from bugbot import logger, utils
×
18
from bugbot.bug.analyzer import BugAnalyzer, BugsStore
×
19
from bugbot.components import ComponentName
×
20
from bugbot.crash import socorro_util
×
21

22
# The max offset from a memory address to be considered "near".
23
OFFSET_64_BIT = 0x1000
×
24
OFFSET_32_BIT = 0x100
×
25
# Allocator poison value addresses.
26
ALLOCATOR_ADDRESSES_64_BIT = (
×
27
    (0xE5E5E5E5E5E5E5E5, OFFSET_64_BIT),
28
    # On 64-bit windows, sometimes it could be doing something with a 32-bit
29
    # value gotten from freed memory, so it'll be 0X00000000E5E5E5E5 +/-, and
30
    # because of the address limitation, quite often it will be
31
    # 0X0000E5E5E5E5E5E5 +/-.
32
    (0x00000000E5E5E5E5, OFFSET_32_BIT),
33
    (0x0000E5E5E5E5E5E5, OFFSET_64_BIT),
34
    (0x4B4B4B4B4B4B4B4B, OFFSET_64_BIT),
35
)
36
ALLOCATOR_ADDRESSES_32_BIT = (
×
37
    (0xE5E5E5E5, OFFSET_32_BIT),
38
    (0x4B4B4B4B, OFFSET_32_BIT),
39
)
40
# Ranges where addresses are considered near allocator poison values.
41
ALLOCATOR_RANGES_64_BIT = (
×
42
    (addr - offset, addr + offset) for addr, offset in ALLOCATOR_ADDRESSES_64_BIT
43
)
44
ALLOCATOR_RANGES_32_BIT = (
×
45
    (addr - offset, addr + offset) for addr, offset in ALLOCATOR_ADDRESSES_32_BIT
46
)
47

48

49
def is_near_null_address(str_address) -> bool:
×
50
    """Check if the address is near null.
51

52
    Args:
53
        str_address: The memory address to check.
54

55
    Returns:
56
        True if the address is near null, False otherwise.
57
    """
58
    address = int(str_address, 0)
×
59
    is_64_bit = len(str_address) >= 18
×
60

61
    if is_64_bit:
×
62
        return -OFFSET_64_BIT <= address <= OFFSET_64_BIT
×
63

64
    return -OFFSET_32_BIT <= address <= OFFSET_32_BIT
×
65

66

67
def is_near_allocator_address(str_address) -> bool:
×
68
    """Check if the address is near an allocator poison value.
69

70
    Args:
71
        str_address: The memory address to check.
72

73
    Returns:
74
        True if the address is near an allocator poison value, False otherwise.
75
    """
76
    address = int(str_address, 0)
×
77
    is_64_bit = len(str_address) >= 18
×
78

79
    return any(
×
80
        low <= address <= high
81
        for low, high in (
82
            ALLOCATOR_RANGES_64_BIT if is_64_bit else ALLOCATOR_RANGES_32_BIT
83
        )
84
    )
85

86

87
# TODO: Move this to libmozdata
88
def generate_signature_page_url(params: dict, tab: str) -> str:
×
89
    """Generate a URL to the signature page on Socorro
90

91
    Args:
92
        params: the parameters for the search query.
93
        tab: the page tab that should be selected.
94

95
    Returns:
96
        The URL of the signature page on Socorro
97
    """
98
    web_url = socorro.Socorro.CRASH_STATS_URL
×
99
    query = lmdutils.get_params_for_url(params)
×
100
    return f"{web_url}/signature/{query}#{tab}"
×
101

102

103
# NOTE: At this point, we will file bugs on bugzilla-dev. Once we are confident
104
# that the bug filing is working as expected, we can switch to filing bugs in
105
# the production instance of Bugzilla.
106
class DevBugzilla(Bugzilla):
×
107
    URL = "https://bugzilla-dev.allizom.org"
×
108
    API_URL = URL + "/rest/bug"
×
109
    ATTACHMENT_API_URL = API_URL + "/attachment"
×
110
    TOKEN = utils.get_login_info()["bz_api_key_dev"]
×
111

112

113
class NoCrashReportFoundError(Exception):
×
114
    """There are no crash reports that meet the required criteria."""
115

116

117
class ClouseauDataAnalyzer:
×
118
    """Analyze the data returned by Crash Clouseau about a specific crash
119
    signature.
120
    """
121

122
    MINIMUM_CLOUSEAU_SCORE_THRESHOLD: int = 8
×
123
    DEFAULT_CRASH_COMPONENT = ComponentName("Core", "General")
×
124

125
    def __init__(self, reports: Iterable[dict], bugs_store: BugsStore):
×
126
        self._clouseau_reports = reports
×
127
        self.bugs_store = bugs_store
×
128

129
    @cached_property
×
130
    def max_clouseau_score(self):
×
131
        """The maximum Clouseau score in the crash reports."""
132
        if not self._clouseau_reports:
×
133
            return 0
×
134
        return max(report["max_score"] for report in self._clouseau_reports)
×
135

136
    @cached_property
×
137
    def regressed_by_potential_bug_ids(self) -> set[int]:
×
138
        """The IDs for the bugs that their patches could have caused the crash."""
139
        minimum_accepted_score = max(
×
140
            self.MINIMUM_CLOUSEAU_SCORE_THRESHOLD, self.max_clouseau_score
141
        )
142
        return {
×
143
            changeset["bug_id"]
144
            for report in self._clouseau_reports
145
            if report["max_score"] >= minimum_accepted_score
146
            for changeset in report["changesets"]
147
            if changeset["max_score"] >= minimum_accepted_score
148
            and not changeset["is_merge"]
149
            and not changeset["is_backedout"]
150
        }
151

152
    @cached_property
×
153
    def regressed_by_patch(self) -> str | None:
×
154
        """The hash of the patch that could have caused the crash."""
155
        minimum_accepted_score = max(
×
156
            self.MINIMUM_CLOUSEAU_SCORE_THRESHOLD, self.max_clouseau_score
157
        )
158
        potential_patches = {
×
159
            changeset["changeset"]
160
            for report in self._clouseau_reports
161
            if report["max_score"] >= minimum_accepted_score
162
            for changeset in report["changesets"]
163
            if changeset["max_score"] >= minimum_accepted_score
164
            and not changeset["is_merge"]
165
            and not changeset["is_backedout"]
166
        }
167
        if len(potential_patches) == 1:
×
168
            return next(iter(potential_patches))
×
169
        return None
×
170

171
    @cached_property
×
172
    def regressed_by(self) -> int | None:
×
173
        """The ID of the bug that one of its patches could have caused
174
        the crash.
175

176
        If there are multiple bugs, the value will be `None`.
177
        """
178
        bug_ids = self.regressed_by_potential_bug_ids
×
179
        if len(bug_ids) == 1:
×
180
            return next(iter(bug_ids))
×
181
        return None
×
182

183
    @cached_property
×
184
    def regressed_by_potential_bugs(self) -> list[BugAnalyzer]:
×
185
        """The bugs whose patches could have caused the crash."""
186
        self.bugs_store.fetch_bugs(
×
187
            self.regressed_by_potential_bug_ids,
188
            [
189
                "id",
190
                "groups",
191
                "assigned_to",
192
                "product",
193
                "component",
194
            ],
195
        )
196
        return [
×
197
            self.bugs_store.get_bug_by_id(bug_id)
198
            for bug_id in self.regressed_by_potential_bug_ids
199
        ]
200

201
    @cached_property
×
202
    def regressed_by_author(self) -> dict | None:
×
203
        """The author of the patch that could have caused the crash.
204

205
        If there are multiple regressors, the value will be `None`.
206

207
        The regressor bug assignee is considered as the author, even if the
208
        assignee is not the patch author.
209
        """
210

211
        if not self.regressed_by:
×
212
            return None
×
213

214
        bug = self.regressed_by_potential_bugs[0]
×
215
        assert bug.id == self.regressed_by
×
216
        return bug.get_field("assigned_to_detail")
×
217

218
    @cached_property
×
219
    def crash_component(self) -> ComponentName:
×
220
        """The component that the crash belongs to.
221

222
        If there are multiple components, the value will be the default one.
223
        """
224
        potential_components = {
×
225
            bug.component for bug in self.regressed_by_potential_bugs
226
        }
227
        if len(potential_components) == 1:
×
228
            return next(iter(potential_components))
×
229
        return self.DEFAULT_CRASH_COMPONENT
×
230

231

232
class SocorroDataAnalyzer(socorro_util.SignatureStats):
×
233
    """Analyze the data returned by Socorro."""
234

235
    _bugzilla_os_legal_values = None
×
236
    _bugzilla_cpu_legal_values_map = None
×
237
    _platforms = [
×
238
        {"short_name": "win", "name": "Windows"},
239
        {"short_name": "mac", "name": "Mac OS X"},
240
        {"short_name": "lin", "name": "Linux"},
241
        {"short_name": "and", "name": "Android"},
242
        {"short_name": "unknown", "name": "Unknown"},
243
    ]
244

245
    def __init__(
×
246
        self,
247
        signature: dict,
248
        num_total_crashes: int,
249
    ):
250
        super().__init__(signature, num_total_crashes, platforms=self._platforms)
×
251

252
    @classmethod
×
253
    def to_bugzilla_op_sys(cls, op_sys: str) -> str:
×
254
        """Return the corresponding OS name in Bugzilla for the provided OS name
255
        from Socorro.
256

257
        If the OS name is not recognized, return "Other".
258
        """
259
        if cls._bugzilla_os_legal_values is None:
×
260
            cls._bugzilla_os_legal_values = set(
×
261
                bugzilla.BugFields.fetch_field_values("op_sys")
262
            )
263

264
        if op_sys in cls._bugzilla_os_legal_values:
×
265
            return op_sys
×
266

267
        if op_sys.startswith("OS X ") or op_sys.startswith("macOS "):
×
268
            op_sys = "macOS"
×
269
        elif op_sys.startswith("Windows"):
×
270
            op_sys = "Windows"
×
271
        elif "Linux" in op_sys or op_sys.startswith("Ubuntu"):
×
272
            op_sys = "Linux"
×
273
        else:
274
            op_sys = "Other"
×
275

276
        return op_sys
×
277

278
    @property
×
279
    def bugzilla_op_sys(self) -> str:
×
280
        """The name of the OS where the crash happens.
281

282
        The value is one of the legal values for Bugzilla's `op_sys` field.
283

284
        - If no OS name is found, the value will be "Unspecified".
285
        - If the OS name is not recognized, the value will be "Other".
286
        - If multiple OS names are found, the value will be "All". Unless the OS
287
          names can be resolved to a common name without a version. For example,
288
          "Windows 10" and "Windows 7" will become "Windows".
289
        """
290
        all_op_sys = {
×
291
            self.to_bugzilla_op_sys(op_sys["term"])
292
            for op_sys in self.signature["facets"]["platform_pretty_version"]
293
        }
294

295
        if len(all_op_sys) > 1:
×
296
            # Resolve to root OS name by removing the version number.
297
            all_op_sys = {op_sys.split(" ")[0] for op_sys in all_op_sys}
×
298

299
        if len(all_op_sys) == 2 and "Other" in all_op_sys:
×
300
            # TODO: explain this workaround.
301
            all_op_sys.remove("Other")
×
302

303
        if len(all_op_sys) == 1:
×
304
            return next(iter(all_op_sys))
×
305

306
        if len(all_op_sys) == 0:
×
307
            return "Unspecified"
×
308

309
        return "All"
×
310

311
    @classmethod
×
312
    def to_bugzilla_cpu(cls, cpu: str) -> str:
×
313
        """Return the corresponding CPU name in Bugzilla for the provided name
314
        from Socorro.
315

316
        If the CPU is not recognized, return "Other".
317
        """
318
        if cls._bugzilla_cpu_legal_values_map is None:
×
319
            cls._bugzilla_cpu_legal_values_map = {
×
320
                value.lower(): value
321
                for value in bugzilla.BugFields.fetch_field_values("rep_platform")
322
            }
323

324
        return cls._bugzilla_cpu_legal_values_map.get(cpu, "Other")
×
325

326
    @property
×
327
    def bugzilla_cpu_arch(self) -> str:
×
328
        """The CPU architecture of the devices where the crash happens.
329

330
        The value is one of the legal values for Bugzilla's `rep_platform` field.
331

332
        - If no CPU architecture is found, the value will be "Unspecified".
333
        - If the CPU architecture is not recognized, the value will be "Other".
334
        - If multiple CPU architectures are found, the value will "All".
335
        """
336
        all_cpu_arch = {
×
337
            self.to_bugzilla_cpu(cpu["term"])
338
            for cpu in self.signature["facets"]["cpu_arch"]
339
        }
340

341
        if len(all_cpu_arch) == 2 and "Other" in all_cpu_arch:
×
342
            all_cpu_arch.remove("Other")
×
343

344
        if len(all_cpu_arch) == 1:
×
345
            return next(iter(all_cpu_arch))
×
346

347
        if len(all_cpu_arch) == 0:
×
348
            return "Unspecified"
×
349

350
        return "All"
×
351

352
    @property
×
353
    def user_comments_page_url(self) -> str:
×
354
        """The URL to the Signature page on Socorro where the Comments tab is
355
        selected.
356
        """
357
        start_date = date.today() - timedelta(weeks=26)
×
358
        params = {
×
359
            "signature": self.signature_term,
360
            "date": socorro.SuperSearch.get_search_date(start_date),
361
        }
362
        return generate_signature_page_url(params, "comments")
×
363

364
    @property
×
365
    def num_user_comments(self) -> int:
×
366
        """The number of crash reports with user comments."""
367
        # TODO: count useful/interesting user comments (e.g., exclude one word comments)
368
        return self.signature["facets"]["cardinality_user_comments"]["value"]
×
369

370
    @property
×
371
    def has_user_comments(self) -> bool:
×
372
        """Whether the crash signature has any reports with a user comment."""
373
        return self.num_user_comments > 0
×
374

375
    @property
×
376
    def top_proto_signature(self) -> str:
×
377
        """The proto signature that occurs the most."""
378
        return self.signature["facets"]["proto_signature"][0]["term"]
×
379

380
    @property
×
381
    def num_top_proto_signature_crashes(self) -> int:
×
382
        """The number of crashes for the most occurring proto signature."""
383
        return self.signature["facets"]["proto_signature"][0]["count"]
×
384

385
    def _build_ids(self) -> Iterator[int]:
×
386
        """Yields the build IDs where the crash occurred."""
387
        for build_id in self.signature["facets"]["build_id"]:
×
388
            yield build_id["term"]
×
389

390
    @property
×
391
    def top_build_id(self) -> int:
×
392
        """The build ID where most crashes occurred."""
393
        return self.signature["facets"]["build_id"][0]["term"]
×
394

395
    @cached_property
×
396
    def num_near_null_crashes(self) -> int:
×
397
        """The number of crashes that occurred on addresses near null."""
398
        return sum(
×
399
            address["count"]
400
            for address in self.signature["facets"]["address"]
401
            if is_near_null_address(address["term"])
402
        )
403

404
    @property
×
405
    def is_near_null_crash(self) -> bool:
×
406
        """Whether all crashes occurred on addresses near null."""
407
        return self.num_near_null_crashes == self.num_crashes
×
408

409
    @property
×
410
    def is_potential_near_null_crash(self) -> bool:
×
411
        """Whether the signature is a potential near null crash.
412

413
        The value will be True if some but not all crashes occurred on addresses
414
        near null.
415
        """
416
        return not self.is_near_null_crash and self.num_near_null_crashes > 0
×
417

418
    @property
×
419
    def is_near_null_related_crash(self) -> bool:
×
420
        """Whether the signature is related to near null crashes.
421

422
        The value will be True if any of the crashes occurred on addresses near
423
        null.
424
        """
425
        return self.is_near_null_crash or self.is_potential_near_null_crash
×
426

427
    @cached_property
×
428
    def num_near_allocator_crashes(self) -> int:
×
429
        """The number of crashes that occurred on addresses near an allocator
430
        poison value.
431
        """
432
        return sum(
×
433
            address["count"]
434
            for address in self.signature["facets"]["address"]
435
            if is_near_allocator_address(address["term"])
436
        )
437

438
    @property
×
439
    def is_near_allocator_crash(self) -> bool:
×
440
        """Whether all crashes occurred on addresses near an allocator poison
441
        value.
442
        """
443
        return self.num_near_allocator_crashes == self.num_crashes
×
444

445
    @property
×
446
    def is_potential_near_allocator_crash(self) -> bool:
×
447
        """Whether the signature is a potential near allocator poison value
448
        crash.
449

450
        The value will be True if some but not all crashes occurred on addresses
451
        near an allocator poison value.
452
        """
453
        return not self.is_near_allocator_crash and self.num_near_allocator_crashes > 0
×
454

455
    @property
×
456
    def is_near_allocator_related_crash(self) -> bool:
×
457
        """Whether the signature is related to near allocator poison value
458
        crashes.
459

460
        The value will be True if any of the crashes occurred on addresses near
461
        an allocator poison value.
462
        """
463
        return self.is_near_allocator_crash or self.is_potential_near_allocator_crash
×
464

465

466
class SignatureAnalyzer(SocorroDataAnalyzer, ClouseauDataAnalyzer):
×
467
    """Analyze the data related to a signature.
468

469
    This includes data from Socorro and Clouseau.
470
    """
471

472
    def __init__(
×
473
        self,
474
        socorro_signature: dict,
475
        num_total_crashes: int,
476
        clouseau_reports: list[dict],
477
        bugs_store: BugsStore,
478
    ):
479
        SocorroDataAnalyzer.__init__(self, socorro_signature, num_total_crashes)
×
480
        ClouseauDataAnalyzer.__init__(self, clouseau_reports, bugs_store)
×
481

482
    def _fetch_crash_reports(
×
483
        self,
484
        proto_signature: str,
485
        build_id: int | Iterable[int],
486
        limit: int = 1,
487
    ) -> Iterator[dict]:
488
        params = {
×
489
            "proto_signature": "=" + proto_signature,
490
            "build_id": build_id,
491
            "_columns": [
492
                "uuid",
493
            ],
494
            "_results_number": limit,
495
        }
496

497
        def handler(res: dict, data: dict):
×
498
            data.update(res)
×
499

500
        data: dict = {}
×
501
        socorro.SuperSearch(params=params, handler=handler, handlerdata=data).wait()
×
502

503
        yield from data["hits"]
×
504

505
    def fetch_representative_processed_crash(self) -> dict:
×
506
        """Fetch a processed crash to represent the signature.
507

508
        This could fetch multiple processed crashes and return the one that is
509
        most likely to be useful.
510
        """
511
        limit_to_top_proto_signature = (
×
512
            self.num_top_proto_signature_crashes / self.num_crashes > 0.6
513
        )
514

515
        reports = itertools.chain(
×
516
            # Reports with a higher score from clouseau are more likely to be
517
            # useful.
518
            sorted(
519
                self._clouseau_reports,
520
                key=lambda report: report["max_score"],
521
                reverse=True,
522
            ),
523
            # Next we try find reports from the top crashing build because they
524
            # are likely to be representative.
525
            self._fetch_crash_reports(self.top_proto_signature, self.top_build_id),
526
            self._fetch_crash_reports(self.top_proto_signature, self._build_ids()),
527
        )
528
        for report in reports:
×
529
            uuid = report["uuid"]
×
530
            processed_crash = socorro.ProcessedCrash.get_processed(uuid)[uuid]
×
531
            if (
×
532
                not limit_to_top_proto_signature
533
                or processed_crash["proto_signature"] == self.top_proto_signature
534
            ):
535
                # TODO(investigate): maybe we should check if the stack is
536
                # corrupted (ask gsvelto or willkg about how to detect that)
537
                return processed_crash
×
538

539
        raise NoCrashReportFoundError(
×
540
            f"No crash report found with the most frequent proto signature for {self.signature_term}."
541
        )
542

543
    @cached_property
×
544
    def is_potential_security_crash(self) -> bool:
×
545
        """Whether the crash is related to a potential security bug.
546

547
        The value will be True if:
548
            - the signature is related to near allocator poison value crashes, or
549
            - one of the potential regressors is a security bug
550
        """
551
        return self.is_near_allocator_related_crash or any(
×
552
            bug.is_security for bug in self.regressed_by_potential_bugs
553
        )
554

555

556
class SignaturesDataFetcher:
×
557
    """Fetch the data related to the given signatures."""
558

559
    MEMORY_ACCESS_ERROR_REASONS = (
×
560
        # On Windows:
561
        "EXCEPTION_ACCESS_VIOLATION_READ",
562
        "EXCEPTION_ACCESS_VIOLATION_WRITE",
563
        "EXCEPTION_ACCESS_VIOLATION_EXEC"
564
        # On Linux:
565
        "SIGSEGV / SEGV_MAPERR",
566
        "SIGSEGV / SEGV_ACCERR",
567
    )
568

569
    EXCLUDED_MOZ_REASON_STRINGS = (
×
570
        "MOZ_CRASH(OOM)",
571
        "MOZ_CRASH(Out of memory)",
572
        "out of memory",
573
        "Shutdown hanging",
574
        # TODO(investigate): do we need to exclude signatures that their reason
575
        # contains `[unhandlable oom]`?
576
        # Example: arena_t::InitChunk | arena_t::AllocRun | arena_t::MallocLarge | arena_t::Malloc | BaseAllocator::malloc | Allocator::malloc | PageMalloc
577
        # "[unhandlable oom]",
578
    )
579

580
    # If any of the crash reason starts with any of the following, then it is
581
    # Network or I/O error.
582
    EXCLUDED_IO_ERROR_REASON_PREFIXES = (
×
583
        "EXCEPTION_IN_PAGE_ERROR_READ",
584
        "EXCEPTION_IN_PAGE_ERROR_WRITE",
585
        "EXCEPTION_IN_PAGE_ERROR_EXEC",
586
    )
587

588
    # TODO(investigate): do we need to exclude all these signatures prefixes?
589
    EXCLUDED_SIGNATURE_PREFIXES = (
×
590
        "OOM | ",
591
        "bad hardware | ",
592
        "shutdownhang | ",
593
    )
594

595
    SUMMARY_DURATION = timedelta(weeks=10)
×
596

597
    def __init__(
×
598
        self,
599
        signatures: Iterable[str],
600
        product: str = "Firefox",
601
        channel: str = "nightly",
602
    ):
603
        self._signatures = set(signatures)
×
604
        self._product = product
×
605
        self._channel = channel
×
606

607
    @classmethod
×
608
    def find_new_actionable_crashes(
×
609
        cls,
610
        product: str,
611
        channel: str,
612
        days_to_check: int = 7,
613
        days_without_crashes: int = 7,
614
    ) -> "SignaturesDataFetcher":
615
        """Find new actionable crashes.
616

617
        Args:
618
            product: The product to check.
619
            channel: The release channel to check.
620
            days_to_check: The number of days to check for crashes.
621
            days_without_crashes: The number of days without crashes before the
622
                `days_to_check` to consider the signature new.
623

624
        Returns:
625
            A list of actionable signatures.
626
        """
627
        duration = days_to_check + days_without_crashes
×
628
        end_date = lmdutils.get_date_ymd("today")
×
629
        start_date = end_date - timedelta(duration)
×
630
        earliest_allowed_date = lmdutils.get_date_str(
×
631
            end_date - timedelta(days_to_check)
632
        )
633
        date_range = socorro.SuperSearch.get_search_date(start_date, end_date)
×
634

635
        params = {
×
636
            "product": product,
637
            "release_channel": channel,
638
            "date": date_range,
639
            # TODO(investigate): should we do a local filter instead of the
640
            # following (should we exclude the signature if one of the crashes
641
            # is a shutdown hang?):
642
            # If the `ipc_shutdown_state` or `shutdown_progress` field are
643
            # non-empty then it's a shutdown hang.
644
            "ipc_shutdown_state": "__null__",
645
            "shutdown_progress": "__null__",
646
            # TODO(investigate): should we use the following instead of the
647
            # local filter.
648
            # "oom_allocation_size": "!__null__",
649
            "_aggs.signature": [
650
                "moz_crash_reason",
651
                "reason",
652
                "_histogram.date",
653
                "_cardinality.install_time",
654
                "_cardinality.oom_allocation_size",
655
            ],
656
            "_results_number": 0,
657
            "_facets_size": 10000,
658
        }
659

660
        def handler(search_resp: dict, data: list):
×
661
            logger.debug(
×
662
                "Total of %d signatures received from Socorro",
663
                len(search_resp["facets"]["signature"]),
664
            )
665

666
            for crash in search_resp["facets"]["signature"]:
×
667
                signature = crash["term"]
×
668
                if any(
×
669
                    signature.startswith(excluded_prefix)
670
                    for excluded_prefix in cls.EXCLUDED_SIGNATURE_PREFIXES
671
                ):
672
                    # Ignore signatures that start with any of the excluded prefixes.
673
                    continue
×
674

675
                facets = crash["facets"]
×
676
                installations = facets["cardinality_install_time"]["value"]
×
677
                if installations <= 1:
×
678
                    # Ignore crashes that only happen on one installation.
679
                    continue
×
680

681
                first_date = facets["histogram_date"][0]["term"]
×
682
                if first_date < earliest_allowed_date:
×
683
                    # The crash is not new, skip it.
684
                    continue
×
685

686
                if any(
×
687
                    reason["term"].startswith(io_error_prefix)
688
                    for reason in facets["reason"]
689
                    for io_error_prefix in cls.EXCLUDED_IO_ERROR_REASON_PREFIXES
690
                ):
691
                    # Ignore Network or I/O error crashes.
692
                    continue
×
693

694
                if crash["count"] < 20:
×
695
                    # For signatures with low volume, having multiple types of
696
                    # memory errors indicates potential bad hardware crashes.
697
                    num_memory_error_types = sum(
×
698
                        reason["term"] in cls.MEMORY_ACCESS_ERROR_REASONS
699
                        for reason in facets["reason"]
700
                    )
701
                    if num_memory_error_types > 1:
×
702
                        # Potential bad hardware crash, skip it.
703
                        continue
×
704

705
                # TODO: Add a filter using the `possible_bit_flips_max_confidence`
706
                # field to exclude bad hardware crashes. The filed is not available yet.
707
                # See: https://bugzilla.mozilla.org/show_bug.cgi?id=1816669#c3
708

709
                # TODO(investigate): is this needed since we are already
710
                # filtering signatures that start with "OOM | "
711
                if facets["cardinality_oom_allocation_size"]["value"]:
×
712
                    # If one of the crashes is an OOM crash, skip it.
713
                    continue
×
714

715
                # TODO(investigate): do we need to check for the `moz_crash_reason`
716
                moz_crash_reasons = facets["moz_crash_reason"]
×
717
                if moz_crash_reasons and any(
×
718
                    excluded_reason in reason["term"]
719
                    for reason in moz_crash_reasons
720
                    for excluded_reason in cls.EXCLUDED_MOZ_REASON_STRINGS
721
                ):
722
                    continue
×
723

724
                data.append(signature)
×
725

726
        signatures: list = []
×
727
        socorro.SuperSearch(
×
728
            params=params,
729
            handler=handler,
730
            handlerdata=signatures,
731
        ).wait()
732

733
        logger.debug(
×
734
            "Total of %d signatures left after applying the filtering criteria",
735
            len(signatures),
736
        )
737

738
        return cls(signatures, product, channel)
×
739

740
    def fetch_clouseau_crash_reports(self) -> dict[str, list]:
×
741
        """Fetch the crash reports data from Crash Clouseau."""
742
        if not self._signatures:
×
743
            return {}
×
744

745
        logger.debug(
×
746
            "Fetch from Clouseau: requesting reports for %d signatures",
747
            len(self._signatures),
748
        )
749

750
        signature_reports = clouseau.Reports.get_by_signatures(
×
751
            self._signatures,
752
            product=self._product,
753
            channel=self._channel,
754
        )
755

756
        logger.debug(
×
757
            "Fetch from Clouseau: received reports for %d signatures",
758
            len(signature_reports),
759
        )
760

761
        return signature_reports
×
762

763
    def fetch_socorro_info(self) -> tuple[list[dict], int]:
×
764
        """Fetch the signature data from Socorro."""
765
        if not self._signatures:
×
766
            return [], 0
×
767

768
        end_date = lmdutils.get_date_ymd("today")
×
769
        start_date = end_date - self.SUMMARY_DURATION
×
770
        date_range = socorro.SuperSearch.get_search_date(start_date, end_date)
×
771

772
        params = {
×
773
            "product": self._product,
774
            # TODO(investigate): should we included all release channels?
775
            "release_channel": self._channel,
776
            # TODO(investigate): should we limit based on the build date as well?
777
            "date": date_range,
778
            # TODO: split signatures into chunks to avoid very long query URLs
779
            "signature": ["=" + signature for signature in self._signatures],
780
            "_aggs.signature": [
781
                "address",
782
                "build_id",
783
                "cpu_arch",
784
                "proto_signature",
785
                "_cardinality.user_comments",
786
                "cpu_arch",
787
                "platform_pretty_version",
788
                # The following are needed for SignatureStats:
789
                "platform",
790
                "is_garbage_collecting",
791
                "_cardinality.install_time",
792
                "startup_crash",
793
                "_histogram.uptime",
794
                "process_type",
795
            ],
796
            "_results_number": 0,
797
            "_facets_size": 10000,
798
        }
799

800
        def handler(search_results: dict, data: dict):
×
801
            data["num_total_crashes"] = search_results["total"]
×
802
            data["signatures"] = search_results["facets"]["signature"]
×
803

804
        logger.debug(
×
805
            "Fetch from Socorro: requesting info for %d signatures",
806
            len(self._signatures),
807
        )
808

809
        data: dict = {}
×
810
        socorro.SuperSearchUnredacted(
×
811
            params=params,
812
            handler=handler,
813
            handlerdata=data,
814
        ).wait()
815

816
        logger.debug(
×
817
            "Fetch from Socorro: received info for %d signatures",
818
            len(data["signatures"]),
819
        )
820

821
        return data["signatures"], data["num_total_crashes"]
×
822

823
    def fetch_bugs(self, include_fields: list[str] = None) -> dict[str, list[dict]]:
×
824
        """Fetch bugs that are filed against the given signatures."""
825
        if not self._signatures:
×
826
            return {}
×
827

828
        params_base: dict = {
×
829
            "include_fields": [
830
                "cf_crash_signature",
831
            ],
832
        }
833

834
        if include_fields:
×
835
            params_base["include_fields"].extend(include_fields)
×
836

837
        params_list = []
×
838
        for signatures_chunk in Connection.chunks(list(self._signatures), 30):
×
839
            params = params_base.copy()
×
840
            n = int(utils.get_last_field_num(params))
×
841
            params[f"f{n}"] = "OP"
×
842
            params[f"j{n}"] = "OR"
×
843
            for signature in signatures_chunk:
×
844
                n += 1
×
845
                params[f"f{n}"] = "cf_crash_signature"
×
846
                params[f"o{n}"] = "regexp"
×
847
                params[f"v{n}"] = rf"\[(@ |@){re.escape(signature)}( \]|\])"
×
848
            params[f"f{n+1}"] = "CP"
×
849
            params_list.append(params)
×
850

851
        signatures_bugs: dict = defaultdict(list)
×
852

853
        def handler(res, data):
×
854
            for bug in res["bugs"]:
×
855
                for signature in utils.get_signatures(bug["cf_crash_signature"]):
×
856
                    if signature in self._signatures:
×
857
                        data[signature].append(bug)
×
858

859
        logger.debug(
×
860
            "Fetch from Bugzilla: requesting bugs for %d signatures",
861
            len(self._signatures),
862
        )
863
        timeout = utils.get_config("common", "bz_query_timeout")
×
864
        Bugzilla(
×
865
            timeout=timeout,
866
            queries=[
867
                connection.Query(Bugzilla.API_URL, params, handler, signatures_bugs)
868
                for params in params_list
869
            ],
870
        ).wait()
871

872
        # TODO: remove the call to DevBugzilla after moving to production
873
        DevBugzilla(
×
874
            timeout=timeout,
875
            queries=[
876
                connection.Query(DevBugzilla.API_URL, params, handler, signatures_bugs)
877
                for params in params_list
878
            ],
879
        ).wait()
880

881
        logger.debug(
×
882
            "Fetch from Bugzilla: received bugs for %d signatures", len(signatures_bugs)
883
        )
884

885
        return signatures_bugs
×
886

887
    def analyze(self) -> list[SignatureAnalyzer]:
×
888
        """Analyze the data related to the signatures."""
889
        bugs = self.fetch_bugs()
×
890
        # TODO(investigate): For now, we are ignoring signatures that have bugs
891
        # filed even if they are closed long time ago. We should investigate
892
        # whether we should include the ones with closed bugs. For example, if
893
        # the bug was closed as Fixed years ago.
894
        self._signatures.difference_update(bugs.keys())
×
895

896
        clouseau_reports = self.fetch_clouseau_crash_reports()
×
897
        # TODO(investigate): For now, we are ignoring signatures that are not
898
        # analyzed by clouseau. We should investigate why they are not analyzed
899
        # and whether we should include them.
900
        self._signatures.intersection_update(clouseau_reports.keys())
×
901

902
        signatures, num_total_crashes = self.fetch_socorro_info()
×
903
        bugs_store = BugsStore()
×
904

905
        return [
×
906
            SignatureAnalyzer(
907
                signature,
908
                num_total_crashes,
909
                clouseau_reports[signature["term"]],
910
                bugs_store,
911
            )
912
            for signature in signatures
913
        ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc