Introduction: Catching the Unseen and Understanding the Unknown

Welcome to Chapter 15! In the previous chapters, you’ve mastered building robust and interactive Angular applications. But what happens when things go wrong? In the real world, errors are inevitable. Users might encounter unexpected issues, APIs might fail, or your application might hit an edge case you never anticipated. Without a solid strategy for handling these situations, your users will have a frustrating experience, and you, as a developer, will be flying blind, unable to diagnose and fix problems effectively.

This chapter dives deep into establishing a comprehensive system for global error handling, structured logging, and observability in your standalone Angular applications. We’ll learn how to gracefully catch unhandled errors, log them intelligently, and gain insights into your application’s health and performance in production. Our goal is not just to fix errors, but to understand why they happen and to provide a smooth, informative experience for the user even when things aren’t perfect.

By the end of this chapter, you’ll have the tools and knowledge to:

  • Catch and process application-wide errors using Angular’s ErrorHandler.
  • Implement a flexible logging service for client-side and server-side logging.
  • Integrate with external observability tools by sending structured error data.
  • Provide user-friendly error messages while maintaining detailed technical logs.
  • Leverage HTTP interceptors to enrich error context and handle API-specific errors.

Let’s transform your Angular app from a black box into a transparent, resilient system!

Core Concepts: Why We Need a Safety Net

Imagine your application as a complex machine. When a part breaks, you want to know immediately, understand what happened, and ideally, have a way to prevent catastrophic failure or at least inform the operator. In software, this means:

  • Global Error Handling: Catching errors that weren’t explicitly handled by try/catch blocks or RxJS catchError operators. These unhandled errors can crash parts of your UI or lead to unexpected behavior. Without a global handler, they often just get logged to the browser console, invisible to you and your users.
  • Structured Logging: Instead of just dumping raw error messages, structured logging means organizing your log data into a consistent format (like JSON). This makes logs easier to search, filter, aggregate, and analyze, especially when you have millions of log entries from thousands of users. It’s the difference between finding a needle in a haystack and finding it in a neatly labeled drawer.
  • Observability: This is the ability to understand the internal state of your application based on the data it emits – primarily logs, metrics (e.g., performance data, error rates), and traces (showing the journey of a request across multiple services). Observability helps you answer questions like “Why is this page slow?” or “How many users are affected by this error?”. It’s about proactive monitoring and deep insights, not just reactive debugging.
  • User-Safe Messaging: Not every error needs to be shown to the user with its full technical stack trace. Some errors are internal issues that should be logged but presented to the user as a polite “Something went wrong, please try again.” or “We’re experiencing technical difficulties.” This prevents user confusion and protects sensitive internal information.

The Angular ErrorHandler: Your Application’s First Responder

Angular provides a built-in mechanism for global error handling: the ErrorHandler service. When an unhandled exception occurs anywhere in your Angular application (component lifecycle, service methods, RxJS subscriptions without catchError), Angular calls the handleError method of this service.

By default, Angular’s ErrorHandler simply logs the error to the browser console. But the magic happens when you provide your own custom implementation of ErrorHandler. This allows you to intercept all unhandled errors and decide what to do with them: log to a remote server, display a user-friendly message, trigger a UI change, or even collect additional diagnostic information.

flowchart TD A["Angular Application"] -->|"Unhandled Error Occurs"| B{Angular's ErrorHandler} B -->|"Calls handleError()"| C["CustomErrorHandlerService"] C -->|"Log Details"| D["LoggingService"] D -->|"Send Structured Log"| E[Remote Logging/Monitoring Service] C -->|"Display Message"| F["UserMessageService"] F -->|"Show User-Friendly UI"| G["User Interface"]

Figure 15.1: Global Error Handling Flow

Why Not Just try/catch Everywhere?

While try/catch blocks are great for handling synchronous errors in specific code segments, they don’t catch:

  • Asynchronous errors (like those in Promises or Observables) unless explicitly handled with .catch() or catchError().
  • Errors in Angular’s internal mechanisms (e.g., change detection, component lifecycle hooks).
  • Errors in template expressions.

The ErrorHandler provides a safety net for all these cases, ensuring no error goes completely unnoticed.

Step-by-Step Implementation: Building Our Observability Stack

Let’s build a robust error handling and logging system for our standalone Angular application.

First, ensure you have an Angular project set up with the latest stable version (as of 2026-02-11, let’s assume Angular v19 or v20, which fully embraces standalone components and app.config.ts).

If you need to create a new project:

ng new my-observability-app --standalone --strict
cd my-observability-app

Step 1: Creating a Custom Error Handler

Our first step is to create a service that implements Angular’s ErrorHandler. This service will be the central point for catching unhandled errors.

Create a new service file:

ng generate service services/custom-error-handler

Now, open src/app/services/custom-error-handler.service.ts and modify it:

// src/app/services/custom-error-handler.service.ts
import { ErrorHandler, Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class CustomErrorHandlerService implements ErrorHandler {

  constructor() { }

  handleError(error: any): void {
    // For now, let's just log it. We'll enhance this soon!
    console.error('An unhandled error occurred:', error);

    // IMPORTANT: Re-throw the error if you want Angular's default console logging
    // or other ErrorHandlers in the chain to still process it.
    // If you don't re-throw, you effectively "swallow" the error.
    // For a global handler, we often want to log and perhaps send to a remote service,
    // but not necessarily stop the default console behavior during development.
    // In production, you might suppress default console logging after remote logging.
    // throw error; // Uncomment if you want default console error behavior as well
  }
}

Explanation:

  • We import { ErrorHandler, Injectable } from '@angular/core';. ErrorHandler is the interface we need to implement.
  • Our CustomErrorHandlerService is providedIn: 'root', making it a singleton available throughout the application.
  • The handleError(error: any): void method is where all the magic happens. Angular will call this method with any unhandled error.
  • For now, we’re simply logging to the console. We explicitly note the decision of whether to re-throw the error. For now, let’s keep it simple and just log.

Next, we need to tell Angular to use our custom error handler instead of the default one. This is done in app.config.ts.

Open src/app/app.config.ts:

// src/app/app.config.ts
import { ApplicationConfig, ErrorHandler } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; // We'll need this soon

import { routes } from './app.routes';
import { CustomErrorHandlerService } from './services/custom-error-handler.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(), // Add this here, even if not used immediately
    { provide: ErrorHandler, useClass: CustomErrorHandlerService } // <-- This is key!
  ]
};

Explanation:

  • We import { ErrorHandler } from '@angular/core'; and our CustomErrorHandlerService.
  • Inside the providers array in appConfig, we add an object literal:
    • provide: ErrorHandler: This tells Angular that we are providing an implementation for the ErrorHandler token.
    • useClass: CustomErrorHandlerService: This specifies that our CustomErrorHandlerService should be used whenever ErrorHandler is requested.
  • We’ve also added provideHttpClient() as we’ll need it shortly for sending logs to a backend.

Let’s test it! Modify your app.component.ts to intentionally throw an error:

// src/app/app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet],
  template: `
    <h1>Welcome to Global Error Handling!</h1>
    <button (click)="throwError()">Trigger an Error</button>
    <router-outlet />
  `,
  styles: []
})
export class AppComponent {
  title = 'my-observability-app';

  throwError(): void {
    // This will be caught by our CustomErrorHandlerService
    throw new Error('This is a test error from AppComponent!');
  }
}

Run your application (ng serve) and click the “Trigger an Error” button. You should see “An unhandled error occurred: Error: This is a test error from AppComponent!” in your browser’s developer console, logged by our CustomErrorHandlerService. Success!

Step 2: Introducing a Dedicated Logging Service

Logging directly from CustomErrorHandlerService can become messy. It’s best practice to abstract logging into its own service. This allows us to easily switch logging targets (console, remote server, different monitoring tools) without touching the error handler.

Create a new service:

ng generate service services/logging

Open src/app/services/logging.service.ts and modify it:

// src/app/services/logging.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment'; // Assuming you have an environment file

// Define a simple interface for structured log messages
export interface LogMessage {
  timestamp: string;
  level: 'info' | 'warn' | 'error' | 'debug';
  message: string;
  context?: any; // Additional data related to the log
  component?: string;
  userId?: string; // Example: Add user context
  correlationId?: string; // For tracing requests
}

@Injectable({
  providedIn: 'root'
})
export class LoggingService {
  private readonly loggingApiUrl = environment.loggingApiUrl || '/api/logs'; // Replace with your actual logging endpoint

  constructor(private http: HttpClient) { }

  private sendLog(log: LogMessage): void {
    // In a real application, you'd batch these or use a dedicated logging library
    // For demonstration, we'll send it directly.
    console.log(`[${log.level.toUpperCase()}] ${log.timestamp} - ${log.message}`, log.context || '');

    // Only send to backend in production or when explicitly configured
    if (environment.production) {
      this.http.post(this.loggingApiUrl, log).subscribe({
        next: () => { /* Log sent successfully */ },
        error: (err) => console.error('Failed to send log to backend:', err)
      });
    }
  }

  info(message: string, context?: any, component?: string): void {
    this.sendLog({
      timestamp: new Date().toISOString(),
      level: 'info',
      message,
      context,
      component
    });
  }

  warn(message: string, context?: any, component?: string): void {
    this.sendLog({
      timestamp: new Date().toISOString(),
      level: 'warn',
      message,
      context,
      component
    });
  }

  error(message: string, context?: any, component?: string): void {
    this.sendLog({
      timestamp: new Date().toISOString(),
      level: 'error',
      message,
      context,
      component
    });
  }

  debug(message: string, context?: any, component?: string): void {
    if (!environment.production) { // Only log debug in non-production environments
      this.sendLog({
        timestamp: new Date().toISOString(),
        level: 'debug',
        message,
        context,
        component
      });
    }
  }
}

Explanation:

  • We import { HttpClient } from '@angular/common/http'; to enable sending logs to a backend.
  • We define a LogMessage interface for structured logging. This is crucial for observability! Instead of just a string, we capture timestamp, level, message, context, component, userId, and correlationId.
  • The sendLog method handles both console logging (for development visibility) and sending to a backend API (conditionally, based on environment.production).
  • We provide helper methods (info, warn, error, debug) for different log levels, making it easy to use this service throughout your application.
  • Important: We’re assuming an environment.ts file. If you don’t have one, create src/environments/environment.ts and src/environments/environment.prod.ts with content like:
    // src/environments/environment.ts
    export const environment = {
      production: false,
      loggingApiUrl: 'http://localhost:3000/api/logs' // Example local logging endpoint
    };
    
    // src/environments/environment.prod.ts
    export const environment = {
      production: true,
      loggingApiUrl: 'https://api.your-prod-domain.com/logs' // Production logging endpoint
    };
    
    (Remember to add provideHttpClient() in app.config.ts if you haven’t yet, as shown in Step 1).

Now, let’s update our CustomErrorHandlerService to use the new LoggingService.

Open src/app/services/custom-error-handler.service.ts:

// src/app/services/custom-error-handler.service.ts
import { ErrorHandler, Injectable } from '@angular/core';
import { LoggingService } from './logging.service'; // Import our logging service
import { HttpErrorResponse } from '@angular/common/http'; // For distinguishing HTTP errors

@Injectable({
  providedIn: 'root'
})
export class CustomErrorHandlerService implements ErrorHandler {

  constructor(private loggingService: LoggingService) { } // Inject LoggingService

  handleError(error: any): void {
    let errorMessage = 'An unknown error occurred!';
    let errorContext: any = {};

    if (error instanceof HttpErrorResponse) {
      // It's an HTTP error (e.g., failed API call)
      errorMessage = `HTTP Error: ${error.status} - ${error.message}`;
      errorContext = {
        url: error.url,
        status: error.status,
        statusText: error.statusText,
        errorBody: error.error // The actual error response from the server
      };
      this.loggingService.error(errorMessage, errorContext, 'HttpClient');
    } else if (error instanceof Error) {
      // It's a client-side JavaScript error
      errorMessage = `Client Error: ${error.message}`;
      errorContext = {
        stack: error.stack // Include stack trace for client errors
      };
      this.loggingService.error(errorMessage, errorContext, 'AppGlobal');
    } else {
      // A non-Error, non-HttpErrorResponse object was thrown
      errorMessage = `Non-standard Error: ${JSON.stringify(error)}`;
      errorContext = { originalError: error };
      this.loggingService.error(errorMessage, errorContext, 'AppGlobal');
    }

    // Optionally re-throw for Angular's default console logger or other handlers
    // console.error(error); // If you want to see the raw error in console too
  }
}

Explanation:

  • We inject LoggingService into our CustomErrorHandlerService.
  • We now use instanceof checks to differentiate between HttpErrorResponse (errors from API calls) and standard Error objects (client-side runtime errors). This allows for more targeted logging and context.
  • For each error type, we construct a descriptive errorMessage and an errorContext object containing relevant details. This is the essence of structured logging.
  • Finally, we call this.loggingService.error() to send our structured error data.

Test it again!

  1. Click the “Trigger an Error” button in app.component.ts. You’ll see a structured log in your console with [ERROR] and Client Error details.
  2. To test HttpErrorResponse, you’d typically have an API call that fails. For now, we’ll demonstrate this conceptually, and you can later integrate it with a failing API endpoint.

Step 3: Enhancing Observability with HTTP Interceptors for Correlation IDs

In distributed systems, understanding the flow of a request is critical. A correlationId (or requestId) is a unique identifier assigned to an initial request that is then passed along to all subsequent operations and services involved in fulfilling that request. This allows you to trace a single user action through your frontend, multiple backend services, and logging systems.

Let’s create an HTTP interceptor to automatically add a correlationId to all outgoing HTTP requests.

Create a new file for our interceptor:

ng generate interceptor interceptors/correlation-id

Open src/app/interceptors/correlation-id.interceptor.ts:

// src/app/interceptors/correlation-id.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { v4 as uuidv4 } from 'uuid'; // For generating unique IDs

export const correlationIdInterceptor: HttpInterceptorFn = (req, next) => {
  let correlationId = sessionStorage.getItem('correlationId'); // Try to reuse if already set for session
  if (!correlationId) {
    correlationId = uuidv4(); // Generate a new UUID
    sessionStorage.setItem('correlationId', correlationId);
  }

  const clonedRequest = req.clone({
    setHeaders: {
      'X-Correlation-ID': correlationId // Common header name
    }
  });

  return next(clonedRequest);
};

