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
+
+
+
+
+
+
+ uuid |
+ name |
+ class |
+ group |
+ message |
+ type |
+ product |
+ app |
+ attributes |
+ additional_info |
+ sent_to |
+ sent_to_info |
+ relevant_tables_info |
+ start |
+ end |
+ duration (ms) |
+ css_id |
+ created_at |
+
+
+
+
+ <% @metrics.each do |metric| %>
+
+ <%= 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 %> |
+
+ <% end %>
+
+
+
+
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