Commit 26bf9ae5 authored by Clement Ho's avatar Clement Ho

Merge branch 'pawel/prometheus-business-metrics-ee-2273' into 'master'

Custom Metrics backend and UI for creating and editing queries

See merge request gitlab-org/gitlab-ee!3799
parents acb08320 26626c34
import _ from 'underscore';
import { s__, n__, sprintf } from '~/locale';
import axios from '../lib/utils/axios_utils'; import axios from '../lib/utils/axios_utils';
import PANEL_STATE from './constants'; import PANEL_STATE from './constants';
import { backOff } from '../lib/utils/common_utils'; import { backOff } from '../lib/utils/common_utils';
...@@ -20,6 +22,7 @@ export default class PrometheusMetrics { ...@@ -20,6 +22,7 @@ export default class PrometheusMetrics {
this.$missingEnvVarMetricsList = this.$missingEnvVarPanel.find('.js-missing-var-metrics-list'); this.$missingEnvVarMetricsList = this.$missingEnvVarPanel.find('.js-missing-var-metrics-list');
this.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('activeMetrics'); this.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('activeMetrics');
this.helpMetricsPath = this.$monitoredMetricsPanel.data('metrics-help-path');
this.$panelToggle.on('click', e => this.handlePanelToggle(e)); this.$panelToggle.on('click', e => this.handlePanelToggle(e));
} }
...@@ -59,23 +62,39 @@ export default class PrometheusMetrics { ...@@ -59,23 +62,39 @@ export default class PrometheusMetrics {
populateActiveMetrics(metrics) { populateActiveMetrics(metrics) {
let totalMonitoredMetrics = 0; let totalMonitoredMetrics = 0;
let totalMissingEnvVarMetrics = 0; let totalMissingEnvVarMetrics = 0;
let totalExporters = 0;
metrics.forEach((metric) => { metrics.forEach((metric) => {
this.$monitoredMetricsList.append(`<li>${metric.group}<span class="badge">${metric.active_metrics}</span></li>`); if (metric.active_metrics > 0) {
totalMonitoredMetrics += metric.active_metrics; totalExporters += 1;
if (metric.metrics_missing_requirements > 0) { this.$monitoredMetricsList.append(`<li>${_.escape(metric.group)}<span class="badge">${_.escape(metric.active_metrics)}</span></li>`);
this.$missingEnvVarMetricsList.append(`<li>${metric.group}</li>`); totalMonitoredMetrics += metric.active_metrics;
totalMissingEnvVarMetrics += 1; if (metric.metrics_missing_requirements > 0) {
this.$missingEnvVarMetricsList.append(`<li>${_.escape(metric.group)}</li>`);
totalMissingEnvVarMetrics += 1;
}
} }
}); });
this.$monitoredMetricsCount.text(totalMonitoredMetrics); if (totalMonitoredMetrics === 0) {
this.showMonitoringMetricsPanelState(PANEL_STATE.LIST); const emptyCommonMetricsText = sprintf(s__('PrometheusService|<p class="text-tertiary">No <a href="%{docsUrl}">common metrics</a> were found</p>'), {
docsUrl: this.helpMetricsPath,
}, false);
this.$monitoredMetricsEmpty.empty();
this.$monitoredMetricsEmpty.append(emptyCommonMetricsText);
this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
} else {
const metricsCountText = sprintf(s__('PrometheusService|%{exporters} with %{metrics} were found'), {
exporters: n__('%d exporter', '%d exporters', totalExporters),
metrics: n__('%d metric', '%d metrics', totalMonitoredMetrics),
});
this.$monitoredMetricsCount.text(metricsCountText);
this.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
if (totalMissingEnvVarMetrics > 0) { if (totalMissingEnvVarMetrics > 0) {
this.$missingEnvVarPanel.removeClass('hidden'); this.$missingEnvVarPanel.removeClass('hidden');
this.$missingEnvVarPanel.find('.flash-container').off('click'); this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics);
this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics); }
} }
} }
......
...@@ -14,6 +14,10 @@ ...@@ -14,6 +14,10 @@
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
} }
.text-tertiary {
color: $gl-text-color-tertiary;
}
.text-primary, .text-primary,
.text-primary:hover { .text-primary:hover {
color: $brand-primary; color: $brand-primary;
......
...@@ -12,6 +12,12 @@ ...@@ -12,6 +12,12 @@
margin: 0; margin: 0;
} }
.flash-warning {
@extend .alert;
@extend .alert-warning;
margin: 0;
}
.flash-alert { .flash-alert {
@extend .alert; @extend .alert;
@extend .alert-danger; @extend .alert-danger;
......
...@@ -230,7 +230,8 @@ ...@@ -230,7 +230,8 @@
} }
.badge { .badge {
font-size: inherit; font-size: 12px;
line-height: 12px;
} }
.panel-heading .badge-count { .panel-heading .badge-count {
...@@ -252,19 +253,29 @@ ...@@ -252,19 +253,29 @@
} }
} }
.loading-metrics, .custom-monitored-metrics {
.empty-metrics { .panel-title {
padding: 30px 10px; display: flex;
align-items: center;
p, > .btn-success {
.btn { margin-left: auto;
margin-top: 10px; }
margin-bottom: 0; }
.custom-metric {
display: flex;
align-items: center;
}
.custom-metric-link-bold {
font-weight: $gl-font-weight-bold;
text-decoration: none;
} }
} }
.loading-metrics .metrics-load-spinner { .loading-metrics .metrics-load-spinner {
color: $loading-color; color: $gl-text-color-secondary;
} }
.metrics-list { .metrics-list {
......
...@@ -18,8 +18,85 @@ module Projects ...@@ -18,8 +18,85 @@ module Projects
end end
end end
def validate_query
respond_to do |format|
format.json do
result = prometheus_adapter.query(:validate, params[:query])
if result.any?
render json: result
else
head :no_content
end
end
end
end
def new
@metric = project.prometheus_metrics.new
end
def index
respond_to do |format|
format.json do
metrics = project.prometheus_metrics
response = {}
if metrics.any?
response[:metrics] = PrometheusMetricSerializer.new(project: project)
.represent(metrics.order(created_at: :asc))
end
render json: response
end
end
end
def create
@metric = project.prometheus_metrics.create(metrics_params)
if @metric.persisted?
redirect_to edit_project_service_path(project, PrometheusService),
notice: 'Metric was successfully added.'
else
render 'new'
end
end
def update
@metric = project.prometheus_metrics.find(params[:id])
@metric.update(metrics_params)
if @metric.persisted?
redirect_to edit_project_service_path(project, PrometheusService),
notice: 'Metric was successfully updated.'
else
render 'edit'
end
end
def edit
@metric = project.prometheus_metrics.find(params[:id])
end
def destroy
metric = project.prometheus_metrics.find(params[:id])
metric.destroy
respond_to do |format|
format.html do
redirect_to edit_project_service_path(project, PrometheusService), status: 303
end
format.json do
head :ok
end
end
end
private private
def metrics_params
params.require(:prometheus_metric).permit(:title, :query, :y_label, :unit, :legend, :group)
end
def prometheus_adapter def prometheus_adapter
@prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter
end end
......
...@@ -26,7 +26,13 @@ module PrometheusAdapter ...@@ -26,7 +26,13 @@ module PrometheusAdapter
query_class = Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query") query_class = Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query")
args.map!(&:id) args.map! do |arg|
if arg.respond_to?(:id)
arg.id
else
arg
end
end
with_reactive_cache(query_class.name, *args, &query_class.method(:transform_reactive_result)) with_reactive_cache(query_class.name, *args, &query_class.method(:transform_reactive_result))
end end
......
...@@ -199,6 +199,8 @@ class Project < ActiveRecord::Base ...@@ -199,6 +199,8 @@ class Project < ActiveRecord::Base
has_one :cluster_project, class_name: 'Clusters::Project' has_one :cluster_project, class_name: 'Clusters::Project'
has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster'
has_many :prometheus_metrics
# Container repositories need to remove data from the container registry, # Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy # which is not managed by the DB. Hence we're still using dependent: :destroy
# here. # here.
......
...@@ -3,25 +3,41 @@ ...@@ -3,25 +3,41 @@
%h4.prepend-top-0 %h4.prepend-top-0
= s_('PrometheusService|Metrics') = s_('PrometheusService|Metrics')
%p %p
= s_('PrometheusService|Metrics are automatically configured and monitored based on a library of metrics from popular exporters.') = s_('PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters.')
= link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus') = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus_library/metrics'), target: '_blank', rel: "noopener noreferrer"
.col-lg-9 .col-lg-9
.panel.panel-default.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(@project, :json) } } .panel.panel-default.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { active_custom_metrics: project_prometheus_metrics_path(@project), environments_data: environments_list_data } }
.panel-heading .panel-heading
%h3.panel-title %h3.panel-title
= s_('PrometheusService|Monitored') = s_('PrometheusService|Custom metrics')
%span.badge.js-custom-monitored-count 0
= link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(@project), class: 'btn btn-success js-new-metric-button hidden'
.panel-body
.flash-container.hidden
.flash-warning
.flash-text
.loading-metrics.js-loading-custom-metrics
%p.prepend-top-10.prepend-left-10
= icon('spinner spin', class: 'metrics-load-spinner')
= s_('PrometheusService|Finding custom metrics...')
.empty-metrics.hidden.js-empty-custom-metrics
= link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(@project), class: 'btn btn-success prepend-top-10 prepend-left-10'
%ul.list-unstyled.metrics-list.hidden.js-custom-metrics-list
.panel.panel-default.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(@project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/metrics') } }
.panel-heading
%h3.panel-title
= s_('PrometheusService|Common metrics')
%span.badge.js-monitored-count 0 %span.badge.js-monitored-count 0
.panel-body .panel-body
.loading-metrics.text-center.js-loading-metrics .loading-metrics.js-loading-metrics
= icon('spinner spin 3x', class: 'metrics-load-spinner') %p.prepend-top-10.prepend-left-10
%p = icon('spinner spin', class: 'metrics-load-spinner')
= s_('PrometheusService|Finding and configuring metrics...') = s_('PrometheusService|Finding and configuring metrics...')
.empty-metrics.text-center.hidden.js-empty-metrics .empty-metrics.hidden.js-empty-metrics
= custom_icon('icon_empty_metrics') %p.text-tertiary.prepend-top-10.prepend-left-10
%p = s_('PrometheusService|Waiting for your first deployment to an environment to find common metrics')
= s_('PrometheusService|No metrics are being monitored. To start monitoring, deploy to an environment.')
= link_to s_('PrometheusService|View environments'), project_environments_path(@project), class: 'btn btn-success'
%ul.list-unstyled.metrics-list.hidden.js-metrics-list %ul.list-unstyled.metrics-list.hidden.js-metrics-list
.panel.panel-default.hidden.js-panel-missing-env-vars .panel.panel-default.hidden.js-panel-missing-env-vars
......
...@@ -78,7 +78,8 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -78,7 +78,8 @@ constraints(ProjectUrlConstrainer.new) do
resource :mattermost, only: [:new, :create] resource :mattermost, only: [:new, :create]
namespace :prometheus do namespace :prometheus do
resources :metrics, constraints: { id: %r{[^\/]+} }, only: [] do resources :metrics, constraints: { id: %r{[^\/]+} }, only: [:index, :new, :create, :edit, :update, :destroy] do
post :validate_query, on: :collection
get :active_common, on: :collection get :active_common, on: :collection
end end
end end
......
...@@ -2003,6 +2003,21 @@ ActiveRecord::Schema.define(version: 20180307164427) do ...@@ -2003,6 +2003,21 @@ ActiveRecord::Schema.define(version: 20180307164427) do
add_index "projects", ["star_count"], name: "index_projects_on_star_count", using: :btree add_index "projects", ["star_count"], name: "index_projects_on_star_count", using: :btree
add_index "projects", ["visibility_level"], name: "index_projects_on_visibility_level", using: :btree add_index "projects", ["visibility_level"], name: "index_projects_on_visibility_level", using: :btree
create_table "prometheus_metrics", force: :cascade do |t|
t.integer "project_id"
t.string "title", null: false
t.string "query", null: false
t.string "y_label"
t.string "unit"
t.string "legend"
t.integer "group", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "prometheus_metrics", ["group"], name: "index_prometheus_metrics_on_group", using: :btree
add_index "prometheus_metrics", ["project_id"], name: "index_prometheus_metrics_on_project_id", using: :btree
create_table "protected_branch_merge_access_levels", force: :cascade do |t| create_table "protected_branch_merge_access_levels", force: :cascade do |t|
t.integer "protected_branch_id", null: false t.integer "protected_branch_id", null: false
t.integer "access_level", default: 40 t.integer "access_level", default: 40
...@@ -2703,6 +2718,7 @@ ActiveRecord::Schema.define(version: 20180307164427) do ...@@ -2703,6 +2718,7 @@ ActiveRecord::Schema.define(version: 20180307164427) do
add_foreign_key "project_mirror_data", "projects", name: "fk_d1aad367d7", on_delete: :cascade add_foreign_key "project_mirror_data", "projects", name: "fk_d1aad367d7", on_delete: :cascade
add_foreign_key "project_repository_states", "projects", on_delete: :cascade add_foreign_key "project_repository_states", "projects", on_delete: :cascade
add_foreign_key "project_statistics", "projects", on_delete: :cascade add_foreign_key "project_statistics", "projects", on_delete: :cascade
add_foreign_key "prometheus_metrics", "projects", on_delete: :cascade
add_foreign_key "protected_branch_merge_access_levels", "namespaces", column: "group_id", name: "fk_98f3d044fe", on_delete: :cascade add_foreign_key "protected_branch_merge_access_levels", "namespaces", column: "group_id", name: "fk_98f3d044fe", on_delete: :cascade
add_foreign_key "protected_branch_merge_access_levels", "protected_branches", name: "fk_8a3072ccb3", on_delete: :cascade add_foreign_key "protected_branch_merge_access_levels", "protected_branches", name: "fk_8a3072ccb3", on_delete: :cascade
add_foreign_key "protected_branch_merge_access_levels", "users" add_foreign_key "protected_branch_merge_access_levels", "users"
......
import IntegrationSettingsForm from '~/integrations/integration_settings_form';
import PrometheusMetrics from 'ee/prometheus_metrics/prometheus_metrics';
document.addEventListener('DOMContentLoaded', () => {
const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
integrationSettingsForm.init();
const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
if (prometheusSettingsWrapper) {
const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
prometheusMetrics.loadActiveCustomMetrics();
}
});
import _ from 'underscore';
import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
import PANEL_STATE from '~/prometheus_metrics/constants';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
export default class EEPrometheusMetrics extends PrometheusMetrics {
constructor(wrapperSelector) {
super(wrapperSelector);
this.$wrapperCustomMetrics = $(wrapperSelector);
this.$monitoredCustomMetricsPanel = this.$wrapperCustomMetrics.find('.js-panel-custom-monitored-metrics');
this.$monitoredCustomMetricsCount = this.$monitoredCustomMetricsPanel.find('.js-custom-monitored-count');
this.$monitoredCustomMetricsLoading = this.$monitoredCustomMetricsPanel.find('.js-loading-custom-metrics');
this.$monitoredCustomMetricsEmpty = this.$monitoredCustomMetricsPanel.find('.js-empty-custom-metrics');
this.$monitoredCustomMetricsList = this.$monitoredCustomMetricsPanel.find('.js-custom-metrics-list');
this.$newCustomMetricButton = this.$monitoredCustomMetricsPanel.find('.js-new-metric-button');
this.$flashCustomMetricsContainer = this.$wrapperCustomMetrics.find('.flash-container');
this.customMetrics = [];
this.environmentsData = [];
this.activeCustomMetricsEndpoint = this.$monitoredCustomMetricsPanel.data('active-custom-metrics');
this.environmentsDataEndpoint = this.$monitoredCustomMetricsPanel.data('environments-data-endpoint');
}
showMonitoringCustomMetricsPanelState(stateName) {
switch (stateName) {
case PANEL_STATE.LOADING:
this.$monitoredCustomMetricsLoading.removeClass('hidden');
this.$monitoredCustomMetricsEmpty.addClass('hidden');
this.$monitoredCustomMetricsList.addClass('hidden');
this.$newCustomMetricButton.addClass('hidden');
break;
case PANEL_STATE.LIST:
this.$monitoredCustomMetricsLoading.addClass('hidden');
this.$monitoredCustomMetricsEmpty.addClass('hidden');
this.$newCustomMetricButton.removeClass('hidden');
this.$monitoredCustomMetricsList.removeClass('hidden');
break;
default:
this.$monitoredCustomMetricsLoading.addClass('hidden');
this.$monitoredCustomMetricsEmpty.removeClass('hidden');
this.$monitoredCustomMetricsList.addClass('hidden');
this.$newCustomMetricButton.addClass('hidden');
break;
}
}
populateCustomMetrics() {
const sortedMetrics = _(this.customMetrics).chain()
.map(metric => ({ ...metric, group: capitalizeFirstCharacter(metric.group) }))
.sortBy('title')
.sortBy('group')
.value();
sortedMetrics.forEach((metric) => {
this.$monitoredCustomMetricsList.append(EEPrometheusMetrics.customMetricTemplate(metric));
});
this.$monitoredCustomMetricsCount.text(this.customMetrics.length);
this.showMonitoringCustomMetricsPanelState(PANEL_STATE.LIST);
if (!this.environmentsData) {
this.showFlashMessage(s__('PrometheusService|These metrics will only be monitored after your first deployment to an environment'));
}
}
showFlashMessage(message) {
this.$flashCustomMetricsContainer.removeClass('hidden');
this.$flashCustomMetricsContainer.find('.flash-text').text(message);
}
loadActiveCustomMetrics() {
super.loadActiveMetrics();
Promise.all([
axios.get(this.activeCustomMetricsEndpoint),
axios.get(this.environmentsDataEndpoint),
])
.then(([customMetrics, environmentsData]) => {
this.environmentsData = environmentsData.data.environments;
if (!customMetrics.data || !customMetrics.data.metrics) {
this.showMonitoringCustomMetricsPanelState(PANEL_STATE.EMPTY);
} else {
this.customMetrics = customMetrics.data.metrics;
this.populateCustomMetrics(customMetrics.data.metrics);
}
})
.catch((customMetricError) => {
this.showFlashMessage(customMetricError);
this.showMonitoringCustomMetricsPanelState(PANEL_STATE.EMPTY);
});
}
static customMetricTemplate(metric) {
return `
<li class="custom-metric">
<a href="${_.escape(metric.edit_path)}" class="custom-metric-link-bold">
${_.escape(metric.group)} / ${_.escape(metric.title)} (${_.escape(metric.unit)})
</a>
</li>
`;
}
}
class PrometheusMetric < ActiveRecord::Base
belongs_to :project, required: true, validate: true, inverse_of: :prometheus_metrics
enum group: [:business, :response, :system]
validates :title, presence: true
validates :query, presence: true
validates :group, presence: true
validates :y_label, presence: true
validates :unit, presence: true
GROUP_TITLES = {
business: _('Business'),
response: _('Response'),
system: _('System')
}.freeze
def group_title
GROUP_TITLES[group.to_sym]
end
def to_query_metric
Gitlab::Prometheus::Metric.new(title: title, required_metrics: [], weight: 0, y_label: y_label, queries: build_queries)
end
private
def build_queries
[
{
query_range: query,
unit: unit,
label: legend
}
]
end
end
class PrometheusMetricEntity < Grape::Entity
include RequestAwareEntity
expose :id
expose :title
expose :group
expose :group_title
expose :unit
expose :edit_path do |prometheus_metric|
edit_project_prometheus_metric_path(prometheus_metric.project, prometheus_metric)
end
end
class PrometheusMetricSerializer < BaseSerializer
entity PrometheusMetricEntity
end
- project = local_assigns.fetch(:project)
- metric = local_assigns.fetch(:metric)
- save_button_text = metric.persisted? ? _('Save changes') : s_('Metrics|Create metric')
.row.prepend-top-default.append-bottom-default
%h3.page-title.text-center
- if metric.persisted?
= s_('Metrics|Edit metric')
- else
= s_('Metrics|New metric')
= form_for [project.namespace.becomes(Namespace), project, metric], html: { class: 'col-lg-8 col-lg-offset-2' } do |f|
= form_errors(metric)
.form-group
= f.label :title, s_('Metrics|Name'), class: 'label-light'
= f.text_field :title, required: true, class: 'form-control', placeholder: s_('Metrics|e.g. Throughput'), autofocus: true
%span.help-block
= s_('Metrics|Used as a title for the chart')
.form-group
= label_tag :group, s_("Metrics|Type"), class: 'append-bottom-10'
.form-group.append-bottom-0
= f.radio_button :group, :business, checked: true
= f.label :group_business, s_("Metrics|Business"), class: 'label-light append-right-10'
= f.radio_button :group, :response
= f.label :group_response, s_("Metrics|Response"), class: 'label-light append-right-10'
= f.radio_button :group, :system
= f.label :group_system, s_("Metrics|System"), class: 'label-light'
%p.text-tertiary
= s_('Metrics|For grouping similar metrics')
.form-group
= f.label :query, s_('Metrics|Query'), class: 'label-light'
= f.text_field :query, required: true, class: 'form-control', placeholder: s_('Metrics|e.g. rate(http_requests_total[5m])')
%span.help-block
= s_('Metrics|Must be a valid PromQL query.')
= link_to "https://prometheus.io/docs/prometheus/latest/querying/basics/", target: "_blank", rel: "noopener noreferrer" do
= sprite_icon("external-link", size: 12)
= s_('Metrics|Prometheus Query Documentation')
.form-group
= f.label :y_label, s_('Metrics|Y-axis label'), class: 'label-light'
= f.text_field :y_label, class: 'form-control', placeholder: s_('Metrics|e.g. Requests/second')
%span.help-block
= s_("Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time.")
.form-group
= f.label :unit, s_('Metrics|Unit label'), class: 'label-light'
= f.text_field :unit, class: 'form-control', placeholder: s_('Metrics|e.g. req/sec')
.form-group
= f.label :legend, s_('Metrics|Legend label (optional)'), class: 'label-light'
= f.text_field :legend, class: 'form-control', placeholder: s_('Metrics|e.g. HTTP requests')
%span.help-block
= s_('Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response.')
.form-actions
= f.submit save_button_text, class: 'btn btn-success'
= link_to _('Cancel'), edit_project_service_path(project, PrometheusService), class: 'btn btn-default pull-right'
- if metric.persisted?
= link_to _('Delete'), project_prometheus_metric_path(project, metric), data: { confirm: s_("This will delete the custom metric, Are you sure?") }, method: :delete, class: "btn btn-danger pull-right append-right-default"
- add_to_breadcrumbs _("Settings"), edit_project_path(@project)
- add_to_breadcrumbs _("Integrations"), project_settings_integrations_path(@project)
- add_to_breadcrumbs "Prometheus", edit_project_service_path(@project, PrometheusService)
- breadcrumb_title = s_('Metrics|Edit metric')
- page_title @metric.title, s_('Metrics|Edit metric')
= render 'form', project: @project, metric: @metric
- add_to_breadcrumbs _("Settings"), edit_project_path(@project)
- add_to_breadcrumbs _("Integrations"), project_settings_integrations_path(@project)
- add_to_breadcrumbs "Prometheus", edit_project_service_path(@project, PrometheusService)
- breadcrumb_title = s_('Metrics|New metric')
- page_title s_('Metrics|New metric')
= render 'form', project: @project, metric: @metric
---
title: Add ability to add Custom Metrics to environment and deployment metrics dashboards
merge_request: 3799
author:
type: added
class CreatePrometheusMetrics < ActiveRecord::Migration
DOWNTIME = false
def change
create_table :prometheus_metrics do |t|
t.references :project, index: true, foreign_key: { on_delete: :cascade }, null: false
t.string :title, null: false
t.string :query, null: false
t.string :y_label
t.string :unit
t.string :legend
t.integer :group, null: false, index: true
t.timestamps_with_timezone null: false
end
end
end
module Gitlab
module Prometheus
module Queries
class ValidateQuery < BaseQuery
def query(query)
client_query(query)
{ valid: true }
rescue Gitlab::PrometheusClient::QueryError => ex
{ valid: false, error: ex.message }
end
def self.transform_reactive_result(result)
result[:query] = result.delete :data
result
end
end
end
end
end
require 'spec_helper'
describe Projects::Prometheus::MetricsController do
let(:user) { create(:user) }
let(:project) { create(:prometheus_project) }
let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) }
before do
allow(controller).to receive(:project).and_return(project)
allow(controller).to receive(:prometheus_adapter).and_return(prometheus_adapter)
project.add_master(user)
sign_in(user)
end
describe 'POST #validate_query' do
before do
allow(prometheus_adapter).to receive(:query).with(:validate, query) { validation_result }
end
let(:query) { 'avg(metric)' }
context 'validation information is ready' do
let(:validation_result) { { valid: true } }
it 'validation data is returned' do
post :validate_query, project_params(format: :json, query: query)
expect(json_response).to eq('valid' => true)
end
end
context 'validation information is not ready' do
let(:validation_result) { {} }
it 'validation data is returned' do
post :validate_query, project_params(format: :json, query: query)
expect(response).to have_gitlab_http_status(204)
end
end
end
describe 'GET #index' do
context 'with custom metric present' do
let!(:prometheus_metric) { create(:prometheus_metric, project: project) }
it 'returns a list of metrics' do
get :index, project_params(format: :json)
expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('prometheus/metrics', dir: 'ee')
end
end
context 'without custom metrics ' do
it 'returns an empty json' do
get :index, project_params(format: :json)
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq({})
end
end
end
describe 'POST #create' do
context 'metric is valid' do
let(:valid_metric) { { prometheus_metric: { title: 'title', query: 'query', group: 'business', y_label: 'label', unit: 'u', legend: 'legend' } } }
it 'shows a success flash message' do
post :create, project_params(valid_metric)
expect(flash[:notice]).to include('Metric was successfully added.')
expect(response).to redirect_to(edit_project_service_path(project, PrometheusService))
end
end
context 'metric is invalid' do
let(:invalid_metric) { { prometheus_metric: { title: 'title' } } }
it 'renders new metric page' do
post :create, project_params(invalid_metric)
expect(response).to have_gitlab_http_status(200)
expect(response).to render_template('new')
end
end
end
describe 'DELETE #destroy' do
context 'format html' do
let!(:metric) { create(:prometheus_metric, project: project) }
it 'destroys the metric' do
delete :destroy, project_params(id: metric.id)
expect(response).to redirect_to(edit_project_service_path(project, PrometheusService))
expect(PrometheusMetric.find_by(id: metric.id)).to be_nil
end
end
context 'format json' do
let!(:metric) { create(:prometheus_metric, project: project) }
it 'destroys the metric' do
delete :destroy, project_params(id: metric.id, format: :json)
expect(response).to have_gitlab_http_status(200)
expect(PrometheusMetric.find_by(id: metric.id)).to be_nil
end
end
end
def project_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace, project_id: project)
end
end
FactoryBot.define do
factory :prometheus_metric, class: PrometheusMetric do
title 'title'
query 'avg(metric)'
y_label 'y_label'
unit 'm/s'
group :business
project
legend 'legend'
end
end
{
"type": "object",
"properties": {
"metrics": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"title": {
"type": "string"
},
"group": {
"type": "string"
},
"group_title": {
"type": "string"
},
"edit_path": {
"type": "string"
}
}
}
}
}
}
require 'spec_helper'
describe Gitlab::Prometheus::Queries::ValidateQuery do
let(:client) { double('prometheus_client') }
let(:query) { 'avg(metric)' }
subject { described_class.new(client) }
context 'valid query' do
before do
allow(client).to receive(:query).with(query)
end
it 'passess query to prometheus' do
expect(subject.query(query)).to eq(valid: true)
expect(client).to have_received(:query).with(query)
end
end
context 'invalid query' do
let(:message) { 'message' }
before do
allow(client).to receive(:query).with(query).and_raise(Gitlab::PrometheusClient::QueryError.new(message))
end
it 'passes query to prometheus' do
expect(subject.query(query)).to eq(valid: false, error: message)
expect(client).to have_received(:query).with(query)
end
end
end
require 'spec_helper'
describe PrometheusMetric do
subject { build(:prometheus_metric) }
it { is_expected.to belong_to(:project) }
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:query) }
it { is_expected.to validate_presence_of(:group) }
describe '#group_title' do
shared_examples 'group_title' do |group, title|
subject { build(:prometheus_metric, group: group).group_title }
it "returns text #{title} for group #{group}" do
expect(subject).to eq(title)
end
end
it_behaves_like 'group_title', :business, 'Business'
it_behaves_like 'group_title', :response, 'Response'
it_behaves_like 'group_title', :system, 'System'
end
describe '#to_query_metric' do
it 'converts to queryable metric object' do
expect(subject.to_query_metric).to be_instance_of(Gitlab::Prometheus::Metric)
end
it 'queryable metric object has title' do
expect(subject.to_query_metric.title).to eq(subject.title)
end
it 'queryable metric object has y_label' do
expect(subject.to_query_metric.y_label).to eq(subject.y_label)
end
it 'queryable metric has no required_metric' do
expect(subject.to_query_metric.required_metrics).to eq([])
end
it 'queryable metric has weight 0' do
expect(subject.to_query_metric.weight).to eq(0)
end
it 'queryable metrics has query description' do
queries = [
{
query_range: subject.query,
unit: subject.unit,
label: subject.legend
}
]
expect(subject.to_query_metric.queries).to eq(queries)
end
end
end
...@@ -65,6 +65,7 @@ project_tree: ...@@ -65,6 +65,7 @@ project_tree:
- :create_access_levels - :create_access_levels
- :project_feature - :project_feature
- :custom_attributes - :custom_attributes
- :prometheus_metrics
- :project_badges - :project_badges
# Only include the following attributes for the models specified. # Only include the following attributes for the models specified.
......
...@@ -10,9 +10,14 @@ module Gitlab ...@@ -10,9 +10,14 @@ module Gitlab
AdditionalMetricsParser.load_groups_from_yaml AdditionalMetricsParser.load_groups_from_yaml
end end
# EE only def self.for_project(project)
def self.for_project(_) common_metrics + custom_metrics(project)
common_metrics end
def self.custom_metrics(project)
project.prometheus_metrics.all.group_by(&:group_title).map do |name, metrics|
MetricGroup.new(name: name, priority: 0, metrics: metrics.map(&:to_query_metric))
end
end end
end end
end end
......
const metrics = [
{
edit_path: '/root/prometheus-test/prometheus/metrics/3/edit',
id: 3,
title: 'Requests',
group: 'Business',
},
{
edit_path: '/root/prometheus-test/prometheus/metrics/2/edit',
id: 2,
title: 'Sales by the hour',
group: 'Business',
},
{
edit_path: '/root/prometheus-test/prometheus/metrics/1/edit',
id: 1,
title: 'Requests',
group: 'Business',
},
];
export default metrics;
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import PrometheusMetrics from 'ee/prometheus_metrics/prometheus_metrics';
import PANEL_STATE from '~/prometheus_metrics/constants';
import metrics from './mock_data';
describe('PrometheusMetrics EE', () => {
const FIXTURE = 'services/prometheus/prometheus_service.html.raw';
const customMetricsEndpoint = 'http://test.host/frontend-fixtures/services-project/prometheus/metrics';
let mock;
preloadFixtures(FIXTURE);
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(customMetricsEndpoint).reply(200, {
metrics,
});
loadFixtures(FIXTURE);
});
afterEach(() => {
mock.restore();
});
describe('Custom Metrics EE', () => {
let prometheusMetrics;
beforeEach(() => {
prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
});
it('should initialize wrapper element refs on the class object', () => {
expect(prometheusMetrics.$wrapperCustomMetrics).not.toBeNull();
expect(prometheusMetrics.$monitoredCustomMetricsPanel).not.toBeNull();
expect(prometheusMetrics.$monitoredCustomMetricsCount).not.toBeNull();
expect(prometheusMetrics.$monitoredCustomMetricsLoading).not.toBeNull();
expect(prometheusMetrics.$monitoredCustomMetricsEmpty).not.toBeNull();
expect(prometheusMetrics.$monitoredCustomMetricsList).not.toBeNull();
expect(prometheusMetrics.$newCustomMetricButton).not.toBeNull();
expect(prometheusMetrics.$flashCustomMetricsContainer).not.toBeNull();
});
it('should contain api endpoints', () => {
expect(prometheusMetrics.activeCustomMetricsEndpoint).toEqual(customMetricsEndpoint);
});
it('should show loading state when called with `loading`', () => {
prometheusMetrics.showMonitoringCustomMetricsPanelState(PANEL_STATE.LOADING);
expect(prometheusMetrics.$monitoredCustomMetricsLoading.hasClass('hidden')).toEqual(false);
expect(prometheusMetrics.$monitoredCustomMetricsEmpty.hasClass('hidden')).toBeTruthy();
expect(prometheusMetrics.$monitoredCustomMetricsList.hasClass('hidden')).toBeTruthy();
});
it('should show metrics list when called with `list`', () => {
prometheusMetrics.showMonitoringCustomMetricsPanelState(PANEL_STATE.LIST);
expect(prometheusMetrics.$monitoredCustomMetricsLoading.hasClass('hidden')).toBeTruthy();
expect(prometheusMetrics.$monitoredCustomMetricsEmpty.hasClass('hidden')).toBeTruthy();
expect(prometheusMetrics.$monitoredCustomMetricsList.hasClass('hidden')).toEqual(false);
});
it('should show empty state when called with `empty`', () => {
prometheusMetrics.showMonitoringCustomMetricsPanelState(PANEL_STATE.EMPTY);
expect(prometheusMetrics.$monitoredCustomMetricsLoading.hasClass('hidden')).toBeTruthy();
expect(prometheusMetrics.$monitoredCustomMetricsEmpty.hasClass('hidden')).toEqual(false);
expect(prometheusMetrics.$monitoredCustomMetricsList.hasClass('hidden')).toBeTruthy();
});
it('should show monitored metrics list', () => {
prometheusMetrics.customMetrics = metrics;
prometheusMetrics.populateCustomMetrics();
const $metricsListLi = prometheusMetrics.$monitoredCustomMetricsList.find('li');
expect(prometheusMetrics.$monitoredCustomMetricsLoading.hasClass('hidden')).toBeTruthy();
expect(prometheusMetrics.$monitoredCustomMetricsList.hasClass('hidden')).toEqual(false);
expect($metricsListLi.length).toEqual(metrics.length);
});
});
});
...@@ -85,7 +85,7 @@ describe('PrometheusMetrics', () => { ...@@ -85,7 +85,7 @@ describe('PrometheusMetrics', () => {
expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy(); expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy(); expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual('12'); expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual('3 exporters with 12 metrics were found');
expect($metricsListLi.length).toEqual(metrics.length); expect($metricsListLi.length).toEqual(metrics.length);
expect($metricsListLi.first().find('.badge').text()).toEqual(`${metrics[0].active_metrics}`); expect($metricsListLi.first().find('.badge').text()).toEqual(`${metrics[0].active_metrics}`);
}); });
......
...@@ -309,6 +309,7 @@ project: ...@@ -309,6 +309,7 @@ project:
- fork_network_member - fork_network_member
- fork_network - fork_network
- custom_attributes - custom_attributes
- prometheus_metrics
- lfs_file_locks - lfs_file_locks
- project_badges - project_badges
award_emoji: award_emoji:
...@@ -316,6 +317,8 @@ award_emoji: ...@@ -316,6 +317,8 @@ award_emoji:
- user - user
priorities: priorities:
- label - label
prometheus_metrics:
- project
timelogs: timelogs:
- issue - issue
- merge_request - merge_request
......
...@@ -552,6 +552,17 @@ ProjectCustomAttribute: ...@@ -552,6 +552,17 @@ ProjectCustomAttribute:
- project_id - project_id
- key - key
- value - value
PrometheusMetric:
- id
- created_at
- updated_at
- project_id
- y_label
- unit
- legend
- title
- query
- group
LfsFileLock: LfsFileLock:
- id - id
- path - path
......
...@@ -75,6 +75,29 @@ describe PrometheusAdapter, :use_clean_rails_memory_store_caching do ...@@ -75,6 +75,29 @@ describe PrometheusAdapter, :use_clean_rails_memory_store_caching do
end end
end end
end end
describe 'validate_query' do
let(:environment) { build_stubbed(:environment, slug: 'env-slug') }
let(:validation_query) { Gitlab::Prometheus::Queries::ValidateQuery.name }
let(:query) { 'avg(response)' }
let(:validation_respone) { { data: { valid: true } } }
around do |example|
Timecop.freeze { example.run }
end
context 'with valid data' do
subject { service.query(:validate, query) }
before do
stub_reactive_cache(service, validation_respone, validation_query, query)
end
it 'returns query data' do
is_expected.to eq(query: { valid: true })
end
end
end
end end
describe '#calculate_reactive_cache' do describe '#calculate_reactive_cache' do
......
...@@ -115,6 +115,50 @@ RSpec.shared_examples 'additional metrics query' do ...@@ -115,6 +115,50 @@ RSpec.shared_examples 'additional metrics query' do
end end
end end
context 'with custom metrics' do
let!(:metric) { create(:prometheus_metric, project: project) }
before do
allow(client).to receive(:query_range).with('avg(metric)', any_args).and_return(query_range_result)
end
context 'without common metrics' do
before do
allow(metric_group_class).to receive(:common_metrics).and_return([])
end
it 'return group data for custom metric' do
queries_with_result = { queries: [{ query_range: 'avg(metric)', unit: 'm/s', label: 'legend', result: query_range_result }] }
expect(query_result).to match_schema('prometheus/additional_metrics_query_result')
expect(query_result.count).to eq(1)
expect(query_result.first[:metrics].count).to eq(1)
expect(query_result.first[:metrics].first).to include(queries_with_result)
end
end
context 'with common metrics' do
before do
allow(client).to receive(:query_range).with('query_range_a', any_args).and_return(query_range_result)
allow(metric_group_class).to receive(:common_metrics).and_return([simple_metric_group(metrics: [simple_metric])])
end
it 'return group data for custom metric' do
custom_queries_with_result = { queries: [{ query_range: 'avg(metric)', unit: 'm/s', label: 'legend', result: query_range_result }] }
common_queries_with_result = { queries: [{ query_range: 'query_range_a', result: query_range_result }] }
expect(query_result).to match_schema('prometheus/additional_metrics_query_result')
expect(query_result.count).to eq(2)
expect(query_result).to all(satisfy { |r| r[:metrics].count == 1 })
expect(query_result[0][:metrics].first).to include(common_queries_with_result)
expect(query_result[1][:metrics].first).to include(custom_queries_with_result)
end
end
end
context 'with two groups with one metric each' do context 'with two groups with one metric each' do
let(:metrics) { [simple_metric(queries: [simple_query])] } let(:metrics) { [simple_metric(queries: [simple_query])] }
......
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