Commit b0932ec6 authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo Committed by Andrew Fontaine

Add performance measures

Includes choice of whether to collect
parent 81b2ae7f
...@@ -54,3 +54,24 @@ export const MR_DIFFS_MARK_DIFF_FILES_END = 'mr-diffs-mark-diff-files-end'; ...@@ -54,3 +54,24 @@ export const MR_DIFFS_MARK_DIFF_FILES_END = 'mr-diffs-mark-diff-files-end';
// Measures // Measures
export const MR_DIFFS_MEASURE_FILE_TREE_DONE = 'mr-diffs-measure-file-tree-done'; export const MR_DIFFS_MEASURE_FILE_TREE_DONE = 'mr-diffs-measure-file-tree-done';
export const MR_DIFFS_MEASURE_DIFF_FILES_DONE = 'mr-diffs-measure-diff-files-done'; export const MR_DIFFS_MEASURE_DIFF_FILES_DONE = 'mr-diffs-measure-diff-files-done';
//
// Pipelines Detail namespace
//
// Marks
export const PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START =
'pipelines-detail-links-mark-calculate-start';
export const PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END =
'pipelines-detail-links-mark-calculate-end';
// Measures
export const PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION =
'Pipelines Detail Graph: Links Calculation';
// Metrics
// Note: These strings must match the backend
// (defined in: app/services/ci/prometheus_metrics/observe_histograms_service.rb)
export const PIPELINES_DETAIL_LINK_DURATION = 'pipeline_graph_link_calculation_duration_seconds';
export const PIPELINES_DETAIL_LINKS_TOTAL = 'pipeline_graph_links_total';
export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_link_per_job_ratio';
...@@ -15,14 +15,19 @@ export default { ...@@ -15,14 +15,19 @@ export default {
StageColumnComponent, StageColumnComponent,
}, },
props: { props: {
pipeline: {
type: Object,
required: true,
},
isLinkedPipeline: { isLinkedPipeline: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
pipeline: { metricsPath: {
type: Object, type: String,
required: true, required: false,
default: '',
}, },
type: { type: {
type: String, type: String,
...@@ -66,6 +71,12 @@ export default { ...@@ -66,6 +71,12 @@ export default {
hasUpstreamPipelines() { hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0); return Boolean(this.pipeline?.upstream?.length > 0);
}, },
metricsConfig() {
return {
path: this.metricsPath,
collectMetrics: true,
};
},
// The show downstream check prevents showing redundant linked columns // The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() { showDownstreamPipelines() {
return ( return (
...@@ -145,6 +156,7 @@ export default { ...@@ -145,6 +156,7 @@ export default {
:container-id="containerId" :container-id="containerId"
:container-measurements="measurements" :container-measurements="measurements"
:highlighted-job="hoveredJobName" :highlighted-job="hoveredJobName"
:metrics-config="metricsConfig"
default-link-color="gl-stroke-transparent" default-link-color="gl-stroke-transparent"
@error="onError" @error="onError"
@highlightedJobsChange="updateHighlightedJobs" @highlightedJobsChange="updateHighlightedJobs"
......
...@@ -14,6 +14,9 @@ export default { ...@@ -14,6 +14,9 @@ export default {
PipelineGraph, PipelineGraph,
}, },
inject: { inject: {
metricsPath: {
default: '',
},
pipelineIid: { pipelineIid: {
default: '', default: '',
}, },
...@@ -108,6 +111,7 @@ export default { ...@@ -108,6 +111,7 @@ export default {
<gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" /> <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" />
<pipeline-graph <pipeline-graph
v-if="pipeline" v-if="pipeline"
:metrics-path="metricsPath"
:pipeline="pipeline" :pipeline="pipeline"
@error="reportFailure" @error="reportFailure"
@refreshPipelineGraph="refreshPipelineGraph" @refreshPipelineGraph="refreshPipelineGraph"
......
import axios from '~/lib/utils/axios_utils';
import { reportToSentry } from '../graph/utils';
export const reportPerformance = (path, stats) => {
axios.post(path, stats).catch((err) => {
reportToSentry('links_inner_perf', `error: ${err}`);
});
};
<script> <script>
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import {
PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
PIPELINES_DETAIL_LINK_DURATION,
PIPELINES_DETAIL_LINKS_TOTAL,
PIPELINES_DETAIL_LINKS_JOB_RATIO,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import { DRAW_FAILURE } from '../../constants'; import { DRAW_FAILURE } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils'; import { createJobsHash, generateJobNeedsDict } from '../../utils';
import { reportToSentry } from '../graph/utils'; import { reportToSentry } from '../graph/utils';
import { parseData } from '../parsing_utils'; import { parseData } from '../parsing_utils';
import { reportPerformance } from './api';
import { generateLinksData } from './drawing_utils'; import { generateLinksData } from './drawing_utils';
export default { export default {
...@@ -26,6 +36,15 @@ export default { ...@@ -26,6 +36,15 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
totalGroups: {
type: Number,
required: true,
},
metricsConfig: {
type: Object,
required: false,
default: () => ({}),
},
defaultLinkColor: { defaultLinkColor: {
type: String, type: String,
required: false, required: false,
...@@ -44,6 +63,9 @@ export default { ...@@ -44,6 +63,9 @@ export default {
}; };
}, },
computed: { computed: {
shouldCollectMetrics() {
return this.metricsConfig.collectMetrics && this.metricsConfig.path;
},
hasHighlightedJob() { hasHighlightedJob() {
return Boolean(this.highlightedJob); return Boolean(this.highlightedJob);
}, },
...@@ -97,10 +119,52 @@ export default { ...@@ -97,10 +119,52 @@ export default {
} }
}, },
methods: { methods: {
beginPerfMeasure() {
if (this.shouldCollectMetrics) {
performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START });
}
},
finishPerfMeasureAndSend() {
if (this.shouldCollectMetrics) {
performanceMarkAndMeasure({
mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
measures: [
{
name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
},
],
});
}
window.requestAnimationFrame(() => {
const duration = window.performance.getEntriesByName(
PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
)[0]?.duration;
if (!duration) {
return;
}
const data = {
histograms: [
{ name: PIPELINES_DETAIL_LINK_DURATION, value: duration },
{ name: PIPELINES_DETAIL_LINKS_TOTAL, value: this.links.length },
{
name: PIPELINES_DETAIL_LINKS_JOB_RATIO,
value: this.links.length / this.totalGroups,
},
],
};
reportPerformance(this.metricsConfig.path, data);
});
},
isLinkHighlighted(linkRef) { isLinkHighlighted(linkRef) {
return this.highlightedLinks.includes(linkRef); return this.highlightedLinks.includes(linkRef);
}, },
prepareLinkData() { prepareLinkData() {
this.beginPerfMeasure();
try { try {
const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
const parsedData = parseData(arrayOfJobs); const parsedData = parseData(arrayOfJobs);
...@@ -109,6 +173,7 @@ export default { ...@@ -109,6 +173,7 @@ export default {
this.$emit('error', DRAW_FAILURE); this.$emit('error', DRAW_FAILURE);
reportToSentry(this.$options.name, err); reportToSentry(this.$options.name, err);
} }
this.finishPerfMeasureAndSend();
}, },
getLinkClasses(link) { getLinkClasses(link) {
return [ return [
......
...@@ -70,6 +70,7 @@ export default { ...@@ -70,6 +70,7 @@ export default {
v-if="showLinkedLayers" v-if="showLinkedLayers"
:container-measurements="containerMeasurements" :container-measurements="containerMeasurements"
:pipeline-data="pipelineData" :pipeline-data="pipelineData"
:total-groups="numGroups"
v-bind="$attrs" v-bind="$attrs"
v-on="$listeners" v-on="$listeners"
> >
......
...@@ -93,8 +93,13 @@ export default async function initPipelineDetailsBundle() { ...@@ -93,8 +93,13 @@ export default async function initPipelineDetailsBundle() {
/* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph' /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph'
); );
const { pipelineProjectPath, pipelineIid } = dataset; const { metricsPath, pipelineProjectPath, pipelineIid } = dataset;
createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid); createPipelinesDetailApp(
SELECTORS.PIPELINE_GRAPH,
pipelineProjectPath,
pipelineIid,
metricsPath,
);
} catch { } catch {
Flash(__('An error occurred while loading the pipeline.')); Flash(__('An error occurred while loading the pipeline.'));
} }
......
...@@ -16,7 +16,7 @@ const apolloProvider = new VueApollo({ ...@@ -16,7 +16,7 @@ const apolloProvider = new VueApollo({
), ),
}); });
const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => { const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid, metricsPath) => {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: selector, el: selector,
...@@ -25,6 +25,7 @@ const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => ...@@ -25,6 +25,7 @@ const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) =>
}, },
apolloProvider, apolloProvider,
provide: { provide: {
metricsPath,
pipelineProjectPath, pipelineProjectPath,
pipelineIid, pipelineIid,
dataMethod: GRAPHQL, dataMethod: GRAPHQL,
......
...@@ -26,4 +26,4 @@ ...@@ -26,4 +26,4 @@
= render "projects/pipelines/with_tabs", pipeline: @pipeline, pipeline_has_errors: pipeline_has_errors = render "projects/pipelines/with_tabs", pipeline: @pipeline, pipeline_has_errors: pipeline_has_errors
.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid } } .js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid } }
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { setHTMLFixture } from 'helpers/fixtures'; import { setHTMLFixture } from 'helpers/fixtures';
import axios from '~/lib/utils/axios_utils';
import {
PIPELINES_DETAIL_LINK_DURATION,
PIPELINES_DETAIL_LINKS_TOTAL,
PIPELINES_DETAIL_LINKS_JOB_RATIO,
} from '~/performance/constants';
import * as perfUtils from '~/performance/utils';
import * as sentryUtils from '~/pipelines/components/graph/utils';
import * as Api from '~/pipelines/components/graph_shared/api';
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
import { createJobsHash } from '~/pipelines/utils'; import { createJobsHash } from '~/pipelines/utils';
import { import {
...@@ -18,7 +28,9 @@ describe('Links Inner component', () => { ...@@ -18,7 +28,9 @@ describe('Links Inner component', () => {
containerMeasurements: { width: 1019, height: 445 }, containerMeasurements: { width: 1019, height: 445 },
pipelineId: 1, pipelineId: 1,
pipelineData: [], pipelineData: [],
totalGroups: 10,
}; };
let wrapper; let wrapper;
const createComponent = (props) => { const createComponent = (props) => {
...@@ -194,4 +206,141 @@ describe('Links Inner component', () => { ...@@ -194,4 +206,141 @@ describe('Links Inner component', () => {
expect(firstLink.classes(hoverColorClass)).toBe(true); expect(firstLink.classes(hoverColorClass)).toBe(true);
}); });
}); });
describe('performance metrics', () => {
let markAndMeasure;
let reportToSentry;
let reportPerformance;
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb());
markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure');
reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry');
reportPerformance = jest.spyOn(Api, 'reportPerformance');
});
afterEach(() => {
mock.restore();
});
describe('with no metrics config object', () => {
beforeEach(() => {
setFixtures(pipelineData);
createComponent({
pipelineData: pipelineData.stages,
});
});
it('is not called', () => {
expect(markAndMeasure).not.toHaveBeenCalled();
expect(reportToSentry).not.toHaveBeenCalled();
expect(reportPerformance).not.toHaveBeenCalled();
});
});
describe('with metrics config set to false', () => {
beforeEach(() => {
setFixtures(pipelineData);
createComponent({
pipelineData: pipelineData.stages,
metricsConfig: {
collectMetrics: false,
metricsPath: '/path/to/metrics',
},
});
});
it('is not called', () => {
expect(markAndMeasure).not.toHaveBeenCalled();
expect(reportToSentry).not.toHaveBeenCalled();
expect(reportPerformance).not.toHaveBeenCalled();
});
});
describe('with no metrics path', () => {
beforeEach(() => {
setFixtures(pipelineData);
createComponent({
pipelineData: pipelineData.stages,
metricsConfig: {
collectMetrics: true,
metricsPath: '',
},
});
});
it('is not called', () => {
expect(markAndMeasure).not.toHaveBeenCalled();
expect(reportToSentry).not.toHaveBeenCalled();
expect(reportPerformance).not.toHaveBeenCalled();
});
});
describe('with metrics path and collect set to true', () => {
const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json';
const duration = 0.0478;
const numLinks = 1;
const metricsData = {
histograms: [
{ name: PIPELINES_DETAIL_LINK_DURATION, value: duration },
{ name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks },
{
name: PIPELINES_DETAIL_LINKS_JOB_RATIO,
value: numLinks / defaultProps.totalGroups,
},
],
};
describe('when no duration is obtained', () => {
beforeEach(() => {
jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => {
return [];
});
setFixtures(pipelineData);
createComponent({
pipelineData: pipelineData.stages,
metricsConfig: {
collectMetrics: true,
path: metricsPath,
},
});
});
it('attempts to collect metrics', () => {
expect(markAndMeasure).toHaveBeenCalled();
expect(reportPerformance).not.toHaveBeenCalled();
expect(reportToSentry).not.toHaveBeenCalled();
});
});
describe('with duration and no error', () => {
beforeEach(() => {
jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => {
return [{ duration }];
});
setFixtures(pipelineData);
createComponent({
pipelineData: pipelineData.stages,
metricsConfig: {
collectMetrics: true,
path: metricsPath,
},
});
});
it('it calls reportPerformance with expected arguments', () => {
expect(markAndMeasure).toHaveBeenCalled();
expect(reportPerformance).toHaveBeenCalled();
expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData);
expect(reportToSentry).not.toHaveBeenCalled();
});
});
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment