Home | Send Feedback

Global error handler in Angular

Published: 5. October 2018  •  javascript

In a previous blog post I showed you how to install a global error handler in JavaScript with window.onerror or window.addEventListener('error', event => { ... }) and then send the error report to a server with the Beacon API.

In this blog post, we take a closer look at how to install a global error handler in an Angular application.

The previously mentioned events also work in an Angular application, but Angular provides a more idiomatic way to install an error handler. All you have to do is creating a class that implements the ErrorHandler interface from the @angular/core package. The class has to implement the handleError() method.

export class AppGlobalErrorhandler implements ErrorHandler {
  handleError(error) {
    console.error(error);
  }
}

Then in app.modules.ts, you need to tell Angular that it should use our error handler. You do that by adding the following entry to the providers configuration.

  providers: [    
    // ...
    {provide: ErrorHandler, useClass: AppGlobalErrorhandler}    
  ],

This is already everything you have to do. From now on, all uncaught exceptions thrown anywhere in your application, are handled by the AppGlobalErrorhandler class.

Sending report to a server

In the next step, we want to send the error reports to a back end. Printing application errors into the browser console is useful during development but does not help us when the application runs in production.

For this example, I wrote a simple Spring Boot application with a RestController and one POST mapping that handles the error report sent from the client.

  @PostMapping("/clientError")
  public void clientError(@RequestBody List<ClientError> clientErrors) {
    for (ClientError cl : clientErrors) {
      System.out.println(cl);
    }
  }

ErrorController.java

In this example, the server prints out the error into the console. In a real application you could send the report with an email to the developers, insert the report into a database or, if your issue reporting system provides an API, directly create an issue.

Next we change the handleError() method. Here the application collects some information from the browser. This gives us a bit more information in what environment the error occurred.

The error object that Angular passes to our error handler contains a property stack that contains the stack trace as a string. Depending on the way how you want to handle the error on the back end, this might not be that useful. In this example, I use the stacktrace.js library to split the stack trace into a string array. Each entry in the array represents one line of the stack trace.

After collecting and preparing the data, the method sends it with a POST request to the server

  async handleError(error) {
    const userAgent = {
      language: navigator.language,
      platform: navigator.platform,
      userAgent: navigator.userAgent,
      connectionDownlink: navigator['connection'].downlink,
      connectionEffectiveType: navigator['connection'].effectiveType
    };
    const stackTrace = await StackTrace.fromError(error, {offline: true});
    const body = JSON.stringify({ts: Date.now(), userAgent, stackTrace});

    fetch(`${environment.serverURL}/clientError`, {
      method: 'POST',
      body: `[${body}]`,
      headers: {
        'content-type': 'application/json'
      }
    });
  }

One thing you need to be aware of is that this can easily overwhelm your back end if you have thousands of users. Imagine your client application periodically connects to a remote API to send and fetch data. If this server suddenly crashes, it would generate a lot of errors and a lot of POST request to your error reporting back end. To prevent such errors, you could analyze the stack trace in your error handler and don't report specific errors to the back end.
Or handle these errors directly in the application code, so that these errors are not uncaught and never sent to the global error handler.
Or, if you still want to send the report to the server, add a random wait time before the error handler sends the report. This way not every client sends the report at the same time.