Explanation:

  • We use the HttpInterceptorFn type, which is the modern, standalone-friendly way to create interceptors in Angular (as of v15+).
  • We generate a UUID (Universally Unique Identifier) using the uuid library. You’ll need to install it: npm install uuid @types/uuid.
  • The correlationId is stored in sessionStorage to maintain it across requests within the same browser session.
  • req.clone() creates a mutable copy of the request, allowing us to add the X-Correlation-ID header.
  • next(clonedRequest) passes the modified request to the next interceptor in the chain or to the HttpClient backend.

Now, register this interceptor in app.config.ts:

// src/app/app.config.ts
import { ApplicationConfig, ErrorHandler } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; // Import withInterceptors

import { routes } from './app.routes';
import { CustomErrorHandlerService } from './services/custom-error-handler.service';
import { correlationIdInterceptor } from './interceptors/correlation-id.interceptor'; // Import our interceptor

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(
      withInterceptors([
        correlationIdInterceptor // Register our correlation ID interceptor
      ])
    ),
    { provide: ErrorHandler, useClass: CustomErrorHandlerService }
  ]
};

Explanation:

  • We import withInterceptors and our correlationIdInterceptor.
  • We pass an array of interceptors to withInterceptors() within provideHttpClient(). The order matters! Interceptors are executed in the order they appear in this array.

Now, any HTTP request made by your Angular app will automatically include the X-Correlation-ID header. Your backend services can then extract this ID from the header and include it in their own logs, allowing you to trace a request end-to-end.

To make our LoggingService aware of this correlationId, we’ll need a way to access it. Since interceptors are separate from services, we can use a service to hold the current correlation ID.

Create a new service:

ng generate service services/correlation-context

Open src/app/services/correlation-context.service.ts:

// src/app/services/correlation-context.service.ts
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class CorrelationContextService {
  private _currentCorrelationId: string | null = null;

  constructor() { }

  setCorrelationId(id: string): void {
    this._currentCorrelationId = id;
  }

  getCorrelationId(): string | null {
    return this._currentCorrelationId;
  }
}

Now, modify correlationIdInterceptor to use this service:

// src/app/interceptors/correlation-id.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { v4 as uuidv4 } from 'uuid';
import { inject } from '@angular/core'; // Import inject for functional interceptors
import { CorrelationContextService } from '../services/correlation-context.service';

export const correlationIdInterceptor: HttpInterceptorFn = (req, next) => {
  const correlationContextService = inject(CorrelationContextService); // Inject the service

  let correlationId = correlationContextService.getCorrelationId();
  if (!correlationId) {
    correlationId = sessionStorage.getItem('correlationId');
    if (!correlationId) {
      correlationId = uuidv4();
      sessionStorage.setItem('correlationId', correlationId);
    }
    correlationContextService.setCorrelationId(correlationId); // Set it in the service
  }

  const clonedRequest = req.clone({
    setHeaders: {
      'X-Correlation-ID': correlationId
    }
  });

  return next(clonedRequest);
};

Explanation:

  • We use inject(CorrelationContextService) to get an instance of our new service within the functional interceptor.
  • The interceptor now sets the correlationId in CorrelationContextService so other services can access it.

Finally, update LoggingService to include the correlationId in its structured logs.

Open src/app/services/logging.service.ts:

// src/app/services/logging.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
import { CorrelationContextService } from './correlation-context.service'; // Import

// ... LogMessage interface remains the same ...

@Injectable({
  providedIn: 'root'
})
export class LoggingService {
  private readonly loggingApiUrl = environment.loggingApiUrl || '/api/logs';

  constructor(
    private http: HttpClient,
    private correlationContextService: CorrelationContextService // Inject
  ) { }

  private sendLog(log: LogMessage): void {
    // Add correlation ID from the service
    log.correlationId = this.correlationContextService.getCorrelationId() || undefined;

    console.log(`[${log.level.toUpperCase()}] ${log.timestamp} - ${log.message}`, log.context || '');

    if (environment.production) {
      this.http.post(this.loggingApiUrl, log).subscribe({
        next: () => { /* Log sent successfully */ },
        error: (err) => console.error('Failed to send log to backend:', err)
      });
    }
  }

  // ... info, warn, error, debug methods remain the same ...
}

Explanation:

  • We inject CorrelationContextService.
  • Before sending any log, we retrieve the current correlationId from the service and add it to our LogMessage object.

Now, every log sent by LoggingService (including errors caught by CustomErrorHandlerService) will include a correlationId, greatly improving your ability to trace issues.

Step 4: User-Friendly Error Messages with a Notification Service

While we want detailed logs for ourselves, we don’t want to bombard users with technical jargon. Let’s create a simple notification service to display user-friendly messages.

Create a new service:

ng generate service services/user-message

Open src/app/services/user-message.service.ts:

// src/app/services/user-message.service.ts
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

// Define an interface for our message
export interface UserNotification {
  message: string;
  type: 'success' | 'info' | 'warning' | 'error';
  duration?: number; // How long to show the message (ms)
}

