Skip to content

Commit

Permalink
Add identifier for explicit frontend error logging (#11481)
Browse files Browse the repository at this point in the history
* Add identifier for explicit frontend error logging

changelog: Internal, Analytics, Add identifier for explicit frontend error logging

* Update frontend production errors debugging

* Update FrontendLogController expected NewRelic call
  • Loading branch information
aduth authored Nov 12, 2024
1 parent 0c46c4c commit ed38d8a
Show file tree
Hide file tree
Showing 13 changed files with 104 additions and 47 deletions.
9 changes: 6 additions & 3 deletions app/forms/frontend_error_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,25 @@ class FrontendErrorForm
validate :validate_filename_extension
validate :validate_filename_host

attr_reader :filename
attr_reader :filename, :error_id

def submit(filename:)
def submit(filename:, error_id:)
@filename = filename
@error_id = error_id

FormResponse.new(success: valid?, errors:, serialize_error_details_only: true)
end

private

def validate_filename_extension
return if File.extname(filename.to_s) == '.js'
return if error_id || File.extname(filename.to_s) == '.js'
errors.add(:filename, :invalid_extension, message: t('errors.general'))
end

def validate_filename_host
return if error_id

begin
return if URI(filename.to_s).host == IdentityConfig.store.domain_name
rescue URI::InvalidURIError; end
Expand Down
5 changes: 4 additions & 1 deletion app/javascript/packages/analytics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Utilities and custom elements for logging events and errors in the application.

Track an event or error from your code using exported function members.

Since JavaScript may be bundled and minified in production environments, including an `errorId` is
required to uniquely identify the source of the error.

```ts
import { trackEvent, trackError } from '@18f/identity-analytics';

Expand All @@ -18,7 +21,7 @@ button.addEventListener('click', () => {
try {
doSomethingRisky();
} catch (error) {
trackError(error);
trackError(error, { errorId: 'exampleId' });
}
```

Expand Down
5 changes: 3 additions & 2 deletions app/javascript/packages/analytics/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,21 @@ describe('trackError', () => {
});

it('tracks event', async () => {
trackError(new Error('Oops!'));
trackError(new Error('Oops!'), { errorId: 'exampleId' });

expect(global.navigator.sendBeacon).to.have.been.calledOnce();

const [actualEndpoint, data] = (global.navigator.sendBeacon as SinonStub).firstCall.args;
expect(actualEndpoint).to.eql(endpoint);

const { event, payload } = JSON.parse(await data.text());
const { name, message, stack } = payload;
const { name, message, stack, error_id: errorId } = payload;

expect(event).to.equal('Frontend Error');
expect(name).to.equal('Error');
expect(message).to.equal('Oops!');
expect(stack).to.be.a('string');
expect(errorId).to.equal('exampleId');
});

context('with event parameter', () => {
Expand Down
13 changes: 9 additions & 4 deletions app/javascript/packages/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { getConfigValue } from '@18f/identity-config';

export { default as isTrackableErrorEvent } from './is-trackable-error-event';

/**
* Metadata used to identify the source of an error.
*/
type ErrorMetadata = { errorId?: never; filename: string } | { errorId: string; filename?: never };

/**
* Logs an event.
*
Expand All @@ -24,8 +29,8 @@ export function trackEvent(event: string, payload?: object) {
* Logs an error.
*
* @param error Error object.
* @param event Error event, if error is caught using an `error` event handler. Including this can
* add additional resolution to the logged error, notably the filename where the error occurred.
* @param metadata Metadata used to identify the source of an error, including either the filename
* from an ErrorEvent object, or a unique identifier.
*/
export const trackError = ({ name, message, stack }: Error, event?: ErrorEvent) =>
trackEvent('Frontend Error', { name, message, stack, filename: event?.filename });
export const trackError = ({ name, message, stack }: Error, { filename, errorId }: ErrorMetadata) =>
trackEvent('Frontend Error', { name, message, stack, filename, error_id: errorId });
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class CaptchaSubmitButtonElement extends HTMLElement {
try {
token = await this.recaptchaClient!.execute(siteKey!, { action });
} catch (error) {
trackError(error);
trackError(error, { errorId: 'recaptchaExecute' });
}

this.tokenInput.value = token;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class WebauthnVerifyButtonElement extends HTMLElement {
this.setInputValue('signature', result.signature);
} catch (error) {
if (!isExpectedWebauthnError(error, { isVerifying: true })) {
trackError(error);
trackError(error, { errorId: 'webauthnVerify' });
}

if (isUserVerificationScreenLockError(error)) {
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/packs/track-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ declare let window: WindowWithInitialErrors;
const { _e: initialErrors } = window;

const handleErrorEvent = (event: ErrorEvent) =>
isTrackableErrorEvent(event) && trackError(event.error, event);
isTrackableErrorEvent(event) && trackError(event.error, { filename: event.filename });
initialErrors.forEach(handleErrorEvent);
window.addEventListener('error', handleErrorEvent);
2 changes: 1 addition & 1 deletion app/javascript/packs/webauthn-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function webauthn() {
})
.catch((error: Error) => {
if (!isExpectedWebauthnError(error)) {
trackError(error);
trackError(error, { errorId: 'webauthnSetup' });
}

reloadWithError(error.name, { force: true });
Expand Down
6 changes: 3 additions & 3 deletions app/services/frontend_error_logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
class FrontendErrorLogger
class FrontendError < StandardError; end

def self.track_error(name:, message:, stack:, filename:)
return unless FrontendErrorForm.new.submit(filename:).success?
def self.track_error(name:, message:, stack:, filename: nil, error_id: nil)
return unless FrontendErrorForm.new.submit(filename:, error_id:).success?

NewRelic::Agent.notice_error(
FrontendError.new,
expected: true,
custom_params: { frontend_error: { name:, message:, stack:, filename: } },
custom_params: { frontend_error: { name:, message:, stack:, filename:, error_id: } },
)
end
end
6 changes: 5 additions & 1 deletion docs/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,10 +373,14 @@ Each error includes a few details to help you debug:
- `message`: Corresponds to [`Error#message`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/message), and is usually a good summary to group by
- `name`: The subclass of the error (e.g. `TypeError`)
- `stack`: A stacktrace of the individual error instance
- `filename`: The URL of the script where the error was raised, if it's an uncaught error
- `error_id`: A unique identifier for tracing caught errors explicitly tracked

Note that NewRelic creates links in stack traces which are invalid, since they include the line and column number. If you encounter an "AccessDenied" error when clicking a stacktrace link, make sure to remove those details after the `.js` in your browser URL.

Debugging these stack traces can be difficult, since files in production are minified, and the stack traces include line numbers and columns for minified files. With the following steps, you can find a reference to the original code:
If an error includes `error_id`, you can use this to search in code for the corresponding call to `trackError` including that value as its `errorId` to trace where the error occurred.

Otherwise, debugging these stack traces can be difficult, since files in production are minified, and the stack traces include line numbers and columns for minified files. With the following steps, you can find a reference to the original code:

1. Download the minified JavaScript file referenced in the stack trace
- Example: https://secure.login.gov/packs/document-capture-e41c853e.digested.js
Expand Down
13 changes: 9 additions & 4 deletions spec/controllers/frontend_log_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@
let(:flow_path) { 'standard' }
let(:event) { 'IdV: location submitted' }
let(:payload) do
{ 'selected_location' => selected_location,
{
'selected_location' => selected_location,
'flow_path' => flow_path,
'opted_in_to_in_person_proofing' => nil }
'opted_in_to_in_person_proofing' => nil,
}
end

it 'succeeds' do
Expand Down Expand Up @@ -94,9 +96,11 @@
{ opt_in_analytics_properties: true }
end
let(:payload) do
{ 'selected_location' => selected_location,
{
'selected_location' => selected_location,
'flow_path' => flow_path,
'opted_in_to_in_person_proofing' => true }
'opted_in_to_in_person_proofing' => true,
}
end

before do
Expand Down Expand Up @@ -207,6 +211,7 @@
message: 'message',
stack: 'stack',
filename: 'filename',
error_id: nil,
},
},
expected: true,
Expand Down
23 changes: 17 additions & 6 deletions spec/forms/frontend_error_form_spec.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
require 'rails_helper'

RSpec.describe FrontendErrorForm do
let(:filename) { 'https://example.com/foo.js' }

subject(:form) { described_class.new }

before do
allow(IdentityConfig.store).to receive(:domain_name).and_return('example.com')
end

describe '#submit' do
subject(:result) { form.submit(filename:) }
subject(:result) { form.submit(filename:, error_id:) }
let(:error_id) { nil }
let(:filename) { 'https://example.com/foo.js' }

context 'with valid filename' do
let(:filename) { 'https://example.com/foo.js' }
Expand All @@ -24,9 +24,20 @@
context 'without filename' do
let(:filename) { nil }

it 'is unsuccessful' do
expect(result.success?).to eq(false)
expect(result.errors).to eq(filename: [t('errors.general'), t('errors.general')])
context 'without error id' do
it 'is unsuccessful' do
expect(result.success?).to eq(false)
expect(result.errors).to eq(filename: [t('errors.general'), t('errors.general')])
end
end

context 'with error id' do
let(:error_id) { 'exampleId' }

it 'is successful' do
expect(result.success?).to eq(true)
expect(result.errors).to eq({})
end
end
end

Expand Down
63 changes: 44 additions & 19 deletions spec/services/frontend_error_logger_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,51 @@
end

describe '.track_event' do
it 'notices an expected error to NewRelic with custom parameters' do
expect(NewRelic::Agent).to receive(:notice_error).with(
kind_of(FrontendErrorLogger::FrontendError),
expected: true,
custom_params: {
frontend_error: {
name: 'name',
message: 'message',
stack: 'stack',
filename: 'filename.js',
let(:payload) { { name: 'name', message: 'message', stack: 'stack' } }
subject(:result) { FrontendErrorLogger.track_error(**payload) }

context 'with filename payload' do
let(:payload) { super().merge(filename: 'filename.js') }

it 'notices an expected error to NewRelic with custom parameters' do
expect(NewRelic::Agent).to receive(:notice_error).with(
kind_of(FrontendErrorLogger::FrontendError),
expected: true,
custom_params: {
frontend_error: {
name: 'name',
message: 'message',
stack: 'stack',
filename: 'filename.js',
error_id: nil,
},
},
)

result
end
end

context 'with error id payload' do
let(:payload) { super().merge(error_id: 'exampleId') }

it 'notices an expected error to NewRelic with custom parameters' do
expect(NewRelic::Agent).to receive(:notice_error).with(
kind_of(FrontendErrorLogger::FrontendError),
expected: true,
custom_params: {
frontend_error: {
name: 'name',
message: 'message',
stack: 'stack',
filename: nil,
error_id: 'exampleId',
},
},
},
)

FrontendErrorLogger.track_error(
name: 'name',
message: 'message',
stack: 'stack',
filename: 'filename.js',
)
)

result
end
end

context 'with unsuccessful validation of request parameters' do
Expand Down

0 comments on commit ed38d8a

Please sign in to comment.