You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by sh...@apache.org on 2023/08/11 13:45:45 UTC

[trafficcontrol] branch master updated: Tpv2 logging overhaul (#7673)

This is an automated email from the ASF dual-hosted git repository.

shamrick pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new 08cd924ae6 Tpv2 logging overhaul (#7673)
08cd924ae6 is described below

commit 08cd924ae653273b2e800a46a6704f218f1e3fa0
Author: ocket8888 <oc...@apache.org>
AuthorDate: Fri Aug 11 07:45:39 2023 -0600

    Tpv2 logging overhaul (#7673)
    
    * Add a logging class
    
    * re-name things to include a Log prefix
    
    Because they're exported from utils, so the import wouldn't stutter in
    most cases even if you decide to import the whole module as a namespace
    (for whatever reason).
    
    * Add middleware for logging and error handling
    
    * Move file compression stuff into middleware
    
    this simplifies it a bit and also switched to using fs/promises which is
    a bit faster
    
    * rework TO proxy handler to use a logger
    
    * Add a front-end logging service
    
    * fixup allowed console methods now that there's a logger
    
    * Fix linting errors arising from not using loggers
    
    * Remove console calls from tests
    
    Also removes some try/catch blocks that appeared to be concealing test
    failures?
    
    * fix non-camelCase component naming
    
    Also alphabetically sorted the declarations for the core module - we
    were declaring a few things multiple times.
    
    * Simplify type guards in server configuration
    
    Makes use of the utils package to avoid the need for 'as' statements
    
    * Add typing for a Node SystemError
    
    * Fix incorrect use of logging middleware
    
    * Try to clean up some errors being hit in the SSR handler
    
    limited success on that front
    
    * fix directory handle leak
    
    * Make non-production server builds debug-able
    
    * add initial debug log for all requests, fix double-writing error responses in some cases
    
    * Move "time elapsed" debug message so that non-error responses also log it
    
    * Remove duplicated check, extraneous substring argument
    
    * Fix missing `req` option to SSR engine handler
    
    * Update a lot of fs access to be asynchronous
    
    * Fix hard-coded VERSION file path
    
    * Cache file compressions instead of recalculating on every request
    
    Made it like 14x faster on my machine - I was a real idiot to not do
    that in the first place.
    
    * Fix lint errors from rebasing
    
    * Make regexp non-polynomial
    
    * Fix comment grammar
---
 experimental/traffic-portal/.eslintrc.json         |   5 +-
 experimental/traffic-portal/angular.json           |   9 +-
 experimental/traffic-portal/middleware.ts          | 181 ++++++++++
 .../traffic-portal/nightwatch/.eslintrc.json       |   3 +-
 experimental/traffic-portal/server.config.ts       | 225 ++++++++-----
 experimental/traffic-portal/server.ts              | 295 ++++++++--------
 .../src/app/api/delivery-service.service.ts        |  15 +-
 .../src/app/api/misc-apis.service.ts               |   7 +-
 .../traffic-portal/src/app/api/server.service.ts   |   6 +-
 .../src/app/api/testing/user.service.ts            |  18 +-
 .../asns/detail/asn-detail.component.spec.ts       |  14 +-
 .../asns/detail/asn-detail.component.ts            |  21 +-
 .../asns/table/asns-table.component.spec.ts        |  10 +-
 .../asns/table/asns-table.component.ts             |  17 +-
 .../cache-group-details.component.ts               |  12 +-
 .../cache-group-table.component.ts                 |   8 +-
 .../detail/coordinate-detail.component.ts          |  17 +-
 .../divisions/detail/division-detail.component.ts  |  17 +-
 .../regions/detail/region-detail.component.ts      |  18 +-
 .../regions/table/regions-table.component.ts       |  13 +-
 .../core/cdns/cdn-detail/cdn-detail.component.ts   |  10 +-
 .../app/core/cdns/cdn-table/cdn-table.component.ts |  10 +-
 .../certs/cert-detail/cert-detail.component.ts     |   5 +-
 .../certs/cert-viewer/cert-viewer.component.ts     |  10 +-
 .../traffic-portal/src/app/core/core.module.ts     |  83 +++--
 .../app/core/currentuser/currentuser.component.ts  |   8 +-
 .../update-password-dialog.component.ts            |   8 +-
 .../deliveryservice/deliveryservice.component.ts   |  13 +-
 .../deliveryservice/ds-card/ds-card.component.ts   |   9 +-
 .../invalidation-jobs.component.ts                 |   8 +-
 .../new-invalidation-job-dialog.component.spec.ts  |   8 +-
 .../new-invalidation-job-dialog.component.ts       |   8 +-
 .../new-delivery-service.component.spec.ts         |  38 +--
 .../new-delivery-service.component.ts              |  11 +-
 .../detail/parameter-detail.component.ts           |  17 +-
 .../profile-detail/profile-detail.component.ts     |  16 +-
 .../servers/capabilities/capabilities.component.ts |   6 +-
 .../phys-loc/detail/phys-loc-detail.component.ts   |  20 +-
 .../server-details/server-details.component.ts     |  22 +-
 .../update-status/update-status.component.ts       |  13 +-
 .../topology-details/topology-details.component.ts |  13 +-
 .../app/core/types/detail/type-detail.component.ts |  17 +-
 .../users/roles/detail/role-detail.component.ts    |  16 +-
 .../users/roles/table/roles-table.component.ts     |  16 +-
 .../tenant-details/tenant-details.component.ts     |  19 +-
 .../app/core/users/tenants/tenants.component.ts    |   6 +-
 .../users/user-details/user-details.component.ts   |  11 +-
 .../user-registration-dialog.component.ts          |   6 +-
 .../src/app/login/login.component.spec.ts          |   6 +-
 .../src/app/login/login.component.ts               |   8 +-
 .../src/app/shared/alert/alert.component.ts        |  18 +-
 .../app/shared/charts/linechart.directive.spec.ts  |   4 +-
 .../src/app/shared/charts/linechart.directive.ts   |   6 +-
 .../shared/current-user/current-user.service.ts    |   7 +-
 .../current-user.testing-service.spec.ts           |   8 +-
 .../generic-table/generic-table.component.ts       |  15 +-
 .../import-json-txt/import-json-txt.component.ts   |  17 +-
 .../app/shared/interceptor/error.interceptor.ts    |  13 +-
 .../app/shared/loading/loading.component.spec.ts   |   6 +-
 .../src/app/shared/logging.service.spec.ts         |  60 ++++
 .../src/app/shared/logging.service.ts              |  74 ++++
 .../app/shared/navigation/navigation.service.ts    |   8 +-
 .../tp-header/tp-header.component.spec.ts          |   6 +-
 .../navigation/tp-header/tp-header.component.ts    |  14 +-
 .../navigation/tp-sidebar/tp-sidebar.component.ts  |  15 +-
 .../traffic-portal/src/app/shared/shared.module.ts |   4 +-
 .../boolean-filter/boolean-filter.component.ts     |   7 +-
 .../shared/theme-manager/theme-manager.service.ts  |  48 +--
 experimental/traffic-portal/src/app/utils/index.ts |   3 +-
 .../traffic-portal/src/app/utils/logging.spec.ts   | 373 +++++++++++++++++++++
 .../traffic-portal/src/app/utils/logging.ts        | 227 +++++++++++++
 .../traffic-portal/src/app/utils/order-by.ts       |  11 +-
 experimental/traffic-portal/src/main.ts            |   6 +
 73 files changed, 1718 insertions(+), 584 deletions(-)

diff --git a/experimental/traffic-portal/.eslintrc.json b/experimental/traffic-portal/.eslintrc.json
index b3b69ce77c..620d628881 100644
--- a/experimental/traffic-portal/.eslintrc.json
+++ b/experimental/traffic-portal/.eslintrc.json
@@ -96,8 +96,7 @@
 					"error",
 					{
 						"allow": [
-							"log",
-							"warn",
+							"trace",
 							"dir",
 							"timeLog",
 							"assert",
@@ -107,8 +106,6 @@
 							"group",
 							"groupEnd",
 							"table",
-							"dirxml",
-							"error",
 							"groupCollapsed",
 							"Console",
 							"profile",
diff --git a/experimental/traffic-portal/angular.json b/experimental/traffic-portal/angular.json
index 5b2c52a685..a2fa69abff 100644
--- a/experimental/traffic-portal/angular.json
+++ b/experimental/traffic-portal/angular.json
@@ -133,7 +133,10 @@
 					"options": {
 						"outputPath": "dist/traffic-portal/server",
 						"main": "server.ts",
-						"tsConfig": "tsconfig.server.json"
+						"tsConfig": "tsconfig.server.json",
+						"sourceMap": true,
+						"buildOptimizer": false,
+						"optimization": false
 					},
 					"configurations": {
 						"production": {
@@ -145,8 +148,8 @@
 								}
 							],
 							"sourceMap": false,
-              "optimization": true,
-              "buildOptimizer": true
+							"optimization": true,
+							"buildOptimizer": true
 						}
 					}
 				},
diff --git a/experimental/traffic-portal/middleware.ts b/experimental/traffic-portal/middleware.ts
new file mode 100644
index 0000000000..28e0645e6b
--- /dev/null
+++ b/experimental/traffic-portal/middleware.ts
@@ -0,0 +1,181 @@
+/**
+ * @license Apache-2.0
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { opendir } from "fs/promises";
+import { join } from "path";
+
+import type { NextFunction, Request, Response } from "express";
+
+import { LogLevel, Logger } from "src/app/utils";
+import { environment } from "src/environments/environment";
+
+import type { ServerConfig } from "./server.config";
+
+/**
+ * StaticFile defines what compression files are available.
+ */
+interface StaticFile {
+	compressions: Array<CompressionType>;
+}
+
+/**
+ * CompressionType defines the different compression algorithms.
+ */
+interface CompressionType {
+	fileExt: string;
+	headerEncoding: string;
+	name: string;
+}
+
+/**
+ * TPResponseLocals are the express.Response.locals properties specific to a
+ * response writer for the TP server.
+ */
+interface TPResponseLocals {
+	config: ServerConfig;
+	foundFiles: Map<string, StaticFile>;
+	logger: Logger;
+	/** The time at which the request was received. */
+	startTime: Date;
+	/**
+	 * The time at which the response was finished being written (or
+	 * `undefined` if not done yet).
+	 */
+	endTime?: Date | undefined;
+}
+
+/**
+ * AuthenticatedResponse is a response writer for endpoints that require
+ * authentication.
+ */
+export type TPResponseWriter = Response<unknown, TPResponseLocals>;
+
+/**
+ * An HTTP request handler for the TP server.
+ */
+export type TPHandler = (req: Request, resp: TPResponseWriter, next: NextFunction) => void | PromiseLike<void>;
+
+const gzip = {
+	fileExt: "gz",
+	headerEncoding: "gzip",
+	name: "gzip"
+};
+const br = {
+	fileExt: "br",
+	headerEncoding: "br",
+	name: "brotli"
+};
+
+/**
+ * getFiles recursively gets all the files in a directory.
+ *
+ * @param path The path to get files from.
+ * @returns Files found in the directory.
+ */
+async function getFiles(path: string): Promise<string[]> {
+	const dir = await opendir(path);
+	let dirEnt = await dir.read();
+	let files = new Array<string>();
+
+	while (dirEnt !== null) {
+		const name = join(path, dirEnt.name);
+
+		if (dirEnt.isDirectory()) {
+			files = files.concat(await getFiles(name));
+		} else {
+			files.push(name);
+		}
+
+		dirEnt = await dir.read();
+	}
+	await dir.close();
+
+	return files;
+}
+
+/**
+ * loggingMiddleWare is a middleware factory for express.js that provides a
+ * logger.
+ * It does also provide a link to server configuration that can be used in
+ * handlers, and a couple other niceties.
+ *
+ * @param config The server configuration.
+ * @returns A middleware that adds a property `logger` to `resp.locals` for
+ * logging purposes.
+ */
+export async function loggingMiddleWare(config: ServerConfig): Promise<TPHandler> {
+	const allFiles = await getFiles(config.browserFolder);
+	const compressedFiles = new Map(
+		allFiles.filter(
+			file => file.match(/\.(br|gz)$/)
+		).map(
+			file => [file, undefined]
+		)
+	);
+	const foundFiles = new Map<string, StaticFile>(
+		allFiles.filter(
+			file => file.match(/\.(js|css|tff|svg)$/)
+		).map(
+			file => {
+				const staticFile: StaticFile = {
+					compressions: []
+				};
+				if (compressedFiles.has(`${file}.${br.fileExt}`)) {
+					staticFile.compressions.push(br);
+				}
+				if (compressedFiles.has(`${file}.${gzip.fileExt}`)) {
+					staticFile.compressions.push(gzip);
+				}
+				return [file, staticFile];
+			}
+		)
+	);
+
+	return async (req: Request, resp: TPResponseWriter, next: NextFunction): Promise<void> => {
+		resp.locals.config = config;
+		const prefix = `${req.ip} HTTP/${req.httpVersion} ${req.method} ${req.url} ${req.hostname}`;
+		resp.locals.logger = new Logger(console, environment.production ? LogLevel.INFO : LogLevel.DEBUG, prefix);
+		resp.locals.logger.debug("handling");
+		resp.locals.startTime = new Date();
+		resp.locals.foundFiles = foundFiles;
+
+		next();
+	};
+}
+
+/**
+ * errorMiddleWare is a middleware for express.js that provides automatic
+ * handling of errors that aren't caught in the endpoint handlers.
+ *
+ * @param err Any error passed along by other handlers.
+ * @param _ The client request - unused.
+ * @param resp The server's response-writer
+ * @param next A function provided by Express which will call the next handler.
+ */
+export function errorMiddleWare(err: unknown, _: Request, resp: TPResponseWriter, next: NextFunction): void {
+	if (err !== null && err !== undefined) {
+		resp.locals.logger.error("unhandled error bubbled to routing:", String(err));
+		if (!environment.production) {
+			console.trace(err);
+		}
+		if (!resp.locals.endTime) {
+			resp.status(502); // "Bad Gateway"
+			resp.write('{"alerts":[{"level":"error","text":"Unknown Traffic Portal server error occurred"}]}\n');
+			resp.end("\n");
+			resp.locals.endTime = new Date();
+			next(err);
+		}
+	}
+}
diff --git a/experimental/traffic-portal/nightwatch/.eslintrc.json b/experimental/traffic-portal/nightwatch/.eslintrc.json
index 700e866729..382602e237 100644
--- a/experimental/traffic-portal/nightwatch/.eslintrc.json
+++ b/experimental/traffic-portal/nightwatch/.eslintrc.json
@@ -43,7 +43,8 @@
 				"no-restricted-imports": [
 					"error",
 					"../"
-				]
+				],
+				"no-console": "off"
 			}
 		}
 	]
diff --git a/experimental/traffic-portal/server.config.ts b/experimental/traffic-portal/server.config.ts
index ce552ca83b..83657b7f2a 100644
--- a/experimental/traffic-portal/server.config.ts
+++ b/experimental/traffic-portal/server.config.ts
@@ -12,11 +12,53 @@
 * limitations under the License.
 */
 
+// Logging cannot be initialized until after the job of the routines in this
+// file are complete.
+/* eslint-disable no-console */
+
 import { execSync } from "child_process";
-import { existsSync, readFileSync } from "fs";
-import { join } from "path";
+import { access, constants, readFile, readdir, realpath } from "fs/promises";
+import { join, sep } from "path";
+
 import { hasProperty } from "src/app/utils";
 
+/**
+ * A Node system error. I don't know why but this isn't exposed by Node - it's a
+ * class but you won't be able to use `instanceof` - and isn't present in Node
+ * typings. I copied the properties and their descriptions from the NodeJS
+ * documentation.
+ */
+type SystemError = Error & {
+	/** If present, the address to which a network connection failed. */
+	readonly address?: string;
+	/** The string error code. */
+	readonly code: string;
+	/** If present, the file path destination when reporting a file system error. */
+	readonly dest?: string;
+	/** The system-provided error number. */
+	readonly errno: number;
+	/** If present, extra details about the error condition. */
+	readonly info?: unknown;
+	/** A system-provided human-readable description of the error. */
+	readonly message: string;
+	/** If present, the file path when reporting a file system error. */
+	readonly path?: string;
+	/** If present, the network connection port that is not available. */
+	readonly port?: number;
+	/** The name of the system call that triggered the error. */
+	readonly syscall: string;
+};
+
+/**
+ * Checks if an {@link Error} is a {@link SystemError}.
+ *
+ * @param e The {@link Error} to check.
+ * @returns Whether `e` is a {@link SystemError}.
+ */
+function isSystemError(e: Error): e is SystemError {
+	return hasProperty(e, "code");
+}
+
 /**
  * ServerVersion contains versioning information for the server,
  * consistent with what other components provide, even if some
@@ -95,30 +137,25 @@ function isServerVersion(v: unknown): v is ServerVersion {
 		return false;
 	}
 
-	if (!Object.prototype.hasOwnProperty.call(v, "version")) {
-		console.error("version missing required field 'version'");
-		return false;
-	}
-	if (typeof((v as {version: unknown}).version) !== "string") {
+	if (!hasProperty(v, "version", "string")) {
+		console.error("version required field 'version' missing or invalid");
 		return false;
 	}
 
-	if (Object.prototype.hasOwnProperty.call(v, "commits") && (typeof((v as {commits: unknown}).commits)) !== "string") {
-		console.error(`version property 'commits' has incorrect type; want: string, got: ${typeof((v as {commits: unknown}).commits)}`);
+	if (hasProperty(v, "commits") && typeof(v.commits) !== "string") {
+		console.error(`version property 'commits' has incorrect type; want: string, got: ${typeof(v.commits)}`);
 		return false;
 	}
-	if (Object.prototype.hasOwnProperty.call(v, "hash") && (typeof((v as {hash: unknown}).hash)) !== "string") {
-		console.error(`version property 'hash' has incorrect type; want: string, got: ${typeof((v as {hash: unknown}).hash)}`);
+	if (hasProperty(v, "hash") && typeof(v.hash) !== "string") {
+		console.error(`version property 'hash' has incorrect type; want: string, got: ${typeof(v.hash)}`);
 		return false;
 	}
-	if (Object.prototype.hasOwnProperty.call(v, "elRelease") && (typeof((v as {elRelease: unknown}).elRelease)) !== "string") {
-		console.error(
-			`version property 'elRelease' has incorrect type; want: string, got: ${typeof (v as {elRelease: unknown}).elRelease}`
-		);
+	if (hasProperty(v, "elRelease") && typeof(v.elRelease) !== "string") {
+		console.error(`version property 'elRelease' has incorrect type; want: string, got: ${typeof(v.elRelease)}`);
 		return false;
 	}
-	if (Object.prototype.hasOwnProperty.call(v, "arch") && (typeof((v as {arch: unknown}).arch)) !== "string") {
-		console.error(`version property 'arch' has incorrect type; want: string, got: ${typeof((v as {arch: unknown}).arch)}`);
+	if (hasProperty(v, "arch") && typeof(v.arch) !== "string") {
+		console.error(`version property 'arch' has incorrect type; want: string, got: ${typeof(v.arch)}`);
 		return false;
 	}
 	return true;
@@ -193,39 +230,27 @@ function isConfig(c: unknown): c is ServerConfig {
 	} else {
 		(c as {insecure: boolean}).insecure = false;
 	}
-	if (!hasProperty(c, "port")) {
-		throw new Error("'port' is required");
+	if (!hasProperty(c, "port", "number")) {
+		throw new Error("required configuration for 'port' is missing or not a valid number");
 	}
-	if (typeof(c.port) !== "number") {
-		throw new Error("'port' must be a number");
+	if (!hasProperty(c, "trafficOps", "string")) {
+		throw new Error("required configuration for 'trafficOps' is missing or not a string");
 	}
-	if (!hasProperty(c, "trafficOps")){
-		throw new Error("'trafficOps' is required");
+	if (!hasProperty(c, "browserFolder", "string")) {
+		throw new Error("required configuration for 'browserFolder' is missing or not a string");
 	}
-	if (typeof(c.trafficOps) !== "string") {
-		throw new Error("'trafficOps' must be a string");
-	}
-	if(!hasProperty(c, "tpv1Url")){
-		throw new Error("'tpv1Url' is required");
-	}
-	if (typeof(c.tpv1Url) !== "string") {
-		throw new Error("'tpv1Url' must be a string");
-	}
-	if (!hasProperty(c, "browserFolder")) {
-		throw new Error("'browserFolder' is required");
-	}
-	if (typeof(c.browserFolder) !== "string") {
-		throw new Error("'browserFolder' must be a string");
+	if(!hasProperty(c, "tpv1Url", "string")){
+		throw new Error("required configuration for 'tpv1Url' is missing or not a string");
 	}
 
 	try {
-		c.trafficOps = new URL(c.trafficOps);
+		(c as {trafficOps: URL | string}).trafficOps = new URL(c.trafficOps);
 	} catch (e) {
 		throw new Error(`'trafficOps' is not a valid URL: ${e}`);
 	}
 
 	try {
-		c.tpv1Url = new URL(c.tpv1Url);
+		(c as {tpv1Url: URL | string}).tpv1Url = new URL(c.tpv1Url);
 	} catch (e) {
 		throw new Error(`'tpv1Url' is not a valid URL: ${e}`);
 	}
@@ -235,17 +260,11 @@ function isConfig(c: unknown): c is ServerConfig {
 			throw new Error("'useSSL' must be a boolean");
 		}
 		if (c.useSSL) {
-			if (!hasProperty(c, "certPath")) {
-				throw new Error("'certPath' is required to use SSL");
+			if (!hasProperty(c, "certPath", "string")) {
+				throw new Error("missing or invalid 'certPath' - required to use SSL");
 			}
-			if (typeof(c.certPath) !== "string") {
-				throw new Error("'certPath' must be a string");
-			}
-			if (!hasProperty(c, "keyPath")) {
-				throw new Error("'keyPath' is required to use SSL");
-			}
-			if (typeof(c.keyPath) !== "string") {
-				throw new Error("'keyPath' must be a string");
+			if (!hasProperty(c, "keyPath", "string")) {
+				throw new Error("missing or invalid 'keyPath' - required to use SSL");
 			}
 		}
 	}
@@ -255,6 +274,30 @@ function isConfig(c: unknown): c is ServerConfig {
 
 const defaultVersionFile = "/etc/traffic-portal/version.json";
 
+/**
+ * Searches recursively upward through the filesystem to find a file named
+ * "VERSION" and returns the real, absolute path to that file.
+ *
+ * @param path The path from which to begin the search.
+ * @returns The path to the VERSION file, assuming it was found.
+ * @throws {Error} If no VERSION file could be found in `path` or any of its
+ * ancestor directories.
+ * @throws {SystemError} If the given path isn't a directory, or directory
+ * traversal fails for some reason.
+ */
+async function findVersionFile(path: string = "."): Promise<string> {
+	for (const ent of await readdir(path)) {
+		if (ent === "VERSION") {
+			return realpath(join(path, ent));
+		}
+	}
+	path = await realpath(join(path, ".."));
+	if (path === sep) {
+		throw new Error("VERSION file not found");
+	}
+	return findVersionFile(path);
+}
+
 /**
  * Retrieves the server's version from the file path provided.
  *
@@ -264,24 +307,36 @@ const defaultVersionFile = "/etc/traffic-portal/version.json";
  * looking for the ATC VERSION file.
  * @returns The parsed server version.
  */