@Injectable({
  providedIn: 'root'
})
export class UserMessageService {
  private _notifications = new Subject<UserNotification>();
  readonly notifications$ = this._notifications.asObservable(); // Expose as observable

  constructor() { }

  show(message: string, type: UserNotification['type'] = 'info', duration: number = 5000): void {
    this._notifications.next({ message, type, duration });
  }

  success(message: string, duration?: number): void {
    this.show(message, 'success', duration);
  }

  error(message: string, duration?: number): void {
    this.show(message, 'error', duration === undefined ? 8000 : duration); // Errors stay longer
  }

  warning(message: string, duration?: number): void {
    this.show(message, 'warning', duration);
  }

  info(message: string, duration?: number): void {
    this.show(message, 'info', duration);
  }
}

Explanation:

  • We use an RxJS Subject (_notifications) to emit new notification messages.
  • notifications$ is exposed as an Observable for components to subscribe to.
  • Helper methods (success, error, warning, info) make it easy to trigger different types of messages.

Now, let’s create a simple component to display these notifications.

Create a new standalone component:

ng generate component components/notification-toast

Open src/app/components/notification-toast/notification-toast.component.ts:

// src/app/components/notification-toast/notification-toast.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { NgIf, NgFor, NgClass } from '@angular/common'; // For structural directives and class binding
import { Subscription } from 'rxjs';
import { UserMessageService, UserNotification } from '../../services/user-message.service';

@Component({
  selector: 'app-notification-toast',
  standalone: true,
  imports: [NgIf, NgFor, NgClass],
  template: `
    <div class="notification-container">
      <div *ngFor="let notification of activeNotifications"
           class="notification"
           [ngClass]="'notification-' + notification.type">
        <span>{{ notification.message }}</span>
        <button (click)="dismissNotification(notification)">X</button>
      </div>
    </div>
  `,
  styles: [`
    .notification-container {
      position: fixed;
      top: 20px;
      right: 20px;
      z-index: 1000;
      display: flex;
      flex-direction: column;
      gap: 10px;
    }
    .notification {
      padding: 10px 15px;
      border-radius: 5px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      color: white;
      min-width: 250px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.2);
    }
    .notification-success { background-color: #4CAF50; }
    .notification-info { background-color: #2196F3; }
    .notification-warning { background-color: #ff9800; }
    .notification-error { background-color: #f44336; }
    .notification button {
      background: none;
      border: none;
      color: white;
      font-weight: bold;
      cursor: pointer;
      margin-left: 10px;
    }
  `]
})
export class NotificationToastComponent implements OnInit, OnDestroy {
  activeNotifications: (UserNotification & { id: number })[] = [];
  private notificationSubscription!: Subscription;
  private nextNotificationId = 0;

  constructor(private userMessageService: UserMessageService) { }

  ngOnInit(): void {
    this.notificationSubscription = this.userMessageService.notifications$.subscribe(
      notification => {
        const notificationWithId = { ...notification, id: this.nextNotificationId++ };
        this.activeNotifications.push(notificationWithId);
        setTimeout(() => this.dismissNotification(notificationWithId), notification.duration);
      }
    );
  }

  dismissNotification(notificationToDismiss: UserNotification & { id: number }): void {
    this.activeNotifications = this.activeNotifications.filter(
      notification => notification.id !== notificationToDismiss.id
    );
  }

  ngOnDestroy(): void {
    this.notificationSubscription.unsubscribe();
  }
}

Explanation:

  • This component subscribes to UserMessageService.notifications$.
  • When a notification is received, it’s added to activeNotifications.
  • A setTimeout is used to automatically dismiss the notification after its duration.
  • Basic styling is included for a toast-like appearance.

Finally, include this component in your app.component.ts so it’s always present:

// src/app/app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { NotificationToastComponent } from './components/notification-toast/notification-toast.component'; // Import

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, NotificationToastComponent], // Add to imports
  template: `
    <app-notification-toast></app-notification-toast> <!-- Add to template -->
    <h1>Welcome to Global Error Handling!</h1>
    <button (click)="throwError()">Trigger a Client Error</button>
    <button (click)="triggerHttpError()">Trigger an HTTP Error (404)</button>
    <router-outlet />
  `,
  styles: []
})
export class AppComponent {
  title = 'my-observability-app';

  // ... throwError() method remains the same ...

  // Add a method to simulate HTTP errors
  constructor(private userMessageService: UserMessageService) {} // Inject UserMessageService

  throwError(): void {
    throw new Error('This is a test client error from AppComponent!');
  }

  triggerHttpError(): void {
    // We'll simulate this more robustly with an interceptor,
    // but for now, let's show a user message directly.
    this.userMessageService.error('Failed to load data. Please try again later.');
    // In a real scenario, an HTTP interceptor would catch the 404 and call userMessageService.
  }
}

Explanation:

  • We import NotificationToastComponent and add it to imports and the template.
  • We inject UserMessageService to manually trigger an error message for demonstration.

Now, modify CustomErrorHandlerService to use UserMessageService for user-facing errors.

