-
Notifications
You must be signed in to change notification settings - Fork 387
Bug 2059046 - [Perfherder] Display Sherlock's Suggested Culprit in Alerts View #9818
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import json | ||
|
|
||
| import pytest | ||
|
|
||
| from treeherder.perf.models import BackfillRecord, BackfillReport | ||
| from treeherder.webapp.api.performance_serializers import BackfillRecordSerializer | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def backfill_record_with_logs(test_perf_alert): | ||
| report = BackfillReport.objects.create(summary=test_perf_alert.summary) | ||
| record = BackfillRecord.objects.create(alert=test_perf_alert, report=report) | ||
| record.backfill_logs = json.dumps( | ||
| [ | ||
| { | ||
| "iteration": 0, | ||
| "status": "initial", | ||
| "detected_push_id": 111, | ||
| "detected_push_revision": "aaaa1111bbbb", | ||
| }, | ||
| {"iteration": 0, "status": "backfill_requested"}, # no push → must be skipped | ||
| { | ||
| "iteration": 1, | ||
| "status": "right", | ||
| "detected_push_id": 222, | ||
| "detected_push_revision": "cccc2222dddd", | ||
| }, | ||
| ] | ||
| ) | ||
| record.save() | ||
| return record | ||
|
|
||
|
|
||
| @pytest.mark.django_db | ||
| def test_get_latest_detected_push_returns_most_recent(backfill_record_with_logs): | ||
| assert backfill_record_with_logs.get_latest_detected_push() == { | ||
| "detected_push_id": 222, | ||
| "detected_push_revision": "cccc2222dddd", | ||
| } | ||
|
|
||
|
|
||
| @pytest.mark.django_db | ||
| def test_serializer_exposes_detected_push(backfill_record_with_logs): | ||
| data = BackfillRecordSerializer(backfill_record_with_logs).data | ||
| assert data["detected_push_id"] == 222 | ||
| assert data["detected_push_revision"] == "cccc2222dddd" | ||
|
|
||
|
|
||
| @pytest.mark.django_db | ||
| def test_detected_push_null_when_no_logs(test_perf_alert): | ||
| report = BackfillReport.objects.create(summary=test_perf_alert.summary) | ||
| record = BackfillRecord.objects.create( | ||
| alert=test_perf_alert, report=report | ||
| ) # backfill_logs='[]' | ||
| data = BackfillRecordSerializer(record).data | ||
| assert data["detected_push_id"] is None | ||
| assert data["detected_push_revision"] is None | ||
|
|
||
|
|
||
| @pytest.mark.django_db | ||
| def test_detected_push_falls_back_to_scalar(test_perf_alert): | ||
| report = BackfillReport.objects.create(summary=test_perf_alert.summary) | ||
| record = BackfillRecord.objects.create( | ||
| alert=test_perf_alert, report=report, last_detected_push_id=999 | ||
| ) | ||
| assert record.get_latest_detected_push() == { | ||
| "detected_push_id": 999, | ||
| "detected_push_revision": None, | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,6 +3,7 @@ import { render, cleanup, waitFor, fireEvent } from '@testing-library/react'; | |||||||||
| import AlertTableRow from '../../../../ui/perfherder/alerts/AlertTableRow'; | ||||||||||
| import testAlertSummaries from '../../mock/alert_summaries'; | ||||||||||
| import { thPlatformMap } from '../../../../ui/helpers/constants'; | ||||||||||
| import { alertBackfillResultStatusMap } from '../../../../ui/perfherder/perf-helpers/constants'; | ||||||||||
|
|
||||||||||
| const testUser = { | ||||||||||
| username: 'mozilla-ldap/test_user@mozilla.com', | ||||||||||
|
|
@@ -445,3 +446,60 @@ describe('graph link highlight', () => { | |||||||||
| expect(setLastClickedGraphAlertId).toHaveBeenCalledWith(testAlert.id); | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| describe('detected push revision', () => { | ||||||||||
| const alertWithBackfill = (backfillOverrides = {}) => ({ | ||||||||||
| ...testAlert, | ||||||||||
| backfill_record: { | ||||||||||
| status: alertBackfillResultStatusMap.successful, | ||||||||||
| total_backfills_successful: 2, | ||||||||||
| ...backfillOverrides, | ||||||||||
| }, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| test('renders the suggested culprit revision, styled italic/muted/small', async () => { | ||||||||||
| const revision = 'a1b2c3d4e5f6'; | ||||||||||
| const { getByText } = alertTableRowTest({ | ||||||||||
| alert: alertWithBackfill({ detected_push_revision: revision }), | ||||||||||
| tags: false, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| const revisionEl = await waitFor(() => | ||||||||||
| getByText(`Suggested culprit: ${revision}`), | ||||||||||
| ); | ||||||||||
| expect(revisionEl).toBeInTheDocument(); | ||||||||||
| expect(revisionEl).toHaveClass('fst-italic'); | ||||||||||
| expect(revisionEl).toHaveClass('text-muted'); | ||||||||||
| expect(revisionEl).toHaveClass('small'); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| test('truncates the suggested culprit revision to 12 characters', async () => { | ||||||||||
| const revision = 'abcdef0123456789abcdef0123456789abcdef01'; // 40-char sha | ||||||||||
| const { getByText, queryByText } = alertTableRowTest({ | ||||||||||
| alert: alertWithBackfill({ detected_push_revision: revision }), | ||||||||||
| tags: false, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| const revisionEl = await waitFor(() => | ||||||||||
| getByText(`Suggested culprit: ${revision.slice(0, 12)}`), | ||||||||||
| ); | ||||||||||
| expect(revisionEl).toBeInTheDocument(); | ||||||||||
| // the full, untruncated revision is not shown | ||||||||||
| expect(queryByText(`Suggested culprit: ${revision}`)).toBeNull(); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| test('does not render the revision span when detected_push_revision is absent', async () => { | ||||||||||
| const alert = alertWithBackfill(); // backfill record present, but no detected push | ||||||||||
| const { container, getByTestId } = alertTableRowTest({ | ||||||||||
| alert, | ||||||||||
| tags: false, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| // the Sherlock icon still renders (backfill_record is present)... | ||||||||||
| await waitFor(() => getByTestId(`alert ${alert.id} sherlock icon`)); | ||||||||||
| // ...but the styled detected-push revision span does not | ||||||||||
| expect( | ||||||||||
| container.querySelector('.fst-italic.text-muted.small'), | ||||||||||
| ).toBeNull(); | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a Bootstrap version update or a style change renames those classes, the test will silently pass even if the element is incorrectly rendered.
Suggested change
|
||||||||||
| }); | ||||||||||
| }); | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -1196,6 +1196,26 @@ def get_backfill_log(self, iteration: int) -> dict: | |||||
| return entry | ||||||
| return None | ||||||
|
|
||||||
| def get_latest_detected_push(self) -> dict | None: | ||||||
| """ | ||||||
| Parse backfill_logs and return the most recent iteration's detected culprit push | ||||||
| as {"detected_push_id": int|None, "detected_push_revision": str|None}, or None | ||||||
| if nothing was ever detected. | ||||||
| """ | ||||||
| for entry in reversed(self.get_backfill_logs()): | ||||||
| if entry.get("detected_push_id") is not None: | ||||||
| return { | ||||||
| "detected_push_id": entry.get("detected_push_id"), | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| "detected_push_revision": entry.get("detected_push_revision"), | ||||||
| } | ||||||
| # Fallback for legacy records or logs that only hold the scalar. | ||||||
| if self.last_detected_push_id is not None: | ||||||
| return { | ||||||
| "detected_push_id": self.last_detected_push_id, | ||||||
| "detected_push_revision": None, | ||||||
| } | ||||||
| return None | ||||||
|
|
||||||
| def save(self, *args, **kwargs): | ||||||
| # refresh parent's latest update time | ||||||
| super().save(*args, **kwargs) | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -90,6 +90,16 @@ class BackfillRecordSerializer(serializers.Serializer): | |||||||||||||||||
| total_backfills_failed = serializers.IntegerField() | ||||||||||||||||||
| total_backfills_successful = serializers.IntegerField() | ||||||||||||||||||
| total_backfills_in_progress = serializers.IntegerField() | ||||||||||||||||||
| detected_push_id = serializers.SerializerMethodField() | ||||||||||||||||||
| detected_push_revision = serializers.SerializerMethodField() | ||||||||||||||||||
|
|
||||||||||||||||||
| def get_detected_push_id(self, obj): | ||||||||||||||||||
| detected = obj.get_latest_detected_push() | ||||||||||||||||||
| return detected["detected_push_id"] if detected else None | ||||||||||||||||||
|
|
||||||||||||||||||
| def get_detected_push_revision(self, obj): | ||||||||||||||||||
| detected = obj.get_latest_detected_push() | ||||||||||||||||||
| return detected["detected_push_revision"] if detected else None | ||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When DRF serializes a BackfillRecord, it calls both The following approach computes the result once sets both keys manually. This is the DRF-idiomatic way to customize serialization output.
Suggested change
There are multiple ways to compute only once detected_push_id and detected_push_revision. This is one option, but I'm open to any alternative solutions you'd prefer to explore. |
||||||||||||||||||
|
|
||||||||||||||||||
| class Meta: | ||||||||||||||||||
| model = BackfillRecord | ||||||||||||||||||
|
|
@@ -101,6 +111,8 @@ class Meta: | |||||||||||||||||
| "total_backfills_failed", | ||||||||||||||||||
| "total_backfills_successful", | ||||||||||||||||||
| "total_backfills_in_progress", | ||||||||||||||||||
| "detected_push_id", | ||||||||||||||||||
| "detected_push_revision", | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -362,7 +362,13 @@ export default class AlertTableRow extends React.Component { | |
| } | ||
|
|
||
| render() { | ||
| const { user = null, alert, alertSummary, lastClickedGraphAlertId, setLastClickedGraphAlertId } = this.props; | ||
| const { | ||
| user = null, | ||
| alert, | ||
| alertSummary, | ||
| lastClickedGraphAlertId, | ||
| setLastClickedGraphAlertId, | ||
| } = this.props; | ||
| const { starred, checkboxSelected, icons } = this.state; | ||
| const { repository, framework, revision } = alertSummary; | ||
|
|
||
|
|
@@ -384,7 +390,8 @@ export default class AlertTableRow extends React.Component { | |
| ? `Classified by ${alert.classifier_email}` | ||
| : 'Classified automatically'; | ||
| const bookmarkClass = starred ? 'visible' : ''; | ||
| const graphActive = lastClickedGraphAlertId !== null && lastClickedGraphAlertId === alert.id; | ||
| const graphActive = | ||
| lastClickedGraphAlertId !== null && lastClickedGraphAlertId === alert.id; | ||
| const noiseProfile = alert.noise_profile || 'N\\A'; | ||
| const noiseProfileTooltip = alert.noise_profile | ||
| ? noiseProfiles[alert.noise_profile.replace('/', '')] | ||
|
|
@@ -398,6 +405,7 @@ export default class AlertTableRow extends React.Component { | |
| alert.side_by_side_available; | ||
|
|
||
| const backfillStatusInfo = this.getBackfillStatusInfo(alert); | ||
| const detectedPushRevision = alert.backfill_record?.detected_push_revision; | ||
| let sherlockTooltip = backfillStatusInfo?.message; | ||
| if (backfillStatusInfo?.displayTasksCount) { | ||
| sherlockTooltip = ( | ||
|
|
@@ -484,19 +492,26 @@ export default class AlertTableRow extends React.Component { | |
| this.getTitleText(alert, alertStatus) | ||
| )} | ||
| {backfillStatusInfo && ( | ||
| <span className="text-darker-info"> | ||
| <SimpleTooltip | ||
| key={alert.id} | ||
| text={ | ||
| <FontAwesomeIcon | ||
| icon={backfillStatusInfo.icon} | ||
| color={backfillStatusInfo.color} | ||
| data-testid={`alert ${alert.id.toString()} sherlock icon`} | ||
| /> | ||
| } | ||
| tooltipText={sherlockTooltip} | ||
| /> | ||
| </span> | ||
| <> | ||
| <span className="text-darker-info"> | ||
| <SimpleTooltip | ||
| key={alert.id} | ||
| text={ | ||
| <FontAwesomeIcon | ||
| icon={backfillStatusInfo.icon} | ||
| color={backfillStatusInfo.color} | ||
| data-testid={`alert ${alert.id.toString()} sherlock icon`} | ||
| /> | ||
| } | ||
| tooltipText={sherlockTooltip} | ||
| /> | ||
| </span> | ||
| {detectedPushRevision && ( | ||
| <span className="ms-1 fst-italic text-muted small"> | ||
| Suggested culprit: {detectedPushRevision.slice(0, 12)} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: As a potential follow-up, we could add a direct link to the Jobs View when clicking on the revision. |
||
| </span> | ||
| )} | ||
| </> | ||
| )} | ||
| </td> | ||
| <td className="table-width-lg"> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Consider extracting this hardcoded slicing value into a constant (e.g., const REVISION_DISPLAY_LENGTH = 12;). Since it’s used frequently throughout the project, this will help keep it consistent and easier to maintain. This can also be addressed later since it involves other files as well.