diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 17d7f610e3f..b03af1f4e3d 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -5,7 +5,8 @@ class HelpController < ApplicationController def feature_toggle_ui_hash(user = current_user) { - programOfficeTeamManagement: FeatureToggle.enabled?(:program_office_team_management, user: user) + programOfficeTeamManagement: FeatureToggle.enabled?(:program_office_team_management, user: user), + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user) } end diff --git a/app/controllers/intakes_controller.rb b/app/controllers/intakes_controller.rb index e7401c09488..4bb2df9afea 100644 --- a/app/controllers/intakes_controller.rb +++ b/app/controllers/intakes_controller.rb @@ -152,7 +152,8 @@ def feature_toggle_ui_hash eduPreDocketAppeals: FeatureToggle.enabled?(:edu_predocket_appeals, user: current_user), updatedAppealForm: FeatureToggle.enabled?(:updated_appeal_form, user: current_user), hlrScUnrecognizedClaimants: FeatureToggle.enabled?(:hlr_sc_unrecognized_claimants, user: current_user), - vhaClaimReviewEstablishment: FeatureToggle.enabled?(:vha_claim_review_establishment, user: current_user) + vhaClaimReviewEstablishment: FeatureToggle.enabled?(:vha_claim_review_establishment, user: current_user), + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user) } end diff --git a/app/controllers/metrics/dashboard_controller.rb b/app/controllers/metrics/dashboard_controller.rb new file mode 100644 index 00000000000..232aeaa9d1b --- /dev/null +++ b/app/controllers/metrics/dashboard_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Metrics::DashboardController < ApplicationController + before_action :require_demo + + def show + no_cache + + @metrics = Metric.includes(:user).where("created_at > ?", 1.hour.ago).order(created_at: :desc) + + begin + render :show, layout: "plain_application" + rescue StandardError => error + Rails.logger.error(error.full_message) + raise error.full_message + end + end + + private + + def require_demo + redirect_to "/unauthorized" unless Rails.deploy_env?(:demo) + end +end diff --git a/app/controllers/metrics/v2/logs_controller.rb b/app/controllers/metrics/v2/logs_controller.rb new file mode 100644 index 00000000000..7ccdc1ec306 --- /dev/null +++ b/app/controllers/metrics/v2/logs_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class Metrics::V2::LogsController < ApplicationController + skip_before_action :verify_authentication + + def create + metric = Metric.create_metric_from_rest(self, allowed_params, current_user) + failed_metric_info = metric&.errors.inspect || allowed_params[:message] + Rails.logger.info("Failed to create metric #{failed_metric_info}") unless metric&.valid? + + if (metric.metric_type === 'error') + error_info = { + name: metric.metric_name, + class: metric.metric_class, + attrs: metric.metric_attributes, + created_at: metric.created_at, + uuid: metric.uuid, + } + error = StandardError.new(error_info) + Raven.capture_exception(error) + end + + head :ok + end + + def allowed_params + params.require(:metric).permit(:uuid, + :name, + :group, + :message, + :type, + :product, + :app_name, + :metric_attributes, + :additional_info, + :sent_to, + :sent_to_info, + :relevant_tables_info, + :start, + :end, + :duration + ) + end +end diff --git a/app/controllers/test/users_controller.rb b/app/controllers/test/users_controller.rb index bf6f2d93bb8..f8e5b214f4d 100644 --- a/app/controllers/test/users_controller.rb +++ b/app/controllers/test/users_controller.rb @@ -62,7 +62,8 @@ class Test::UsersController < ApplicationController stats: "/stats", jobs: "/jobs", admin: "/admin", - test_veterans: "/test/data" + test_veterans: "/test/data", + metrics_dashboard: "/metrics/dashboard" } } ].freeze diff --git a/app/jobs/update_appellant_representation_job.rb b/app/jobs/update_appellant_representation_job.rb index 081741c104b..36ee5b65857 100644 --- a/app/jobs/update_appellant_representation_job.rb +++ b/app/jobs/update_appellant_representation_job.rb @@ -7,7 +7,6 @@ class UpdateAppellantRepresentationJob < CaseflowJob include ActionView::Helpers::DateHelper queue_with_priority :low_priority application_attr :queue - APP_NAME = "caseflow_job" METRIC_GROUP_NAME = UpdateAppellantRepresentationJob.name.underscore TOTAL_NUMBER_OF_APPEALS_TO_UPDATE = 1000 diff --git a/app/models/metric.rb b/app/models/metric.rb new file mode 100644 index 00000000000..f28623bb62e --- /dev/null +++ b/app/models/metric.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +class Metric < CaseflowRecord + belongs_to :user + + METRIC_TYPES = { error: "error", log: "log", performance: "performance", info: "info" }.freeze + LOG_SYSTEMS = { datadog: "datadog", rails_console: "rails_console", javascript_console: "javascript_console" } + PRODUCT_TYPES = { + queue: "queue", + hearings: "hearings", + intake: "intake", + vha: "vha", + efolder: "efolder", + reader: "reader", + caseflow: "caseflow", # Default product + # Added below because MetricService has usages of this as a service + vacols: "vacols", + bgs: "bgs", + gov_delivery: "gov_delivery", + mpi: "mpi", + pexip: "pexip", + va_dot_gov: "va_dot_gov", + va_notify: "va_notify", + vbms: "vbms", + }.freeze + APP_NAMES = { caseflow: "caseflow", efolder: "efolder" }.freeze + METRIC_GROUPS = { service: "service" }.freeze + + validates :metric_type, inclusion: { in: METRIC_TYPES.values } + validates :metric_product, inclusion: { in: PRODUCT_TYPES.values } + validates :metric_group, inclusion: { in: METRIC_GROUPS.values } + validates :app_name, inclusion: { in: APP_NAMES.values } + validate :sent_to_in_log_systems + + def self.create_metric(klass, params, user) + create(default_object(klass, params, user)) + end + + def self.create_metric_from_rest(klass, params, user) + params[:metric_attributes] = JSON.parse(params[:metric_attributes]) if params[:metric_attributes] + params[:additional_info] = JSON.parse(params[:additional_info]) if params[:additional_info] + params[:sent_to_info] = JSON.parse(params[:sent_to_info]) if params[:sent_to_info] + params[:relevant_tables_info] = JSON.parse(params[:relevant_tables_info]) if params[:relevant_tables_info] + + create(default_object(klass, params, user)) + end + + def sent_to_in_log_systems + invalid_systems = sent_to - LOG_SYSTEMS.values + msg = "contains invalid log systems. The following are valid log systems #{LOG_SYSTEMS.values}" + errors.add(:sent_to, msg) if !invalid_systems.empty? + end + + def css_id + user.css_id + end + + private + + # Returns an object with defaults set if below symbols are not found in params default object. + # Looks for these symbols in params parameter + # - uuid + # - name + # - group + # - message + # - type + # - product + # - app_name + # - metric_attributes + # - additional_info + # - sent_to + # - sent_to_info + # - relevant_tables_info + # - start + # - end + # - duration + def self.default_object(klass, params, user) + { + uuid: params[:uuid], + user: user || User.new(full_name: "Stand in user for testing", css_id: SecureRandom.uuid, station_id: 'Metrics'), + metric_name: params[:name] || METRIC_TYPES[:log], + metric_class: klass&.try(:name) || klass&.class.name || self.name, + metric_group: params[:group] || METRIC_GROUPS[:service], + metric_message: params[:message] || METRIC_TYPES[:log], + metric_type: params[:type] || METRIC_TYPES[:log], + metric_product: PRODUCT_TYPES[params[:product]] || PRODUCT_TYPES[:caseflow], + app_name: params[:app_name] || APP_NAMES[:caseflow], + metric_attributes: params[:metric_attributes], + additional_info: params[:additional_info], + sent_to: Array(params[:sent_to]).flatten, + sent_to_info: params[:sent_to_info], + relevant_tables_info: params[:relevant_tables_info], + start: params[:start], + end: params[:end], + duration: calculate_duration(params[:start], params[:end], params[:duration]), + } + end + + def self.calculate_duration(start, end_time, duration) + return duration if duration || !start || !end_time + + end_time - start + end + +end diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb index b9b83f77df1..aa74ab6f7ed 100644 --- a/app/services/metrics_service.rb +++ b/app/services/metrics_service.rb @@ -4,35 +4,86 @@ # see https://dropwizard.github.io/metrics/3.1.0/getting-started/ for abstractions on metric types class MetricsService - def self.record(description, service: nil, name: "unknown") + def self.record(description, service: nil, name: "unknown", caller: nil) + return nil unless FeatureToggle.enabled?(:metrics_monitoring, user: current_user) + return_value = nil app = RequestStore[:application] || "other" service ||= app + uuid = SecureRandom.uuid + metric_name= 'request_latency' + sent_to = [[Metric::LOG_SYSTEMS[:rails_console]]] + sent_to_info = nil + start = Time.now Rails.logger.info("STARTED #{description}") stopwatch = Benchmark.measure do return_value = yield end + stopped = Time.now if service latency = stopwatch.real - DataDogService.emit_gauge( + sent_to_info = { metric_group: "service", - metric_name: "request_latency", + metric_name: metric_name, metric_value: latency, app_name: app, attrs: { service: service, - endpoint: name + endpoint: name, + uuid: uuid } - ) + } + DataDogService.emit_gauge(sent_to_info) + + sent_to << Metric::LOG_SYSTEMS[:datadog] end Rails.logger.info("FINISHED #{description}: #{stopwatch}") + + metric_params = { + name: metric_name, + message: description, + type: Metric::METRIC_TYPES[:performance], + product: service, + attrs: { + service: service, + endpoint: name + }, + sent_to: sent_to, + sent_to_info: sent_to_info, + start: start, + end: stopped, + duration: stopwatch.total * 1000 # values is in seconds and we want milliseconds + } + store_record_metric(uuid, metric_params, caller) + return_value - rescue StandardError + rescue StandardError => error + Rails.logger.error("#{error.message}\n#{error.backtrace.join("\n")}") + Raven.capture_exception(error, extra: { type: "request_error", service: service, name: name, app: app }) + increment_datadog_counter("request_error", service, name, app) if service + metric_params = { + name: "Stand in object if metrics_service.record fails", + message: "Variables not initialized before failure", + type: Metric::METRIC_TYPES[:error], + product: "", + attrs: { + service: "", + endpoint: "" + }, + sent_to: [[Metric::LOG_SYSTEMS[:rails_console]]], + sent_to_info: "", + start: 'Time not recorded', + end: 'Time not recorded', + duration: 'Time not recorded' + } + + store_record_metric(uuid, metric_params, caller) + # Re-raise the same error. We don't want to interfere at all in normal error handling. # This is just to capture the metric. raise @@ -51,4 +102,27 @@ def self.record(description, service: nil, name: "unknown") } ) end + + private + + def self.store_record_metric(uuid, params, caller) + + name ="caseflow.server.metric.#{params[:name]&.downcase.gsub(/::/, '.')}" + params = { + uuid: uuid, + name: name, + message: params[:message], + type: params[:type], + product: params[:product], + metric_attributes: params[:attrs], + sent_to: params[:sent_to], + sent_to_info: params[:sent_to_info], + start: params[:start], + end: params[:end], + duration: params[:duration], + } + metric = Metric.create_metric(caller || self, params, RequestStore[:current_user]) + failed_metric_info = metric&.errors.inspect + Rails.logger.info("Failed to create metric #{failed_metric_info}") unless metric&.valid? + end end diff --git a/app/views/certifications/v2.html.erb b/app/views/certifications/v2.html.erb index 6b739da67b0..8634f07ea5d 100644 --- a/app/views/certifications/v2.html.erb +++ b/app/views/certifications/v2.html.erb @@ -4,6 +4,9 @@ dropdownUrls: dropdown_urls, feedbackUrl: feedback_url, buildDate: build_date, - vacolsId: @certification.vacols_id + vacolsId: @certification.vacols_id, + featureToggles: { + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user) + } }) %> <% end %> diff --git a/app/views/decision_reviews/index.html.erb b/app/views/decision_reviews/index.html.erb index 548d1cdb9ad..e377f8dce05 100644 --- a/app/views/decision_reviews/index.html.erb +++ b/app/views/decision_reviews/index.html.erb @@ -10,7 +10,8 @@ businessLine: business_line.name, businessLineUrl: business_line.url, featureToggles: { - decisionReviewQueueSsnColumn: FeatureToggle.enabled?(:decision_review_queue_ssn_column, user: current_user) + decisionReviewQueueSsnColumn: FeatureToggle.enabled?(:decision_review_queue_ssn_column, user: current_user), + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user) }, baseTasksUrl: business_line.tasks_url, taskFilterDetails: task_filter_details diff --git a/app/views/dispatch/establish_claims/index.html.erb b/app/views/dispatch/establish_claims/index.html.erb index 8a9cc4d7d64..3c8c256783a 100644 --- a/app/views/dispatch/establish_claims/index.html.erb +++ b/app/views/dispatch/establish_claims/index.html.erb @@ -8,6 +8,9 @@ buildDate: build_date, buttonText: start_text, userQuota: user_quota && user_quota.to_hash, - currentUserHistoricalTasks: current_user_historical_tasks.map(&:to_hash) + currentUserHistoricalTasks: current_user_historical_tasks.map(&:to_hash), + featureToggles: { + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user) + } }) %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/hearings/index.html.erb b/app/views/hearings/index.html.erb index db78283f635..55241926043 100644 --- a/app/views/hearings/index.html.erb +++ b/app/views/hearings/index.html.erb @@ -29,6 +29,9 @@ userIsDvc: current_user.can_view_judge_team_management?, userIsHearingManagement: current_user.in_hearing_management_team?, userIsBoardAttorney: current_user.attorney?, - userIsHearingAdmin: current_user.in_hearing_admin_team? + userIsHearingAdmin: current_user.in_hearing_admin_team?, + featureToggles: { + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user) + } }) %> <% end %> diff --git a/app/views/inbox/index.html.erb b/app/views/inbox/index.html.erb index 65ba31a93e8..dba5d4f67ae 100644 --- a/app/views/inbox/index.html.erb +++ b/app/views/inbox/index.html.erb @@ -8,6 +8,9 @@ inbox: { messages: messages, pagination: pagination + }, + featureToggles: { + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user) } }) %> <% end %> diff --git a/app/views/intake_manager/index.html.erb b/app/views/intake_manager/index.html.erb index 09ed9a2c07e..9659d728be5 100644 --- a/app/views/intake_manager/index.html.erb +++ b/app/views/intake_manager/index.html.erb @@ -5,5 +5,8 @@ dropdownUrls: dropdown_urls, feedbackUrl: feedback_url, buildDate: build_date + featureToggles: { + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user) + } }) %> <% end %> diff --git a/app/views/metrics/dashboard/show.html.erb b/app/views/metrics/dashboard/show.html.erb new file mode 100644 index 00000000000..b427d5c3290 --- /dev/null +++ b/app/views/metrics/dashboard/show.html.erb @@ -0,0 +1,83 @@ + + + +

Metrics Dashboard

+

Shows metrics created in past hour

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + <% @metrics.each do |metric| %> + + + + + + + + + + + + + + + + + + + + + <% end %> + +
uuidnameclassgroupmessagetypeproductappattributesadditional_infosent_tosent_to_inforelevant_tables_infostartendduration (ms)css_idcreated_at
<%= metric.uuid %><%= metric.metric_name %><%= metric.metric_class %><%= metric.metric_group %><%= metric.metric_message %><%= metric.metric_type %><%= metric.metric_product %><%= metric.app_name %><%= metric.metric_attributes %><%= metric.additional_info %><%= metric.sent_to %><%= metric.sent_to_info %><%= metric.relevant_tables_info %><%= metric.start %><%= metric.end %><%= metric.duration %><%= metric.css_id %><%= metric.created_at %>
+
+
diff --git a/app/views/queue/index.html.erb b/app/views/queue/index.html.erb index e0ba7a1038a..5fa1ce56ec4 100644 --- a/app/views/queue/index.html.erb +++ b/app/views/queue/index.html.erb @@ -53,6 +53,7 @@ cavc_remand_granted_substitute_appellant: FeatureToggle.enabled?(:cavc_remand_granted_substitute_appellant, user: current_user), cavc_dashboard_workflow: FeatureToggle.enabled?(:cavc_dashboard_workflow, user: current_user), cc_appeal_workflow: FeatureToggle.enabled?(:cc_appeal_workflow, user: current_user), + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user), cc_vacatur_visibility: FeatureToggle.enabled?(:cc_vacatur_visibility, user: current_user) } }) %> diff --git a/app/views/reader/appeal/index.html.erb b/app/views/reader/appeal/index.html.erb index 8201c415679..5862f351d1e 100644 --- a/app/views/reader/appeal/index.html.erb +++ b/app/views/reader/appeal/index.html.erb @@ -12,6 +12,13 @@ windowSlider: FeatureToggle.enabled?(:window_slider, user: current_user), readerSelectorsMemoized: FeatureToggle.enabled?(:bulk_upload_documents, user: current_user), readerGetDocumentLogging: FeatureToggle.enabled?(:reader_get_document_logging, user: current_user), + metricsLogRestError: FeatureToggle.enabled?(:metrics_log_rest_error, user: current_user), + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user), + metricsLoadScreen: FeatureToggle.enabled?(:metrics_load_screen, user: current_user), + metricsRecordPDFJSGetDocument: FeatureToggle.enabled?(:metrics_get_pdfjs_doc, user: current_user), + metricsReaderRenderText: FeatureToggle.enabled?(:metrics_reader_render_text, user: current_user), + metricsLogRestSuccess: FeatureToggle.enabled?(:metrics_log_rest_success, user: current_user), + metricsPdfStorePages: FeatureToggle.enabled?(:metrics_pdf_store_pages, user: current_user), prefetchDisabled: FeatureToggle.enabled?(:prefetch_disabled, user: current_user) }, buildDate: build_date diff --git a/app/views/test/users/index.html.erb b/app/views/test/users/index.html.erb index f8a29402c45..3bb0dff6ff5 100644 --- a/app/views/test/users/index.html.erb +++ b/app/views/test/users/index.html.erb @@ -14,6 +14,10 @@ appSelectList: Test::UsersController::APPS, userSession: user_session, timezone: { getlocal: Time.now.getlocal.zone, zone: Time.zone.name }, - epTypes: ep_types + epTypes: ep_types, + featureToggles: { + interfaceVersion2: FeatureToggle.enabled?(:interface_version_2, user: current_user), + metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user) + } }) %> <% end %> diff --git a/client/app/components/LoadingDataDisplay.jsx b/client/app/components/LoadingDataDisplay.jsx index 449e54a0e61..f9fbb32e876 100644 --- a/client/app/components/LoadingDataDisplay.jsx +++ b/client/app/components/LoadingDataDisplay.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import LoadingScreen from './LoadingScreen'; import StatusMessage from './StatusMessage'; import COPY from '../../COPY'; +import { recordAsyncMetrics } from '../util/Metrics'; const PROMISE_RESULTS = { SUCCESS: 'SUCCESS', @@ -42,10 +43,24 @@ class LoadingDataDisplay extends React.PureComponent { this.setState({ promiseStartTimeMs: Date.now() }); + const metricData = { + message: this.props.loadingComponentProps?.message || 'loading screen', + type: 'performance', + data: { + failStatusMessageProps: this.props.failStatusMessageProps, + loadingComponentProps: this.props.loadingComponentProps, + slowLoadMessage: this.props.slowLoadMessage, + slowLoadThresholdMs: this.props.slowLoadThresholdMs, + timeoutMs: this.props.timeoutMs + } + }; + + const shouldRecordMetrics = this.props.metricsLoadScreen; + // Promise does not give us a way to "un-then" and stop listening // when the component unmounts. So we'll leave this reference dangling, // but at least we can use this._isMounted to avoid taking action if necessary. - promise.then( + recordAsyncMetrics(promise, metricData, shouldRecordMetrics).then( () => { if (!this._isMounted) { return; @@ -162,7 +177,8 @@ LoadingDataDisplay.propTypes = { loadingComponentProps: PropTypes.object, slowLoadMessage: PropTypes.string, slowLoadThresholdMs: PropTypes.number, - timeoutMs: PropTypes.number + timeoutMs: PropTypes.number, + metricsLoadScreen: PropTypes.bool, }; LoadingDataDisplay.defaultProps = { @@ -173,7 +189,8 @@ LoadingDataDisplay.defaultProps = { errorComponent: StatusMessage, loadingComponentProps: {}, failStatusMessageProps: {}, - failStatusMessageChildren: DEFAULT_UNKNOWN_ERROR_MSG + failStatusMessageChildren: DEFAULT_UNKNOWN_ERROR_MSG, + metricsLoadScreen: false, }; export default LoadingDataDisplay; diff --git a/client/app/index.js b/client/app/index.js index 49067c2ca5e..94d8d5cd734 100644 --- a/client/app/index.js +++ b/client/app/index.js @@ -13,6 +13,9 @@ import { render } from 'react-dom'; import { forOwn } from 'lodash'; import { BrowserRouter, Switch } from 'react-router-dom'; +// Internal Dependencies +import { storeMetrics } from './util/Metrics'; + // Redux Store Dependencies import ReduxBase from 'app/components/ReduxBase'; import rootReducer from 'store/root'; @@ -55,6 +58,7 @@ import Inbox from 'app/inbox'; import Explain from 'app/explain'; import MPISearch from 'app/mpi/MPISearch'; import Admin from 'app/admin'; +import uuid from 'uuid'; const COMPONENTS = { // New Version 2.0 Root Component @@ -93,6 +97,36 @@ const COMPONENTS = { }; const componentWrapper = (component) => (props, railsContext, domNodeId) => { + window.onerror = (event, source, lineno, colno, error) => { + if (props.featureToggles?.metricsBrowserError) { + const id = uuid.v4(); + const data = { + event, + source, + lineno, + colno, + error + }; + const t0 = performance.now(); + const start = Date.now(); + const t1 = performance.now(); + const end = Date.now(); + const duration = t1 - t0; + + storeMetrics( + id, + data, + { type: 'error', + product: 'browser', + start, + end, + duration } + ); + } + + return true; + }; + /* eslint-disable */ const wrapComponent = (Component) => ( diff --git a/client/app/reader/DecisionReviewer.jsx b/client/app/reader/DecisionReviewer.jsx index 2d676eb5e62..3625ad70d9a 100644 --- a/client/app/reader/DecisionReviewer.jsx +++ b/client/app/reader/DecisionReviewer.jsx @@ -90,7 +90,8 @@ export class DecisionReviewer extends React.PureComponent { return + vacolsId={vacolsId} + featureToggles={this.props.featureToggles}> + vacolsId={vacolsId} + featureToggles={this.props.featureToggles}> { @@ -198,7 +210,8 @@ DocumentSearch.propTypes = { setSearchIsLoading: PropTypes.func, showSearchBar: PropTypes.func, totalMatchesInFile: PropTypes.number, - updateSearchIndex: PropTypes.func + updateSearchIndex: PropTypes.func, + featureToggles: PropTypes.object, }; const mapStateToProps = (state, props) => ({ @@ -209,7 +222,8 @@ const mapStateToProps = (state, props) => ({ currentMatchIndex: getCurrentMatchIndex(state, props), matchIndexToHighlight: state.searchActionReducer.indexToHighlight, hidden: state.pdfViewer.hideSearchBar, - textExtracted: !_.isEmpty(state.searchActionReducer.extractedText) + textExtracted: !_.isEmpty(state.searchActionReducer.extractedText), + featureToggles: props.featureToggles, }); const mapDispatchToProps = (dispatch) => ({ diff --git a/client/app/reader/PdfFile.jsx b/client/app/reader/PdfFile.jsx index 1941496aa7e..30137804c85 100644 --- a/client/app/reader/PdfFile.jsx +++ b/client/app/reader/PdfFile.jsx @@ -24,6 +24,7 @@ import { INTERACTION_TYPES } from '../reader/analytics'; import { getCurrentMatchIndex, getMatchesPerPageInFile, getSearchTerm } from './selectors'; import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry'; import uuid from 'uuid'; +import { storeMetrics, recordAsyncMetrics } from '../util/Metrics'; PDFJS.GlobalWorkerOptions.workerSrc = pdfjsWorker; @@ -49,7 +50,9 @@ export class PdfFile extends React.PureComponent { cache: true, withCredentials: true, timeout: true, - responseType: 'arraybuffer' + responseType: 'arraybuffer', + metricsLogRestError: this.props.featureToggles.metricsLogRestError, + metricsLogRestSuccess: this.props.featureToggles.metricsLogRestSuccess }; window.addEventListener('keydown', this.keyListener); @@ -70,9 +73,20 @@ export class PdfFile extends React.PureComponent { getDocument = (requestOptions) => { return ApiUtil.get(this.props.file, requestOptions). then((resp) => { + const metricData = { + message: `Getting PDF document id: "${this.props.documentId}"`, + type: 'performance', + product: 'reader', + data: { + file: this.props.file, + } + }; + this.loadingTask = PDFJS.getDocument({ data: resp.body }); + const promise = this.loadingTask.promise; - return this.loadingTask.promise; + return recordAsyncMetrics(promise, metricData, + this.props.featureToggles.metricsRecordPDFJSGetDocument); }, (reason) => this.onRejected(reason, 'getDocument')). then((pdfDocument) => { this.pdfDocument = pdfDocument; @@ -90,7 +104,21 @@ export class PdfFile extends React.PureComponent { return this.props.setPdfDocument(this.props.file, this.pdfDocument); }, (reason) => this.onRejected(reason, 'setPdfDocument')). catch((error) => { - console.error(`${uuid.v4()} : GET ${this.props.file} : ${error}`); + const id = uuid.v4(); + const data = { + file: this.props.file + }; + const message = `${id} : GET ${this.props.file} : ${error}`; + + console.error(message); + storeMetrics( + id, + data, + { message, + type: 'error', + product: 'browser', + } + ); this.loadingTask = null; this.props.setDocumentLoadError(this.props.file); }); @@ -217,6 +245,7 @@ export class PdfFile extends React.PureComponent { isFileVisible={this.props.isVisible} scale={this.props.scale} pdfDocument={this.props.pdfDocument} + featureToggles={this.props.featureToggles} /> ; } diff --git a/client/app/reader/PdfPage.jsx b/client/app/reader/PdfPage.jsx index e4fcf0597da..599f030d385 100644 --- a/client/app/reader/PdfPage.jsx +++ b/client/app/reader/PdfPage.jsx @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Mark from 'mark.js'; +import uuid, { v4 as uuidv4 } from 'uuid'; import CommentLayer from './CommentLayer'; import { connect } from 'react-redux'; @@ -12,12 +13,11 @@ import { bindActionCreators } from 'redux'; import { PDF_PAGE_HEIGHT, PDF_PAGE_WIDTH, SEARCH_BAR_HEIGHT, PAGE_DIMENSION_SCALE, PAGE_MARGIN } from './constants'; import { pageNumberOfPageIndex } from './utils'; import * as PDFJS from 'pdfjs-dist'; -import { collectHistogram } from '../util/Metrics'; +import { collectHistogram, recordMetrics, recordAsyncMetrics, storeMetrics } from '../util/Metrics'; import { css } from 'glamor'; import classNames from 'classnames'; import { COLORS } from '../constants/AppConstants'; -import uuid from 'uuid'; const markStyle = css({ '& mark': { @@ -183,6 +183,7 @@ export class PdfPage extends React.PureComponent { }; drawText = (page, text) => { + if (!this.textLayer) { return; } @@ -212,32 +213,88 @@ export class PdfPage extends React.PureComponent { setUpPage = () => { // eslint-disable-next-line no-underscore-dangle if (this.props.pdfDocument && !this.props.pdfDocument._transport.destroyed) { - this.props.pdfDocument. - getPage(pageNumberOfPageIndex(this.props.pageIndex)). - then((page) => { - this.page = page; + const pageMetricData = { + message: 'Storing PDF page', + product: 'pdfjs.document.pages', + type: 'performance', + data: { + file: this.props.file, + documentId: this.props.documentId, + pageIndex: this.props.pageIndex, + numPagesInDoc: this.props.pdfDocument.numPages, + }, + }; - this.getText(page).then((text) => { - this.drawText(page, text); - }); + const textMetricData = { + message: 'Storing PDF page text', + product: 'pdfjs.document.pages', + type: 'performance', + data: { + file: this.props.file, + documentId: this.props.documentId, + }, + }; - this.drawPage(page).then(() => { - collectHistogram({ - group: 'front_end', - name: 'pdf_page_render_time_in_ms', - value: this.measureTimeStartMs ? performance.now() - this.measureTimeStartMs : 0, - appName: 'Reader', - attrs: { - overscan: this.props.windowingOverscan, - documentType: this.props.documentType, - pageCount: this.props.pdfDocument.pdfInfo?.numPages - } - }); + const pageAndTextFeatureToggle = this.props.featureToggles.metricsPdfStorePages; + const document = this.props.pdfDocument; + const pageIndex = pageNumberOfPageIndex(this.props.pageIndex); + const pageResult = recordAsyncMetrics(document.getPage(pageIndex), pageMetricData, pageAndTextFeatureToggle); + + pageResult.then((page) => { + this.page = page; + + const readerRenderText = { + uuid: uuidv4(), + message: 'Searching within Reader document text', + type: 'performance', + product: 'reader', + data: { + documentId: this.props.documentId, + documentType: this.props.documentType, + file: this.props.file + }, + }; + + const textResult = recordAsyncMetrics(this.getText(page), textMetricData, pageAndTextFeatureToggle); + + textResult.then((text) => { + recordMetrics(this.drawText(page, text), readerRenderText, + this.props.featureToggles.metricsReaderRenderText); + }); + + this.drawPage(page).then(() => { + collectHistogram({ + group: 'front_end', + name: 'pdf_page_render_time_in_ms', + value: this.measureTimeStartMs ? performance.now() - this.measureTimeStartMs : 0, + appName: 'Reader', + attrs: { + documentId: this.props.documentId, + overscan: this.props.windowingOverscan, + documentType: this.props.documentType, + pageCount: this.props.pdfDocument.numPages + } }); - }). - catch((error) => { - console.error(`${uuid.v4()} : setUpPage ${this.props.file} : ${error}`); }); + }).catch((error) => { + const id = uuid.v4(); + const data = { + documentId: this.props.documentId, + documentType: this.props.documentType, + file: this.props.file + }; + const message = `${id} : setUpPage ${this.props.file} : ${error}`; + + console.error(message); + storeMetrics( + id, + data, + { message, + type: 'error', + product: 'browser', + } + ); + }); } }; @@ -358,7 +415,8 @@ PdfPage.propTypes = { searchText: PropTypes.string, setDocScrollPosition: PropTypes.func, setSearchIndexToHighlight: PropTypes.func, - windowingOverscan: PropTypes.string + windowingOverscan: PropTypes.string, + featureToggles: PropTypes.object }; const mapDispatchToProps = (dispatch) => ({ diff --git a/client/app/reader/PdfUI.jsx b/client/app/reader/PdfUI.jsx index dafeb71ba0b..3b4c28e932c 100644 --- a/client/app/reader/PdfUI.jsx +++ b/client/app/reader/PdfUI.jsx @@ -317,7 +317,7 @@ export class PdfUI extends React.Component {
- + + failStatusMessageChildren={failStatusMessageChildren} + metricsLoadScreen={this.props.featureToggles.metricsLoadScreen}> {this.props.children} ; @@ -66,7 +67,8 @@ ReaderLoadingScreen.propTypes = { onReceiveAnnotations: PropTypes.func, onReceiveDocs: PropTypes.func, onReceiveManifests: PropTypes.func, - vacolsId: PropTypes.string + vacolsId: PropTypes.string, + featureToggles: PropTypes.object }; const mapStateToProps = (state) => ({ diff --git a/client/app/util/ApiUtil.js b/client/app/util/ApiUtil.js index a1274bc2d4c..cd36638d572 100644 --- a/client/app/util/ApiUtil.js +++ b/client/app/util/ApiUtil.js @@ -2,8 +2,10 @@ import request from 'superagent'; import nocache from 'superagent-no-cache'; import ReactOnRails from 'react-on-rails'; import StringUtil from './StringUtil'; +import uuid from 'uuid'; import _ from 'lodash'; import { timeFunctionPromise } from '../util/PerfDebug'; +import moment from 'moment'; export const STANDARD_API_TIMEOUT_MILLISECONDS = 60 * 1000; export const RESPONSE_COMPLETE_LIMIT_MILLISECONDS = 5 * 60 * 1000; @@ -40,23 +42,125 @@ export const getHeadersObject = (options = {}) => { return headers; }; +export const postMetricLogs = (data) => { + return request. + post('/metrics/v2/logs'). + set(getHeadersObject()). + send(data). + use(nocache). + on('error', (err) => console.error(`Metric not recorded\nUUID: ${uuid.v4()}.\n: ${err}`)). + end(); +}; + +// eslint-disable-next-line no-unused-vars +const errorHandling = (url, error, method, options = {}) => { + const id = uuid.v4(); + const message = `UUID: ${id}.\nProblem with ${method} ${url}.\n${error}`; + + console.error(new Error(message)); + options.t1 = performance.now(); + options.end = moment().format(); + options.duration = options.t1 - options.t0; + + // Need to renable this check before going to master + if (options?.metricsLogRestError) { + const data = { + metric: { + uuid: id, + name: `caseflow.client.rest.${method.toLowerCase()}.error`, + message, + type: 'error', + product: 'caseflow', + metric_attributes: JSON.stringify({ + method, + url, + error + }), + sent_to: 'javascript_console', + start: options.start, + end: options.end, + duration: options.duration, + } + }; + + postMetricLogs(data); + } +}; + +const successHandling = (url, res, method, options = {}) => { + const id = uuid.v4(); + const message = `UUID: ${id}.\nSuccess with ${method} ${url}.\n${res.status}`; + + // Need to renable this check before going to master + options.t1 = performance.now(); + options.end = moment().format(); + options.duration = options.t1 - options.t0; + + if (options?.metricsLogRestSuccess) { + const data = { + metric: { + uuid: id, + name: `caseflow.client.rest.${method.toLowerCase()}.info`, + message, + type: 'info', + product: 'caseflow', + metric_attributes: JSON.stringify({ + method, + url + }), + sent_to: 'javascript_console', + sent_to_info: JSON.stringify({ + metric_group: 'Rest call', + metric_name: 'Javascript request', + metric_value: options.duration, + app_name: 'JS reader', + attrs: { + service: 'rest service', + endpoint: url, + uuid: id + } + }), + + start: options.start, + end: options.end, + duration: options.duration, + } + }; + + postMetricLogs(data); + } +}; + const httpMethods = { delete(url, options = {}) { + options.t0 = performance.now(); + options.start = moment().format(); + return request. delete(url). set(getHeadersObject(options.headers)). send(options.data). - use(nocache); + use(nocache). + on('error', (err) => errorHandling(url, err, 'DELETE', options)). + then((res) => { + successHandling(url, res, 'DELETE', options); + + return res; + }); }, get(url, options = {}) { const timeoutSettings = Object.assign({}, defaultTimeoutSettings, _.get(options, 'timeout', {})); + options.t0 = performance.now(); + options.start = moment().format(); + let promise = request. get(url). set(getHeadersObject(options.headers)). query(options.query). - timeout(timeoutSettings); + timeout(timeoutSettings). + on('error', (err) => errorHandling(url, err, 'GET', options)); if (options.responseType) { promise.responseType(options.responseType); @@ -67,36 +171,72 @@ const httpMethods = { } if (options.cache) { - return promise; + return promise. + then((res) => { + successHandling(url, res, 'GET', options); + return res; + }); } return promise. - use(nocache); + use(nocache). + then((res) => { + successHandling(url, res, 'GET', options); + return res; + }); }, patch(url, options = {}) { + options.t0 = performance.now(); + options.start = moment().format(); + return request. post(url). set(getHeadersObject({ 'X-HTTP-METHOD-OVERRIDE': 'patch' })). send(options.data). - use(nocache); + use(nocache). + on('error', (err) => errorHandling(url, err, 'PATCH', options)). + then((res) => { + successHandling(url, res, 'PATCH', options); + + return res; + }); }, post(url, options = {}) { + options.t0 = performance.now(); + options.start = moment().format(); + return request. post(url). set(getHeadersObject(options.headers)). send(options.data). - use(nocache); + use(nocache). + on('error', (err) => errorHandling(url, err, 'POST', options)). + then((res) => { + successHandling(url, res, 'POST', options); + + return res; + }); }, put(url, options = {}) { + options.t0 = performance.now(); + options.start = moment().format(); + return request. put(url). set(getHeadersObject(options.headers)). send(options.data). - use(nocache); + use(nocache). + on('error', (err) => errorHandling(url, err, 'PUT', options)). + then((res) => { + successHandling(url, res, 'PUT', options); + + return res; + }); } + }; // TODO(jd): Fill in other HTTP methods as needed diff --git a/client/app/util/Metrics.js b/client/app/util/Metrics.js index 029aeeaafb3..77d365a3f82 100644 --- a/client/app/util/Metrics.js +++ b/client/app/util/Metrics.js @@ -1,6 +1,141 @@ -import ApiUtil from './ApiUtil'; +import ApiUtil, { postMetricLogs } from './ApiUtil'; import _ from 'lodash'; import moment from 'moment'; +import uuid from 'uuid'; + +// ------------------------------------------------------------------------------------------ +// Metric Storage and recording +// ------------------------------------------------------------------------------------------ + +const metricMessage = (uniqueId, data, message) => message ? message : `${uniqueId}\n${data}`; + +/** + * If a uuid wasn't provided assume that metric also wasn't sent to javascript console + * and send with UUID to console + */ +const checkUuid = (uniqueId, data, message, type) => { + let id = uniqueId; + const isError = type === 'error'; + + if (!uniqueId) { + id = uuid.v4(); + if (isError) { + console.error(metricMessage(uniqueId, data, message)); + } else { + // eslint-disable-next-line no-console + console.log(metricMessage(uniqueId, data, message)); + } + } + + return id; +}; + +/** + * uniqueId should be V4 UUID + * If a uniqueId is not presented one will be generated for it + * + * Data is an object containing information that will be stored in metric_attributes + * + * If a message is not provided one will be created based on the data passed in + * + * Product is which area of Caseflow did the metric come from: queue, hearings, intake, vha, case_distribution, reader + * + */ +export const storeMetrics = (uniqueId, data, { message, type = 'log', product, start, end, duration }) => { + const metricType = ['log', 'error', 'performance'].includes(type) ? type : 'log'; + const productArea = product ? product : 'caseflow'; + + const postData = { + metric: { + uuid: uniqueId, + name: `caseflow.client.${productArea}.${metricType}`, + message: metricMessage(uniqueId, data, message), + type: metricType, + product: productArea, + metric_attributes: JSON.stringify(data), + sent_to: 'javascript_console', + start, + end, + duration + } + }; + + postMetricLogs(postData); +}; + +export const recordMetrics = (targetFunction, { uniqueId, data, message, type = 'log', product }, + saveMetrics = true) => { + + let id = checkUuid(uniqueId, data, message, type); + + const t0 = performance.now(); + const start = Date.now(); + const name = targetFunction?.name || message; + + // eslint-disable-next-line no-console + console.info(`STARTED: ${id} ${name}`); + const result = () => targetFunction(); + const t1 = performance.now(); + const end = Date.now(); + + const duration = t1 - t0; + + // eslint-disable-next-line no-console + console.info(`FINISHED: ${id} ${name} in ${duration} milliseconds`); + + if (saveMetrics) { + const metricData = { + ...data, + name + }; + + storeMetrics(uniqueId, metricData, { message, type, product, start, end, duration }); + } + + return result; +}; + +/** + * Hopefully this doesn't cause issues and preserves the async of the promise or async function + * + * Might need to split into async and promise versions if issues + */ +export const recordAsyncMetrics = async (promise, { uniqueId, data, message, type = 'log', product }, + saveMetrics = true) => { + + let id = checkUuid(uniqueId, data, message, type); + + const t0 = performance.now(); + const start = Date.now(); + const name = message || promise; + + // eslint-disable-next-line no-console + console.info(`STARTED: ${id} ${name}`); + const prom = () => promise; + const result = await prom(); + const t1 = performance.now(); + const end = Date.now(); + + const duration = t1 - t0; + + // eslint-disable-next-line no-console + console.info(`FINISHED: ${id} ${name} in ${duration} milliseconds`); + + if (saveMetrics) { + const metricData = { + ...data, + name + }; + + storeMetrics(uniqueId, metricData, { message, type, product, start, end, duration }); + } + + return result; +}; + +// ------------------------------------------------------------------------------------------ +// Histograms +// ------------------------------------------------------------------------------------------ const INTERVAL_TO_SEND_METRICS_MS = moment.duration(60, 'seconds'); @@ -30,4 +165,23 @@ export const collectHistogram = (data) => { initialize(); histograms.push(ApiUtil.convertToSnakeCase(data)); + + const id = uuid.v4(); + const metricsData = data; + const time = Date(Date.now()).toString(); + const readerData = { + message: `Render document content for "${ data.attrs.documentType }"`, + type: 'performance', + product: 'pdfjs.document.render', + start: time, + end: Date(Date.now()).toString(), + duration: data.value, + }; + + if (data.value > 0) { + storeMetrics(id, metricsData, readerData); + } else if (data.attrs.pageCount < 2) { + storeMetrics(id, metricsData, readerData); + } }; + diff --git a/client/test/app/util/ApiUtil.test.js b/client/test/app/util/ApiUtil.test.js index bad4a427729..78924a0beee 100644 --- a/client/test/app/util/ApiUtil.test.js +++ b/client/test/app/util/ApiUtil.test.js @@ -14,7 +14,9 @@ jest.mock('superagent', () => ({ set: jest.fn().mockReturnThis(), accept: jest.fn().mockReturnThis(), timeout: jest.fn().mockReturnThis(), - use: jest.fn().mockReturnThis() + use: jest.fn().mockReturnThis(), + on: jest.fn().mockReturnThis(), + then: jest.fn().mockReturnThis() })); const defaultHeaders = { @@ -53,6 +55,25 @@ describe('ApiUtil', () => { expect(request.use).toHaveBeenCalledWith(nocache); expect(req).toMatchObject(request); }); + + test('calls success handling method when calls the api request', () => { + const successHandling = jest.fn(); + + const res = {}; + + // Setup the test + const options = { data: { sample: 'data' } }; + + // Run the test + const req = ApiUtil.patch('/foo', options); + + // Expectations + req.then(() => { + // Assert that successHandling method is called + expect(request.then).toHaveBeenCalled(res); + expect(successHandling).toHaveBeenCalled(); + }) + }); }); describe('.post', () => { @@ -71,6 +92,25 @@ describe('ApiUtil', () => { expect(req).toMatchObject(request); }); + test('calls success handling method when calls the api request', () => { + const successHandling = jest.fn(); + + const res = {}; + + // Setup the test + const options = { data: { sample: 'data' } }; + + // Run the test + const req = ApiUtil.post('/bar', options); + + // Expectations + req.then(() => { + // Assert that successHandling method is called + expect(request.then).toHaveBeenCalled(res); + expect(successHandling).toHaveBeenCalled(); + }) + }); + test('attaches custom headers when provided', () => { // Setup the test const options = { headers: { sample: 'header' } }; @@ -127,5 +167,24 @@ describe('ApiUtil', () => { expect(request.use).toHaveBeenCalledWith(nocache); expect(req).toMatchObject(request); }); + + test('calls success handling method when calls the api request', () => { + const successHandling = jest.fn(); + + const res = {}; + + // Setup the test + const options = { query: { bar: 'baz' } }; + + // Run the test + const req = ApiUtil.get('/foo', options); + + // Expectations + req.then(() => { + // Assert that successHandling method is called + expect(request.then).toHaveBeenCalled(res); + expect(successHandling).toHaveBeenCalled(); + }) + }); }); }); diff --git a/config/routes.rb b/config/routes.rb index 81670f08808..b13b2cc3cda 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,8 +89,13 @@ namespace :v1 do resources :histogram, only: :create end + namespace :v2 do + resources :logs, only: :create + end + get 'dashboard' => 'dashboard#show' end + namespace :dispatch do get "/", to: redirect("/dispatch/establish-claim") get 'missing-decision', to: 'establish_claims#unprepared_tasks' diff --git a/db/migrate/20230523174750_create_metrics_table.rb b/db/migrate/20230523174750_create_metrics_table.rb new file mode 100644 index 00000000000..10e8aeb0598 --- /dev/null +++ b/db/migrate/20230523174750_create_metrics_table.rb @@ -0,0 +1,29 @@ +class CreateMetricsTable < ActiveRecord::Migration[5.2] + def change + create_table :metrics do |t| + t.uuid :uuid, default: -> { "uuid_generate_v4()" }, null: false, comment: "Unique ID for the metric, can be used to search within various systems for the logging" + t.references :user, null: false, foreign_key: true, comment: "The ID of the user who generated metric." + t.string :metric_name, null: false, comment: "Name of metric" + t.string :metric_class, null: false, comment: "Class of metric, use reflection to find value to populate this" + t.string :metric_group, null: false, default: "service", comment: "Metric group: service, etc" + t.string :metric_message, null: false, comment: "Message or log for metric" + t.string :metric_type, null: false, comment: "Type of metric: ERROR, LOG, PERFORMANCE, etc" + t.string :metric_product, null: false, comment: "Where in application: Queue, Hearings, Intake, VHA, etc" + t.string :app_name, null: false, comment: "Application name: caseflow or efolder" + t.json :metric_attributes, comment: "Store attributes relevant to the metric: OS, browser, etc" + t.json :additional_info, comment: "additional data to store for the metric" + t.string :sent_to, array: true, comment: "Which system metric was sent to: Datadog, Rails Console, Javascript Console, etc " + t.json :sent_to_info, comment: "Additional information for which system metric was sent to" + t.json :relevant_tables_info, comment: "Store information to tie metric to database table(s)" + t.timestamp :start, comment: "When metric recording started" + t.timestamp :end, comment: "When metric recording stopped" + t.float :duration, comment: "Time in milliseconds from start to end" + t.timestamps + end + + add_index :metrics, :metric_name + add_index :metrics, :metric_product + add_index :metrics, :app_name + add_index :metrics, :sent_to + end +end diff --git a/db/schema.rb b/db/schema.rb index 71f378159e5..32ca77d0c23 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1251,6 +1251,33 @@ t.index ["updated_at"], name: "index_messages_on_updated_at" end + create_table "metrics", force: :cascade do |t| + t.json "additional_info", comment: "additional data to store for the metric" + t.string "app_name", null: false, comment: "Application name: caseflow or efolder" + t.datetime "created_at", null: false + t.float "duration", comment: "Time in milliseconds from start to end" + t.datetime "end", comment: "When metric recording stopped" + t.json "metric_attributes", comment: "Store attributes relevant to the metric: OS, browser, etc" + t.string "metric_class", null: false, comment: "Class of metric, use reflection to find value to populate this" + t.string "metric_group", default: "service", null: false, comment: "Metric group: service, etc" + t.string "metric_message", null: false, comment: "Message or log for metric" + t.string "metric_name", null: false, comment: "Name of metric" + t.string "metric_product", null: false, comment: "Where in application: Queue, Hearings, Intake, VHA, etc" + t.string "metric_type", null: false, comment: "Type of metric: ERROR, LOG, PERFORMANCE, etc" + t.json "relevant_tables_info", comment: "Store information to tie metric to database table(s)" + t.string "sent_to", comment: "Which system metric was sent to: Datadog, Rails Console, Javascript Console, etc ", array: true + t.json "sent_to_info", comment: "Additional information for which system metric was sent to" + t.datetime "start", comment: "When metric recording started" + t.datetime "updated_at", null: false + t.bigint "user_id", null: false, comment: "The ID of the user who generated metric." + t.uuid "uuid", default: -> { "uuid_generate_v4()" }, null: false, comment: "Unique ID for the metric, can be used to search within various systems for the logging" + t.index ["app_name"], name: "index_metrics_on_app_name" + t.index ["metric_name"], name: "index_metrics_on_metric_name" + t.index ["metric_product"], name: "index_metrics_on_metric_product" + t.index ["sent_to"], name: "index_metrics_on_sent_to" + t.index ["user_id"], name: "index_metrics_on_user_id" + end + create_table "mpi_update_person_events", force: :cascade do |t| t.bigint "api_key_id", null: false, comment: "API Key used to initiate the event" t.datetime "completed_at", comment: "Timestamp of when update was completed, regardless of success or failure" @@ -2136,6 +2163,7 @@ add_foreign_key "membership_requests", "users", column: "decider_id" add_foreign_key "membership_requests", "users", column: "requestor_id" add_foreign_key "messages", "users" + add_foreign_key "metrics", "users" add_foreign_key "mpi_update_person_events", "api_keys" add_foreign_key "nod_date_updates", "appeals" add_foreign_key "nod_date_updates", "users" diff --git a/spec/controllers/metrics/v2/logs_controller_spec.rb b/spec/controllers/metrics/v2/logs_controller_spec.rb new file mode 100644 index 00000000000..23a824d7a94 --- /dev/null +++ b/spec/controllers/metrics/v2/logs_controller_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +describe Metrics::V2::LogsController, type: :controller do + let(:current_user) { create(:user) } + let(:request_params) do + { + metric: { + uuid: SecureRandom.uuid, + method: "123456789", + name: 'log', + group: 'service', + message: 'This is a test', + type: 'performance', + product: 'reader', + } + } + end + + before do + @raven_called = false + end + before { User.authenticate!(user: current_user) } + + context "with good request" do + it "returns 200 for request params" do + post :create, params: request_params + expect(@raven_called).to eq(false) + expect(response.status).to eq(200) + end + end + + context "With error type record to sentry" do + it "Records to Sentry" do + capture_raven_log + request_params[:metric][:type] = 'error' + post :create, params: request_params + expect(@raven_called).to eq(true) + end + end + + def capture_raven_log + allow(Raven).to receive(:capture_exception) { @raven_called = true } + end +end diff --git a/spec/jobs/update_appellant_representation_job_spec.rb b/spec/jobs/update_appellant_representation_job_spec.rb index 107e2975451..5bfe84c9db4 100644 --- a/spec/jobs/update_appellant_representation_job_spec.rb +++ b/spec/jobs/update_appellant_representation_job_spec.rb @@ -43,7 +43,7 @@ ) expect(DataDogService).to receive(:emit_gauge).with( app_name: "queue_job", - attrs: { endpoint: "AppellantNotification.appeal_mapper", service: "queue_job" }, + attrs: { endpoint: "AppellantNotification.appeal_mapper", service: "queue_job", uuid: anything }, metric_group: "service", metric_name: "request_latency", metric_value: anything diff --git a/spec/models/metric_spec.rb b/spec/models/metric_spec.rb new file mode 100644 index 00000000000..e18bc1a076c --- /dev/null +++ b/spec/models/metric_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +describe Metric do + let(:user) { create(:user) } + + before { User.authenticate!(user: user) } + + describe "create_metric" do + let!(:params) do + { + uuid: SecureRandom.uuid, + method: "123456789", + name: 'log', + group: 'service', + message: 'This is a test', + type: 'performance', + product: 'reader', + } + end + + it "creates a javascript metric for performance" do + metric = Metric.create_metric(self, params, user) + + expect(metric.valid?).to be true + expect(metric.metric_type).to eq(Metric::METRIC_TYPES[:performance]) + end + + it "creates a javascript metric for log" do + params[:type] = 'log' + metric = Metric.create_metric(self, params, user) + + expect(metric.valid?).to be true + expect(metric.metric_type).to eq(Metric::METRIC_TYPES[:log]) + end + + it "creates a javascript metric for error" do + params[:type] = 'error' + metric = Metric.create_metric(self, params, user) + + expect(metric.valid?).to be true + expect(metric.metric_type).to eq(Metric::METRIC_TYPES[:error]) + end + + it "creates a javascript metric with invalid sent_to" do + metric = Metric.create_metric(self, params.merge({sent_to: 'fake'}), user) + + expect(metric.valid?).to be false + end + end +end