Open src/app/services/custom-error-handler.service.ts:

// src/app/services/custom-error-handler.service.ts
import { ErrorHandler, Injectable } from '@angular/core';
import { LoggingService } from './logging.service';
import { HttpErrorResponse } from '@angular/common/http';
import { UserMessageService } from './user-message.service'; // Import

@Injectable({
  providedIn: 'root'
})
export class CustomErrorHandlerService implements ErrorHandler {

  constructor(
    private loggingService: LoggingService,
    private userMessageService: UserMessageService // Inject
  ) { }

  handleError(error: any): void {
    let errorMessage = 'An unexpected error occurred!';
    let userFriendlyMessage = 'Something went wrong. Please try again.';
    let errorContext: any = {};

    if (error instanceof HttpErrorResponse) {
      errorMessage = `HTTP Error: ${error.status} - ${error.message}`;
      errorContext = {
        url: error.url,
        status: error.status,
        statusText: error.statusText,
        errorBody: error.error
      };

      if (error.status >= 500) {
        userFriendlyMessage = 'Our servers are experiencing issues. Please bear with us.';
      } else if (error.status === 404) {
        userFriendlyMessage = 'The requested resource could not be found.';
      } else if (error.status === 401 || error.status === 403) {
        userFriendlyMessage = 'You are not authorized to perform this action.';
      } else {
        userFriendlyMessage = `Failed to complete request (Code: ${error.status}).`;
      }

      this.loggingService.error(errorMessage, errorContext, 'HttpClient');
      this.userMessageService.error(userFriendlyMessage); // Show user-friendly message
    } else if (error instanceof Error) {
      errorMessage = `Client Error: ${error.message}`;
      errorContext = { stack: error.stack };
      this.loggingService.error(errorMessage, errorContext, 'AppGlobal');
      this.userMessageService.error(userFriendlyMessage); // Generic message for client errors
    } else {
      errorMessage = `Non-standard Error: ${JSON.stringify(error)}`;
      errorContext = { originalError: error };
      this.loggingService.error(errorMessage, errorContext, 'AppGlobal');
      this.userMessageService.error(userFriendlyMessage);
    }

    // console.error(error); // Keep for dev visibility if desired
  }
}

Explanation:

  • We inject UserMessageService.
  • For each error type, we now define a userFriendlyMessage in addition to the errorMessage.
  • We use this.userMessageService.error() to display the user-friendly message, keeping the technical details for the logs.
  • Notice how we differentiate user messages based on HTTP status codes – a common and good practice!

Test it!

  1. Run ng serve.
  2. Click “Trigger a Client Error”. You’ll see “Something went wrong. Please try again.” in a red toast notification, and a detailed client error in your console.
  3. Click “Trigger an HTTP Error (404)” (this only triggers the UserMessageService directly for now). You’ll see “Failed to load data. Please try again later.” (or the specific message you set in triggerHttpError).

This layered approach ensures that users get helpful feedback while developers get the detailed information needed for debugging and monitoring.

Step 5: Advanced HTTP Error Handling with Interceptors

While our CustomErrorHandlerService catches HttpErrorResponse instances, sometimes you want to handle HTTP errors before they reach the global error handler, especially for specific status codes or to transform errors. For instance, to ensure HTTP errors also trigger user messages, we can use another interceptor.

Create a new interceptor:

ng generate interceptor interceptors/http-error-handler

Open src/app/interceptors/http-error-handler.interceptor.ts:

// src/app/interceptors/http-error-handler.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
import { inject } from '@angular/core';
import { UserMessageService } from '../services/user-message.service';
import { LoggingService } from '../services/logging.service';

export const httpErrorHandlerInterceptor: HttpInterceptorFn = (req, next) => {
  const userMessageService = inject(UserMessageService);
  const loggingService = inject(LoggingService);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      let userFriendlyMessage = 'An API error occurred. Please try again.';
      const errorMessage = `HTTP Error (Interceptor): ${error.status} - ${error.message}`;
      const errorContext = {
        url: req.url,
        method: req.method,
        status: error.status,
        statusText: error.statusText,
        errorBody: error.error
      };

      if (error.status >= 500) {
        userFriendlyMessage = 'Server is currently unavailable. We are working on it!';
      } else if (error.status === 401) {
        userFriendlyMessage = 'Your session has expired or you are not authorized.';
        // Potentially trigger a logout or token refresh flow here
      } else if (error.status === 403) {
        userFriendlyMessage = 'You do not have permission to access this resource.';
      } else if (error.status === 404) {
        userFriendlyMessage = 'The requested item was not found.';
      } else if (error.status === 0) { // Network error, CORS, etc.
        userFriendlyMessage = 'Could not connect to the server. Check your internet.';
      }
      // Add more specific error handling based on your API's error responses

      loggingService.error(errorMessage, errorContext, 'HttpInterceptor');
      userMessageService.error(userFriendlyMessage);

      // IMPORTANT: Always re-throw the error so that subsequent handlers (like CustomErrorHandlerService)
      // or component-specific `catchError` operators can still process it.
      return throwError(() => error);
    })
  );
};

