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

mozilla / relman-auto-nag / #4646

pending completion
#4646

push

coveralls-python

web-flow
[file_crash_bug] Show the error message when failing to file a bug (#2151)

646 of 3460 branches covered (18.67%)

1830 of 8576 relevant lines covered (21.34%)

0.21 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.components import ComponentName
×
19
from bugbot.crash import socorro_util
×
20

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

47

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

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

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

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

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

65

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

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

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

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

85

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

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

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

101

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

111

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

115

116
class ClouseauDataAnalyzer:
×
117
    """Analyze the data returned by Crash Clouseau"""
118

119
    MINIMUM_CLOUSEAU_SCORE_THRESHOLD: int = 8
×
120
    DEFAULT_CRASH_COMPONENT = ComponentName("Core", "General")
×
121

122
    def __init__(self, reports: Iterable[dict]):
×
123
        self._clouseau_reports = reports
×
124

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

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

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

167
    @cached_property
×
168
    def regressed_by(self) -> int | None:
×
169
        """The ID of the bug that one of its patches could have caused
170
        the crash.
171

172
        If there are multiple bugs, the value will be `None`.
173
        """
174
        bug_ids = self.regressed_by_potential_bug_ids
×
175
        if len(bug_ids) == 1:
×
176
            return next(iter(bug_ids))
×
177
        return None
×
178

179
    @cached_property
×
180
    def regressed_by_potential_bugs(self) -> list[dict]:
×
181
        """The bugs whose patches could have caused the crash."""
182

183
        def handler(bug: dict, data: list):
×
184
            data.append(bug)
×
185

186
        bugs: list[dict] = []
×
187
        Bugzilla(
×
188
            bugids=self.regressed_by_potential_bug_ids,
189
            include_fields=[
190
                "id",
191
                "groups",
192
                "assigned_to",
193
                "product",
194
                "component",
195
            ],
196
            bughandler=handler,
197
            bugdata=bugs,
198
        ).wait()
199

200
        return bugs
×
201

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

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

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

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

215
        bug = self.regressed_by_potential_bugs[0]
×
216
        assert bug["id"] == self.regressed_by
×
217
        return bug["assigned_to_detail"]
×
218

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

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

233

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

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

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

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

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

266
        if op_sys in cls._bugzilla_os_legal_values:
×
267
            return op_sys
×
268

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

278
        return op_sys
×
279

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

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

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

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

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

305
        if len(all_op_sys) == 1:
×
306
            return next(iter(all_op_sys))
×
307

308
        if len(all_op_sys) == 0:
×
309
            return "Unspecified"
×
310

311
        return "All"
×
312

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

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

326
        return cls._bugzilla_cpu_legal_values_map.get(cpu, "Other")
×
327

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

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

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

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

346
        if len(all_cpu_arch) == 1:
×
347
            return next(iter(all_cpu_arch))
×
348

349
        if len(all_cpu_arch) == 0:
×
350
            return "Unspecified"
×
351

352
        return "All"
×
353

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

467

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

471
    This includes data from Socorro and Clouseau.
472
    """
473

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

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

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

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

504
        yield from data["hits"]
×
505

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

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

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

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

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

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

557

558
class SignaturesDataFetcher:
×
559
    """Fetch the data related to the given signatures."""
560

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

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

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

590
    # TODO(investigate): do we need to exclude all these signatures prefixes?
591
    EXCLUDED_SIGNATURE_PREFIXES = (
×
592
        "OOM | ",
593
        "bad hardware | ",
594
        "shutdownhang | ",
595
    )
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
        # TODO(investigate): should we increase the duration to 6 months?
769
        duration = timedelta(weeks=1)
×
770
        end_date = lmdutils.get_date_ymd("today")
×
771
        start_date = end_date - duration
×
772
        date_range = socorro.SuperSearch.get_search_date(start_date, end_date)
×
773

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

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

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

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

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

823
        return data["signatures"], data["num_total_crashes"]
×
824

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

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

836
        if include_fields:
×
837
            params_base["include_fields"].extend(include_fields)
×
838

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

853
        signatures_bugs: dict = defaultdict(list)
×
854

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

861
        logger.debug(
×
862
            "Fetch from Bugzilla: requesting bugs for %d signatures",
863
            len(self._signatures),
864
        )
865
        Bugzilla(
×
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
            queries=[
875
                connection.Query(DevBugzilla.API_URL, params, handler, signatures_bugs)
876
                for params in params_list
877
            ],
878
        ).wait()
879

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

884
        return signatures_bugs
×
885

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

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

901
        signatures, num_total_crashes = self.fetch_socorro_info()
×
902

903
        return [
×
904
            SignatureAnalyzer(
905
                signature,
906
                num_total_crashes,
907
                clouseau_reports[signature["term"]],
908
            )
909
            for signature in signatures
910
        ]
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