Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions tests/perf/auto_perf_sheriffing/test_backfill_record.py
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,
}
58 changes: 58 additions & 0 deletions tests/ui/perfherder/alerts-view/alerts_table_row_test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)}`),

Copy link
Copy Markdown
Collaborator

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.

);
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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
).toBeNull();
expect(
container.querySelector('[data-testid$="suggested-culprit"]'),
).toBeNull();

});
});
4 changes: 2 additions & 2 deletions tests/ui/perfherder/alerts-view/alerts_test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ test('selecting all alerts and marking them as acknowledged updates all alerts',
expect(alertCheckbox1).toHaveProperty('checked', true);
expect(alertCheckbox2).toHaveProperty('checked', true);
});
let acknowledgeButton = await waitFor(() => getByText('Acknowledge'));
const acknowledgeButton = await waitFor(() => getByText('Acknowledge'));

fireEvent.click(acknowledgeButton);

Expand Down Expand Up @@ -391,7 +391,7 @@ test('selecting the alert summary checkbox then deselecting one alert only updat
expect(alertCheckbox4).toHaveProperty('checked', true);
});

let acknowledgeButton = await waitFor(() => getByText('Acknowledge'));
const acknowledgeButton = await waitFor(() => getByText('Acknowledge'));
fireEvent.click(acknowledgeButton);

// only the selected alert has been updated
Expand Down
20 changes: 20 additions & 0 deletions treeherder/perf/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"detected_push_id": entry.get("detected_push_id"),
"detected_push_id": entry["detected_push_id"],

"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)
Expand Down
12 changes: 12 additions & 0 deletions treeherder/webapp/api/performance_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When DRF serializes a BackfillRecord, it calls both get_detected_push_id and get_detected_push_revision. Each calls get_latest_detected_push(), which in turn calls json.loads() on the backfill logs. That means the JSON is parsed twice for every record in the API response.

The following approach computes the result once sets both keys manually. This is the DRF-idiomatic way to customize serialization output.
to_representation is a special method built into DRF that every serializer already has. It's the method DRF calls internally when it converts a model object into a Python dict (before turning it into JSON).
Instead of adding two new fields — you're overriding that built-in method to inject your two values after the normal serialization runs.

Suggested change
return detected["detected_push_revision"] if detected else None
def to_representation(self, instance):
data = super().to_representation(instance) # 1. run normal serialization first
# (all existing fields are in `data`)
detected = instance.get_latest_detected_push() # 2. compute once
data["detected_push_id"] = ... # 3. add field 1 to the dict
data["detected_push_revision"] = ... # 4. add field 2 to the dict
return data # 5. return the final dict

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
Expand All @@ -101,6 +111,8 @@ class Meta:
"total_backfills_failed",
"total_backfills_successful",
"total_backfills_in_progress",
"detected_push_id",
"detected_push_revision",
)


Expand Down
45 changes: 30 additions & 15 deletions ui/perfherder/alerts/AlertTableRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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('/', '')]
Expand All @@ -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 = (
Expand Down Expand Up @@ -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)}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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">
Expand Down