Commit 55f2a5de authored by Kamil Trzciński's avatar Kamil Trzciński Committed by Rémy Coutable

Added Prometheus Service and Prometheus graphs

parent a5db7f54
import PrometheusGraph from './monitoring/prometheus_graph'; // TODO: Maybe Make this a bundle
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */
/* global UsernameValidator */
/* global ActiveTabMemoizer */
......@@ -297,6 +298,8 @@ const UserCallout = require('./user_callout');
case 'ci:lints:show':
new gl.CILintEditor();
break;
case 'projects:environments:metrics':
new PrometheusGraph();
case 'users:show':
new UserCallout();
break;
......
This diff is collapsed.
......@@ -143,3 +143,71 @@
}
}
}
.prometheus-graph {
text {
fill: $stat-graph-axis-fill;
}
}
.x-axis path,
.y-axis path,
.label-x-axis-line,
.label-y-axis-line {
fill: none;
stroke-width: 1;
shape-rendering: crispEdges;
}
.x-axis path,
.y-axis path {
stroke: $stat-graph-axis-fill;
}
.label-x-axis-line,
.label-y-axis-line {
stroke: $border-color;
}
.y-axis {
line {
stroke: $stat-graph-axis-fill;
stroke-width: 1;
}
}
.metric-area {
opacity: 0.8;
}
.prometheus-graph-overlay {
fill: none;
opacity: 0.0;
pointer-events: all;
}
.rect-text-metric {
fill: $white-light;
stroke-width: 1;
stroke: $black;
}
.rect-axis-text {
fill: $white-light;
}
.text-metric,
.text-median-metric,
.text-metric-usage,
.text-metric-date {
fill: $black;
}
.text-metric-date {
font-weight: 200;
}
.selected-metric-line {
stroke: $black;
stroke-width: 1;
}
......@@ -5,7 +5,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :authorize_create_deployment!, only: [:stop]
before_action :authorize_update_environment!, only: [:edit, :update]
before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize]
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize]
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics]
before_action :verify_api_request!, only: :terminal_websocket_authorize
def index
......@@ -109,6 +109,19 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
def metrics
# Currently, this acts as a hint to load the metrics details into the cache
# if they aren't there already
@metrics = environment.metrics || {}
respond_to do |format|
format.html
format.json do
render json: @metrics, status: @metrics.any? ? :ok : :no_content
end
end
end
private
def verify_api_request!
......
......@@ -74,6 +74,10 @@ module GitlabRoutingHelper
namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args)
end
def environment_metrics_path(environment, *args)
metrics_namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args)
end
def issue_path(entity, *args)
namespace_project_issue_path(entity.project.namespace, entity.project, entity, *args)
end
......
......@@ -145,6 +145,14 @@ class Environment < ActiveRecord::Base
project.deployment_service.terminals(self) if has_terminals?
end
def has_metrics?
project.monitoring_service.present? && available? && last_deployment.present?
end
def metrics
project.monitoring_service.metrics(self) if has_metrics?
end
# An environment name is not necessarily suitable for use in URLs, DNS
# or other third-party contexts, so provide a slugified version. A slug has
# the following properties:
......
......@@ -113,6 +113,7 @@ class Project < ActiveRecord::Base
has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project
has_one :external_wiki_service, dependent: :destroy
has_one :kubernetes_service, dependent: :destroy, inverse_of: :project
has_one :prometheus_service, dependent: :destroy, inverse_of: :project
has_one :mock_ci_service, dependent: :destroy
has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id"
......@@ -771,6 +772,14 @@ class Project < ActiveRecord::Base
@deployment_service ||= deployment_services.reorder(nil).find_by(active: true)
end
def monitoring_services
services.where(category: :monitoring)
end
def monitoring_service
@monitoring_service ||= monitoring_services.reorder(nil).find_by(active: true)
end
def jira_tracker?
issues_tracker.to_param == 'jira'
end
......
# Base class for monitoring services
#
# These services integrate with a deployment solution like Prometheus
# to provide additional features for environments.
class MonitoringService < Service
default_value_for :category, 'monitoring'
def self.supported_events
%w()
end
# Environments have a number of metrics
def metrics(environment)
raise NotImplementedError
end
end
class PrometheusService < MonitoringService
include ReactiveCaching
self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] }
self.reactive_cache_lease_timeout = 30.seconds
self.reactive_cache_refresh_interval = 30.seconds
self.reactive_cache_lifetime = 1.minute
# Access to prometheus is directly through the API
prop_accessor :api_url
with_options presence: true, if: :activated? do
validates :api_url, url: true
end
after_save :clear_reactive_cache!
def initialize_properties
if properties.nil?
self.properties = {}
end
end
def title
'Prometheus'
end
def description
'Prometheus monitoring'
end
def help
'Retrieves `container_cpu_usage_seconds_total` and `container_memory_usage_bytes` from the configured Prometheus server. An `environment` label is required on each metric to identify the Environment.'
end
def self.to_param
'prometheus'
end
def fields
[
{
type: 'text',
name: 'api_url',
title: 'API URL',
placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/'
}
]
end
# Check we can connect to the Prometheus API
def test(*args)
client.ping
{ success: true, result: 'Checked API endpoint' }
rescue Gitlab::PrometheusError => err
{ success: false, result: err }
end
def metrics(environment)
with_reactive_cache(environment.slug) do |data|
data
end
end
# Cache metrics for specific environment
def calculate_reactive_cache(environment_slug)
return unless active? && project && !project.pending_delete?
memory_query = %{sum(container_memory_usage_bytes{container_name="app",environment="#{environment_slug}"})/1024/1024}
cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name="app",environment="#{environment_slug}"}[2m]))}
{
success: true,
metrics: {
# Memory used in MB
memory_values: client.query_range(memory_query, start: 8.hours.ago),
memory_current: client.query(memory_query),
# CPU Usage rate in cores.
cpu_values: client.query_range(cpu_query, start: 8.hours.ago),
cpu_current: client.query(cpu_query)
},
last_update: Time.now.utc
}
rescue Gitlab::PrometheusError => err
{ success: false, result: err.message }
end
def client
@prometheus ||= Gitlab::Prometheus.new(api_url: api_url)
end
end
......@@ -232,6 +232,7 @@ class Service < ActiveRecord::Base
mattermost
pipelines_email
pivotaltracker
prometheus
pushover
redmine
slack_slash_commands
......
- environment = local_assigns.fetch(:environment)
- return unless environment.has_metrics? && can?(current_user, :read_environment, environment)
= link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do
= icon('area-chart')
- @no_container = true
- page_title "Metrics for environment", @environment.name
= render "projects/pipelines/head"
%div{ class: container_class }
.top-area
.row
.col-sm-6
%h3.page-title
Environment:
= @environment.name
.col-sm-6
.nav-controls
= render 'projects/deployments/actions', deployment: @environment.last_deployment
.row
.col-sm-12
%svg.prometheus-graph{ 'graph-type' => 'cpu_values' }
.row
.col-sm-12
%svg.prometheus-graph{ 'graph-type' => 'memory_values' }
......@@ -8,6 +8,7 @@
%h3.page-title= @environment.name
.col-md-3
.nav-controls
= render 'projects/environments/metrics_button', environment: @environment
= render 'projects/environments/terminal_button', environment: @environment
= render 'projects/environments/external_url', environment: @environment
- if can?(current_user, :update_environment, @environment)
......
......@@ -159,6 +159,7 @@ constraints(ProjectUrlConstrainer.new) do
member do
post :stop
get :terminal
get :metrics
get '/terminal.ws/authorize', to: 'environments#terminal_websocket_authorize', constraints: { format: nil }
end
......
......@@ -422,6 +422,14 @@ module API
desc: 'Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.'
}
],
'prometheus' => [
{
required: true,
name: :api_url,
type: String,
desc: 'Prometheus API Base URL, like http://prometheus.example.com/'
}
],
'pushover' => [
{
required: true,
......@@ -558,6 +566,7 @@ module API
SlackSlashCommandsService,
PipelinesEmailService,
PivotaltrackerService,
PrometheusService,
PushoverService,
RedmineService,
SlackService,
......
module Gitlab
PrometheusError = Class.new(StandardError)
# Helper methods to interact with Prometheus network services & resources
class Prometheus
attr_reader :api_url
def initialize(api_url:)
@api_url = api_url
end
def ping
json_api_get('query', query: '1')
end
def query(query)
get_result('vector') do
json_api_get('query', query: query)
end
end
def query_range(query, start: 8.hours.ago)
get_result('matrix') do
json_api_get('query_range',
query: query,
start: start.to_f,
end: Time.now.utc.to_f,
step: 1.minute.to_i)
end
end
private
def json_api_get(type, args = {})
get(join_api_url(type, args))
rescue Errno::ECONNREFUSED
raise PrometheusError, 'Connection refused'
end
def join_api_url(type, args = {})
url = URI.parse(api_url)
rescue URI::Error
raise PrometheusError, "Invalid API URL: #{api_url}"
else
url.path = [url.path.sub(%r{/+\z}, ''), 'api', 'v1', type].join('/')
url.query = args.to_query
url.to_s
end
def get(url)
handle_response(HTTParty.get(url))
end
def handle_response(response)
if response.code == 200 && response['status'] == 'success'
response['data'] || {}
elsif response.code == 400
raise PrometheusError, response['error'] || 'Bad data received'
else
raise PrometheusError, "#{response.code} - #{response.body}"
end
end
def get_result(expected_type)
data = yield
data['result'] if data['resultType'] == expected_type
end
end
end
......@@ -187,6 +187,52 @@ describe Projects::EnvironmentsController do
end
end
describe 'GET #metrics' do
before do
allow(controller).to receive(:environment).and_return(environment)
end
context 'when environment has no metrics' do
before do
expect(environment).to receive(:metrics).and_return(nil)
end
it 'returns a metrics page' do
get :metrics, environment_params
expect(response).to be_ok
end
context 'when requesting metrics as JSON' do
it 'returns a metrics JSON document' do
get :metrics, environment_params(format: :json)
expect(response).to have_http_status(204)
expect(json_response).to eq({})
end
end
end
context 'when environment has some metrics' do
before do
expect(environment).to receive(:metrics).and_return({
success: true,
metrics: {},
last_update: 42
})
end
it 'returns a metrics JSON document' do
get :metrics, environment_params(format: :json)
expect(response).to be_ok
expect(json_response['success']).to be(true)
expect(json_response['metrics']).to eq({})
expect(json_response['last_update']).to eq(42)
end
end
end
def environment_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace,
project_id: project,
......
......@@ -195,4 +195,15 @@ FactoryGirl.define do
factory :kubernetes_project, parent: :empty_project do
kubernetes_service
end
factory :prometheus_project, parent: :empty_project do
after :create do |project|
project.create_prometheus_service(
active: true,
properties: {
api_url: 'https://prometheus.example.com'
}
)
end
end
end
require 'spec_helper'
feature 'Environment > Metrics', :feature do
include PrometheusHelpers
given(:user) { create(:user) }
given(:project) { create(:prometheus_project) }
given(:pipeline) { create(:ci_pipeline, project: project) }
given(:build) { create(:ci_build, pipeline: pipeline) }
given(:environment) { create(:environment, project: project) }
given(:current_time) { Time.now.utc }
background do
project.add_developer(user)
create(:deployment, environment: environment, deployable: build)
stub_all_prometheus_requests(environment.slug)
login_as(user)
visit_environment(environment)
end
around do |example|
Timecop.freeze(current_time) { example.run }
end
context 'with deployments and related deployable present' do
scenario 'shows metrics' do
click_link('See metrics')
expect(page).to have_css('svg.prometheus-graph')
end
end
def visit_environment(environment)
visit namespace_project_environment_path(environment.project.namespace,
environment.project,
environment)
end
end
......@@ -37,13 +37,7 @@ feature 'Environment', :feature do
scenario 'does show deployment SHA' do
expect(page).to have_link(deployment.short_sha)
end
scenario 'does not show a re-deploy button for deployment without build' do
expect(page).not_to have_link('Re-deploy')
end
scenario 'does not show terminal button' do
expect(page).not_to have_terminal_button
end
end
......@@ -58,13 +52,7 @@ feature 'Environment', :feature do
scenario 'does show build name' do
expect(page).to have_link("#{build.name} (##{build.id})")
end
scenario 'does show re-deploy button' do
expect(page).to have_link('Re-deploy')
end
scenario 'does not show terminal button' do
expect(page).not_to have_terminal_button
end
......@@ -117,9 +105,6 @@ feature 'Environment', :feature do
it 'displays a web terminal' do
expect(page).to have_selector('#terminal')
end
it 'displays a link to the environment external url' do
expect(page).to have_link(nil, href: environment.external_url)
end
end
......@@ -147,10 +132,6 @@ feature 'Environment', :feature do
on_stop: 'close_app')
end
scenario 'does show stop button' do
expect(page).to have_link('Stop')
end
scenario 'does allow to stop environment' do
click_link('Stop')
......
%div
.top-area
.row
.col-sm-6
%h3.page-title
Metrics for environment
.row
.col-sm-12
%svg.prometheus-graph{ 'graph-type' => 'cpu_values' }
.row
.col-sm-12
%svg.prometheus-graph{ 'graph-type' => 'memory_values' }
\ No newline at end of file
import 'jquery';
import es6Promise from 'es6-promise';
import '~/lib/utils/common_utils';
import PrometheusGraph from '~/monitoring/prometheus_graph';
import { prometheusMockData } from './prometheus_mock_data';
es6Promise.polyfill();
describe('PrometheusGraph', () => {
const fixtureName = 'static/environments/metrics.html.raw';
const prometheusGraphContainer = '.prometheus-graph';
const prometheusGraphContents = `${prometheusGraphContainer}[graph-type=cpu_values]`;
preloadFixtures(fixtureName);
beforeEach(() => {
loadFixtures(fixtureName);
this.prometheusGraph = new PrometheusGraph();
const self = this;
const fakeInit = (metricsResponse) => {
self.prometheusGraph.transformData(metricsResponse);
self.prometheusGraph.createGraph();
};
spyOn(this.prometheusGraph, 'init').and.callFake(fakeInit);
});
it('initializes graph properties', () => {
// Test for the measurements