Explanation:

  • This interceptor uses the catchError RxJS operator to intercept errors from the next(req) observable.
  • Inside catchError, we check the HttpErrorResponse status and provide a more specific userFriendlyMessage.
  • Both loggingService and userMessageService are injected using inject().
  • Crucially, we return throwError(() => error); to re-throw the error. If we didn’t, the error would be “swallowed” here, and CustomErrorHandlerService would never see it. Re-throwing ensures the error propagates to any component-level catchError or eventually to our global CustomErrorHandlerService (though our interceptor already handled the user messaging and logging). This gives us flexibility.

Now, register this interceptor in app.config.ts. Make sure it comes after the correlationIdInterceptor if you want the correlationId to be present on the request when this interceptor runs.

Open src/app/app.config.ts:

// src/app/app.config.ts
import { ApplicationConfig, ErrorHandler } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';

import { routes } from './app.routes';
import { CustomErrorHandlerService } from './services/custom-error-handler.service';
import { correlationIdInterceptor } from './interceptors/correlation-id.interceptor';
import { httpErrorHandlerInterceptor } from './interceptors/http-error-handler.interceptor'; // Import

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(
      withInterceptors([
        correlationIdInterceptor,
        httpErrorHandlerInterceptor // Register our HTTP error handler interceptor
      ])
    ),
    { provide: ErrorHandler, useClass: CustomErrorHandlerService }
  ]
};

Explanation:

  • The order in withInterceptors matters. correlationIdInterceptor runs first, adding the ID. Then httpErrorHandlerInterceptor runs, processing the response (or error).

Test it! To properly test this, you’ll need a backend endpoint that returns a 404. Let’s create a simple service and modify app.component.ts to call it.

Create a new service:

ng generate service services/data

Open src/app/services/data.service.ts:

// src/app/services/data.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class DataService {
  constructor(private http: HttpClient) { }

  getNonExistentData(): Observable<any> {
    // This URL should return a 404 Not Found
    return this.http.get('/api/non-existent-data');
  }
}

Now, modify app.component.ts to use this service:

// src/app/app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { NotificationToastComponent } from './components/notification-toast/notification-toast.component';
import { DataService } from './services/data.service'; // Import
import { UserMessageService } from './services/user-message.service'; // Keep this for direct messages

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, NotificationToastComponent],
  template: `
    <app-notification-toast></app-notification-toast>
    <h1>Welcome to Global Error Handling!</h1>
    <button (click)="throwError()">Trigger a Client Error</button>
    <button (click)="loadDataWithError()">Load Non-Existent Data (HTTP 404)</button>
    <router-outlet />
  `,
  styles: []
})
export class AppComponent {
  title = 'my-observability-app';

  constructor(
    private dataService: DataService,
    private userMessageService: UserMessageService // Still useful for direct messages
  ) { }

  throwError(): void {
    throw new Error('This is a test client error from AppComponent!');
  }

  loadDataWithError(): void {
    this.dataService.getNonExistentData().subscribe({
      next: (data) => console.log('Data loaded:', data),
      error: (err) => {
        console.error('Error handled in component (but interceptor already ran):', err);
        // The interceptor already showed a user message and logged.
        // We could show a *different* message here if needed,
        // or just let the global handler take over if we didn't re-throw.
      }
    });
  }
}

Explanation:

  • We inject DataService.
  • loadDataWithError() calls getNonExistentData(). Since /api/non-existent-data doesn’t exist, this will result in a 404.
  • The httpErrorHandlerInterceptor will catch this 404, log it, and display a user-friendly message.
  • The error callback in subscribe will also be triggered because our interceptor re-throws the error. This shows how you can have multiple layers of error handling.

To make /api/non-existent-data actually return a 404:

  • If you’re using ng serve, it will proxy requests. By default, it might just return a generic page.
  • For a real test, you’d need a simple Node.js Express server (or similar) running on http://localhost:3000 (matching loggingApiUrl and where /api routes are expected) that returns a 404 for this specific path.

Example Express server (save as server.js and run node server.js):

// server.js
const express = require('express');
const app = express();
const port = 3000;

app.use(express.json()); // For parsing application/json

// Allow CORS from Angular dev server
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'http://localhost:4200'); // Your Angular dev server port
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, X-Correlation-ID');
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  if (req.method === 'OPTIONS') {
    return res.sendStatus(200); // Handle preflight requests
  }
  next();
});

// Simulate a logging endpoint
app.post('/api/logs', (req, res) => {
  console.log('Received log:', req.body);
  res.status(200).send({ message: 'Log received' });
});

// Endpoint that returns 404
app.get('/api/non-existent-data', (req, res) => {
  console.log('Request for non-existent data received.');
  res.status(404).json({ message: 'Resource not found', code: 'NOT_FOUND' });
});