-export function getVersion(path?: string): ServerVersion {
+export async function getVersion(path?: string): Promise<ServerVersion> {
 	if (!path) {
 		path = defaultVersionFile;
 	}
 
-	if (existsSync(path)) {
-		const v = JSON.parse(readFileSync(path, {encoding: "utf8"}));
+	try {
+		const v = JSON.parse(await readFile(path, {encoding: "utf8"}));
 		if (isServerVersion(v)) {
 			return v;
 		}
 		throw new Error(`contents of version file '${path}' does not represent an ATC version`);
+	} catch (e) {
+		if (e instanceof Error && isSystemError(e)) {
+			if (e.code !== "ENOENT") {
+				throw new Error(`file at "${path}" could not be read: ${e.message}`);
+			}
+		} else {
+			throw new Error(`file at "${path}" could not be read: ${e}`);
+		}
 	}
 
-	if (!existsSync("../../../../VERSION")) {
-		throw new Error(`'${path}' doesn't exist and '../../../../VERSION' doesn't exist`);
+	let versionFilePath: string;
+	try {
+		versionFilePath = await findVersionFile();
+	} catch (e) {
+		throw new Error(`'${path}' doesn't exist and couldn't find a VERSION file from which to read a server version: ${e}`);
 	}
+
 	const ver: ServerVersion = {
-		version: readFileSync("../../../../VERSION", {encoding: "utf8"}).trimEnd()
+		version: (await readFile(versionFilePath, {encoding: "utf8"})).trimEnd()
 	};
 
 	try {
@@ -326,8 +381,8 @@ export const defaultConfig: ServerConfig = {
 	browserFolder: "/opt/traffic-portal/browser",
 	insecure: false,
 	port: 4200,
-	trafficOps: new URL("https://example.com"),
 	tpv1Url: new URL("https://example.com"),
+	trafficOps: new URL("https://example.com"),
 	version: { version: "" }
 };
 /**
@@ -337,35 +392,41 @@ export const defaultConfig: ServerConfig = {
  * @param ver The version to use for the server.
  * @returns A full configuration for the server.
  */
-export function getConfig(args: Args, ver: ServerVersion): ServerConfig {
+export async function getConfig(args: Args, ver: ServerVersion): Promise<ServerConfig> {
 	let cfg = defaultConfig;
 	cfg.version = ver;
 
 	let readFromFile = false;
-	if (existsSync(args.configFile)) {
-		const cfgFromFile = JSON.parse(readFileSync(args.configFile, {encoding: "utf8"}));
-		try {
-			if (isConfig(cfgFromFile)) {
-				cfg = cfgFromFile;
-				cfg.version = ver;
-			}
-		} catch (err) {
-			throw new Error(`invalid configuration file at '${args.configFile}': ${err}`);
+	try {
+		const cfgFromFile = JSON.parse(await readFile(args.configFile, {encoding: "utf8"}));
+		if (isConfig(cfgFromFile)) {
+			cfg = cfgFromFile;
+			cfg.version = ver;
+		} else {
+			throw new Error("bad contents; doesn't represent a configuration file");
 		}
 		readFromFile = true;
-	} else if (args.configFile !== defaultConfigFile) {
-		throw new Error(`no such configuration file: ${args.configFile}`);
+	} catch (err) {
+		const msg = `invalid configuration file at '${args.configFile}'`;
+		if (err instanceof Error) {
+			if (!isSystemError(err) || (err.code !== "ENOENT" || args.configFile !== defaultConfigFile)) {
+				throw new Error(`${msg}: ${err.message}`);
+			}
+		} else {
+			throw new Error(`${msg}: ${err}`);
+		}
 	}
 
-	let folder = cfg.browserFolder;
 	if(args.browserFolder !== defaultConfig.browserFolder) {
-		folder = args.browserFolder;
+		cfg.browserFolder = args.browserFolder;
 	}
-	if(!existsSync(folder)) {
-		throw new Error(`no such folder: ${folder}`);
-	}
-	if(!existsSync(join(folder, "index.html"))) {
-		throw new Error(`no such browser file: ${join(folder, "index.html")}`);
+
+	try {
+		if (!(await readdir(cfg.browserFolder)).includes("index.html")) {
+			throw new Error("directory doesn't include an 'index.html' file");
+		}
+	} catch (e) {
+		throw new Error(`setting browser directory: ${e instanceof Error ? e.message : e}`);
 	}
 
 	if(args.port !== defaultConfig.port) {
@@ -412,8 +473,8 @@ export function getConfig(args: Args, ver: ServerVersion): ServerConfig {
 				insecure: cfg.insecure,
 				keyPath: args.keyPath,
 				port: cfg.port,
-				trafficOps: cfg.trafficOps,
 				tpv1Url: cfg.tpv1Url,
+				trafficOps: cfg.trafficOps,
 				useSSL: true,
 				version: ver
 			};
@@ -427,11 +488,15 @@ export function getConfig(args: Args, ver: ServerVersion): ServerConfig {
 	}
 
 	if (cfg.useSSL) {
-		if (!existsSync(cfg.certPath)) {
-			throw new Error(`no such certificate file: ${cfg.certPath}`);
+		try {
+			await access(cfg.certPath, constants.R_OK);
+		} catch (e) {
+			throw new Error(`checking certificate file "${cfg.certPath}": ${e instanceof Error ? e.message : e}`);
 		}
-		if (!existsSync(cfg.keyPath)) {
-			throw new Error(`no such key file: ${cfg.keyPath}`);
+		try {
+			await access(cfg.keyPath, constants.R_OK);
+		} catch (e) {
+			throw new Error(`checking key file "${cfg.keyPath}": ${e instanceof Error ? e.message : e}`);
 		}
 	}
 
diff --git a/experimental/traffic-portal/server.ts b/experimental/traffic-portal/server.ts
index 78702d2ddc..853a1ae45d 100644
--- a/experimental/traffic-portal/server.ts
+++ b/experimental/traffic-portal/server.ts
@@ -13,7 +13,7 @@
 */
 import "zone.js/node";
 
-import { existsSync, readdirSync, readFileSync, statSync } from "fs";
+import { readFileSync } from "fs";
 import { createServer as createRedirectServer } from "http";
 import { createServer, request, RequestOptions } from "https";
 import { join } from "path";
@@ -27,71 +27,103 @@ import {
 	defaultConfigFile,
 	getConfig,
 	getVersion,
-	ServerConfig,
+	type ServerConfig,
 	versionToString
 } from "server.config";
 
-import { AppServerModule } from "./src/main.server";
+import { hasProperty, Logger, LogLevel } from "src/app/utils";
+import { environment } from "src/environments/environment";
+import { AppServerModule } from "src/main.server";
 
-/**
- * StaticFile defines what compression files are available.
- */
-interface StaticFile {
-	compressions: Array<CompressionType>;
-}
+import { errorMiddleWare, loggingMiddleWare, type TPResponseWriter } from "./middleware";
+
+const typeMap = new Map([
+	["js", "application/javascript"],
+	["css", "text/css"],
+	["ttf", "font/ttf"],
+	["svg", "image/svg+xml"]
+]);
 
 /**
- * CompressionType defines the different compression algorithms.
+ * A handler for serving files from compressed variants.
+ *
+ * @param req The client request.
+ * @param res Response writer.
+ * @param next A delegation to the next handler, to be called if this handler
+ * determines it can't write a response (which is always because this handler
+ * doesn't do that).
+ * @returns nothing. This is just required because we're returning void function
+ * calls. Not actually sure why, seems like a bug to me.
  */
-interface CompressionType {
-	fileExt: string;
-	headerEncoding: string;
-	name: string;
-}
+function compressedFileHandler(req: express.Request, res: TPResponseWriter, next: express.NextFunction): void {
+	const type = req.path.split(".").pop();
+	if (type === undefined || !typeMap.has(type)) {
+		res.locals.logger.debug("unrecognized/non-compress-able file extension:", type);
+		return next();
+	}
+	const path = join(res.locals.config.browserFolder, req.path.substring(1));
+	const file = res.locals.foundFiles.get(path);
+	if(!file || file.compressions.length === 0) {
+		res.locals.logger.debug("file", path, "doesn't have any available compression");
+		return next();
+	}
+	const acceptedEncodings = req.acceptsEncodings();
+	for(const compression of file.compressions) {
+		if (!acceptedEncodings.includes(compression.headerEncoding)) {
+			continue;
+		}
+		req.url = req.url.replace(`${req.path}`, `${req.path}.${compression.fileExt}`);
+		res.set("Content-Encoding", compression.headerEncoding);
+		res.set("Content-Type", typeMap.get(type));
+		res.locals.logger.info("Serving", compression.name, "compressed file", req.path);
+		return next();
+	}
 
-const gzip = {
-	fileExt: "gz",
-	headerEncoding: "gzip",
-	name: "gzip"
-};
-const br = {
-	fileExt: "br",
-	headerEncoding: "br",
-	name: "brotli"
-};
+	res.locals.logger.debug("no file found that matches an encoding the client accepts - serving uncompressed");
+	next();
+}
 
 /**
- * getFiles recursively gets all the files in a directory.
+ * A handler for proxy-ing the Traffic Ops API.
  *
- * @param path The path to get files from.
- * @returns Files found in the directory.
+ * @param req The client's request.
+ * @param res The server's response writer.
  */
-function getFiles(path: string): string[] {
-	const all = readdirSync(path)
-		.map(file => join(path, file));
-	const dirs = all
-		.filter(file => statSync(file).isDirectory());
-	let files = all
-		.filter(file => !statSync(file).isDirectory());
-	for (const dir of dirs) {
-		files = files.concat(getFiles(dir));
+function toProxyHandler(req: express.Request, res: TPResponseWriter): void {
+	const {logger, config} = res.locals;
+
+	logger.debug(`Making TO API request to \`${req.originalUrl}\``);
+
+	const fwdRequest: RequestOptions = {
+		headers: req.headers,
+		host: config.trafficOps.hostname,
+		method: req.method,
+		path: req.originalUrl,
+		port: config.trafficOps.port,
+		rejectUnauthorized: !config.insecure,
+	};
+
+	try {
+		const proxyRequest = request(fwdRequest, r => {
+			res.writeHead(r.statusCode ?? 502, r.headers);
+			r.pipe(res);
+		});
+		req.pipe(proxyRequest);
+	} catch (e) {
+		logger.error("proxy-ing request:", e);
 	}
-	return files;
+	res.locals.endTime = new Date();
 }
 
-let config: ServerConfig;
 /**
  * The Express app is exported so that it can be used by serverless Functions.
  *
- * @param serverConfig Server configuration
+ * @param serverConfig Server configuration.
  * @returns The Express.js application.
  */
-export function app(serverConfig: ServerConfig): express.Express {
+export async function app(serverConfig: ServerConfig): Promise<express.Express> {
 	const server = express();
 	const indexHtml = join(serverConfig.browserFolder, "index.html");
-	if (!existsSync(indexHtml)) {
-		throw new Error(`Unable to start TP server, unable to find browser index.html at: ${indexHtml}`);
-	}
 
 	// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
 	server.engine("html", ngExpressEngine({
@@ -101,101 +133,57 @@ export function app(serverConfig: ServerConfig): express.Express {
 	server.set("view engine", "html");
 	server.set("views", "./");
 
-	const allFiles = getFiles(serverConfig.browserFolder);
-	const compressedFiles = new Map(allFiles
-		.filter(file => file.match(/\.(br|gz)$/))
-		.map(file => [file, undefined]));
-	const foundFiles = new Map<string, StaticFile>(allFiles
-		.filter(file => file.match(/\.(js|css|tff|svg)$/))
-		.map(file => {
-			const staticFile: StaticFile = {
-				compressions: []
-			};
-			if (compressedFiles.has(`${file}.${br.fileExt}`)) {
-				staticFile.compressions.push(br);
-			}
-			if (compressedFiles.has(`${file}.${gzip.fileExt}`)) {
-				staticFile.compressions.push(gzip);
-			}
-			return [file, staticFile];
-		}));
-
-	const typeMap = new Map([
-		["js", "application/javascript"],
-		["css", "text/css"],
-		["ttf", "font/ttf"],
-		["svg", "image/svg+xml"]
-	]);
+	// Express 4.x doesn't handle Promise rejections (need to be manually
+	// propagated with `next`), so it's not technically accurate to say that
+	// void Promises are the same as void. Using `async`, though, is so much
+	// easier than not doing that, so we're gonna go ahead and pretend that
+	// `Promise<void>` is the same as `void`, in this one case.
+	//
+	// Note: Express 5.x fully supports async handlers - including seamless
+	// rejections - but it's still in beta at the time of this writing.
+	const loggingMW: express.RequestHandler = await loggingMiddleWare(serverConfig) as express.RequestHandler;
+	server.use(loggingMW);
 
 	// Could just use express compression `server.use(compression())` but that is calculated for each request
-	server.get("*.(js|css|ttf|svg)", function(req, res, next) {
-		const type = req.path.split(".").pop();
-		if (type === undefined || !typeMap.has(type)) {
-			return next();
-		}
-		const path = join(serverConfig.browserFolder, req.path.substring(1, req.path.length));
-		const file = foundFiles.get(path);
-		if(!file || file.compressions.length === 0) {
-			return next();
-		}
-		const acceptedEncodings = req.acceptsEncodings();
-		for(const compression of file.compressions) {
-			if (acceptedEncodings.indexOf(compression.headerEncoding) === -1) {
-				continue;
-			}
-			req.url = req.url.replace(`${req.path}`, `${req.path}.${compression.fileExt}`);
-			res.set("Content-Encoding", compression.headerEncoding);
-			res.set("Content-Type", typeMap.get(type));
-			console.log(`Serving ${compression.name} compressed file ${req.path}`);
-			return next();
-		}
-		next();
-	});
-	// Example Express Rest API endpoints
-	// server.get('/api/**', (req, res) => { });
-	// Serve static files from /browser
-	server.get("*.*", express.static(serverConfig.browserFolder, {
-		maxAge: "1y"
-	}));
-
-	/**
-	 * A handler for proxying the Traffic Ops API.
-	 *
-	 * @param req The client's request.
-	 * @param res The server's response writer.
-	 */
-	function toProxyHandler(req: express.Request, res: express.Response): void {
-		console.log(`Making TO API request to \`${req.originalUrl}\``);
+	server.get("*.(js|css|ttf|svg)", compressedFileHandler);
 
-		const fwdRequest: RequestOptions = {
-			headers: req.headers,
-			host: config.trafficOps.hostname,
-			method: req.method,
-			path: req.originalUrl,
-			port: config.trafficOps.port,
-			rejectUnauthorized: !config.insecure,
-		};
-
-		try {
-			const proxiedRequest = request(fwdRequest, (r) => {
-				res.writeHead(r.statusCode ?? 502, r.headers);
-				r.pipe(res);
-			});
-			req.pipe(proxiedRequest);
-		} catch (e) {
-			console.error("proxying request:", e);
+	server.get(
+		"*.*",
+		(req, res: TPResponseWriter, next) => {
+			express.static(res.locals.config.browserFolder, {maxAge: "1y"})(req, res, next);
+			// Express's static handler doesn't call `next` and calling it
+			// yourself will break it for some reason, so we need to do this by
+			// hand here.
+			const elapsed = (new Date()).valueOf() - res.locals.startTime.valueOf();
+			res.locals.logger.info("handled in", elapsed, "milliseconds with code", res.statusCode);
 		}
-	}
+	);
 
 	server.use("api/**", toProxyHandler);
 	server.use("/api/**", toProxyHandler);
 
 	// All regular routes use the Universal engine
-	server.get("*", (req, res) => {
-		res.render(indexHtml, {providers: [
-			{provide: APP_BASE_HREF, useValue: req.baseUrl},
-			{provide: "TP_V1_URL", useValue: config.tpv1Url}
-		], req});
+	server.get("*", (req, res: TPResponseWriter) => {
+		res.render(
+			indexHtml,
+			{
+				providers: [
+					{provide: APP_BASE_HREF, useValue: req.baseUrl},
+					{provide: "TP_V1_URL", useValue: res.locals.config.tpv1Url},
+				],
+				req
+			},
+		);
+		res.locals.endTime = new Date();
+	});
+
+	server.use(errorMiddleWare);
+	server.use((_, resp: TPResponseWriter) => {
+		if (!resp.locals.endTime) {
+			resp.locals.endTime = new Date();
+		}
+		const elapsed = resp.locals.endTime.valueOf() - resp.locals.startTime.valueOf();
+		resp.locals.logger.info("handled in", elapsed, "milliseconds with code", resp.statusCode);
 	});
 
 	server.enable("trust proxy");
@@ -207,8 +195,8 @@ export function app(serverConfig: ServerConfig): express.Express {
  *
  * @returns An exit code for the process.
  */
-function run(): number {
-	const version = getVersion();
+async function run(): Promise<number> {
+	const version = await getVersion();
 	const parser = new ArgumentParser({
 		// Nothing I can do about this, library specifies its interface.
 		/* eslint-disable @typescript-eslint/naming-convention */
@@ -283,15 +271,20 @@ function run(): number {
 		version: versionToString(version)
 	});
 
+	let config: ServerConfig;
 	try {
-		config = getConfig(parser.parse_args(), version);
+		config = await getConfig(parser.parse_args(), version);
 	} catch (e) {
+		// Logger cannot be initialized before reading server configuration
+		// eslint-disable-next-line no-console
 		console.error(`Failed to initialize server configuration: ${e}`);
 		return 1;
 	}
 
+	const logger = new Logger(console, environment.production ? LogLevel.INFO : LogLevel.DEBUG);
+
 	// Start up the Node server
-	const server = app(config);
+	const server = await app(config);
 
 	if (config.useSSL) {
 		let cert: string;
@@ -302,7 +295,10 @@ function run(): number {
 			key = readFileSync(config.keyPath, {encoding: "utf8"});
 			ca = config.certificateAuthPaths.map(c => readFileSync(c, {encoding: "utf8"}));
 		} catch (e) {
-			console.error("reading SSL key/cert:", e);
+			logger.error("reading SSL key/cert:", String(e));
+			if (!environment.production) {
+				console.trace(e);
+			}
 			return 1;
 		}
 		createServer(
@@ -314,14 +310,14 @@ function run(): number {
 			},
 			server
 		).listen(config.port, () => {
-			console.log(`Node Express server listening on port ${config.port}`);
+			logger.debug(`Node Express server listening on port ${config.port}`);
 		});
 		try {
 			const redirectServer = createRedirectServer(
 				(req, res) => {
 					if (!req.url) {
 						res.statusCode = 500;
-						console.error("got HTTP request for redirect that had no URL");
+						logger.error("got HTTP request for redirect that had no URL");
 						res.end();
 						return;
 					}
@@ -332,20 +328,18 @@ function run(): number {
 			);
 			redirectServer.listen(80);
 			redirectServer.on("error", e => {
-				console.error(`redirect server encountered error: ${e}`);
-				if (Object.prototype.hasOwnProperty.call(e, "code") && (e as typeof e & {
-					code: unknown;
-				}).code === "EACCES") {
-					console.warn("access to port 80 not allowed; closing redirect server");
+				logger.error("redirect server encountered error:", String(e));
+				if (hasProperty(e, "code", "string") && e.code === "EACCES") {
+					logger.warn("access to port 80 not allowed; closing redirect server");
 					redirectServer.close();
 				}
 			});
 		} catch (e) {
-			console.warn("Failed to initialize HTTP-to-HTTPS redirect listener:", e);
+			logger.warn("Failed to initialize HTTP-to-HTTPS redirect listener:", e);
 		}
 	} else {
 		server.listen(config.port, () => {
-			console.log(`Node Express server listening on port ${config.port}`);
+			logger.debug(`Node Express server listening on port ${config.port}`);
 		});
 	}
 	return 0;
@@ -362,12 +356,17 @@ try {
 	const mainModule = __non_webpack_require__.main;
 	const moduleFilename = mainModule && mainModule.filename || "";
 	if (moduleFilename === __filename || moduleFilename.includes("iisnode")) {
-		const code = run();
-		if (code) {
-			process.exit(code);
-		}
+		run().then(
+			code => {
+				if (code) {
+					process.exit(code);
+				}
+			}
+		);
 	}
 } catch (e) {
+	// Logger cannot be initialized before reading server configuration
+	// eslint-disable-next-line no-console
 	console.error("Encountered error while running server:", e);
 	process.exit(1);
 }
diff --git a/experimental/traffic-portal/src/app/api/delivery-service.service.ts b/experimental/traffic-portal/src/app/api/delivery-service.service.ts
index 714dfe9f03..186e045bfa 100644
--- a/experimental/traffic-portal/src/app/api/delivery-service.service.ts
+++ b/experimental/traffic-portal/src/app/api/delivery-service.service.ts
@@ -30,6 +30,8 @@ import type {
 	TPSData,
 } from "src/app/models";
 
+import { LoggingService } from "../shared/logging.service";
+
 import { APIService } from "./base-api.service";
 
 /**
@@ -61,7 +63,9 @@ function defaultDataSet(label: string): DataSetWithSummary {
  */
 export function constructDataSetFromResponse(r: DSStats): DataSetWithSummary {
 	if (!r.series) {
-		console.error("raw DS stats response:", r);
+		// logging service not accessible in this scope
+		// eslint-disable-next-line no-console
+		console.debug("raw DS stats response:", r);
 		throw new Error("invalid data set response");
 	}
 
@@ -115,12 +119,7 @@ export class DeliveryServiceService extends APIService {
 	/** This is where DS Types are cached, as they are presumed to not change (often). */
 	private deliveryServiceTypes: Array<TypeFromResponse>;
 
-	/**
-	 * Injects the Angular HTTP client service into the parent constructor.
-	 *
-	 * @param http The Angular HTTP client service.
-	 */
-	constructor(http: HttpClient) {
+	constructor(http: HttpClient, private readonly log: LoggingService) {
 		super(http);
 		this.deliveryServiceTypes = new Array<TypeFromResponse>();
 	}
@@ -308,7 +307,7 @@ export class DeliveryServiceService extends APIService {
 				}
 				return series;
 			}
-			console.error("data response:", r);
+			this.log.debug("data response:", r);
 			throw new Error("no data series found");
 		}
 		return this.get<DSStats>(path, undefined, params).toPromise();
diff --git a/experimental/traffic-portal/src/app/api/misc-apis.service.ts b/experimental/traffic-portal/src/app/api/misc-apis.service.ts
index 3ff154e2fc..03f334b19b 100644
--- a/experimental/traffic-portal/src/app/api/misc-apis.service.ts
+++ b/experimental/traffic-portal/src/app/api/misc-apis.service.ts
@@ -17,6 +17,7 @@ import { Injectable } from "@angular/core";
 import type { ISORequest, OSVersions } from "trafficops-types";
 
 import { AlertService } from "../shared/alert/alert.service";
+import { LoggingService } from "../shared/logging.service";
 
 import { APIService, hasAlerts } from "./base-api.service";
 
@@ -29,7 +30,7 @@ import { APIService, hasAlerts } from "./base-api.service";
 @Injectable()
 export class MiscAPIsService extends APIService{
 
-	constructor(http: HttpClient, private readonly alertsService: AlertService) {
+	constructor(http: HttpClient, private readonly alertsService: AlertService, private readonly log: LoggingService) {
 		super(http);
 	}
 
@@ -69,7 +70,7 @@ export class MiscAPIsService extends APIService{
 						body.alerts.forEach(a => this.alertsService.newAlert(a));
 					}
 				} catch (innerError) {
-					console.error("during handling request failure, encountered an error trying to parse error-level alerts:", innerError);
+					this.log.error("during handling request failure, encountered an error trying to parse error-level alerts:", innerError);
 				}
 				throw new Error(`POST isos failed with status ${e.status} ${e.statusText}`);
 			}
@@ -79,7 +80,7 @@ export class MiscAPIsService extends APIService{
 			throw new Error(`POST isos returned no response body - ${response.status} ${response.statusText}`);
 		}
 		if (response.body.type !== "application/octet-stream") {
-			console.warn("data returned by TO for ISO generation is of unrecognized MIME type", response.body.type);
+			this.log.warn("data returned by TO for ISO generation is of unrecognized MIME type", response.body.type);
 		}
 		return response.body;
 	}
diff --git a/experimental/traffic-portal/src/app/api/server.service.ts b/experimental/traffic-portal/src/app/api/server.service.ts
index 4b1afea5ba..be5c66a069 100644
--- a/experimental/traffic-portal/src/app/api/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/server.service.ts
@@ -29,6 +29,8 @@ import type {
 	ServerQueueResponse,
 } from "trafficops-types";
 
+import { LoggingService } from "../shared/logging.service";
+
 import { APIService } from "./base-api.service";
 
 /**
@@ -37,7 +39,7 @@ import { APIService } from "./base-api.service";
 @Injectable()
 export class ServerService extends APIService {
 
-	constructor(http: HttpClient) {
+	constructor(http: HttpClient, private readonly log: LoggingService) {
 		super(http);
 	}
 
@@ -83,7 +85,7 @@ export class ServerService extends APIService {
 			// This is, unfortunately, possible, despite the many assumptions to
 			// the contrary.
 			if (servers.length > 1) {
-				console.warn(
+				this.log.warn(
 					"Traffic Ops returned",
 					servers.length,
 					`servers with host name '${idOrName}' - selecting the first arbitrarily`
diff --git a/experimental/traffic-portal/src/app/api/testing/user.service.ts b/experimental/traffic-portal/src/app/api/testing/user.service.ts
index 66f35f9713..607e9f3ebe 100644
--- a/experimental/traffic-portal/src/app/api/testing/user.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/user.service.ts
@@ -27,6 +27,8 @@ import type {
 	ResponseUser
 } from "trafficops-types";
 
+import { LoggingService } from "src/app/shared/logging.service";
+
 /**
  * Represents a request to register a user via email using the `/users/register`
  * API endpoint.
@@ -103,6 +105,8 @@ export class UserService {
 
 	private readonly tokens = new Map<string, string>();
 
+	constructor(private readonly log: LoggingService) {}
+
 	/**
 	 * Performs authentication with the Traffic Ops server.
 	 *
@@ -120,19 +124,19 @@ export class UserService {
 	public async login(uOrT: string, p?: string): Promise<HttpResponse<object> | null> {
 		if (p !== undefined) {
 			if (uOrT !== this.testAdminUsername || p !== this.testAdminPassword) {
-				console.error("Invalid username or password.");
+				this.log.error("Invalid username or password.");
 				return null;
 			}
 			return new HttpResponse({body: {alerts: [{level: "success", text: "Successfully logged in."}]}, status: 200});
 		}
 		const email = this.tokens.get(uOrT);
 		if (email === undefined) {
-			console.error(`token '${uOrT}' did not match any set token for any user`);
+			this.log.error(`token '${uOrT}' did not match any set token for any user`);
 			return null;
 		}
 		const user = this.users.find(u=>u.email === email);
 		if (!user) {
-			console.error(`email '${email}' associated with token '${uOrT}' did not belong to any User`);
+			this.log.error(`email '${email}' associated with token '${uOrT}' did not belong to any User`);
 			return null;
 		}
 		this.tokens.delete(uOrT);
@@ -187,7 +191,7 @@ export class UserService {
 		if (user) {
 			return transformUser(user);
 		}
-		console.warn("stored admin username not found in stored users: from now on the current user will be (more or less) random");
+		this.log.warn("stored admin username not found in stored users: from now on the current user will be (more or less) random");
 		user = this.users[0];
 		if (!user) {
 			throw new Error("no users exist");
@@ -204,7 +208,7 @@ export class UserService {
 	public async updateCurrentUser(user: ResponseCurrentUser): Promise<boolean> {
 		const storedUser = this.users.findIndex(u=>u.id === user.id);
 		if (storedUser < 0) {
-			console.error(`no such User: #${user.id}`);
+			this.log.error(`no such User: #${user.id}`);
 			return false;
 		}
 		this.testAdminUsername = user.username;
@@ -555,11 +559,11 @@ export class UserService {
 	 */
 	public async resetPassword(email: string): Promise<void> {
 		if (!this.users.some(u=>u.email === email)) {
-			console.error(`no User exists with email '${email}' - TO doesn't expose that information with an error, so neither will we`);
+			this.log.error(`no User exists with email '${email}' - TO doesn't expose that information with an error, so neither will we`);
 			return;
 		}
 		const token = (Math.random() + 1).toString(36).substring(2);
-		console.log("setting token", token, "for email", email);
+		this.log.debug("setting token", token, "for email", email);
 		this.tokens.set(token, email);
 	}
 
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.spec.ts b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.spec.ts
index 10f2a3492a..278140921a 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.spec.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.spec.ts
@@ -18,19 +18,19 @@ import { RouterTestingModule } from "@angular/router/testing";
 import { ReplaySubject } from "rxjs";
 
 import { APITestingModule } from "src/app/api/testing";
-import { AsnDetailComponent } from "src/app/core/cache-groups/asns/detail/asn-detail.component";
+import { ASNDetailComponent } from "src/app/core/cache-groups/asns/detail/asn-detail.component";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 describe("AsnDetailComponent", () => {
-	let component: AsnDetailComponent;
-	let fixture: ComponentFixture<AsnDetailComponent>;
+	let component: ASNDetailComponent;
+	let fixture: ComponentFixture<ASNDetailComponent>;
 	let route: ActivatedRoute;
 	let paramMap: jasmine.Spy;
 
 	const headerSvc = jasmine.createSpyObj([],{headerHidden: new ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
 	beforeEach(async () => {
 		await TestBed.configureTestingModule({
-			declarations: [ AsnDetailComponent ],
+			declarations: [ ASNDetailComponent ],
 			imports: [ APITestingModule, RouterTestingModule, MatDialogModule ],
 			providers: [ { provide: NavigationService, useValue: headerSvc } ]
 		})
@@ -39,7 +39,7 @@ describe("AsnDetailComponent", () => {
 		route = TestBed.inject(ActivatedRoute);
 		paramMap = spyOn(route.snapshot.paramMap, "get");
 		paramMap.and.returnValue(null);
-		fixture = TestBed.createComponent(AsnDetailComponent);
+		fixture = TestBed.createComponent(ASNDetailComponent);
 		component = fixture.componentInstance;
 		fixture.detectChanges();
 	});
@@ -52,7 +52,7 @@ describe("AsnDetailComponent", () => {
 	it("new asn", async () => {
 		paramMap.and.returnValue("new");
 
-		fixture = TestBed.createComponent(AsnDetailComponent);
+		fixture = TestBed.createComponent(ASNDetailComponent);
 		component = fixture.componentInstance;
 		fixture.detectChanges();
 		await fixture.whenStable();
@@ -65,7 +65,7 @@ describe("AsnDetailComponent", () => {
 	it("existing asn", async () => {
 		paramMap.and.returnValue("1");
 
-		fixture = TestBed.createComponent(AsnDetailComponent);
+		fixture = TestBed.createComponent(ASNDetailComponent);
 		component = fixture.componentInstance;
 		fixture.detectChanges();
 		await fixture.whenStable();
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.ts
index cb06437296..cc80a543f7 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.ts
@@ -19,6 +19,7 @@ import { ResponseASN, ResponseCacheGroup } from "trafficops-types";
 
 import { CacheGroupService } from "src/app/api";
 import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -29,13 +30,19 @@ import { NavigationService } from "src/app/shared/navigation/navigation.service"
 	styleUrls: ["./asn-detail.component.scss"],
 	templateUrl: "./asn-detail.component.html"
 })
-export class AsnDetailComponent implements OnInit {
+export class ASNDetailComponent implements OnInit {
 	public new = false;
 	public asn!: ResponseASN;
 	public cachegroups!: Array<ResponseCacheGroup>;
-	constructor(private readonly route: ActivatedRoute, private readonly cacheGroupService: CacheGroupService,
-		private readonly location: Location, private readonly dialog: MatDialog,
-		private readonly header: NavigationService) {
+
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly cacheGroupService: CacheGroupService,
+		private readonly location: Location,
+		private readonly dialog: MatDialog,
+		private readonly header: NavigationService,
+		private readonly log: LoggingService,
+	) {
 	}
 
 	/**
@@ -45,7 +52,7 @@ export class AsnDetailComponent implements OnInit {
 		this.cachegroups = await this.cacheGroupService.getCacheGroups();
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -63,7 +70,7 @@ export class AsnDetailComponent implements OnInit {
 		}
 		const numID = parseInt(ID, 10);
 		if (Number.isNaN(numID)) {
-			console.error("route parameter 'id' was non-number:", ID);
+			this.log.error("route parameter 'id' was non-number:", ID);
 			return;
 		}
 
@@ -76,7 +83,7 @@ export class AsnDetailComponent implements OnInit {
 	 */
 	public async deleteAsn(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new ASN");
+			this.log.error("Unable to delete new ASN");
 			return;
 		}
 		const ref = this.dialog.open(DecisionDialogComponent, {
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/asns/table/asns-table.component.spec.ts b/experimental/traffic-portal/src/app/core/cache-groups/asns/table/asns-table.component.spec.ts
index 6524f51baa..12ce08366a 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/asns/table/asns-table.component.spec.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/asns/table/asns-table.component.spec.ts
@@ -17,20 +17,20 @@ import { MatDialogModule } from "@angular/material/dialog";
 import { RouterTestingModule } from "@angular/router/testing";
 
 import { APITestingModule } from "src/app/api/testing";
-import { AsnsTableComponent } from "src/app/core/cache-groups/asns/table/asns-table.component";
+import { ASNsTableComponent } from "src/app/core/cache-groups/asns/table/asns-table.component";
 
 describe("CacheGroupTableComponent", () => {
-	let component: AsnsTableComponent;
-	let fixture: ComponentFixture<AsnsTableComponent>;
+	let component: ASNsTableComponent;
+	let fixture: ComponentFixture<ASNsTableComponent>;
 
 	beforeEach(async () => {
 		await TestBed.configureTestingModule({
-			declarations: [ AsnsTableComponent ],
+			declarations: [ ASNsTableComponent ],
 			imports: [ APITestingModule, RouterTestingModule, MatDialogModule ]
 		})
 			.compileComponents();
 
-		fixture = TestBed.createComponent(AsnsTableComponent);
+		fixture = TestBed.createComponent(ASNsTableComponent);
 		component = fixture.componentInstance;
 		fixture.detectChanges();
 	});
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/asns/table/asns-table.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/asns/table/asns-table.component.ts
index 598abe1476..1ab0c37145 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/asns/table/asns-table.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/asns/table/asns-table.component.ts
@@ -27,6 +27,7 @@ import type {
 	ContextMenuItem,
 	DoubleClickLink
 } from "src/app/shared/generic-table/generic-table.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -37,12 +38,18 @@ import { NavigationService } from "src/app/shared/navigation/navigation.service"
 	styleUrls: ["./asns-table.component.scss"],
 	templateUrl: "./asns-table.component.html"
 })
-export class AsnsTableComponent implements OnInit {
+export class ASNsTableComponent implements OnInit {
 	/** List of asns */
 	public asns: Promise<Array<ResponseASN>>;
 
-	constructor(private readonly route: ActivatedRoute, private readonly headerSvc: NavigationService,
-		private readonly api: CacheGroupService, private readonly dialog: MatDialog, public readonly auth: CurrentUserService) {
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly headerSvc: NavigationService,
+		private readonly api: CacheGroupService,
+		private readonly dialog: MatDialog,
+		public readonly auth: CurrentUserService,
+		private readonly log: LoggingService,
+	) {
 		this.fuzzySubject = new BehaviorSubject<string>("");
 		this.asns = this.api.getASNs();
 		this.headerSvc.headerTitle.next("ASNs");
@@ -59,7 +66,7 @@ export class AsnsTableComponent implements OnInit {
 				}
 			},
 			e => {
-				console.error("Failed to get query parameters:", e);
+				this.log.error("Failed to get query parameters:", e);
 			}
 		);
 	}
@@ -126,7 +133,7 @@ export class AsnsTableComponent implements OnInit {
 	 */
 	public async handleContextMenu(evt: ContextMenuActionEvent<ResponseASN>): Promise<void> {
 		if (Array.isArray(evt.data)) {
-			console.error("cannot delete multiple ASNs at once:", evt.data);
+			this.log.error("cannot delete multiple ASNs at once:", evt.data);
 			return;
 		}
 		const data = evt.data;
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts
index cc4b5280e0..7842b7b810 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts
@@ -21,6 +21,7 @@ import { LocalizationMethod, localizationMethodToString, TypeFromResponse, type
 
 import { CacheGroupService, TypeService } from "src/app/api";
 import { DecisionDialogComponent, type DecisionDialogData } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -72,7 +73,8 @@ export class CacheGroupDetailsComponent implements OnInit {
 		private readonly typesAPI: TypeService,
 		private readonly location: Location,
 		private readonly dialog: MatDialog,
-		private readonly navSvc: NavigationService
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
 	) {
 	}
 
@@ -84,7 +86,7 @@ export class CacheGroupDetailsComponent implements OnInit {
 	public async ngOnInit(): Promise<void> {
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -104,7 +106,7 @@ export class CacheGroupDetailsComponent implements OnInit {
 		await cgsPromise;
 		const idx = this.cacheGroups.findIndex(c => c.id === numID);
 		if (idx < 0) {
-			console.error(`no such Cache Group: #${ID}`);
+			this.log.error(`no such Cache Group: #${ID}`);
 			return;
 		}
 		this.cacheGroup = this.cacheGroups.splice(idx, 1)[0];
@@ -165,7 +167,7 @@ export class CacheGroupDetailsComponent implements OnInit {
 	 */
 	public async delete(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new Cache Group");
+			this.log.error("Unable to delete new Cache Group");
 			return;
 		}
 		const ref = this.dialog.open<DecisionDialogComponent, DecisionDialogData, boolean>(
@@ -199,7 +201,7 @@ export class CacheGroupDetailsComponent implements OnInit {
 		}
 		const {value} = this.typeCtrl;
 		if (value === null) {
-			return console.error("cannot create Cache Group of null Type");
+			return this.log.error("cannot create Cache Group of null Type");
 		}
 		this.cacheGroup.typeId = value;
 		this.cacheGroup.shortName = this.cacheGroup.name;
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
index 0ca1331cad..2bba71171a 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
@@ -39,6 +39,7 @@ import type {
 	ContextMenuItem,
 	DoubleClickLink
 } from "src/app/shared/generic-table/generic-table.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -200,7 +201,8 @@ export class CacheGroupTableComponent implements OnInit {
 		private readonly dialog: MatDialog,
 		private readonly alerts: AlertService,
 		public readonly auth: CurrentUserService,
-		private readonly navSvc: NavigationService
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
 	) {
 		this.fuzzySubject = new BehaviorSubject<string>("");
 		this.cacheGroups = this.api.getCacheGroups();
@@ -292,13 +294,13 @@ export class CacheGroupTableComponent implements OnInit {
 				break;
 			case "delete":
 				if (Array.isArray(a.data)) {
-					console.error("cannot delete multiple cache groups at once:", a.data);
+					this.log.error("cannot delete multiple cache groups at once:", a.data);
 					return;
 				}
 				this.delete(a.data);
 				break;
 			default:
-				console.error("unrecognized context menu action:", a.action);
+				this.log.error("unrecognized context menu action:", a.action);
 		}
 	}
 }
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.ts
index fe18e46388..91b46961a4 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.ts
@@ -20,6 +20,7 @@ import { ResponseCoordinate } from "trafficops-types";
 
 import { CacheGroupService } from "src/app/api";
 import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -34,8 +35,14 @@ export class CoordinateDetailComponent implements OnInit {
 	public new = false;
 	public coordinate!: ResponseCoordinate;
 
-	constructor(private readonly route: ActivatedRoute, private readonly cacheGroupService: CacheGroupService,
-		private readonly location: Location, private readonly dialog: MatDialog, private readonly navSvc: NavigationService) { }
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly cacheGroupService: CacheGroupService,
+		private readonly location: Location,
+		private readonly dialog: MatDialog,
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * Angular lifecycle hook where data is initialized.
@@ -43,7 +50,7 @@ export class CoordinateDetailComponent implements OnInit {
 	public async ngOnInit(): Promise<void> {
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -61,7 +68,7 @@ export class CoordinateDetailComponent implements OnInit {
 		}
 		const numID = parseInt(ID, 10);
 		if (Number.isNaN(numID)) {
-			console.error("route parameter 'id' was non-number:", ID);
+			this.log.error("route parameter 'id' was non-number:", ID);
 			return;
 		}
 
@@ -74,7 +81,7 @@ export class CoordinateDetailComponent implements OnInit {
 	 */
 	public async deleteCoordinate(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new coordinate");
+			this.log.error("Unable to delete new coordinate");
 			return;
 		}
 		const ref = this.dialog.open(DecisionDialogComponent, {
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.ts
index d9d742f0ca..4a9081e598 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.ts
@@ -19,6 +19,7 @@ import { ResponseDivision } from "trafficops-types";
 
 import { CacheGroupService } from "src/app/api";
 import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -32,8 +33,14 @@ export class DivisionDetailComponent implements OnInit {
 	public new = false;
 	public division!: ResponseDivision;
 
-	constructor(private readonly route: ActivatedRoute, private readonly cacheGroupService: CacheGroupService,
-		private readonly location: Location, private readonly dialog: MatDialog, private readonly navSvc: NavigationService) { }
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly cacheGroupService: CacheGroupService,
+		private readonly location: Location,
+		private readonly dialog: MatDialog,
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * Angular lifecycle hook where data is initialized.
@@ -41,7 +48,7 @@ export class DivisionDetailComponent implements OnInit {
 	public async ngOnInit(): Promise<void> {
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -57,7 +64,7 @@ export class DivisionDetailComponent implements OnInit {
 		}
 		const numID = parseInt(ID, 10);
 		if (Number.isNaN(numID)) {
-			console.error("route parameter 'id' was non-number:", ID);
+			this.log.error("route parameter 'id' was non-number:", ID);
 			return;
 		}
 
@@ -70,7 +77,7 @@ export class DivisionDetailComponent implements OnInit {
 	 */
 	public async deleteDivision(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new division");
+			this.log.error("Unable to delete new division");
 			return;
 		}
 		const ref = this.dialog.open(DecisionDialogComponent, {
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.ts
index c195bd4758..042f7faddf 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.ts
@@ -19,6 +19,7 @@ import { ResponseDivision, ResponseRegion } from "trafficops-types";
 
 import { CacheGroupService } from "src/app/api";
 import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -34,9 +35,14 @@ export class RegionDetailComponent implements OnInit {
 	public region!: ResponseRegion;
 	public divisions!: Array<ResponseDivision>;
 
-	constructor(private readonly route: ActivatedRoute, private readonly cacheGroupService: CacheGroupService,
-		private readonly location: Location, private readonly dialog: MatDialog,
-		private readonly header: NavigationService) {
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly cacheGroupService: CacheGroupService,
+		private readonly location: Location,
+		private readonly dialog: MatDialog,
+		private readonly header: NavigationService,
+		private readonly log: LoggingService,
+	) {
 	}
 
 	/**
@@ -46,7 +52,7 @@ export class RegionDetailComponent implements OnInit {
 		this.divisions = await this.cacheGroupService.getDivisions();
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -64,7 +70,7 @@ export class RegionDetailComponent implements OnInit {
 		}
 		const numID = parseInt(ID, 10);
 		if (Number.isNaN(numID)) {
-			console.error("route parameter 'id' was non-number:", ID);
+			this.log.error("route parameter 'id' was non-number:", ID);
 			return;
 		}
 
@@ -77,7 +83,7 @@ export class RegionDetailComponent implements OnInit {
 	 */
 	public async deleteRegion(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new region");
+			this.log.error("Unable to delete new region");
 			return;
 		}
 		const ref = this.dialog.open(DecisionDialogComponent, {
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/regions/table/regions-table.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/regions/table/regions-table.component.ts
index 4a31e6277e..e4e966f89d 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/regions/table/regions-table.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/regions/table/regions-table.component.ts
@@ -27,6 +27,7 @@ import type {
 	ContextMenuItem,
 	DoubleClickLink
 } from "src/app/shared/generic-table/generic-table.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -41,8 +42,14 @@ export class RegionsTableComponent implements OnInit {
 	/** List of regions */
 	public regions: Promise<Array<ResponseRegion>>;
 
-	constructor(private readonly route: ActivatedRoute, private readonly headerSvc: NavigationService,
-		private readonly api: CacheGroupService, private readonly dialog: MatDialog, public readonly auth: CurrentUserService) {
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly headerSvc: NavigationService,
+		private readonly api: CacheGroupService,
+		private readonly dialog: MatDialog,
+		public readonly auth: CurrentUserService,
+		private readonly log: LoggingService,
+	) {
 		this.fuzzySubject = new BehaviorSubject<string>("");
 		this.regions = this.api.getRegions();
 		this.headerSvc.headerTitle.next("Regions");
@@ -59,7 +66,7 @@ export class RegionsTableComponent implements OnInit {
 				}
 			},
 			e => {
-				console.error("Failed to get query parameters:", e);
+				this.log.error("Failed to get query parameters:", e);
 			}
 		);
 	}
diff --git a/experimental/traffic-portal/src/app/core/cdns/cdn-detail/cdn-detail.component.ts b/experimental/traffic-portal/src/app/core/cdns/cdn-detail/cdn-detail.component.ts
index fc8d14c480..53fe744732 100644
--- a/experimental/traffic-portal/src/app/core/cdns/cdn-detail/cdn-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/cdns/cdn-detail/cdn-detail.component.ts
@@ -23,6 +23,7 @@ import {
 	DecisionDialogComponent,
 	DecisionDialogData,
 } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -50,7 +51,8 @@ export class CDNDetailComponent implements OnInit {
 		private readonly api: CDNService,
 		private readonly location: Location,
 		private readonly dialog: MatDialog,
-		private readonly navSvc: NavigationService
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
 	) {
 	}
 
@@ -60,7 +62,7 @@ export class CDNDetailComponent implements OnInit {
 	public async ngOnInit(): Promise<void> {
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -78,7 +80,7 @@ export class CDNDetailComponent implements OnInit {
 		await cdnsPromise;
 		const index = this.cdns.findIndex(c => c.id === numID);
 		if (index < 0) {
-			console.error(`no such CDN: #${ID}`);
+			this.log.error(`no such CDN: #${ID}`);
 			return;
 		}
 		this.cdn = this.cdns.splice(index, 1)[0];
@@ -99,7 +101,7 @@ export class CDNDetailComponent implements OnInit {
 	 */
 	public async delete(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new CDN");
+			this.log.error("Unable to delete new CDN");
 			return;
 		}
 		const ref = this.dialog.open<DecisionDialogComponent, DecisionDialogData, boolean>(
diff --git a/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.ts b/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.ts
index a5d6d07cb0..8bf05e0199 100644
--- a/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.ts
+++ b/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.ts
@@ -31,6 +31,7 @@ import type {
 	ContextMenuItem,
 	DoubleClickLink
 } from "src/app/shared/generic-table/generic-table.component";
+import { LoggingService } from "src/app/shared/logging.service";
 
 /**
  * CDNTableComponent is the controller for the "CDNs" table.
@@ -164,6 +165,7 @@ export class CDNTableComponent implements OnInit {
 		public readonly auth: CurrentUserService,
 		private readonly dialog: MatDialog,
 		private readonly route: ActivatedRoute,
+		private readonly log: LoggingService,
 	) {
 		this.fuzzySubject = new BehaviorSubject<string>("");
 		this.cdns = this.api.getCDNs();
@@ -250,27 +252,27 @@ export class CDNTableComponent implements OnInit {
 		switch (a.action) {
 			case "queue":
 				if (Array.isArray(a.data)) {
-					console.error("cannot queue multiple cdns at once:", a.data);
+					this.log.error("cannot queue multiple cdns at once:", a.data);
 					return;
 				}
 				this.queueUpdates(a.data);
 				break;
 			case "dequeue":
 				if (Array.isArray(a.data)) {
-					console.error("cannot dequeue multiple cdns at once:", a.data);
+					this.log.error("cannot dequeue multiple cdns at once:", a.data);
 					return;
 				}
 				this.queueUpdates(a.data, false);
 				break;
 			case "delete":
 				if (Array.isArray(a.data)) {
-					console.error("cannot delete multiple cdns at once:", a.data);
+					this.log.error("cannot delete multiple cdns at once:", a.data);
 					return;
 				}
 				this.delete(a.data);
 				break;
 			default:
-				console.error("unrecognized context menu action:", a.action);
+				this.log.error("unrecognized context menu action:", a.action);
 		}
 	}
 }
diff --git a/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.ts b/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.ts
index 8f7a6c748c..30cdd50c72 100644
--- a/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/certs/cert-detail/cert-detail.component.ts
@@ -16,6 +16,7 @@ import { AbstractControl, FormControl, ValidationErrors, ValidatorFn } from "@an
 import { pki, Hex } from "node-forge";
 
 import { oidToName, pkiCertToSHA1, pkiCertToSHA256 } from "src/app/core/certs/cert.util";
+import { LoggingService } from "src/app/shared/logging.service";
 
 /**
  * Author contains the information about an author from a cert issuer/subject
@@ -75,6 +76,8 @@ export class CertDetailComponent implements OnChanges {
 	public sha1: Hex = "";
 	public sha256: Hex = "";
 
+	constructor(private readonly log: LoggingService) { }
+
 	/**
 	 * processAttributes converts attributes into an author
 	 *
@@ -86,7 +89,7 @@ export class CertDetailComponent implements OnChanges {
 		for (const attr of attrs) {
 			if (attr.name && attr.value) {
 				if (typeof attr.value !== "string") {
-					console.warn(`Unknown attribute value ${attr.value}`);
+					this.log.warn(`Unknown attribute value ${attr.value}`);
 					continue;
 				}
 				switch (attr.name) {
diff --git a/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.ts b/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.ts
index 0276e73f7c..4f25fbb918 100644
--- a/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.ts
+++ b/experimental/traffic-portal/src/app/core/certs/cert-viewer/cert-viewer.component.ts
@@ -19,6 +19,7 @@ import { pki } from "node-forge";
 import { type ResponseDeliveryServiceSSLKey } from "trafficops-types";
 
 import { DeliveryServiceService } from "src/app/api";
+import { LoggingService } from "src/app/shared/logging.service";
 
 /**
  * What type of cert is it
@@ -63,8 +64,9 @@ export class CertViewerComponent implements OnInit {
 	constructor(
 		private readonly route: ActivatedRoute,
 		private readonly dsAPI: DeliveryServiceService,
-		private readonly router: Router) {
-	}
+		private readonly router: Router,
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * newCert creates a cert from an input string.
@@ -77,7 +79,7 @@ export class CertViewerComponent implements OnInit {
 		try {
 			return pki.certificateFromPem(input) as AugmentedCertificate;
 		} catch (e) {
-			console.error(`ran into issue creating certificate from input ${input}`, e);
+			this.log.error(`ran into issue creating certificate from input ${input}`, e);
 			return NULL_CERT;
 		}
 	}
@@ -143,7 +145,7 @@ export class CertViewerComponent implements OnInit {
 				rootFirst = false;
 			} else {
 				invalid = true;
-				console.error(`Cert chain is invalid, cert ${i-1} and ${i} are not related`);
+				this.log.error(`Cert chain is invalid, cert ${i-1} and ${i} are not related`);
 			}
 		}
 
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts b/experimental/traffic-portal/src/app/core/core.module.ts
index acc912ae87..4a5f1a96ad 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -26,8 +26,8 @@ import { AppUIModule } from "../app.ui.module";
 import { AuthenticatedGuard } from "../guards/authenticated-guard.service";
 import { SharedModule } from "../shared/shared.module";
 
-import { AsnDetailComponent } from "./cache-groups/asns/detail/asn-detail.component";
-import { AsnsTableComponent } from "./cache-groups/asns/table/asns-table.component";
+import { ASNDetailComponent } from "./cache-groups/asns/detail/asn-detail.component";
+import { ASNsTableComponent } from "./cache-groups/asns/table/asns-table.component";
 import { CacheGroupDetailsComponent } from "./cache-groups/cache-group-details/cache-group-details.component";
 import { CacheGroupTableComponent } from "./cache-groups/cache-group-table/cache-group-table.component";
 import { CoordinateDetailComponent } from "./cache-groups/coordinates/detail/coordinate-detail.component";
@@ -85,8 +85,8 @@ export const ROUTES: Routes = [
 		path: "certs"
 	},
 	{ component: DashboardComponent, path: "" },
-	{ component: AsnDetailComponent, path: "asns/:id"},
-	{ component: AsnsTableComponent, path: "asns" },
+	{ component: ASNDetailComponent, path: "asns/:id"},
+	{ component: ASNsTableComponent, path: "asns" },
 	{ component: DivisionsTableComponent, path: "divisions" },
 	{ component: DivisionDetailComponent, path: "divisions/:id" },
 	{ component: RegionsTableComponent, path: "regions" },
@@ -134,53 +134,52 @@ export const ROUTES: Routes = [
  */
 @NgModule({
 	declarations: [
-		UsersComponent,
-		ServerDetailsComponent,
-		ServersTableComponent,
-		DeliveryserviceComponent,
-		NewDeliveryServiceComponent,
+		ASNDetailComponent,
+		ASNsTableComponent,
+		CacheGroupDetailsComponent,
+		CacheGroupTableComponent,
+		CapabilitiesComponent,
+		CapabilityDetailsComponent,
+		CDNDetailComponent,
+		CDNTableComponent,
+		ChangeLogsComponent,
+		CoordinateDetailComponent,
+		CoordinatesTableComponent,
 		CurrentuserComponent,
-		UpdatePasswordDialogComponent,
 		DashboardComponent,
+		DeliveryserviceComponent,
+		DivisionDetailComponent,
+		DivisionsTableComponent,
 		DsCardComponent,
 		InvalidationJobsComponent,
-		CacheGroupTableComponent,
-		NewInvalidationJobDialogComponent,
-		UpdateStatusComponent,
-		UserDetailsComponent,
-		TenantsComponent,
-		UserRegistrationDialogComponent,
-		RolesTableComponent,
-		RoleDetailComponent,
-		TenantDetailsComponent,
-		ChangeLogsComponent,
+		ISOGenerationFormComponent,
 		LastDaysComponent,
-		UserRegistrationDialogComponent,
-		PhysLocTableComponent,
+		NewDeliveryServiceComponent,
+		NewInvalidationJobDialogComponent,
+		ParameterDetailComponent,
+		ParametersTableComponent,
 		PhysLocDetailComponent,
-		AsnsTableComponent,
-		AsnDetailComponent,
-		DivisionsTableComponent,
-		DivisionDetailComponent,
-		RegionsTableComponent,
+		PhysLocTableComponent,
+		ProfileDetailComponent,
+		ProfileTableComponent,
 		RegionDetailComponent,
-		CacheGroupDetailsComponent,
-		TypesTableComponent,
-		TypeDetailComponent,
-		CoordinatesTableComponent,
-		CoordinateDetailComponent,
-		StatusesTableComponent,
+		RegionsTableComponent,
+		RoleDetailComponent,
+		RolesTableComponent,
+		ServerDetailsComponent,
+		ServersTableComponent,
 		StatusDetailsComponent,
-		ISOGenerationFormComponent,
-		CDNTableComponent,
-		CDNDetailComponent,
-		ParametersTableComponent,
-		ParameterDetailComponent,
-		ProfileTableComponent,
-		ProfileDetailComponent,
-		CapabilitiesComponent,
-		CapabilityDetailsComponent,
+		StatusesTableComponent,
+		TenantDetailsComponent,
+		TenantsComponent,
 		TopologyDetailsComponent,
+		TypeDetailComponent,
+		TypesTableComponent,
+		UpdatePasswordDialogComponent,
+		UpdateStatusComponent,
+		UserDetailsComponent,
+		UserRegistrationDialogComponent,
+		UsersComponent,
 	],
 	exports: [],
 	imports: [
diff --git a/experimental/traffic-portal/src/app/core/currentuser/currentuser.component.ts b/experimental/traffic-portal/src/app/core/currentuser/currentuser.component.ts
index 3a1d06995a..00ddec2d29 100644
--- a/experimental/traffic-portal/src/app/core/currentuser/currentuser.component.ts
+++ b/experimental/traffic-portal/src/app/core/currentuser/currentuser.component.ts
@@ -18,6 +18,7 @@ import { ResponseCurrentUser } from "trafficops-types";
 
 import { UserService } from "src/app/api";
 import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 import {ThemeManagerService} from "src/app/shared/theme-manager/theme-manager.service";
 
@@ -54,7 +55,8 @@ export class CurrentuserComponent implements OnInit {
 		private readonly route: ActivatedRoute,
 		private readonly router: Router,
 		private readonly navSvc: NavigationService,
-		public readonly themeSvc: ThemeManagerService
+		public readonly themeSvc: ThemeManagerService,
+		private readonly log: LoggingService
 	) {
 		this.currentUser = this.auth.currentUser;
 	}
@@ -141,12 +143,12 @@ export class CurrentuserComponent implements OnInit {
 		if (success) {
 			const updated = await this.auth.updateCurrentUser();
 			if (!updated) {
-				console.warn("Failed to fetch current user after successful update");
+				this.log.warn("Failed to fetch current user after successful update");
 			}
 			this.currentUser = this.auth.currentUser;
 			this.cancelEdit();
 		} else {
-			console.warn("Editing the current user failed");
+			this.log.warn("Editing the current user failed");
 			this.cancelEdit();
 		}
 	}
diff --git a/experimental/traffic-portal/src/app/core/currentuser/update-password-dialog/update-password-dialog.component.ts b/experimental/traffic-portal/src/app/core/currentuser/update-password-dialog/update-password-dialog.component.ts
index d524b76c15..3923269044 100644
--- a/experimental/traffic-portal/src/app/core/currentuser/update-password-dialog/update-password-dialog.component.ts
+++ b/experimental/traffic-portal/src/app/core/currentuser/update-password-dialog/update-password-dialog.component.ts
@@ -16,6 +16,7 @@ import { MatDialogRef } from "@angular/material/dialog";
 import { Subject } from "rxjs";
 
 import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
+import { LoggingService } from "src/app/shared/logging.service";
 
 /**
  * This is the controller for the "Update Password" dialog box/form.
@@ -36,8 +37,9 @@ export class UpdatePasswordDialogComponent {
 
 	constructor(
 		private readonly dialog: MatDialogRef<UpdatePasswordDialogComponent>,
-		private readonly auth: CurrentUserService
-	) { }
+		private readonly auth: CurrentUserService,
+		private readonly log: LoggingService,
+	) {}
 
 	/**
 	 * Cancels the password update, closing the dialog box.
@@ -63,7 +65,7 @@ export class UpdatePasswordDialogComponent {
 		}
 
 		if (!this.auth.currentUser) {
-			console.error("Cannot update null user");
+			this.log.error("Cannot update null user");
 			return;
 		}
 
diff --git a/experimental/traffic-portal/src/app/core/deliveryservice/deliveryservice.component.ts b/experimental/traffic-portal/src/app/core/deliveryservice/deliveryservice.component.ts
index 676d0e230d..51d3e65e7a 100644
--- a/experimental/traffic-portal/src/app/core/deliveryservice/deliveryservice.component.ts
+++ b/experimental/traffic-portal/src/app/core/deliveryservice/deliveryservice.component.ts
@@ -20,6 +20,7 @@ import { AlertLevel, ResponseDeliveryService } from "trafficops-types";
 import { DeliveryServiceService } from "src/app/api";
 import type { DataPoint, DataSet } from "src/app/models";
 import { AlertService } from "src/app/shared/alert/alert.service";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -77,14 +78,12 @@ export class DeliveryserviceComponent implements OnInit {
 	/** The size of each single interval for data grouping, in seconds. */
 	private bucketSize = 300;
 
-	/**
-	 * Constructor.
-	 */
 	constructor(
 		private readonly route: ActivatedRoute,
 		private readonly api: DeliveryServiceService,
 		private readonly alerts: AlertService,
-		private readonly navSvc: NavigationService
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
 	) {
 		this.bandwidthData.next([{
 			backgroundColor: "#BA3C57",
@@ -116,7 +115,7 @@ export class DeliveryserviceComponent implements OnInit {
 
 		const DSID = this.route.snapshot.paramMap.get("id");
 		if (!DSID) {
-			console.error("Missing route 'id' parameter");
+			this.log.error("Missing route 'id' parameter");
 			return;
 		}
 
@@ -185,7 +184,7 @@ export class DeliveryserviceComponent implements OnInit {
 			data = await this.api.getDSKBPS(xmlID, this.from, this.to, interval, false, true);
 		} catch (e) {
 			this.alerts.newAlert(AlertLevel.WARNING, "Edge-Tier bandwidth data not found!");
-			console.error(`Failed to get edge KBPS data for '${xmlID}':`, e);
+			this.log.error(`Failed to get edge KBPS data for '${xmlID}':`, e);
 			return;
 		}
 
@@ -232,7 +231,7 @@ export class DeliveryserviceComponent implements OnInit {
 				]);
 			},
 			e => {
-				console.error(`Failed to get edge TPS data for '${this.deliveryservice.xmlId}':`, e);
+				this.log.error(`Failed to get edge TPS data for '${this.deliveryservice.xmlId}':`, e);
 				this.alerts.newAlert(AlertLevel.WARNING, "Edge-Tier transaction data not found!");
 			}
 		);
diff --git a/experimental/traffic-portal/src/app/core/deliveryservice/ds-card/ds-card.component.ts b/experimental/traffic-portal/src/app/core/deliveryservice/ds-card/ds-card.component.ts
index c10ac704f6..cbf2481afd 100644
--- a/experimental/traffic-portal/src/app/core/deliveryservice/ds-card/ds-card.component.ts
+++ b/experimental/traffic-portal/src/app/core/deliveryservice/ds-card/ds-card.component.ts
@@ -21,6 +21,7 @@ import type {
 	DataPoint,
 	DataSet,
 } from "src/app/models";
+import { LoggingService } from "src/app/shared/logging.service";
 
 /**
  * DsCardComponent is a component for displaying information about a Delivery
@@ -118,7 +119,7 @@ export class DsCardComponent implements OnInit {
 		return "";
 	}
 
-	constructor(private readonly dsAPI: DeliveryServiceService) {
+	constructor(private readonly dsAPI: DeliveryServiceService, private readonly log: LoggingService) {
 		this.available = 100;
 		this.maintenance = 0;
 		this.utilized = 0;
@@ -151,10 +152,6 @@ export class DsCardComponent implements OnInit {
 	 * 00:00 UTC the current day and ending at the current date/time.
 	 */
 	public toggle(): void {
-		if (!this.deliveryService.id) {
-			console.error("Toggling DS card for DS with no ID:", this.deliveryService);
-			return;
-		}
 		if (!this.open) {
 			if (!this.loaded) {
 				this.loaded = true;
@@ -226,7 +223,7 @@ export class DsCardComponent implements OnInit {
 				fillColor: "#3CBA9F",
 				label: "Edge-tier Bandwidth"
 			}]);
-			console.error(`Failed getting edge KBPS for DS '${xmlID}':`, e);
+			this.log.error(`Failed getting edge KBPS for DS '${xmlID}':`, e);
 		}
 	}
 }
diff --git a/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/invalidation-jobs.component.ts b/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/invalidation-jobs.component.ts
index ccef99e4b6..0480879fc6 100644
--- a/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/invalidation-jobs.component.ts
+++ b/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/invalidation-jobs.component.ts
@@ -17,6 +17,7 @@ import { ActivatedRoute } from "@angular/router";
 import { ResponseDeliveryService, ResponseInvalidationJob } from "trafficops-types";
 
 import { DeliveryServiceService, InvalidationJobService } from "src/app/api";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 import {
@@ -51,21 +52,22 @@ export class InvalidationJobsComponent implements OnInit {
 		private readonly jobAPI: InvalidationJobService,
 		private readonly dsAPI: DeliveryServiceService,
 		private readonly dialog: MatDialog,
-		private readonly navSvc: NavigationService
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
 	) {
 		this.jobs = new Array<ResponseInvalidationJob>();
 	}
 
 	/**
 	 * Runs initialization, fetching the jobs and Delivery Service data from
-	 * Traffic Ops and setting the pageload date/time.
+	 * Traffic Ops and setting the date/time on page load.
 	 */
 	public async ngOnInit(): Promise<void> {
 		this.navSvc.headerTitle.next("Loading - Content Invalidation Jobs");
 		this.now = new Date();
 		const idParam = this.route.snapshot.paramMap.get("id");
 		if (!idParam) {
-			console.error("Missing route 'id' parameter");
+			this.log.error("Missing route 'id' parameter");
 			return;
 		}
 		this.dsID = parseInt(idParam, 10);
diff --git a/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/new-invalidation-job-dialog/new-invalidation-job-dialog.component.spec.ts b/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/new-invalidation-job-dialog/new-invalidation-job-dialog.component.spec.ts
index fdb768b699..8bf52574e7 100644
--- a/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/new-invalidation-job-dialog/new-invalidation-job-dialog.component.spec.ts
+++ b/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/new-invalidation-job-dialog/new-invalidation-job-dialog.component.spec.ts
@@ -25,14 +25,14 @@ describe("NewInvalidationJobDialogComponent", () => {
 	let component: NewInvalidationJobDialogComponent;
 	let fixture: ComponentFixture<NewInvalidationJobDialogComponent>;
 	const dialogRef = {
-		close: jasmine.createSpy("dialog 'close' method", (): void => console.log("dialog closed"))
+		close: jasmine.createSpy("dialog 'close' method", (): void => { /* Do nothing */ })
 	};
 	const dialogData = {
 		dsID: -1
 	};
 
 	beforeEach(async () => {
-		dialogRef.close = jasmine.createSpy("dialog 'close' method", (): void => console.log("dialog closed"));
+		dialogRef.close = jasmine.createSpy("dialog 'close' method", (): void => { /* Do nothing */ });
 		await TestBed.configureTestingModule({
 			declarations: [ NewInvalidationJobDialogComponent ],
 			imports: [
@@ -128,7 +128,7 @@ describe("NewInvalidationJobDialogComponent - editing", () => {
 	let component: NewInvalidationJobDialogComponent;
 	let fixture: ComponentFixture<NewInvalidationJobDialogComponent>;
 	const dialogRef = {
-		close: jasmine.createSpy("dialog 'close' method", (): void => console.log("dialog closed"))
+		close: jasmine.createSpy("dialog 'close' method", (): void => { /* Do nothing */ })
 	};
 	const dialogData = {
 		dsID: -1,
@@ -142,7 +142,7 @@ describe("NewInvalidationJobDialogComponent - editing", () => {
 	};
 
 	beforeEach(async () => {
-		dialogRef.close = jasmine.createSpy("dialog 'close' method", (): void => console.log("dialog closed"));
+		dialogRef.close = jasmine.createSpy("dialog 'close' method", (): void => { /* Do nothing */ });
 		await TestBed.configureTestingModule({
 			declarations: [ NewInvalidationJobDialogComponent ],
 			imports: [
diff --git a/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/new-invalidation-job-dialog/new-invalidation-job-dialog.component.ts b/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/new-invalidation-job-dialog/new-invalidation-job-dialog.component.ts
index 6102ba30ed..048f9b29cb 100644
--- a/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/new-invalidation-job-dialog/new-invalidation-job-dialog.component.ts
+++ b/experimental/traffic-portal/src/app/core/deliveryservice/invalidation-jobs/new-invalidation-job-dialog/new-invalidation-job-dialog.component.ts
@@ -18,6 +18,7 @@ import { Subject } from "rxjs";
 import { JobType, ResponseInvalidationJob } from "trafficops-types";
 
 import { InvalidationJobService } from "src/app/api";
+import { LoggingService } from "src/app/shared/logging.service";
 
 /**
  * Gets the time part of a Date as a string.
@@ -95,7 +96,8 @@ export class NewInvalidationJobDialogComponent {
 	constructor(
 		private readonly dialogRef: MatDialogRef<NewInvalidationJobDialogComponent>,
 		private readonly jobAPI: InvalidationJobService,
-		@Inject(MAT_DIALOG_DATA) data: NewInvalidationJobDialogData
+		@Inject(MAT_DIALOG_DATA) data: NewInvalidationJobDialogData,
+		private readonly log: LoggingService,
 	) {
 		this.job = data.job;
 		if (this.job) {
@@ -152,7 +154,7 @@ export class NewInvalidationJobDialogComponent {
 			await this.jobAPI.updateInvalidationJob(job);
 			this.dialogRef.close(true);
 		} catch (e) {
-			console.error("error:", e);
+			this.log.error(`failed to edit Job #${j.id}:`, e);
 		}
 	}
 
@@ -193,7 +195,7 @@ export class NewInvalidationJobDialogComponent {
 			await this.jobAPI.createInvalidationJob(job);
 			this.dialogRef.close(true);
 		} catch (err) {
-			console.error("error: ", err);
+			this.log.error("failed to create invalidation job: ", err);
 		}
 	}
 
diff --git a/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.spec.ts b/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.spec.ts
index dfed761f47..429b7c1c76 100644
--- a/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.spec.ts
+++ b/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.spec.ts
@@ -110,27 +110,23 @@ describe("NewDeliveryServiceComponent", () => {
 	});
 
 	it("should set meta info properly", async () => {
-		try {
-			const stepper = await loader.getHarness(MatStepperHarness);
-			const steps = await stepper.getSteps();
-			await steps[1].select();
-			component.displayName.setValue("test._QUEST");
-			component.infoURL.setValue("ftp://this-is-a-weird.url/");
-			component.description.setValue("test description");
-			component.setMetaInformation();
-
-			expect(component.deliveryService.displayName).toEqual("test._QUEST", "test._QUEST");
-			expect(component.deliveryService.xmlId).toEqual("test-quest", "test-quest");
-			expect(component.deliveryService.longDesc).toEqual("test description", "test description");
-			expect(component.deliveryService.infoUrl).toEqual("ftp://this-is-a-weird.url/", "ftp://this-is-a-weird.url/");
-			expect(await parallel(() => steps.map(async step => step.isSelected()))).toEqual([
-				false,
-				false,
-				true
-			]);
-		} catch (e) {
-			console.error("Error occurred:", e);
-		}
+		const stepper = await loader.getHarness(MatStepperHarness);
+		const steps = await stepper.getSteps();
+		await steps[1].select();
+		component.displayName.setValue("test._QUEST");
+		component.infoURL.setValue("ftp://this-is-a-weird.url/");
+		component.description.setValue("test description");
+		component.setMetaInformation();
+
+		expect(component.deliveryService.displayName).toEqual("test._QUEST", "test._QUEST");
+		expect(component.deliveryService.xmlId).toEqual("test-quest", "test-quest");
+		expect(component.deliveryService.longDesc).toEqual("test description", "test description");
+		expect(component.deliveryService.infoUrl).toEqual("ftp://this-is-a-weird.url/", "ftp://this-is-a-weird.url/");
+		expect(await parallel(() => steps.map(async step => step.isSelected()))).toEqual([
+			false,
+			false,
+			true
+		]);
 	});
 
 	it("should set infrastructure info properly for HTTP Delivery Services", () => {
diff --git a/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.ts b/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.ts
index a8882a6d1b..828164dbf7 100644
--- a/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.ts
+++ b/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.ts
@@ -31,6 +31,7 @@ import {
 
 import { CDNService, DeliveryServiceService } from "src/app/api";
 import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 import { IPV4, IPV6 } from "src/app/utils";
 
@@ -145,7 +146,8 @@ export class NewDeliveryServiceComponent implements OnInit {
 		private readonly auth: CurrentUserService,
 		private readonly router: Router,
 		private readonly navSvc: NavigationService,
-		@Inject(DOCUMENT) private readonly document: Document
+		@Inject(DOCUMENT) private readonly document: Document,
+		private readonly log: LoggingService,
 	) { }
 
 	/**
@@ -173,7 +175,7 @@ export class NewDeliveryServiceComponent implements OnInit {
 			}
 		);
 		if (!this.auth.currentUser || !this.auth.currentUser.tenantId) {
-			console.error("Cannot set default CDN - user has no tenant");
+			this.log.error("Cannot set default CDN - user has no tenant");
 			return typeP;
 		}
 		const dsP = this.dsAPI.getDeliveryServices().then(
@@ -261,7 +263,7 @@ export class NewDeliveryServiceComponent implements OnInit {
 		try {
 			url = new URL(this.originURL.value ?? "");
 		} catch (e) {
-			console.error("invalid origin URL:", e);
+			this.log.error("invalid origin URL:", e);
 			return;
 		}
 		this.deliveryService.orgServerFqdn = url.origin;
@@ -353,7 +355,7 @@ export class NewDeliveryServiceComponent implements OnInit {
 					try {
 						this.setDNSBypass(this.bypassLoc.value ?? "");
 					} catch (e) {
-						console.error(e);
+						this.log.error("failed to set DNS bypass:", e);
 						const nativeBypassElement = this.document.getElementById("bypass-loc") as HTMLInputElement;
 						nativeBypassElement.setCustomValidity(e instanceof Error ? e.message : String(e));
 						nativeBypassElement.reportValidity();
@@ -370,7 +372,6 @@ export class NewDeliveryServiceComponent implements OnInit {
 
 		this.dsAPI.createDeliveryService(this.deliveryService).then(
 			v => {
-				console.log("New Delivery Service '%s' created", v.displayName);
 				this.router.navigate(["/"], {queryParams: {search: encodeURIComponent(v.displayName)}});
 			}
 		);
diff --git a/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.ts b/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.ts
index cc79127ecb..a78d4e11a8 100644
--- a/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.ts
@@ -20,6 +20,7 @@ import { ResponseParameter } from "trafficops-types";
 
 import { ProfileService } from "src/app/api";
 import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -38,8 +39,14 @@ export class ParameterDetailComponent implements OnInit {
 		{ label: "false", value: false }
 	];
 
-	constructor(private readonly route: ActivatedRoute, private readonly profileService: ProfileService,
-		private readonly location: Location, private readonly dialog: MatDialog, private readonly navSvc: NavigationService) { }
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly profileService: ProfileService,
+		private readonly location: Location,
+		private readonly dialog: MatDialog,
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * Angular lifecycle hook where data is initialized.
@@ -47,7 +54,7 @@ export class ParameterDetailComponent implements OnInit {
 	public async ngOnInit(): Promise<void> {
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -68,7 +75,7 @@ export class ParameterDetailComponent implements OnInit {
 
 		const numID = parseInt(ID, 10);
 		if (Number.isNaN(numID)) {
-			console.error("route parameter 'id' was non-number: ", ID);
+			this.log.error("route parameter 'id' was non-number: ", ID);
 			return;
 		}
 
@@ -81,7 +88,7 @@ export class ParameterDetailComponent implements OnInit {
 	 */
 	public async deleteParameter(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new parameter");
+			this.log.error("Unable to delete new parameter");
 			return;
 		}
 		const ref = this.dialog.open(DecisionDialogComponent, {
diff --git a/experimental/traffic-portal/src/app/core/profiles/profile-detail/profile-detail.component.ts b/experimental/traffic-portal/src/app/core/profiles/profile-detail/profile-detail.component.ts
index 78e0c362ff..cfc4381399 100644
--- a/experimental/traffic-portal/src/app/core/profiles/profile-detail/profile-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/profiles/profile-detail/profile-detail.component.ts
@@ -19,6 +19,7 @@ import { ProfileType, ResponseCDN, ResponseProfile } from "trafficops-types";
 
 import { CDNService, ProfileService } from "src/app/api";
 import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -59,23 +60,14 @@ export class ProfileDetailComponent implements OnInit {
 		{ value: "GROVE_PROFILE" }
 	];
 
-	/**
-	 * Constructor.
-	 *
-	 * @param api The Profiles API which is used to provide functions for create, edit and delete profiles.
-	 * @param cdnService The CDN service API which is used to provide cdns.
-	 * @param dialog Dialog manager
-	 * @param navSvc Manages the header
-	 * @param route A reference to the route of this view which is used to get the 'id' query parameter of profile.
-	 * @param router Angular router
-	 */
 	constructor(
 		private readonly api: ProfileService,
 		private readonly cdnService: CDNService,
 		private readonly dialog: MatDialog,
 		private readonly navSvc: NavigationService,
 		private readonly route: ActivatedRoute,
-		private readonly router: Router
+		private readonly router: Router,
+		private readonly log: LoggingService,
 	) { }
 
 	/**
@@ -133,7 +125,7 @@ export class ProfileDetailComponent implements OnInit {
 	 */
 	public async deleteProfile(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new profile");
+			this.log.error("Unable to delete new profile");
 			return;
 		}
 		const ref = this.dialog.open(DecisionDialogComponent, {
diff --git a/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.ts b/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.ts
index 5fe946ec49..88a0e30aa5 100644
--- a/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.ts
+++ b/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.ts
@@ -26,6 +26,7 @@ import type {
 	ContextMenuItem,
 	DoubleClickLink
 } from "src/app/shared/generic-table/generic-table.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -124,7 +125,8 @@ export class CapabilitiesComponent implements OnInit {
 		private readonly route: ActivatedRoute,
 		private readonly navSvc: NavigationService,
 		private readonly dialog: MatDialog,
-		public readonly auth: CurrentUserService
+		public readonly auth: CurrentUserService,
+		private readonly log: LoggingService
 	) {
 		this.fuzzySubject = new BehaviorSubject<string>("");
 		this.capabilities = this.api.getCapabilities();
@@ -173,7 +175,7 @@ export class CapabilitiesComponent implements OnInit {
 				}
 				break;
 			default:
-				console.warn("unrecognized context menu action:", evt.action);
+				this.log.warn("unrecognized context menu action:", evt.action);
 		}
 	}
 }
diff --git a/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.ts b/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.ts
index b93e84ccbb..51b9461615 100644
--- a/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.ts
@@ -19,6 +19,7 @@ import { ResponsePhysicalLocation, ResponseRegion } from "trafficops-types";
 
 import { CacheGroupService, PhysicalLocationService } from "src/app/api";
 import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -35,10 +36,15 @@ export class PhysLocDetailComponent implements OnInit {
 	public physLocation!: ResponsePhysicalLocation;
 	public regions!: Array<ResponseRegion>;
 
-	constructor(private readonly route: ActivatedRoute, private readonly cacheGroupService: CacheGroupService,
-		private readonly location: Location, private readonly dialog: MatDialog, private readonly navSvc: NavigationService,
-		private readonly physLocService: PhysicalLocationService) {
-	}
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly cacheGroupService: CacheGroupService,
+		private readonly location: Location,
+		private readonly dialog: MatDialog,
+		private readonly navSvc: NavigationService,
+		private readonly physLocService: PhysicalLocationService,
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * Angular lifecycle hook.
@@ -47,7 +53,7 @@ export class PhysLocDetailComponent implements OnInit {
 		this.regions = await this.cacheGroupService.getRegions();
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -74,7 +80,7 @@ export class PhysLocDetailComponent implements OnInit {
 		}
 		const numID = parseInt(ID, 10);
 		if (Number.isNaN(numID)) {
-			console.error("route parameter 'id' was non-number:", ID);
+			this.log.error("route parameter 'id' was non-number:", ID);
 			return;
 		}
 
@@ -87,7 +93,7 @@ export class PhysLocDetailComponent implements OnInit {
 	 */
 	public async deletePhysicalLocation(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new physLocation");
+			this.log.error("Unable to delete new physLocation");
 			return;
 		}
 		const ref = this.dialog.open(DecisionDialogComponent, {
diff --git a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts
index 3f78be5141..9b1f25b139 100644
--- a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts
+++ b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts
@@ -34,6 +34,7 @@ import {
 	DecisionDialogComponent,
 	DecisionDialogData
 } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 import { IP, IP_WITH_CIDR, AutocompleteValue } from "src/app/utils";
 
@@ -118,7 +119,8 @@ export class ServerDetailsComponent implements OnInit {
 		private readonly typeService: TypeService,
 		private readonly physlocService: PhysicalLocationService,
 		private readonly navSvc: NavigationService,
-		private readonly dialog: MatDialog
+		private readonly dialog: MatDialog,
+		private readonly log: LoggingService,
 	) {
 	}
 
@@ -128,7 +130,7 @@ export class ServerDetailsComponent implements OnInit {
 	public ngOnInit(): void {
 		const handleErr = (obj: string): (e: unknown) => void =>
 			(e: unknown): void => {
-				console.error(`Failed to get ${obj}:`, e);
+				this.log.error(`Failed to get ${obj}:`, e);
 			};
 
 		this.cacheGroupService.getCacheGroups().then(
@@ -164,7 +166,7 @@ export class ServerDetailsComponent implements OnInit {
 
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -178,7 +180,7 @@ export class ServerDetailsComponent implements OnInit {
 				}
 			).catch(
 				e => {
-					console.error(`Failed to get server #${ID}:`, e);
+					this.log.error(`Failed to get server #${ID}:`, e);
 				}
 			);
 		} else {
@@ -265,7 +267,7 @@ export class ServerDetailsComponent implements OnInit {
 					this.router.navigate(["server", s.id]);
 				},
 				err => {
-					console.error("failed to create server:", err);
+					this.log.error("failed to create server:", err);
 				}
 			);
 		} else {
@@ -275,7 +277,7 @@ export class ServerDetailsComponent implements OnInit {
 					this.updateTitlebar();
 				},
 				err => {
-					console.error(`failed to update server: ${err}`);
+					this.log.error(`failed to update server: ${err}`);
 				}
 			);
 		}
@@ -285,7 +287,7 @@ export class ServerDetailsComponent implements OnInit {
 	 */
 	public delete(): void {
 		if (this.isNew) {
-			console.error("Unable to delete new Cache Group");
+			this.log.error("Unable to delete new Cache Group");
 			return;
 		}
 		const ref = this.dialog.open<DecisionDialogComponent, DecisionDialogData, boolean>(
@@ -324,7 +326,7 @@ export class ServerDetailsComponent implements OnInit {
 			}
 		},
 		err => {
-			console.error(`failed to queue updates: ${err}`);
+			this.log.error(`failed to queue updates: ${err}`);
 		});
 	}
 
@@ -338,7 +340,7 @@ export class ServerDetailsComponent implements OnInit {
 			}
 		},
 		err => {
-			console.error(`failed to dequeue updates: ${err}`);
+			this.log.error(`failed to dequeue updates: ${err}`);
 		});
 	}
 
@@ -450,7 +452,7 @@ export class ServerDetailsComponent implements OnInit {
 					s => this.server = s
 				).catch(
 					err => {
-						console.error("Failed to reload servers:", err);
+						this.log.error("Failed to reload servers:", err);
 					}
 				);
 			}
diff --git a/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.ts b/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.ts
index 4f8d0627b7..57c89bb31c 100644
--- a/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.ts
+++ b/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.ts
@@ -16,6 +16,7 @@ import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
 import type { ResponseServer, ResponseStatus } from "trafficops-types";
 
 import { ServerService } from "src/app/api/server.service";
+import { LoggingService } from "src/app/shared/logging.service";
 
 /**
  * UpdateStatusComponent is the controller for the "Update Server Status" dialog box.
@@ -53,10 +54,12 @@ export class UpdateStatusComponent implements OnInit {
 		return `${len} servers`;
 	}
 
-	/** Constructor. */
-	constructor(private readonly dialogRef: MatDialogRef<UpdateStatusComponent>,
+	constructor(
+		private readonly dialogRef: MatDialogRef<UpdateStatusComponent>,
 		@Inject(MAT_DIALOG_DATA) private readonly dialogServers: Array<ResponseServer>,
-		private readonly api: ServerService) {
+		private readonly api: ServerService,
+		private readonly log: LoggingService,
+	) {
 		this.servers = this.dialogServers;
 	}
 
@@ -70,7 +73,7 @@ export class UpdateStatusComponent implements OnInit {
 			}
 		).catch(
 			e => {
-				console.error("Failed to get Statuses:", e);
+				this.log.error("Failed to get Statuses:", e);
 			}
 		);
 		if (this.servers.length < 1) {
@@ -110,7 +113,7 @@ export class UpdateStatusComponent implements OnInit {
 			await Promise.all(observables);
 			this.dialogRef.close(true);
 		} catch (err) {
-			console.error("something went wrong trying to update", this.serverName, "servers:", err);
+			this.log.error("something went wrong trying to update", this.serverName, "servers:", err);
 			this.dialogRef.close(false);
 		}
 	}
diff --git a/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.ts b/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.ts
index 81089fedd1..de41c41781 100644
--- a/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.ts
+++ b/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.ts
@@ -24,9 +24,8 @@ import {
 	DecisionDialogComponent,
 	DecisionDialogData,
 } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
-import {
-	NavigationService
-} from "src/app/shared/navigation/navigation.service";
+import { LoggingService } from "src/app/shared/logging.service";
+import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
  * TopologyDetailComponent is the controller for a Topology's "detail" page.
@@ -61,8 +60,8 @@ export class TopologyDetailsComponent implements OnInit {
 		private readonly location: Location,
 		private readonly dialog: MatDialog,
 		private readonly navSvc: NavigationService,
-	) {
-	}
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * Angular lifecycle hook where data is initialized.
@@ -81,7 +80,7 @@ export class TopologyDetailsComponent implements OnInit {
 		await topologiesPromise;
 		const index = this.topologies.findIndex(c => c.name === name);
 		if (index < 0) {
-			console.error(`no such Topology: ${name}`);
+			this.log.error(`no such Topology: ${name}`);
 			this.loading = false;
 			return;
 		}
@@ -116,7 +115,7 @@ export class TopologyDetailsComponent implements OnInit {
 	 */
 	public async delete(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new Topology");
+			this.log.error("Unable to delete new Topology");
 			return;
 		}
 		const ref = this.dialog.open<DecisionDialogComponent, DecisionDialogData, boolean>(
diff --git a/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.ts b/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.ts
index 2a88f7913f..ef1ec52507 100644
--- a/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.ts
@@ -20,6 +20,7 @@ import { TypeFromResponse } from "trafficops-types";
 
 import { TypeService } from "src/app/api";
 import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -34,8 +35,14 @@ export class TypeDetailComponent implements OnInit {
 	public new = false;
 	public type!: TypeFromResponse;
 
-	constructor(private readonly route: ActivatedRoute, private readonly typeService: TypeService,
-		private readonly location: Location, private readonly dialog: MatDialog, private readonly navSvc: NavigationService) { }
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly typeService: TypeService,
+		private readonly location: Location,
+		private readonly dialog: MatDialog,
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * Angular lifecycle hook where data is initialized.
@@ -43,7 +50,7 @@ export class TypeDetailComponent implements OnInit {
 	public async ngOnInit(): Promise<void> {
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -62,7 +69,7 @@ export class TypeDetailComponent implements OnInit {
 
 		const numID = parseInt(ID, 10);
 		if (Number.isNaN(numID)) {
-			console.error("route parameter 'id' was non-number: ", ID);
+			this.log.error("route parameter 'id' was non-number: ", ID);
 			return;
 		}
 
@@ -75,7 +82,7 @@ export class TypeDetailComponent implements OnInit {
 	 */
 	public async deleteType(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new type");
+			this.log.error("Unable to delete new type");
 			return;
 		}
 		const ref = this.dialog.open(DecisionDialogComponent, {
diff --git a/experimental/traffic-portal/src/app/core/users/roles/detail/role-detail.component.ts b/experimental/traffic-portal/src/app/core/users/roles/detail/role-detail.component.ts
index 2454abae81..c6203eeccf 100644
--- a/experimental/traffic-portal/src/app/core/users/roles/detail/role-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/users/roles/detail/role-detail.component.ts
@@ -19,6 +19,7 @@ import { ResponseRole } from "trafficops-types";
 
 import { UserService } from "src/app/api";
 import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -39,10 +40,15 @@ export class RoleDetailComponent implements OnInit {
 	 */
 	private name = "";
 
-	constructor(private readonly route: ActivatedRoute, private readonly router: Router,
-		private readonly userService: UserService, private readonly location: Location,
-		private readonly dialog: MatDialog, private readonly header: NavigationService) {
-	}
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly router: Router,
+		private readonly userService: UserService,
+		private readonly location: Location,
+		private readonly dialog: MatDialog,
+		private readonly header: NavigationService,
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * Angular lifecycle hook where data is initialized.
@@ -82,7 +88,7 @@ export class RoleDetailComponent implements OnInit {
 	 */
 	public async deleteRole(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new role");
+			this.log.error("Unable to delete new role");
 			return;
 		}
 		const ref = this.dialog.open(DecisionDialogComponent, {
diff --git a/experimental/traffic-portal/src/app/core/users/roles/table/roles-table.component.ts b/experimental/traffic-portal/src/app/core/users/roles/table/roles-table.component.ts
index 5e66bff8bc..dfc9228aa5 100644
--- a/experimental/traffic-portal/src/app/core/users/roles/table/roles-table.component.ts
+++ b/experimental/traffic-portal/src/app/core/users/roles/table/roles-table.component.ts
@@ -23,6 +23,7 @@ import { UserService } from "src/app/api";
 import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
 import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
 import type { ContextMenuActionEvent, ContextMenuItem, DoubleClickLink } from "src/app/shared/generic-table/generic-table.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -36,8 +37,15 @@ import { NavigationService } from "src/app/shared/navigation/navigation.service"
 export class RolesTableComponent implements OnInit {
 	/** List of roles */
 	public roles: Promise<Array<ResponseRole>>;
-	constructor(private readonly route: ActivatedRoute, private readonly headerSvc: NavigationService,
-		private readonly api: UserService, private readonly dialog: MatDialog, public readonly auth: CurrentUserService) {
+
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly headerSvc: NavigationService,
+		private readonly api: UserService,
+		private readonly dialog: MatDialog,
+		public readonly auth: CurrentUserService,
+		private readonly log: LoggingService,
+	) {
 		this.fuzzySubject = new BehaviorSubject<string>("");
 		this.roles = this.api.getRoles();
 		this.headerSvc.headerTitle.next("Roles");
@@ -54,7 +62,7 @@ export class RolesTableComponent implements OnInit {
 				}
 			},
 			e => {
-				console.error("Failed to get query parameters:", e);
+				this.log.error("Failed to get query parameters:", e);
 			}
 		);
 	}
@@ -123,7 +131,7 @@ export class RolesTableComponent implements OnInit {
 	 */
 	public async handleContextMenu(evt: ContextMenuActionEvent<ResponseRole>): Promise<void> {
 		if (Array.isArray(evt.data)) {
-			console.error("cannot delete multiple roles at once:", evt.data);
+			this.log.error("cannot delete multiple roles at once:", evt.data);
 			return;
 		}
 		const data = evt.data;
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts
index 89b0dc593b..43d9cb8e0b 100644
--- a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts
@@ -18,6 +18,7 @@ import { RequestTenant, ResponseTenant, Tenant } from "trafficops-types";
 
 import { UserService } from "src/app/api";
 import { TreeData } from "src/app/models";
+import { LoggingService } from "src/app/shared/logging.service";
 
 /**
  * TenantsDetailsComponent is the controller for the tenant add/edit form.
@@ -33,8 +34,12 @@ export class TenantDetailsComponent implements OnInit {
 	public tenants = new Array<ResponseTenant>();
 	public displayTenant: TreeData;
 
-	constructor(private readonly route: ActivatedRoute, private readonly userService: UserService,
-		private readonly location: Location) {
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly userService: UserService,
+		private readonly location: Location,
+		private readonly log: LoggingService,
+	) {
 		this.displayTenant = {
 			children: [],
 			id: -1,
@@ -50,7 +55,7 @@ export class TenantDetailsComponent implements OnInit {
 	public update(evt: TreeData): void {
 		const tenant = this.tenants.find(t => t.id === evt.id);
 		if (tenant === undefined) {
-			console.error(`Unknown tenant selected ${evt.id}`);
+			this.log.error(`Unknown tenant selected ${evt.id}`);
 			return;
 		}
 		this.tenant.parentId = tenant.id;
@@ -102,7 +107,7 @@ export class TenantDetailsComponent implements OnInit {
 	public async ngOnInit(): Promise<void> {
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 
@@ -119,12 +124,12 @@ export class TenantDetailsComponent implements OnInit {
 		}
 		const numID = parseInt(ID, 10);
 		if (Number.isNaN(numID)) {
-			console.error("route parameter 'id' was non-number:", ID);
+			this.log.error("route parameter 'id' was non-number:", ID);
 			return;
 		}
 		const tenant = this.tenants.find(t => t.id === numID);
 		if (!tenant) {
-			console.error(`Unable to find tenant with id ${numID}`);
+			this.log.error(`Unable to find tenant with id ${numID}`);
 			return;
 		}
 		this.tenant = tenant;
@@ -155,7 +160,7 @@ export class TenantDetailsComponent implements OnInit {
 	 */
 	public async deleteTenant(): Promise<void> {
 		if (this.new) {
-			console.error("Unable to delete new tenant");
+			this.log.error("Unable to delete new tenant");
 			return;
 		}
 		await this.userService.deleteTenant((this.tenant as ResponseTenant).id);
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.ts b/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.ts
index 2a73e5d880..a30216c66e 100644
--- a/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.ts
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.ts
@@ -25,6 +25,7 @@ import type {
 	ContextMenuItem,
 	DoubleClickLink
 } from "src/app/shared/generic-table/generic-table.component";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -96,7 +97,8 @@ export class TenantsComponent implements OnInit, OnDestroy {
 	constructor(
 		private readonly userService: UserService,
 		public readonly auth: CurrentUserService,
-		private readonly navSvc: NavigationService
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
 	) {
 		this.navSvc.headerTitle.next("Tenant");
 		this.subscription = this.auth.userChanged.subscribe(
@@ -182,7 +184,7 @@ export class TenantsComponent implements OnInit, OnDestroy {
 	 * @param a The action selected from the context menu.
 	 */
 	public handleContextMenu(a: ContextMenuActionEvent<Readonly<ResponseTenant>>): void {
-		console.log("action:", a);
+		this.log.debug("action:", a);
 	}
 
 	/**
diff --git a/experimental/traffic-portal/src/app/core/users/user-details/user-details.component.ts b/experimental/traffic-portal/src/app/core/users/user-details/user-details.component.ts
index 511143e626..21b14cff69 100644
--- a/experimental/traffic-portal/src/app/core/users/user-details/user-details.component.ts
+++ b/experimental/traffic-portal/src/app/core/users/user-details/user-details.component.ts
@@ -19,6 +19,7 @@ import type { PostRequestUser, ResponseRole, ResponseTenant, ResponseUser, User
 
 import { UserService } from "src/app/api";
 import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
+import { LoggingService } from "src/app/shared/logging.service";
 
 /**
  * UserDetailsComponent is the controller for the page for viewing/editing a
@@ -39,9 +40,9 @@ export class UserDetailsComponent implements OnInit {
 	constructor(
 		private readonly userService: UserService,
 		private readonly route: ActivatedRoute,
-		private readonly currentUserService: CurrentUserService
-	) {
-	}
+		private readonly currentUserService: CurrentUserService,
+		private readonly log: LoggingService
+	) { }
 
 	/** Angular lifecycle hook */
 	public async ngOnInit(): Promise<void> {
@@ -51,7 +52,7 @@ export class UserDetailsComponent implements OnInit {
 		]);
 		const ID = this.route.snapshot.paramMap.get("id");
 		if (ID === null) {
-			console.error("missing required route parameter 'id'");
+			this.log.error("missing required route parameter 'id'");
 			return;
 		}
 		await rolesAndTenants;
@@ -70,7 +71,7 @@ export class UserDetailsComponent implements OnInit {
 		}
 		const numID = parseInt(ID, 10);
 		if (Number.isNaN(numID)) {
-			console.error("route parameter 'id' was non-number:", ID);
+			this.log.error("route parameter 'id' was non-number:", ID);
 			return;
 		}
 		this.user = await this.userService.getUsers(numID);
diff --git a/experimental/traffic-portal/src/app/core/users/user-registration-dialog/user-registration-dialog.component.ts b/experimental/traffic-portal/src/app/core/users/user-registration-dialog/user-registration-dialog.component.ts
index b02294a6dd..a0ca6e1a24 100644
--- a/experimental/traffic-portal/src/app/core/users/user-registration-dialog/user-registration-dialog.component.ts
+++ b/experimental/traffic-portal/src/app/core/users/user-registration-dialog/user-registration-dialog.component.ts
@@ -17,6 +17,7 @@ import { ResponseRole, ResponseTenant } from "trafficops-types";
 
 import { UserService } from "src/app/api";
 import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
+import { LoggingService } from "src/app/shared/logging.service";
 
 /**
  * Controller for a dialog that opens to register a new user.
@@ -38,7 +39,8 @@ export class UserRegistrationDialogComponent implements OnInit {
 	constructor(
 		private readonly userService: UserService,
 		private readonly auth: CurrentUserService,
-		private readonly dialogRef: MatDialogRef<UserRegistrationDialogComponent>
+		private readonly dialogRef: MatDialogRef<UserRegistrationDialogComponent>,
+		private readonly log: LoggingService,
 	) { }
 
 	/**
@@ -81,7 +83,7 @@ export class UserRegistrationDialogComponent implements OnInit {
 			await this.userService.registerUser(this.email, this.role, this.tenant);
 			this.dialogRef.close();
 		} catch (err) {
-			console.error("failed to register user:", err);
+			this.log.error("failed to register user:", err);
 		}
 	}
 }
diff --git a/experimental/traffic-portal/src/app/login/login.component.spec.ts b/experimental/traffic-portal/src/app/login/login.component.spec.ts
index 593c7d32a6..aac736a15a 100644
--- a/experimental/traffic-portal/src/app/login/login.component.spec.ts
+++ b/experimental/traffic-portal/src/app/login/login.component.spec.ts
@@ -83,11 +83,7 @@ describe("LoginComponent", () => {
 	});
 
 	it("should exist", () => {
-		try{
-			expect(component).toBeTruthy();
-		} catch (e) {
-			console.error("error in 'should exist' for LoginComponent:", e);
-		}
+		expect(component).toBeTruthy();
 	});
 
 	it("submits a login request", async () => {
diff --git a/experimental/traffic-portal/src/app/login/login.component.ts b/experimental/traffic-portal/src/app/login/login.component.ts
index a70a5b0b5f..24083015d7 100644
--- a/experimental/traffic-portal/src/app/login/login.component.ts
+++ b/experimental/traffic-portal/src/app/login/login.component.ts
@@ -18,6 +18,7 @@ import { Router, ActivatedRoute, DefaultUrlSerializer } from "@angular/router";
 import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
+import { LoggingService } from "../shared/logging.service";
 import { AutocompleteValue } from "../utils";
 
 import { ResetPasswordDialogComponent } from "./reset-password-dialog/reset-password-dialog.component";
@@ -50,7 +51,8 @@ export class LoginComponent implements OnInit {
 		private readonly router: Router,
 		private readonly auth: CurrentUserService,
 		private readonly dialog: MatDialog,
-		private readonly navSvc: NavigationService
+		private readonly navSvc: NavigationService,
+		private readonly log: LoggingService,
 	) {
 		this.navSvc.headerHidden.next(true);
 		this.navSvc.sidebarHidden.next(true);
@@ -74,7 +76,7 @@ export class LoginComponent implements OnInit {
 					this.router.navigate(["/core/me"], {queryParams: {edit: true, updatePassword: true}});
 				}
 			} catch (e) {
-				console.error("token login failed:", e);
+				this.log.error("token login failed:", e);
 			}
 		}
 	}
@@ -99,7 +101,7 @@ export class LoginComponent implements OnInit {
 				this.router.navigate(tree.root.children.primary.segments.map(s=>s.path), {queryParams: tree.queryParams});
 			}
 		} catch (err) {
-			console.error("login failed:", err);
+			this.log.error("login failed:", err);
 		}
 	}
 
diff --git a/experimental/traffic-portal/src/app/shared/alert/alert.component.ts b/experimental/traffic-portal/src/app/shared/alert/alert.component.ts
index f0b977967f..03f0caafdd 100644
--- a/experimental/traffic-portal/src/app/shared/alert/alert.component.ts
+++ b/experimental/traffic-portal/src/app/shared/alert/alert.component.ts
@@ -15,6 +15,8 @@ import { Component, OnDestroy } from "@angular/core";
 import { MatSnackBar } from "@angular/material/snack-bar";
 import { Subscription } from "rxjs";
 
+import { LoggingService } from "../logging.service";
+
 import { AlertService } from "./alert.service";
 
 /**
@@ -33,12 +35,10 @@ export class AlertComponent implements OnDestroy {
 	/** The duration for which Alerts will linger until dismissed. `undefined` means forever. */
 	public duration: number | undefined = 10000;
 
-	/**
-	 * Constructor.
-	 */
 	constructor(
 		private readonly alerts: AlertService,
-		private readonly snackBar: MatSnackBar
+		private readonly snackBar: MatSnackBar,
+		log: LoggingService
 	) {
 		this.subscription = this.alerts.alerts.subscribe(
 			a => {
@@ -48,23 +48,23 @@ export class AlertComponent implements OnDestroy {
 					}
 					switch (a.level) {
 						case "success":
-							console.log("alert: ", a.text);
+							log.debug("alert:", a.text);
 							break;
 						case "info":
-							console.log("alert: ", a.text);
+							log.info("alert:", a.text);
 							break;
 						case "warning":
-							console.warn("alert: ", a.text);
+							log.warn("alert:", a.text);
 							break;
 						case "error":
-							console.error("alert: ", a.text);
+							log.error("alert:", a.text);
 							break;
 					}
 					this.snackBar.open(a.text, "dismiss", {duration: this.duration, verticalPosition: "top"});
 				}
 			},
 			e => {
-				console.error("Error in alerts subscription:", e);
+				log.error("Error in alerts subscription:", e);
 			}
 		);
 	}
diff --git a/experimental/traffic-portal/src/app/shared/charts/linechart.directive.spec.ts b/experimental/traffic-portal/src/app/shared/charts/linechart.directive.spec.ts
index 5e88b18514..266a5572b7 100644
--- a/experimental/traffic-portal/src/app/shared/charts/linechart.directive.spec.ts
+++ b/experimental/traffic-portal/src/app/shared/charts/linechart.directive.spec.ts
@@ -16,6 +16,8 @@ import { BehaviorSubject } from "rxjs";
 
 import type { DataSet } from "src/app/models";
 
+import { LoggingService } from "../logging.service";
+
 import { LinechartDirective } from "./linechart.directive";
 
 describe("LinechartDirective", () => {
@@ -24,7 +26,7 @@ describe("LinechartDirective", () => {
 
 	beforeEach(()=>{
 		dataSets =  new BehaviorSubject<Array<DataSet | null>|null>(null);
-		directive = new LinechartDirective(new ElementRef(document.createElement("canvas")));
+		directive = new LinechartDirective(new ElementRef(document.createElement("canvas")), new LoggingService());
 		directive.chartDataSets = dataSets;
 		directive.ngAfterViewInit();
 	});
diff --git a/experimental/traffic-portal/src/app/shared/charts/linechart.directive.ts b/experimental/traffic-portal/src/app/shared/charts/linechart.directive.ts
index 2871b1e5a3..3545d64046 100644
--- a/experimental/traffic-portal/src/app/shared/charts/linechart.directive.ts
+++ b/experimental/traffic-portal/src/app/shared/charts/linechart.directive.ts
@@ -18,6 +18,8 @@ import { from, type Observable, type Subscription } from "rxjs";
 
 import type { DataSet } from "src/app/models/data";
 
+import { LoggingService } from "../logging.service";
+
 /**
  * LinechartDirective decorates canvases by creating a rendering context for
  * ChartJS charts.
@@ -54,7 +56,7 @@ export class LinechartDirective implements AfterViewInit, OnDestroy {
 	/** Chart.js configuration options. */
 	private opts: Chart.ChartConfiguration = {};
 
-	constructor(private readonly element: ElementRef) { }
+	constructor(private readonly element: ElementRef, private readonly log: LoggingService) { }
 
 	/**
 	 * Initializes the chart using the input data.
@@ -177,7 +179,7 @@ export class LinechartDirective implements AfterViewInit, OnDestroy {
 	 * @param e The error that occurred.
 	 */
 	private dataError(e: Error): void {
-		console.error("data error occurred:", e);
+		this.log.error("data error occurred:", e);
 		this.destroyChart();
 		if (this.ctx) {
 			this.ctx.font = "30px serif";
diff --git a/experimental/traffic-portal/src/app/shared/current-user/current-user.service.ts b/experimental/traffic-portal/src/app/shared/current-user/current-user.service.ts
index 1eb1198806..6f97264d0b 100644
--- a/experimental/traffic-portal/src/app/shared/current-user/current-user.service.ts
+++ b/experimental/traffic-portal/src/app/shared/current-user/current-user.service.ts
@@ -18,6 +18,8 @@ import { Capability, ResponseCurrentUser } from "trafficops-types";
 
 import { UserService } from "src/app/api";
 
+import { LoggingService } from "../logging.service";
+
 /**
  * This service keeps track of the currently authenticated user.
  *
@@ -52,7 +54,7 @@ export class CurrentUserService {
 		return this.currentUser !== null;
 	}
 
-	constructor(private readonly router: Router, private readonly api: UserService) {
+	constructor(private readonly router: Router, private readonly api: UserService, private readonly log: LoggingService) {
 		this.updateCurrentUser();
 	}
 
@@ -86,7 +88,8 @@ export class CurrentUserService {
 				}
 			).catch(
 				e => {
-					console.error("Failed to update current user:", e);
+					const msg = e instanceof Error ? e.message : String(e);
+					this.log.error(`Failed to update current user: ${msg}`);
 					return false;
 				}
 			).finally(() => this.updatingUserPromise = null );
diff --git a/experimental/traffic-portal/src/app/shared/current-user/current-user.testing-service.spec.ts b/experimental/traffic-portal/src/app/shared/current-user/current-user.testing-service.spec.ts
index 4d2d631cf2..52ab6eb70e 100644
--- a/experimental/traffic-portal/src/app/shared/current-user/current-user.testing-service.spec.ts
+++ b/experimental/traffic-portal/src/app/shared/current-user/current-user.testing-service.spec.ts
@@ -13,7 +13,9 @@
 */
 import { EventEmitter, Injectable } from "@angular/core";
 import { BehaviorSubject } from "rxjs";
-import { Capability, ResponseCurrentUser } from "trafficops-types";
+import type { Capability, ResponseCurrentUser } from "trafficops-types";
+
+import { LoggingService } from "../logging.service";
 
 /**
  * This is a mock for the {@link CurrentUserService} service for testing.
@@ -58,6 +60,8 @@ export class CurrentUserTestingService {
 	public permissions: BehaviorSubject<Set<string>> = new BehaviorSubject(new Set(["ALL"]));
 	public readonly loggedIn = true;
 
+	constructor(private readonly log: LoggingService) {}
+
 	/**
 	 * Gets the current user if currentuser is not already set
 	 *
@@ -132,7 +136,7 @@ export class CurrentUserTestingService {
 	 */
 	public logout(withRedirect?: boolean): void {
 		if (withRedirect) {
-			console.warn("testing service does not navigate!");
+			this.log.warn("testing service does not navigate!");
 		}
 	}
 }
diff --git a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
index ec47410d85..61b7c69c60 100644
--- a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
+++ b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
@@ -45,6 +45,7 @@ import type { BehaviorSubject, Subscription } from "rxjs";
 
 import { fuzzyScore } from "src/app/utils";
 
+import { LoggingService } from "../logging.service";
 import { BooleanFilterComponent } from "../table-components/boolean-filter/boolean-filter.component";
 import { EmailCellRendererComponent } from "../table-components/email-cell-renderer/email-cell-renderer.component";
 import { SSHCellRendererComponent } from "../table-components/ssh-cell-renderer/ssh-cell-renderer.component";
@@ -405,7 +406,7 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 		return (this.columnAPI.getColumns() ?? []).reverse();
 	}
 
-	constructor(private readonly router: Router, private readonly route: ActivatedRoute) {
+	constructor(private readonly router: Router, private readonly route: ActivatedRoute, private readonly log: LoggingService) {
 		this.gridOptions = {
 			defaultColDef: {
 				filter: true,
@@ -478,7 +479,7 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 					this.gridAPI.setFilterModel(JSON.parse(filterState));
 				}
 			} catch (e) {
-				console.error(`Failed to retrieve stored column sort info from localStorage (key=${this.context}_table_filter:`, e);
+				this.log.error(`Failed to retrieve stored column sort info from localStorage (key=${this.context}_table_filter:`, e);
 			}
 			setUpQueryParamFilter(this.route.snapshot.queryParamMap, this.cols, this.gridAPI);
 			this.gridAPI.onFilterChanged();
@@ -494,13 +495,13 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 			const colstates = localStorage.getItem(`${this.context}_table_columns`);
 			if (colstates) {
 				if (!this.columnAPI.applyColumnState(JSON.parse(colstates))) {
-					console.error("Failed to load stored column state: one or more columns not found");
+					this.log.error("Failed to load stored column state: one or more columns not found");
 				}
 			} else {
 				this.gridAPI.sizeColumnsToFit();
 			}
 		} catch (e) {
-			console.error(`Failure to retrieve required column info from localStorage (key=${this.context}_table_columns):`, e);
+			this.log.error(`Failure to retrieve required column info from localStorage (key=${this.context}_table_columns):`, e);
 		}
 
 	}
@@ -681,7 +682,7 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 		if (this.columnAPI) {
 			const column = this.columnAPI.getColumn(col);
 			if (!column) {
-				console.error(`Failed to set visibility for column '${col}': no such column`);
+				this.log.error(`Failed to set visibility for column '${col}': no such column`);
 				return;
 			}
 			const visible = column.isVisible();
@@ -725,12 +726,12 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 	 */
 	public onCellContextMenu(params: CellContextMenuEvent): void {
 		if (!params.event || !(params.event instanceof MouseEvent)) {
-			console.warn("cellContextMenu fired with no underlying event");
+			this.log.warn("cellContextMenu fired with no underlying event");
 			return;
 		}
 
 		if (!this.contextmenu) {
-			console.warn("element reference to 'contextmenu' still null after view init");
+			this.log.warn("element reference to 'contextmenu' still null after view init");
 			return;
 		}
 
diff --git a/experimental/traffic-portal/src/app/shared/import-json-txt/import-json-txt.component.ts b/experimental/traffic-portal/src/app/shared/import-json-txt/import-json-txt.component.ts
index 6a20435ac1..270b6663ee 100644
--- a/experimental/traffic-portal/src/app/shared/import-json-txt/import-json-txt.component.ts
+++ b/experimental/traffic-portal/src/app/shared/import-json-txt/import-json-txt.component.ts
@@ -18,6 +18,7 @@ import { MAT_DIALOG_DATA } from "@angular/material/dialog";
 import { AlertLevel } from "trafficops-types";
 
 import { AlertService } from "../alert/alert.service";
+import { LoggingService } from "../logging.service";
 
 /**
  * Contains the structure of the data that {@link ImportJsonTxtComponent}
@@ -74,16 +75,20 @@ export class ImportJsonTxtComponent {
 	}
 
 	/**
-	 * Creates an instance of import json edit txt component.
+	 * Constructor.
 	 *
-	 * @param dialogRef Dialog manager
-	 * @param alertService Alert service manager
-	 * @param datePipe Default angular date pipe for formating date
+	 * @param data Data passed as input to the component.
+	 * @param dialogRef Angular dialog service.
+	 * @param alertService Alerts service.
+	 * @param datePipe Default Angular pipe used for formatting dates.
+	 * @param log Logging service.
 	 */
 	constructor(
 		@Inject(MAT_DIALOG_DATA) public readonly data: ImportJsonTxtComponentModel,
 		private readonly alertService: AlertService,
-		private readonly datePipe: DatePipe) { }
+		private readonly datePipe: DatePipe,
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * Hosts listener for drag over
@@ -137,7 +142,7 @@ export class ImportJsonTxtComponent {
 	 */
 	public uploadFile(event: Event): void {
 		if (!(event.target instanceof HTMLInputElement) || !event.target.files) {
-			console.warn("file uploading triggered on non-file-input element:", event.target);
+			this.log.warn("file uploading triggered on non-file-input element:", event.target);
 			return;
 		}
 
diff --git a/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts b/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
index 55bc33993e..de36720f85 100644
--- a/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
+++ b/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
@@ -19,6 +19,7 @@ import { catchError } from "rxjs/operators";
 import type { Alert } from "trafficops-types";
 
 import { AlertService } from "../alert/alert.service";
+import { LoggingService } from "../logging.service";
 
 /**
  * This class intercepts any and all HTTP error responses and checks for
@@ -29,7 +30,8 @@ export class ErrorInterceptor implements HttpInterceptor {
 
 	constructor(
 		private readonly alerts: AlertService,
-		private readonly router: Router
+		private readonly router: Router,
+		private readonly log: LoggingService,
 	) {}
 
 	/**
@@ -54,7 +56,12 @@ export class ErrorInterceptor implements HttpInterceptor {
 	 */
 	public intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
 		return next.handle(request).pipe(catchError((err: HttpErrorResponse) => {
-			console.error("HTTP Error: ", err);
+			// I don't know why, but sometimes these errors have just no content
+			// and stringify to simply just the word "Error". So in order to get
+			// anything at all useful out of them, I'm adding a stack trace at
+			// the debugging level.
+			this.log.error(`HTTP error: ${err.message || err.error || err}`);
+			this.log.debug(err);
 
 			if (typeof(err.error) === "string") {
 				try {
@@ -63,7 +70,7 @@ export class ErrorInterceptor implements HttpInterceptor {
 						this.raiseAlerts(body.alerts);
 					}
 				} catch (e) {
-					console.error("non-JSON HTTP error response:", e);
+					this.log.error("non-JSON HTTP error response:", e);
 				}
 			} else if (typeof(err.error) === "object" && Array.isArray(err.error.alerts)) {
 				this.raiseAlerts(err.error.alerts);
diff --git a/experimental/traffic-portal/src/app/shared/loading/loading.component.spec.ts b/experimental/traffic-portal/src/app/shared/loading/loading.component.spec.ts
index 6c1709ba62..a424ad75db 100644
--- a/experimental/traffic-portal/src/app/shared/loading/loading.component.spec.ts
+++ b/experimental/traffic-portal/src/app/shared/loading/loading.component.spec.ts
@@ -37,10 +37,6 @@ describe("LoadingComponent", () => {
 	});
 
 	afterAll(() => {
-		try{
-			TestBed.resetTestingModule();
-		} catch (e) {
-			console.error("error in LoadingComponent afterAll:", e);
-		}
+		TestBed.resetTestingModule();
 	});
 });
diff --git a/experimental/traffic-portal/src/app/shared/logging.service.spec.ts b/experimental/traffic-portal/src/app/shared/logging.service.spec.ts
new file mode 100644
index 0000000000..ba844b49a8
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/logging.service.spec.ts
@@ -0,0 +1,60 @@
+/**
+ * @license Apache-2.0
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { TestBed } from "@angular/core/testing";
+
+import { LoggingService } from "./logging.service";
+
+describe("LoggingService", () => {
+	let service: LoggingService;
+	const arg = "test";
+
+	beforeEach(() => {
+		TestBed.configureTestingModule({});
+		service = TestBed.inject(LoggingService);
+	});
+
+	it("should be created", () => {
+		expect(service).toBeTruthy();
+	});
+
+	it("logs debug messages", () => {
+		const debugSpy = spyOn(console, "debug");
+		expect(debugSpy).not.toHaveBeenCalled();
+		service.debug(arg);
+		expect(debugSpy).toHaveBeenCalledTimes(1);
+	});
+
+	it("logs error messages", () => {
+		const errorSpy = spyOn(console, "error");
+		expect(errorSpy).not.toHaveBeenCalled();
+		service.error(arg);
+		expect(errorSpy).toHaveBeenCalledTimes(1);
+	});
+
+	it("logs info messages", () => {
+		const infoSpy = spyOn(console, "info");
+		expect(infoSpy).not.toHaveBeenCalled();
+		service.info(arg);
+		expect(infoSpy).toHaveBeenCalledTimes(1);
+	});
+
+	it("logs warning messages", () => {
+		const warnSpy = spyOn(console, "warn");
+		expect(warnSpy).not.toHaveBeenCalled();
+		service.warn(arg);
+		expect(warnSpy).toHaveBeenCalledTimes(1);
+	});
+});
diff --git a/experimental/traffic-portal/src/app/shared/logging.service.ts b/experimental/traffic-portal/src/app/shared/logging.service.ts
new file mode 100644
index 0000000000..73b6bb7512
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/logging.service.ts
@@ -0,0 +1,74 @@
+/**
+ * @license Apache-2.0
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from "@angular/core";
+
+import { LogLevel, Logger } from "../utils";
+
+/**
+ * LoggingService is for logging things in a consistent way across the UI.
+ *
+ * It's basically just a thin wrapper around a {@link Logger} so that only one
+ * instance needs to exist and injection makes its setup consistent across all
+ * usages.
+ */
+@Injectable({
+	providedIn: "root"
+})
+export class LoggingService {
+
+	public logger: Logger;
+
+	constructor() {
+		this.logger = new Logger(console, LogLevel.DEBUG, "", false);
+	}
+
+	/**
+	 * Logs a debugging message.
+	 *
+	 * @param args Anything you want to log.
+	 */
+	public debug(...args: unknown[]): void {
+		this.logger.debug(...args);
+	}
+
+	/**
+	 * Logs an error message.
+	 *
+	 * @param args Anything you want to log.
+	 */
+	public error(...args: unknown[]): void {
+		this.logger.error(...args);
+	}
+
+	/**
+	 * Logs an informational message.
+	 *
+	 * @param args Anything you want to log.
+	 */
+	public info(...args: unknown[]): void {
+		this.logger.info(...args);
+	}
+
+	/**
+	 * Logs a warning message.
+	 *
+	 * @param args Anything you want to log.
+	 */
+	public warn(...args: unknown[]): void {
+		this.logger.warn(...args);
+	}
+}
diff --git a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
index fc762b7069..faa1d87a92 100644
--- a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
+++ b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
@@ -19,6 +19,8 @@ import { UserService } from "src/app/api";
 import { LOCAL_TPV1_URL } from "src/app/app.component";
 import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
 
+import { LoggingService } from "../logging.service";
+
 /**
  * Defines the type of the header nav
  */
@@ -67,7 +69,9 @@ export class NavigationService {
 	constructor(
 		private readonly auth: CurrentUserService,
 		private readonly api: UserService,
-		@Inject(PLATFORM_ID) private readonly platformId: object) {
+		@Inject(PLATFORM_ID) private readonly platformId: object,
+		private readonly log: LoggingService,
+	) {
 		if (isPlatformBrowser(this.platformId)) {
 			this.tpv1Url = window.localStorage.getItem(LOCAL_TPV1_URL) ?? this.tpv1Url;
 		}
@@ -291,7 +295,7 @@ export class NavigationService {
 	 */
 	public async logout(): Promise<void> {
 		if (!(await this.api.logout())) {
-			console.warn("Failed to log out - clearing user data anyway!");
+			this.log.warn("Failed to log out - clearing user data anyway!");
 		}
 		this.auth.logout();
 	}
diff --git a/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.spec.ts b/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.spec.ts
index d7fee1e2f9..0ec720c429 100644
--- a/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.spec.ts
+++ b/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.spec.ts
@@ -54,10 +54,6 @@ describe("TpHeaderComponent", () => {
 	});
 
 	afterAll(() => {
-		try{
-			TestBed.resetTestingModule();
-		} catch (e) {
-			console.error("error in TpHeaderComponent afterAll:", e);
-		}
+		TestBed.resetTestingModule();
 	});
 });
diff --git a/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.ts b/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.ts
index b9572489ea..b84ef8bf7b 100644
--- a/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.ts
+++ b/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.ts
@@ -13,8 +13,9 @@
 */
 import {Component, OnInit} from "@angular/core";
 
-import {HeaderNavigation, HeaderNavType, NavigationService} from "src/app/shared/navigation/navigation.service";
-import {ThemeManagerService} from "src/app/shared/theme-manager/theme-manager.service";
+import { LoggingService } from "src/app/shared/logging.service";
+import { HeaderNavigation, HeaderNavType, NavigationService } from "src/app/shared/navigation/navigation.service";
+import { ThemeManagerService } from "src/app/shared/theme-manager/theme-manager.service";
 
 /**
  * TpHeaderComponent is the controller for the standard Traffic Portal header.
@@ -58,8 +59,11 @@ export class TpHeaderComponent implements OnInit {
 		});
 	}
 
-	constructor(public readonly themeSvc: ThemeManagerService, private readonly headerSvc: NavigationService) {
-	}
+	constructor(
+		public readonly themeSvc: ThemeManagerService,
+		private readonly headerSvc: NavigationService,
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * Calls a navs click function, throws an error if null
@@ -82,7 +86,7 @@ export class TpHeaderComponent implements OnInit {
 	 */
 	public navRouterLink(nav: HeaderNavigation): string {
 		if(nav.routerLink === undefined) {
-			console.error(`nav ${nav.text} does not have a routerLink`);
+			this.log.error(`nav ${nav.text} does not have a routerLink`);
 			return "";
 		}
 		return nav.routerLink;
diff --git a/experimental/traffic-portal/src/app/shared/navigation/tp-sidebar/tp-sidebar.component.ts b/experimental/traffic-portal/src/app/shared/navigation/tp-sidebar/tp-sidebar.component.ts
index 61fd89d8f0..28a2e8e7dc 100644
--- a/experimental/traffic-portal/src/app/shared/navigation/tp-sidebar/tp-sidebar.component.ts
+++ b/experimental/traffic-portal/src/app/shared/navigation/tp-sidebar/tp-sidebar.component.ts
@@ -20,6 +20,7 @@ import { Router, RouterEvent, Event, NavigationEnd, IsActiveMatchOptions } from
 import { filter } from "rxjs/operators";
 
 import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
+import { LoggingService } from "src/app/shared/logging.service";
 import { NavigationService, TreeNavNode } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -92,10 +93,12 @@ export class TpSidebarComponent implements OnInit, AfterViewInit {
 		return !this.childToParent.has(this.nodeHandle(node));
 	}
 
-	constructor(private readonly navService: NavigationService,
+	constructor(
+		private readonly navService: NavigationService,
 		private readonly route: Router,
-		public readonly user: CurrentUserService) {
-	}
+		public readonly user: CurrentUserService,
+		private readonly log: LoggingService,
+	) { }
 
 	/**
 	 * Adds to childToParent from the given node.
@@ -119,11 +122,11 @@ export class TpSidebarComponent implements OnInit, AfterViewInit {
 		this.navService.sidebarHidden.subscribe(hidden => {
 			if(hidden && this.sidenav.opened) {
 				this.sidenav.close().catch(err => {
-					console.error(`Unable to close sidebar: ${err}`);
+					this.log.error(`Unable to close sidebar: ${err}`);
 				});
 			} else if (!hidden && !this.sidenav.opened) {
 				this.sidenav.open().catch(err => {
-					console.error(`Unable to open sidebar: ${err}`);
+					this.log.error(`Unable to open sidebar: ${err}`);
 				});
 			}
 		});
@@ -157,7 +160,7 @@ export class TpSidebarComponent implements OnInit, AfterViewInit {
 								this.treeCtrl.expand(parent);
 								parent = this.childToParent.get(this.nodeHandle(parent));
 								if(depth++ > 5) {
-									console.error(`Maximum depth ${depth} reached, aborting expand on ${parent?.name ?? "unknown"}`);
+									this.log.error(`Maximum depth ${depth} reached, aborting expand on ${parent?.name ?? "unknown"}`);
 									break;
 								}
 							}
diff --git a/experimental/traffic-portal/src/app/shared/shared.module.ts b/experimental/traffic-portal/src/app/shared/shared.module.ts
index b52520be22..d271e98a21 100644
--- a/experimental/traffic-portal/src/app/shared/shared.module.ts
+++ b/experimental/traffic-portal/src/app/shared/shared.module.ts
@@ -32,6 +32,7 @@ import { AlertInterceptor } from "./interceptor/alerts.interceptor";
 import { DateReviverInterceptor } from "./interceptor/date-reviver.interceptor";
 import { ErrorInterceptor } from "./interceptor/error.interceptor";
 import { LoadingComponent } from "./loading/loading.component";
+import { LoggingService } from "./logging.service";
 import { ObscuredTextInputComponent } from "./obscured-text-input/obscured-text-input.component";
 import { BooleanFilterComponent } from "./table-components/boolean-filter/boolean-filter.component";
 import { EmailCellRendererComponent } from "./table-components/email-cell-renderer/email-cell-renderer.component";
@@ -88,7 +89,8 @@ import { CustomvalidityDirective } from "./validation/customvalidity.directive";
 		{ multi: true, provide: HTTP_INTERCEPTORS, useClass: AlertInterceptor },
 		{ multi: true, provide: HTTP_INTERCEPTORS, useClass: DateReviverInterceptor },
 		FileUtilsService,
-		DatePipe
+		DatePipe,
+		LoggingService
 	]
 })
 export class SharedModule { }
diff --git a/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.ts b/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.ts
index cf4f95f305..959f5398f1 100644
--- a/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.ts
+++ b/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.ts
@@ -12,10 +12,11 @@
 * limitations under the License.
 */
 import { Component } from "@angular/core";
-// import { FormControl } from "@angular/forms";
 import { AgFilterComponent } from "ag-grid-angular";
 import { IDoesFilterPassParams, IFilterParams } from "ag-grid-community";
 
+import { LoggingService } from "src/app/shared/logging.service";
+
 /** A model that fully describes the state of a Boolean Filter. */
 interface BooleanFilterModel {
 	/** Whether or not filtering *should* be done. */
@@ -45,6 +46,8 @@ export class BooleanFilterComponent implements AgFilterComponent {
 	/** Initialization parameters. */
 	private params!: IFilterParams;
 
+	constructor(private readonly log: LoggingService) {}
+
 	/**
 	 * Called by AG-Grid to check if the filter is in effect.
 	 *
@@ -130,7 +133,7 @@ export class BooleanFilterComponent implements AgFilterComponent {
 	public agInit(params: IFilterParams): void {
 		this.params = params;
 		if (!params.colDef.field) {
-			console.error("No column name found on boolean-filter parameters");
+			this.log.error("No column name found on boolean-filter parameters");
 			return;
 		}
 		this.field = params.colDef.field;
diff --git a/experimental/traffic-portal/src/app/shared/theme-manager/theme-manager.service.ts b/experimental/traffic-portal/src/app/shared/theme-manager/theme-manager.service.ts
index ff7cb1e518..8338df1ad9 100644
--- a/experimental/traffic-portal/src/app/shared/theme-manager/theme-manager.service.ts
+++ b/experimental/traffic-portal/src/app/shared/theme-manager/theme-manager.service.ts
@@ -12,8 +12,10 @@
 * limitations under the License.
 */
 
-import {DOCUMENT} from "@angular/common";
-import {EventEmitter, Inject, Injectable} from "@angular/core";
+import { DOCUMENT } from "@angular/common";
+import { EventEmitter, Inject, Injectable } from "@angular/core";
+
+import { LoggingService } from "../logging.service";
 
 /**
  * Defines a theme. If fileName is null, it is the default theme
@@ -24,7 +26,8 @@ export interface Theme {
 }
 
 /**
- *
+ * The ThemeManagerService manages the user's theming settings, to be applied
+ * throughout the UI.
  */
 @Injectable({
 	providedIn: "root"
@@ -35,7 +38,20 @@ export class ThemeManagerService {
 
 	public themeChanged = new EventEmitter<Theme>();
 
-	constructor(@Inject(DOCUMENT) private readonly document: Document) {
+	/**
+	 * Provides a "safe" accessor for the local session storage. According to
+	 * typings, `Document.defaultView` may be `null`, but if it isn't then
+	 * `Document.defaultView.localStorage` definitely *isn't* `null`. That's
+	 * simply untrue. So this provides that check for you.
+	 */
+	private get localStorage(): Storage | null {
+		if (this.document.defaultView && this.document.defaultView.localStorage) {
+			return this.document.defaultView.localStorage;
+		}
+		return null;
+	}
+
+	constructor(@Inject(DOCUMENT) private readonly document: Document, private readonly log: LoggingService) {
 		this.initTheme();
 	}
 
@@ -91,12 +107,10 @@ export class ThemeManagerService {
 	 * @param theme Theme to be stored
 	 */
 	private storeTheme(theme: Theme): void {
-		if(this.document.defaultView) {
-			try {
-				this.document.defaultView.localStorage.setItem(this.storageKey, JSON.stringify(theme));
-			} catch (e) {
-				console.error(`Unable to store theme into local storage: ${e}`);
-			}
+		try {
+			this.localStorage?.setItem(this.storageKey, JSON.stringify(theme));
+		} catch (e) {
+			this.log.error(`Unable to store theme into local storage: ${e}`);
 		}
 	}
 
@@ -106,12 +120,10 @@ export class ThemeManagerService {
 	 * @returns The stored theme name or null
 	 */
 	private loadStoredTheme(): Theme | null {
-		if(this.document.defaultView) {
-			try {
-				return JSON.parse(this.document.defaultView.localStorage.getItem(this.storageKey) ?? "null");
-			} catch (e) {
-				console.error(`Unable to load theme from local storage: ${e}`);
-			}
+		try {
+			return JSON.parse(this.localStorage?.getItem(this.storageKey) ?? "null");
+		} catch (e) {
+			this.log.error(`Unable to load theme from local storage: ${e}`);
 		}
 		return null;
 	}
@@ -120,9 +132,7 @@ export class ThemeManagerService {
 	 * Clears theme saved in local storage
 	 */
 	private clearStoredTheme(): void {
-		if(this.document.defaultView) {
-			this.document.defaultView.localStorage.removeItem(this.storageKey);
-		}
+		this.localStorage?.removeItem(this.storageKey);
 	}
 
 	/**
diff --git a/experimental/traffic-portal/src/app/utils/index.ts b/experimental/traffic-portal/src/app/utils/index.ts
index 605c9818c3..af85a1f6f8 100644
--- a/experimental/traffic-portal/src/app/utils/index.ts
+++ b/experimental/traffic-portal/src/app/utils/index.ts
@@ -12,9 +12,10 @@
 * limitations under the License.
 */
 
-export * from "./order-by";
 export * from "./fuzzy";
 export * from "./ip";
+export * from "./logging";
+export * from "./order-by";
 export * from "./time";
 
 /**
diff --git a/experimental/traffic-portal/src/app/utils/logging.spec.ts b/experimental/traffic-portal/src/app/utils/logging.spec.ts
new file mode 100644
index 0000000000..4a78081527
--- /dev/null
+++ b/experimental/traffic-portal/src/app/utils/logging.spec.ts
@@ -0,0 +1,373 @@
+/**
+ * @license Apache-2.0
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Logger, LogLevel, logLevelToString, type LogStreams } from "./logging";
+
+/**
+ * TestingStreams is a Streams implementation that pushes each log line to a
+ * publicly available array per stream, allowing for easy inspection by testing
+ * routines afterward.
+ */
+class TestingStreams implements LogStreams {
+	public readonly debugStream = new Array<string>();
+	public readonly errorStream = new Array<string>();
+	public readonly infoStream = new Array<string>();
+	public readonly warnStream = new Array<string>();
+
+	/**
+	 * Logs to the debug stream.
+	 *
+	 * @param args anything
+	 */
+	public debug(...args: unknown[]): void {
+		this.debugStream.push(args.join(" "));
+	}
+	/**
+	 * Logs to the debug stream.
+	 *
+	 * @param args anything
+	 */
+	public error(...args: unknown[]): void {
+		this.errorStream.push(args.join(" "));
+	}
+	/**
+	 * Logs to the info stream.
+	 *
+	 * @param args anything
+	 */
+	public info(...args: unknown[]): void {
+		this.infoStream.push(args.join(" "));
+	}
+	/**
+	 * Logs to the warning stream.
+	 *
+	 * @param args anything
+	 */
+	public warn(...args: unknown[]): void {
+		this.warnStream.push(args.join(" "));
+	}
+}
+
+const timestampPattern = "\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d\\.\\d+Z";
+
+describe("logging utility functions", () => {
+	it("converts debug level to a string", () => {
+		expect(logLevelToString(LogLevel.DEBUG)).toBe("DEBUG");
+	});
+	it("converts error level to a string", () => {
+		expect(logLevelToString(LogLevel.ERROR)).toBe("ERROR");
+	});
+	it("converts info level to a string", () => {
+		expect(logLevelToString(LogLevel.INFO)).toBe("INFO");
+	});
+	it("converts warn level to a string", () => {
+		expect(logLevelToString(LogLevel.WARN)).toBe("WARN");
+	});
+});
+
+describe("Logger", () => {
+	let streams: TestingStreams;
+
+	beforeEach(() => {
+		streams = new TestingStreams();
+		expect(streams.debugStream).toHaveSize(0);
+		expect(streams.errorStream).toHaveSize(0);
+		expect(streams.infoStream).toHaveSize(0);
+		expect(streams.warnStream).toHaveSize(0);
+	});
+
+	describe("prefix-less logging", () => {
+		let logger: Logger;
+		const msg = "testquest";
+		beforeEach(() => {
+			logger = new Logger(streams, LogLevel.DEBUG, "", false, false);
+		});
+
+		it("logs to the debug stream", () => {
+			logger.debug(msg);
+			expect(streams.debugStream).toHaveSize(1);
+			expect(streams.debugStream).toContain(msg);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+		});
+
+		it("logs to the error stream", () => {
+			logger.error(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(1);
+			expect(streams.errorStream).toContain(msg);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+		});
+
+		it("logs to the info stream", () => {
+			logger.info(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(1);
+			expect(streams.infoStream).toContain(msg);
+			expect(streams.warnStream).toHaveSize(0);
+		});
+
+		it("logs to the warn stream", () => {
+			logger.warn(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(1);
+			expect(streams.warnStream).toContain(msg);
+		});
+	});
+
+	describe("static prefixed logging", () => {
+		let logger: Logger;
+		const prefix = "test";
+		const msg = "quest";
+		beforeEach(() => {
+			logger = new Logger(streams, LogLevel.DEBUG, prefix, false, false);
+		});
+
+		it("logs to the debug stream", () => {
+			logger.debug(msg);
+			expect(streams.debugStream).toHaveSize(1);
+			expect(streams.debugStream).toContain(`${prefix}: ${msg}`);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+		});
+
+		it("logs to the error stream", () => {
+			logger.error(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(1);
+			expect(streams.errorStream).toContain(`${prefix}: ${msg}`);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+		});
+
+		it("logs to the info stream", () => {
+			logger.info(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(1);
+			expect(streams.infoStream).toContain(`${prefix}: ${msg}`);
+			expect(streams.warnStream).toHaveSize(0);
+		});
+
+		it("logs to the warn stream", () => {
+			logger.warn(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(1);
+			expect(streams.warnStream).toContain(`${prefix}: ${msg}`);
+		});
+	});
+
+	describe("timestamp-prefixed logging", () => {
+		let logger: Logger;
+		const msg = "testquest";
+		beforeEach(() => {
+			logger = new Logger(streams, LogLevel.DEBUG, "", false, true);
+		});
+
+		it("logs to the debug stream", () => {
+			logger.debug(msg);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+			if (streams.debugStream.length !== 1) {
+				return fail(`incorrect stream size after logging; want: 1, got: ${streams.debugStream.length}`);
+			}
+			expect(streams.debugStream[0]).toMatch(`^${timestampPattern}: ${msg}$`);
+		});
+
+		it("logs to the error stream", () => {
+			logger.error(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+			if (streams.errorStream.length !== 1) {
+				return fail(`incorrect stream size after logging; want: 1, got: ${streams.errorStream.length}`);
+			}
+			expect(streams.errorStream[0]).toMatch(`^${timestampPattern}: ${msg}$`);
+		});
+
+		it("logs to the info stream", () => {
+			logger.info(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+			if (streams.infoStream.length !== 1) {
+				return fail(`incorrect stream size after logging; want: 1, got: ${streams.infoStream.length}`);
+			}
+			expect(streams.infoStream[0]).toMatch(`^${timestampPattern}: ${msg}$`);
+		});
+
+		it("logs to the warn stream", () => {
+			logger.warn(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			if (streams.warnStream.length !== 1) {
+				return fail(`incorrect stream size after logging; want: 1, got: ${streams.warnStream.length}`);
+			}
+			expect(streams.warnStream[0]).toMatch(`^${timestampPattern}: ${msg}$`);
+		});
+	});
+
+	describe("log-level-prefixed logging", () => {
+		let logger: Logger;
+		const msg = "testquest";
+		beforeEach(() => {
+			logger = new Logger(streams, LogLevel.DEBUG, "", true, false);
+		});
+
+		it("logs to the debug stream", () => {
+			logger.debug(msg);
+			expect(streams.debugStream).toHaveSize(1);
+			expect(streams.debugStream).toContain(`${logLevelToString(LogLevel.DEBUG)}: ${msg}`);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+		});
+
+		it("logs to the error stream", () => {
+			logger.error(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(1);
+			expect(streams.errorStream).toContain(`${logLevelToString(LogLevel.ERROR)}: ${msg}`);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+		});
+
+		it("logs to the info stream", () => {
+			logger.info(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(1);
+			expect(streams.infoStream).toContain(`${logLevelToString(LogLevel.INFO)}: ${msg}`);
+			expect(streams.warnStream).toHaveSize(0);
+		});
+
+		it("logs to the warn stream", () => {
+			logger.warn(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(1);
+			expect(streams.warnStream).toContain(`${logLevelToString(LogLevel.WARN)}: ${msg}`);
+		});
+	});
+
+	describe("fully-prefixed logging", () => {
+		let logger: Logger;
+		const prefix = "test";
+		const msg = "quest";
+		beforeEach(() => {
+			logger = new Logger(streams, LogLevel.DEBUG, prefix);
+		});
+
+		it("logs to the debug stream", () => {
+			logger.debug(msg);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+			if (streams.debugStream.length !== 1) {
+				return fail(`incorrect stream size after logging; want: 1, got: ${streams.debugStream.length}`);
+			}
+			expect(streams.debugStream[0]).toMatch(`^${logLevelToString(LogLevel.DEBUG)} ${timestampPattern} ${prefix}: ${msg}$`);
+		});
+
+		it("logs to the error stream", () => {
+			logger.error(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+			if (streams.errorStream.length !== 1) {
+				return fail(`incorrect stream size after logging; want: 1, got: ${streams.errorStream.length}`);
+			}
+			expect(streams.errorStream[0]).toMatch(`^${logLevelToString(LogLevel.ERROR)} ${timestampPattern} ${prefix}: ${msg}$`);
+		});
+
+		it("logs to the info stream", () => {
+			logger.info(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+			if (streams.infoStream.length !== 1) {
+				return fail(`incorrect stream size after logging; want: 1, got: ${streams.infoStream.length}`);
+			}
+			expect(streams.infoStream[0]).toMatch(`^${logLevelToString(LogLevel.INFO)} ${timestampPattern} ${prefix}: ${msg}$`);
+		});
+
+		it("logs to the warn stream", () => {
+			logger.warn(msg);
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(0);
+			expect(streams.infoStream).toHaveSize(0);
+			if (streams.warnStream.length !== 1) {
+				return fail(`incorrect stream size after logging; want: 1, got: ${streams.warnStream.length}`);
+			}
+			expect(streams.warnStream[0]).toMatch(`^${logLevelToString(LogLevel.WARN)} ${timestampPattern} ${prefix}: ${msg}$`);
+		});
+	});
+
+	describe("log-level specification", () => {
+		it("won't log above INFO if set to INFO", () => {
+			const logger = new Logger(streams, LogLevel.INFO);
+
+			logger.debug("anything");
+			logger.error("anything");
+			logger.info("anything");
+			logger.warn("anything");
+
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(1);
+			expect(streams.infoStream).toHaveSize(1);
+			expect(streams.warnStream).toHaveSize(1);
+		});
+
+		it("won't log above WARN if set to WARN", () => {
+			const logger = new Logger(streams, LogLevel.WARN);
+
+			logger.debug("anything");
+			logger.error("anything");
+			logger.info("anything");
+			logger.warn("anything");
+
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(1);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(1);
+		});
+
+		it("won't log above ERROR if set to ERROR", () => {
+			const logger = new Logger(streams, LogLevel.ERROR);
+
+			logger.debug("anything");
+			logger.error("anything");
+			logger.info("anything");
+			logger.warn("anything");
+
+			expect(streams.debugStream).toHaveSize(0);
+			expect(streams.errorStream).toHaveSize(1);
+			expect(streams.infoStream).toHaveSize(0);
+			expect(streams.warnStream).toHaveSize(0);
+		});
+	});
+});
diff --git a/experimental/traffic-portal/src/app/utils/logging.ts b/experimental/traffic-portal/src/app/utils/logging.ts
new file mode 100644
index 0000000000..7efafa8c3b
--- /dev/null
+++ b/experimental/traffic-portal/src/app/utils/logging.ts
@@ -0,0 +1,227 @@
+/**
+ * @license Apache-2.0
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * LogStreams are the underlying raw event writers used by {@link Logger}s. The
+ * simplest and most useful example of a LogStreams implementation is `console`.
+ */
+export interface LogStreams {
+	debug(...args: unknown[]): void;
+	info(...args: unknown[]): void;
+	error(...args: unknown[]): void;
+	warn(...args: unknown[]): void;
+}
+
+/**
+ * A LogLevel describes the verbosity of logging. Each level is cumulative,
+ * meaning that a logger set to some level will also log all of the levels above
+ * it.
+ */
+export const enum LogLevel {
+	/** Log only errors. */
+	ERROR,
+	/** Log warnings and errors. */
+	WARN,
+	/** Log informational messages, warnings, and errors. */
+	INFO,
+	/** Log debugging messages, informational messages, warnings, and errors. */
+	DEBUG,
+}
+
+/**
+ * Converts a log level to a human-readable string.
+ *
+ * @example
+ * console.log(logLevelToString(LogLevel.DEBUG));
+ * // Output:
+ * // DEBUG
+ *
+ * @param level The level to convert.
+ * @returns A string representation of `level`.
+ */
+export function logLevelToString(level: LogLevel): string {
+	switch(level) {
+		case LogLevel.DEBUG:
+			return "DEBUG";
+		case LogLevel.ERROR:
+			return "ERROR";
+		case LogLevel.INFO:
+			return "INFO";
+		case LogLevel.WARN:
+			return "WARN";
+	}
+}
+
+/**
+ * A Logger logs things. The output streams are customizable, mostly for testing
+ * but also in case we want to write directly to a file handle someday.
+ *
+ * The output format is a bit customizable, it allows for messages to be
+ * prefixed in a number of ways:
+ * - With the level at which the message was logged
+ * - With a timestamp for the time at which logging occurred (ISO format)
+ * - With some static string
+ *
+ * in that order. For example, if all of them are specified:
+ *
+ * @example
+ * (new Logger(console, LogLevel.DEBUG, "test", true, true)).info("quest");
+ * // Output (example date is UNIX epoch):
+ * // INFO 1970-01-01T00:00:00.000Z test: quest
+ */
+export class Logger {
+	private readonly prefix: string;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param streams The output stream abstractions.
+	 * @param level The level at which the logger operates. Any level higher
+	 * than the one specified will not be logged.
+	 * @param prefix If given, prepends a prefix to each message.
+	 * @param useLevelPrefixes If true, log lines will be prefixed with the name
+	 * of the level at which they were logged (useful if all streams point to
+	 * the same file descriptor).
+	 * @param timestamps If true, each log line will be accompanied by a
+	 * timestamp prefix (note that the time is determined when logging occurs,
+	 * not necessarily when the logging method is called).
+	 */
+	constructor(
+		private readonly streams: LogStreams,
+		level: LogLevel,
+		prefix: string = "",
+		private readonly useLevelPrefixes: boolean = true,
+		private readonly timestamps: boolean = true,
+	) {
+		if (prefix) {
+			prefix = prefix.trim().replace(/:$/, "").trimEnd();
+		}
+
+		this.prefix = prefix;
+
+		const doNothing = (): void => { /* Do nothing */ };
+		switch (level) {
+			case LogLevel.ERROR:
+				this.warn = doNothing;
+			case LogLevel.WARN:
+				this.info = doNothing;
+			case LogLevel.INFO:
+				this.debug = doNothing;
+		}
+
+		// saves time later; getPrefix will make these same checks and return
+		// the same value if they all go the same way.
+		if (!this.timestamps && !this.useLevelPrefixes && !this.prefix) {
+			this.getPrefix = (): string => "";
+		}
+	}
+
+	/**
+	 * Constructs a prefix for logging at a given level based on the Logger's
+	 * configuration.
+	 *
+	 * @param level The level at which a message is being logged.
+	 * @returns A prefix, or an empty string if no prefix is to be used.
+	 */
+	private getPrefix(level: LogLevel): string {
+		const parts = new Array<string>();
+
+		if (this.timestamps) {
+			parts.push(new Date().toISOString());
+		}
+
+		if (this.useLevelPrefixes) {
+			parts.unshift(logLevelToString(level));
+		}
+
+		if (this.prefix) {
+			parts.push(this.prefix);
+		}
+
+		// This colon isn't a problem, because if none of the above checks to
+		// add content to `parts` passed, the constructor would have optimized
+		// this whole method away.
+		return `${parts.join(" ")}:`;
+	}
+
+	/**
+	 * Logs a message at the DEBUG level.
+	 *
+	 * @param args Anything representable as text. Be careful passing objects;
+	 * while technically allowed, this will probably cause multi-line log
+	 * messages which are not easy to parse. Similarly, please don't use
+	 * newlines.
+	 */
+	public debug(...args: unknown[]): void {
+		const prefix = this.getPrefix(LogLevel.DEBUG);
+		if (prefix) {
+			this.streams.debug(prefix, ...args);
+			return;
+		}
+		this.streams.debug(...args);
+	}
+
+	/**
+	 * Logs a message at the ERROR level.
+	 *
+	 * @param args Anything representable as text. Be careful passing objects;
+	 * while technically allowed, this will probably cause multi-line log
+	 * messages which are not easy to parse. Similarly, please don't use
+	 * newlines.
+	 */
+	public error(...args: unknown[]): void {
+		const prefix = this.getPrefix(LogLevel.ERROR);
+		if (prefix) {
+			this.streams.error(prefix, ...args);
+			return;
+		}
+		this.streams.error(...args);
+	}
+
+	/**
+	 * Logs a message at the INFO level.
+	 *
+	 * @param args Anything representable as text. Be careful passing objects;
+	 * while technically allowed, this will probably cause multi-line log
+	 * messages which are not easy to parse. Similarly, please don't use
+	 * newlines.
+	 */
+	public info(...args: unknown[]): void {
+		const prefix = this.getPrefix(LogLevel.INFO);
+		if (prefix) {
+			this.streams.info(prefix, ...args);
+			return;
+		}
+		this.streams.info(...args);
+	}
+
+	/**
+	 * Logs a message at the WARN level.
+	 *
+	 * @param args Anything representable as text. Be careful passing objects;
+	 * while technically allowed, this will probably cause multi-line log
+	 * messages which are not easy to parse. Similarly, please don't use
+	 * newlines.
+	 */
+	public warn(...args: unknown[]): void {
+		const prefix = this.getPrefix(LogLevel.WARN);
+		if (prefix) {
+			this.streams.warn(prefix, ...args);
+			return;
+		}
+		this.streams.warn(...args);
+	}
+}
diff --git a/experimental/traffic-portal/src/app/utils/order-by.ts b/experimental/traffic-portal/src/app/utils/order-by.ts
index e62997da02..f0100d045e 100644
--- a/experimental/traffic-portal/src/app/utils/order-by.ts
+++ b/experimental/traffic-portal/src/app/utils/order-by.ts
@@ -12,6 +12,10 @@
 * limitations under the License.
 */
 
+import { environment } from "src/environments/environment";
+
+import { LogLevel, Logger } from "./logging";
+
 /**
  * Implements a single comparison between two values
  *
@@ -72,6 +76,7 @@ function cmpr(a: unknown, b: unknown): number {
  * @returns The sorted array
  */
 export function orderBy<T extends any>(value: Array<T>, property: string | Array<string>): Array<T> {
+	const logger = new Logger(console, environment.production ? LogLevel.INFO : LogLevel.DEBUG, "orderBy call", false);
 	return value.sort((a: any, b: any) => {
 		/* eslint-enable @typescript-eslint/no-explicit-any */
 
@@ -86,11 +91,11 @@ export function orderBy<T extends any>(value: Array<T>, property: string | Array
 
 			let bail = false;
 			if (!Object.prototype.hasOwnProperty.call(a, p)) {
-				console.error("object", a, `has no property "${p}"!`);
+				logger.debug("object", a, `has no property "${p}"!`);
 				bail = true;
 			}
 			if (!Object.prototype.hasOwnProperty.call(b, p)) {
-				console.error("object", b, `has no property "${p}"!`);
+				logger.debug("object", b, `has no property "${p}"!`);
 				bail = true;
 			}
 
@@ -105,7 +110,7 @@ export function orderBy<T extends any>(value: Array<T>, property: string | Array
 			try {
 				result = cmpr(aProp, bProp);
 			} catch (e) {
-				console.error("property", p, "is not the same type on objects", a, "and", b, `! (${e})`);
+				logger.debug("property", p, "is not the same type on objects", a, "and", b, `! (${e})`);
 				return 0;
 			}
 
diff --git a/experimental/traffic-portal/src/main.ts b/experimental/traffic-portal/src/main.ts
index ca62c10ae2..fee4beeb8c 100644
--- a/experimental/traffic-portal/src/main.ts
+++ b/experimental/traffic-portal/src/main.ts
@@ -24,5 +24,11 @@ if (environment.production) {
 
 document.addEventListener("DOMContentLoaded", () => {
 	platformBrowserDynamic().bootstrapModule(AppModule)
+		// Bootstrap failures will not be combined with logging service
+		// messages, because in that case no logging service could have been
+		// initialized. Therefore, consistency is unbroken, and for ease of
+		// debugging it's probably best not to mess with the format of Angular
+		// framework errors anyhow.
+		// eslint-disable-next-line no-console
 		.catch(err => console.error(err));
 });