delayRandom(maxSeconds) {
  return new Promise(resolve => {
    setTimeout(resolve, Math.random() * maxSeconds * 1000);
  });
}

  ...
  await this.delayRandom(30);
  fetch(`${environment.serverURL}/clientError`, {
  ....

On the back end, you also need to handle this situation when clients send the same error multiple times. If you send reports via email, this could easily clog the developer's inbox. One approach is to create a key from the error report. For example, you could create a hash from the first three lines of the stack trace and then use this hash as key in a database or issue management system.

Lie-Fi and offline

If you are creating a web application that also runs when the device is offline or has a spotty internet connection, you might also be interested in error reports that occur during these offline periods. In this scenario, we can't directly send the report with fetch and hope the device is online.

Probably the best way to handle this is with the Background Sync API. Unfortunately, Background Sync is not yet supported by all browsers, but if you are interested in this API take a look at my blog post.

In this example, we're going to utilize a different approach. The error handler first tries to send the report with fetch. If that fails, it stores the report in IndexedDB and starts a job that tries to send the data periodically.

This is an approach that I learned from this video from Google:
https://www.youtube.com/watch?v=1nzCeB9sjWk

In this example, we're not going to use the native IndexedDB API. Instead, we're using Dexie.js. First, we create a class that extends from Dexie and defines the structure of the database. The following code creates a database with the name ClientErrors containing one object store errors. Each object in this object store contains the fields id and error. The error field stores the error report as JSON that we want to send to our error reporting back end.

import Dexie from 'dexie';


export class ClientErrorDb extends Dexie {
  errors!: Dexie.Table<ClientError, string>;

  constructor() {
    super('ClientErrors');
    this.version(1).stores({
      errors: '++id'
    });
  }
}

export interface ClientError {
  id?: number;
  error: string;
}


clientErrorDb.ts

See also my blog post about Dexie.js and Typescript for more information.

Next, we create a service that instantiates the ClientErrorDb class and provides some methods we're going to call from the global error handler.

import {ClientError, ClientErrorDb} from './clientErrorDb';
import {Injectable} from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class ClientErrorService {

  private db: ClientErrorDb;

  constructor() {
    this.db = new ClientErrorDb();
  }

  async store(body: string): Promise<void> {
    await this.db.errors.add({error: body});
  }

  async delete(ids: string[]): Promise<void> {
    await this.db.errors.bulkDelete(ids);
  }

  async getAll(): Promise<ClientError[]> {
    return this.db.errors.toArray();
  }

}

clientError.service.ts


Like in any other class, we can inject other services into the global error handler. Here we inject the ClientErrorService service into our handler.

@Injectable()
export class AppGlobalErrorhandler implements ErrorHandler {

  private isRetryRunning = false;

  constructor(private readonly clientErrorService: ClientErrorService) {
    this.sendStoredErrors();
    window.addEventListener('online', () => this.sendStoredErrors());
  }

app.global.errorhandler.ts

When the user opens the application, the constructor first calls the sendStoredErrors() method. This method looks for stored error reports in the database and tries to send them to the server.

Next, the constructor installs an event listener for the online event. Each time the browser emits this event, the application calls the sendStoredErrors() method. See this MDN page for more information about the online event

The handleError() method collects a few browser information, like in the example before. It then tries to post the data to the server. When this call fails, it stores the report into the database and schedules a call to sendStoredErrors() method in 60 seconds with setTimeout().

  async handleError(error: any): Promise<void> {
    console.error(error);

    // @ts-ignore
    const connection = navigator.connection;

    const userAgent = {
      language: navigator.language,
      platform: navigator.platform,
      userAgent: navigator.userAgent,
      connectionType: connection?.type,
    };
    const stackTrace = await StackTrace.fromError(error, {offline: true});
    const body = JSON.stringify({ts: Date.now(), userAgent, stackTrace});

    const wasOK = await this.sendError(body);
    if (!wasOK) {
      await this.clientErrorService.store(body);
      setTimeout(() => this.sendStoredErrors(), 60_000);
    }

  }

app.global.errorhandler.ts

The sendStoredErrors() first fetches all stored error reports from IndexedDB. If there is one or more, it tries to send them with the sendError() method. If this call succeeds, the method deletes all sent reports in IndexedDB, if the call fails, it schedules a new job run with setTimeout(). The method increases the wait time exponentially up to a maximum of 32 minutes.

  private async sendStoredErrors(): Promise<void> {
    if (this.isRetryRunning) {
      return;
    }

    let attempts = 1;
    const retry = async () => {
      const errors = await this.clientErrorService.getAll();
      if (errors.length === 0) {
        return;
      }

      const wasOK = await this.sendError(errors.map(error => error.error));
      if (wasOK) {
        const deleteIds: string[] = [];
        for (const error of errors) {
          if (error.id) {
            deleteIds.push(String(error.id));
          }
        }
        await this.clientErrorService.delete(deleteIds);
        this.isRetryRunning = false;
        return;
      }

      this.isRetryRunning = true;
      if (attempts < 32) {
        attempts = attempts * 2;
      }
      setTimeout(retry, attempts * 60_000);
    };

    await retry();
  }

app.global.errorhandler.ts

And finally, the code for the sendError() method. This method handles either single or multiple reports. Before sending the reports to the server, it wraps them into a JSON array. On the server, we map the request body to a java.util.List and don't have to care if the client sends only one or multiple error reports.

The method then tries to send a POST request and returns either true when the call succeeds or false if it fails.

  private async sendError(errors: string[] | string): Promise<boolean> {
    if (navigator.onLine) {
      try {

        let body;
        if (Array.isArray(errors)) {
          body = `[${errors.join(',')}]`;
        } else {
          body = `[${errors}]`;
        }

        const response = await fetch(`${environment.serverURL}/clientError`, {
          method: 'POST',
          body,
          headers: {
            'content-type': 'application/json'
          }
        });
        if (response.ok) {
          return true;
        }
      } catch (error) {
        console.log(error);
      }
    }

    return false;
  }

app.global.errorhandler.ts


In this blog post, we created a global error handler in an Angular application that sends error reports to a back end. If the device does not have a connection to our error reporting back end, it stores the reports in IndexedDB and tries to post them periodically.

You find the complete source for this application on GitHub:
https://github.com/ralscha/blog/tree/master/ngerrorhandler