Commit b80b3fa6 authored by Thong Kuah's avatar Thong Kuah

Merge branch '31923-Snowplow-custom-events-Monitor' into 'master'

Snowplow custom events for Monitor: Health Product Categories

See merge request gitlab-org/gitlab!18157
parents c3898cdf 5e4781ac
...@@ -4,6 +4,8 @@ import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ ...@@ -4,6 +4,8 @@ import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale'; import { __ } from '~/locale';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { trackViewInSentryOptions, trackClickErrorLinkToSentryOptions } from '../utils';
export default { export default {
fields: [ fields: [
...@@ -21,6 +23,9 @@ export default { ...@@ -21,6 +23,9 @@ export default {
Icon, Icon,
TimeAgo, TimeAgo,
}, },
directives: {
TrackEvent: TrackEventDirective,
},
props: { props: {
indexPath: { indexPath: {
type: String, type: String,
...@@ -53,6 +58,8 @@ export default { ...@@ -53,6 +58,8 @@ export default {
}, },
methods: { methods: {
...mapActions(['startPolling', 'restartPolling']), ...mapActions(['startPolling', 'restartPolling']),
trackViewInSentryOptions,
trackClickErrorLinkToSentryOptions,
}, },
}; };
</script> </script>
...@@ -65,7 +72,13 @@ export default { ...@@ -65,7 +72,13 @@ export default {
</div> </div>
<div v-else> <div v-else>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank"> <gl-button
v-track-event="trackViewInSentryOptions(externalUrl)"
class="my-3 ml-auto"
variant="primary"
:href="externalUrl"
target="_blank"
>
{{ __('View in Sentry') }} {{ __('View in Sentry') }}
<icon name="external-link" class="flex-shrink-0" /> <icon name="external-link" class="flex-shrink-0" />
</gl-button> </gl-button>
...@@ -80,7 +93,12 @@ export default { ...@@ -80,7 +93,12 @@ export default {
</template> </template>
<template slot="error" slot-scope="errors"> <template slot="error" slot-scope="errors">
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<gl-link :href="errors.item.externalUrl" class="d-flex text-dark" target="_blank"> <gl-link
v-track-event="trackClickErrorLinkToSentryOptions(errors.item.externalUrl)"
:href="errors.item.externalUrl"
class="d-flex text-dark"
target="_blank"
>
<strong class="text-truncate">{{ errors.item.title.trim() }}</strong> <strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
<icon name="external-link" class="ml-1 flex-shrink-0" /> <icon name="external-link" class="ml-1 flex-shrink-0" />
</gl-link> </gl-link>
......
/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
/**
* Tracks snowplow event when user clicks View in Sentry btn
* @param {String} externalUrl that will be send as a property for the event
*/
export const trackViewInSentryOptions = url => ({
category: 'Error Tracking',
action: 'click_view_in_sentry',
label: 'External Url',
property: url,
});
/**
* Tracks snowplow event when User clicks on error link to Sentry
* @param {String} externalUrl that will be send as a property for the event
*/
export const trackClickErrorLinkToSentryOptions = url => ({
category: 'Error Tracking',
action: 'click_error_link_to_sentry',
label: 'Error Link',
property: url,
});
...@@ -21,7 +21,14 @@ import MonitorSingleStatChart from './charts/single_stat.vue'; ...@@ -21,7 +21,14 @@ import MonitorSingleStatChart from './charts/single_stat.vue';
import GraphGroup from './graph_group.vue'; import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
import { sidebarAnimationDuration, timeWindows } from '../constants'; import { sidebarAnimationDuration, timeWindows } from '../constants';
import { getTimeDiff, getTimeWindow } from '../utils'; import TrackEventDirective from '~/vue_shared/directives/track_event';
import {
getTimeDiff,
getTimeWindow,
downloadCSVOptions,
generateLinkToChartOptions,
} from '../utils';
let sidebarMutationObserver; let sidebarMutationObserver;
...@@ -43,6 +50,7 @@ export default { ...@@ -43,6 +50,7 @@ export default {
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
}, },
props: { props: {
externalDashboardUrl: { externalDashboardUrl: {
...@@ -322,6 +330,8 @@ export default { ...@@ -322,6 +330,8 @@ export default {
groupHasData(group) { groupHasData(group) {
return this.chartsWithData(group.metrics).length > 0; return this.chartsWithData(group.metrics).length > 0;
}, },
downloadCSVOptions,
generateLinkToChartOptions,
}, },
addMetric: { addMetric: {
title: s__('Metrics|Add metric'), title: s__('Metrics|Add metric'),
...@@ -552,10 +562,19 @@ export default { ...@@ -552,10 +562,19 @@ export default {
<template slot="button-content"> <template slot="button-content">
<icon name="ellipsis_v" class="text-secondary" /> <icon name="ellipsis_v" class="text-secondary" />
</template> </template>
<gl-dropdown-item :href="downloadCsv(graphData)" download="chart_metrics.csv"> <gl-dropdown-item
v-track-event="downloadCSVOptions(graphData.title)"
:href="downloadCsv(graphData)"
download="chart_metrics.csv"
>
{{ __('Download CSV') }} {{ __('Download CSV') }}
</gl-dropdown-item> </gl-dropdown-item>
<gl-dropdown-item <gl-dropdown-item
v-track-event="
generateLinkToChartOptions(
generateLink(groupData.group, graphData.title, graphData.y_label),
)
"
class="js-chart-link" class="js-chart-link"
:data-clipboard-text=" :data-clipboard-text="
generateLink(groupData.group, graphData.title, graphData.y_label) generateLink(groupData.group, graphData.title, graphData.y_label)
......
...@@ -13,6 +13,8 @@ import Icon from '~/vue_shared/components/icon.vue'; ...@@ -13,6 +13,8 @@ import Icon from '~/vue_shared/components/icon.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorSingleStatChart from './charts/single_stat.vue'; import MonitorSingleStatChart from './charts/single_stat.vue';
import MonitorEmptyChart from './charts/empty_chart.vue'; import MonitorEmptyChart from './charts/empty_chart.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
export default { export default {
components: { components: {
...@@ -27,6 +29,7 @@ export default { ...@@ -27,6 +29,7 @@ export default {
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
}, },
props: { props: {
clipboardText: { clipboardText: {
...@@ -84,6 +87,8 @@ export default { ...@@ -84,6 +87,8 @@ export default {
showToast() { showToast() {
this.$toast.show(__('Link copied')); this.$toast.show(__('Link copied'));
}, },
downloadCSVOptions,
generateLinkToChartOptions,
}, },
}; };
</script> </script>
...@@ -121,13 +126,18 @@ export default { ...@@ -121,13 +126,18 @@ export default {
<template slot="button-content"> <template slot="button-content">
<icon name="ellipsis_v" class="text-secondary" /> <icon name="ellipsis_v" class="text-secondary" />
</template> </template>
<gl-dropdown-item :href="downloadCsv" download="chart_metrics.csv"> <gl-dropdown-item
v-track-event="downloadCSVOptions(graphData.title)"
:href="downloadCsv"
download="chart_metrics.csv"
>
{{ __('Download CSV') }} {{ __('Download CSV') }}
</gl-dropdown-item> </gl-dropdown-item>
<gl-dropdown-item <gl-dropdown-item
v-track-event="generateLinkToChartOptions(clipboardText)"
class="js-chart-link" class="js-chart-link"
:data-clipboard-text="clipboardText" :data-clipboard-text="clipboardText"
@click="showToast" @click="showToast(clipboardText)"
> >
{{ __('Generate link to chart') }} {{ __('Generate link to chart') }}
</gl-dropdown-item> </gl-dropdown-item>
......
...@@ -45,4 +45,47 @@ export const graphDataValidatorForValues = (isValues, graphData) => { ...@@ -45,4 +45,47 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
); );
}; };
/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
/**
* Checks that element that triggered event is located on cluster health check dashboard
* @param {HTMLElement} element to check against
* @returns {boolean}
*/
const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
/**
* Tracks snowplow event when user generates link to metric chart
* @param {String} chart link that will be sent as a property for the event
* @return {Object} config object for event tracking
*/
export const generateLinkToChartOptions = chartLink => {
const isCLusterHealthBoard = isClusterHealthBoard();
const category = isCLusterHealthBoard
? 'Cluster Monitoring'
: 'Incident Management::Embedded metrics';
const action = isCLusterHealthBoard
? 'generate_link_to_cluster_metric_chart'
: 'generate_link_to_metrics_chart';
return { category, action, label: 'Chart link', property: chartLink };
};
/**
* Tracks snowplow event when user downloads CSV of cluster metric
* @param {String} chart title that will be sent as a property for the event
*/
export const downloadCSVOptions = title => {
const isCLusterHealthBoard = isClusterHealthBoard();
const category = isCLusterHealthBoard
? 'Cluster Monitoring'
: 'Incident Management::Embedded metrics';
const action = isCLusterHealthBoard
? 'download_csv_of_cluster_metric_chart'
: 'download_csv_of_metrics_dashboard_chart';
return { category, action, label: 'Chart title', property: title };
};
export default {}; export default {};
import Tracking from '~/tracking';
export default {
bind(el, binding) {
el.dataset.trackingOptions = JSON.stringify(binding.value || {});
el.addEventListener('click', () => {
const { category, action, label, property, value } = JSON.parse(el.dataset.trackingOptions);
if (!category || !action) {
return;
}
Tracking.event(category, action, { label, property, value });
});
},
update(el, binding) {
if (binding.value !== binding.oldValue) {
el.dataset.trackingOptions = JSON.stringify(binding.value || {});
}
},
};
...@@ -13,9 +13,14 @@ module Projects ...@@ -13,9 +13,14 @@ module Projects
def update def update
result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute
track_events(result)
render_update_response(result) render_update_response(result)
end end
# overridden in EE
def track_events(result)
end
private private
# overridden in EE # overridden in EE
......
...@@ -10,6 +10,7 @@ module Issues ...@@ -10,6 +10,7 @@ module Issues
def add_link(link) def add_link(link)
if can_add_link? && (link = parse_link(link)) if can_add_link? && (link = parse_link(link))
track_meeting_added_event
success(_('Zoom meeting added'), append_to_description(link)) success(_('Zoom meeting added'), append_to_description(link))
else else
error(_('Failed to add a Zoom meeting')) error(_('Failed to add a Zoom meeting'))
...@@ -22,6 +23,7 @@ module Issues ...@@ -22,6 +23,7 @@ module Issues
def remove_link def remove_link
if can_remove_link? if can_remove_link?
track_meeting_removed_event
success(_('Zoom meeting removed'), remove_from_description) success(_('Zoom meeting removed'), remove_from_description)
else else
error(_('Failed to remove a Zoom meeting')) error(_('Failed to remove a Zoom meeting'))
...@@ -44,6 +46,14 @@ module Issues ...@@ -44,6 +46,14 @@ module Issues
issue.description || '' issue.description || ''
end end
def track_meeting_added_event
::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id)
end
def track_meeting_removed_event
::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id)
end
def success(message, description) def success(message, description)
ServiceResponse ServiceResponse
.success(message: message, payload: { description: description }) .success(message: message, payload: { description: description })
......
---
title: 'Snowplow custom events for Monitor: Health Product Categories'
merge_request: 18157
author:
type: added
...@@ -64,7 +64,7 @@ module EE ...@@ -64,7 +64,7 @@ module EE
end end
if incident_management_available? if incident_management_available?
permitted_params[:incident_management_setting_attributes] = [:create_issue, :send_email, :issue_template_key] permitted_params[:incident_management_setting_attributes] = ::Gitlab::Tracking::IncidentManagement.tracking_keys.keys
end end
permitted_params permitted_params
...@@ -82,6 +82,17 @@ module EE ...@@ -82,6 +82,17 @@ module EE
end end
end end
end end
override :track_events
def track_events(result)
super
if result[:status] == :success
::Gitlab::Tracking::IncidentManagement.track_from_params(
update_params[:incident_management_setting_attributes]
)
end
end
end end
end end
end end
......
...@@ -380,6 +380,34 @@ describe Projects::Settings::OperationsController do ...@@ -380,6 +380,34 @@ describe Projects::Settings::OperationsController do
) )
end end
end end
context 'updating each incident management setting' do
let(:project) { create(:project) }
let(:new_incident_management_settings) { {} }
before do
project.add_maintainer(user)
end
shared_examples 'a gitlab tracking event' do |params, event_key|
it "creates a gitlab tracking event #{event_key}" do
new_incident_management_settings = params
expect(Gitlab::Tracking).to receive(:event)
.with('IncidentManagement::Settings', event_key, kind_of(Hash))
update_project(project,
incident_management_params: new_incident_management_settings)
end
end
it_behaves_like 'a gitlab tracking event', { create_issue: '1' }, 'enabled_issue_auto_creation_on_alerts'
it_behaves_like 'a gitlab tracking event', { create_issue: '0' }, 'disabled_issue_auto_creation_on_alerts'
it_behaves_like 'a gitlab tracking event', { issue_template_key: 'template' }, 'enabled_issue_template_on_alerts'
it_behaves_like 'a gitlab tracking event', { issue_template_key: nil }, 'disabled_issue_template_on_alerts'
it_behaves_like 'a gitlab tracking event', { send_email: '1' }, 'enabled_sending_emails'
it_behaves_like 'a gitlab tracking event', { send_email: '0' }, 'disabled_sending_emails'
end
end end
context 'without a license' do context 'without a license' do
......
# frozen_string_literal: true
module Gitlab
module Tracking
module IncidentManagement
class << self
def track_from_params(incident_params)
return if incident_params.blank?
incident_params.each do |k, v|
prefix = ['', '0'].include?(v.to_s) ? 'disabled' : 'enabled'
key = tracking_keys.dig(k, :name)
label = tracking_keys.dig(k, :label)
next if key.blank?
details = label ? { label: label, property: v } : {}
::Gitlab::Tracking.event('IncidentManagement::Settings', "#{prefix}_#{key}", **details )
end
end
def tracking_keys
{
create_issue: {
name: 'issue_auto_creation_on_alerts'
},
issue_template_key: {
name: 'issue_template_on_alerts',
label: 'Template name'
},
send_email: {
name: 'sending_emails'
}
}.with_indifferent_access.freeze
end
end
end
end
end
import * as errorTrackingUtils from '~/error_tracking/utils';
const externalUrl = 'https://sentry.io/organizations/test-sentry-nk/issues/1/?project=1';
describe('Error Tracking Events', () => {
describe('trackViewInSentryOptions', () => {
it('should return correct event options', () => {
expect(errorTrackingUtils.trackViewInSentryOptions(externalUrl)).toEqual({
category: 'Error Tracking',
action: 'click_view_in_sentry',
label: 'External Url',
property: externalUrl,
});
});
});
describe('trackClickErrorLinkToSentryOptions', () => {
it('should return correct event options', () => {
expect(errorTrackingUtils.trackClickErrorLinkToSentryOptions(externalUrl)).toEqual({
category: 'Error Tracking',
action: 'click_error_link_to_sentry',
label: 'Error Link',
property: externalUrl,
});
});
});
});
import * as monitoringUtils from '~/monitoring/utils';
describe('Snowplow Events', () => {
const generatedLink = 'http://chart.link.com';
const chartTitle = 'Some metric chart';
describe('trackGenerateLinkToChartEventOptions', () => {
it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => {
document.body.dataset.page = 'groups:clusters:show';
expect(monitoringUtils.generateLinkToChartOptions(generatedLink)).toEqual({
category: 'Cluster Monitoring',
action: 'generate_link_to_cluster_metric_chart',
label: 'Chart link',
property: generatedLink,
});
});
it('should return Incident Management event options if located on Metrics Dashboard', () => {
document.body.dataset.page = 'metrics:show';
expect(monitoringUtils.generateLinkToChartOptions(generatedLink)).toEqual({
category: 'Incident Management::Embedded metrics',
action: 'generate_link_to_metrics_chart',
label: 'Chart link',
property: generatedLink,
});
});
});
describe('trackDownloadCSVEvent', () => {
it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => {
document.body.dataset.page = 'groups:clusters:show';
expect(monitoringUtils.downloadCSVOptions(chartTitle)).toEqual({
category: 'Cluster Monitoring',
action: 'download_csv_of_cluster_metric_chart',
label: 'Chart title',
property: chartTitle,
});
});
it('should return Incident Management event options if located on Metrics Dashboard', () => {
document.body.dataset.page = 'metriss:show';
expect(monitoringUtils.downloadCSVOptions(chartTitle)).toEqual({
category: 'Incident Management::Embedded metrics',
action: 'download_csv_of_metrics_dashboard_chart',
label: 'Chart title',
property: chartTitle,
});
});
});
});
import Vue from 'vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Tracking from '~/tracking';
import TrackEvent from '~/vue_shared/directives/track_event';
jest.mock('~/tracking');
const Component = Vue.component('dummy-element', {
directives: {
TrackEvent,
},
data() {
return {
trackingOptions: null,
};
},
template: '<button id="trackable" v-track-event="trackingOptions"></button>',
});
const localVue = createLocalVue();
let wrapper;
let button;
describe('Error Tracking directive', () => {
beforeEach(() => {
wrapper = shallowMount(localVue.extend(Component), {
localVue,
});
button = wrapper.find('#trackable');
});
it('should not track the event if required arguments are not provided', () => {
button.trigger('click');
expect(Tracking.event).not.toHaveBeenCalled();
});
it('should track event on click if tracking info provided', () => {
const trackingOptions = {
category: 'Tracking',
action: 'click_trackable_btn',
label: 'Trackable Info',
};
wrapper.setData({ trackingOptions });
const { category, action, label, property, value } = trackingOptions;
button.trigger('click');
expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property, value });
});
});
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Tracking::IncidentManagement do
describe '.track_from_params' do
shared_examples 'a tracked event' do |label, value = nil|
it 'creates the tracking event with the correct details' do
expect(::Gitlab::Tracking)
.to receive(:event)
.with(
'IncidentManagement::Settings',
label,
value || kind_of(Hash)
)
end
end
after do
described_class.track_from_params(params)
end
context 'known params' do
known_params = described_class.tracking_keys
known_params.each do |key, values|
context "param #{key}" do
let(:params) { { key => '1' } }
it_behaves_like 'a tracked event', "enabled_#{known_params[key][:name]}"
end
end
context 'different input values' do
shared_examples 'the correct prefixed event name' do |input, enabled|
let(:params) { { issue_template_key: input } }
it 'matches' do
expect(::Gitlab::Tracking)
.to receive(:event)
.with(
anything,
"#{enabled}_issue_template_on_alerts",
anything
)
end
end
it_behaves_like 'the correct prefixed event name', 1, 'enabled'
it_behaves_like 'the correct prefixed event name', '1', 'enabled'
it_behaves_like 'the correct prefixed event name', 'template', 'enabled'
it_behaves_like 'the correct prefixed event name', '', 'disabled'
it_behaves_like 'the correct prefixed event name', nil, 'disabled'
end
context 'param with label' do
let(:params) { { issue_template_key: '1' } }
it_behaves_like 'a tracked event', "enabled_issue_template_on_alerts", { label: 'Template name', property: '1' }
end
context 'param without label' do
let(:params) { { create_issue: '1' } }
it_behaves_like 'a tracked event', "enabled_issue_auto_creation_on_alerts", {}
end
end
context 'unknown params' do
let(:params) { { 'unknown' => '1' } }
it 'does not create the tracking event' do
expect(::Gitlab::Tracking)
.not_to receive(:event)
end
end
end
end
...@@ -51,6 +51,12 @@ describe Issues::ZoomLinkService do ...@@ -51,6 +51,12 @@ describe Issues::ZoomLinkService do
expect(result.payload[:description]) expect(result.payload[:description])
.to eq("#{issue.description}\n\n#{zoom_link}") .to eq("#{issue.description}\n\n#{zoom_link}")
end end
it 'tracks the add event' do
expect(Gitlab::Tracking).to receive(:event)
.with('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id)
result
end
end end
shared_examples 'cannot add link' do shared_examples 'cannot add link' do
...@@ -135,6 +141,13 @@ describe Issues::ZoomLinkService do ...@@ -135,6 +141,13 @@ describe Issues::ZoomLinkService do
.to eq(issue.description.delete_suffix("\n\n#{zoom_link}")) .to eq(issue.description.delete_suffix("\n\n#{zoom_link}"))
end end
it 'tracks the remove event' do
expect(Gitlab::Tracking).to receive(:event)
.with('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id)
result
end
context 'with insufficient permissions' do context 'with insufficient permissions' do
include_context 'insufficient permissions' include_context 'insufficient permissions'
include_examples 'cannot remove link' include_examples 'cannot remove link'
......
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