// Default catch-all for other /api routes
app.all('/api/*', (req, res) => {
  res.status(500).json({ message: 'Generic API error', code: 'GENERIC_API_ERROR' });
});

app.listen(port, () => {
  console.log(`Mock API server listening at http://localhost:${port}`);
});

Install express: npm install express. Run node server.js in a separate terminal. Then run your Angular app ng serve. Now, click “Load Non-Existent Data (HTTP 404)”. You should see the user message “The requested item was not found.” (from httpErrorHandlerInterceptor) and detailed logs in both your browser console and the Node.js server console.

This setup provides a robust, layered approach to global error handling, logging, and observability.

Mini-Challenge: Customizing Error Display Based on User Role

Challenge: Extend the httpErrorHandlerInterceptor to display a different user-friendly message for a 403 Forbidden error if the current user has an “Admin” role versus a “Guest” role. Assume you have a AuthService that can provide the user’s role.

Hint:

  1. Create a dummy AuthService with a getUserRole() method that returns 'Admin' or 'Guest'.
  2. Inject AuthService into httpErrorHandlerInterceptor using inject().
  3. Inside the catchError block, check error.status === 403 and then use authService.getUserRole() to determine the message.

What to observe/learn:

  • How to conditionally customize user messages based on application state (like user roles).
  • Further practice with inject() in functional interceptors.

Common Pitfalls & Troubleshooting

  1. Swallowing Errors: The most dangerous pitfall! If your ErrorHandler or an HttpInterceptor catches an error but doesn’t re-throw it (throw error or return throwError(() => error)), the error stops propagating. This means no other handlers (including default browser logging or component-specific catchError) will see it. Always consider if you need to re-throw.

    • Troubleshooting: If errors disappear from your console or CustomErrorHandlerService isn’t called for certain errors, check your catchError blocks in interceptors and services for missing throwError.
  2. Circular Dependencies: This can occur if your CustomErrorHandlerService tries to inject a service that, directly or indirectly, also depends on ErrorHandler. Angular’s dependency injector will detect this.

    • Example: If LoggingService needed ErrorHandler (which it shouldn’t), and CustomErrorHandlerService needed LoggingService, you’d have a cycle.
    • Troubleshooting: Look for dependency injection errors in the console. Restructure your services to break cycles. Often, this means having a simpler ErrorHandler that only injects core logging/messaging, and those services don’t depend back on ErrorHandler.
  3. Over-logging Sensitive Data (PII): Be extremely careful not to log Personally Identifiable Information (PII) or sensitive business data to external logging services.

    • Troubleshooting: Regularly audit your log payloads. Implement data scrubbing or redaction in your LoggingService or on the backend before storing logs. Never include raw request bodies or query parameters if they might contain PII in your default error contexts.
  4. Logging Performance Overhead: Sending a lot of detailed logs, especially from every user interaction or on every error, can impact application performance and generate significant network traffic.

    • Troubleshooting:
      • Batching: Instead of sending each log individually, collect logs over a short period (e.g., 5 seconds) and send them in a single batch request.
      • Sampling: In high-traffic applications, you might only log a percentage of non-critical events.
      • Throttling: Limit the rate at which similar errors are sent.
  5. Debugging ErrorHandler and Interceptors: These are global and run early in the application lifecycle, sometimes before other parts are fully initialized.

    • Troubleshooting: Use console.log statements liberally within your ErrorHandler and interceptors during development. Ensure your app.config.ts setup is correct for provider order and withInterceptors. If an error isn’t caught, trace its origin: is it synchronous? asynchronous? happening in a zone?

Summary: Building a Resilient and Understandable Application

Phew! You’ve just equipped your Angular application with a powerful safety net and a pair of x-ray glasses for production. Here’s a quick recap of what we covered:

  • Global Error Handling with ErrorHandler: You learned how to override Angular’s default ErrorHandler with your custom service (CustomErrorHandlerService) to catch all unhandled errors throughout your application.
  • Structured Logging with LoggingService: We built a dedicated LoggingService to abstract logging concerns, allowing you to send rich, structured log data (including correlationId, component, context) to the console and a remote backend.
  • Enhanced Observability with CorrelationContextService and correlationIdInterceptor: You implemented an HTTP interceptor to automatically inject a unique X-Correlation-ID header into all outgoing requests, enabling end-to-end tracing of user actions across your frontend and backend services.
  • User-Safe Messaging with UserMessageService and NotificationToastComponent: You created a system to display user-friendly, non-technical messages to your users via toast notifications, improving the user experience during errors.
  • Layered HTTP Error Handling with httpErrorHandlerInterceptor: You leveraged another HTTP interceptor to specifically catch HttpErrorResponse instances, providing more granular control over logging and user messaging for API-related issues before they reach the global ErrorHandler.

By applying these patterns, you’re not just fixing bugs; you’re building a system that tells you what went wrong, where, when, and potentially why, all while keeping your users informed and calm. This deep system-level understanding is crucial for maintaining and scaling production-ready Angular applications.

References

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.