Repository: apache/trafficcontrol
Pull request #7692 (2023-08-02 18:39:55)

Added Api contract test case for server server capabilities endpoint
## Which Traffic Control components are affected by this PR?

- Traffic Ops Test

## What is the best way to verify this PR?
Step 1 : Follow steps from README file(traffic_ops/testing/api_contract) to run api-contract-tests.

Step 2 : To execute API Contract Testcase , navigate to traffic_ops/testing/api_contract/v4
pytest --to-user Username --to-password Password --to-url URL --config Config Path --request-template Request template --response-template Response template

Note: Config, Request template and response template paths are optional.

## PR submission checklist
- [x] This PR has tests 

- [x] This PR **DOES NOT FIX A SERIOUS SECURITY VULNERABILITY** (see [the Apache Software Foundation's security guidelines](https://apache.org/security) for details)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index df9d6e6fa..8dd14d625 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -71,6 +71,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - [##7605](https://github.com/apache/trafficcontrol/pull/#7605) *Traffic Ops* Fixes `cachegroups_request_comments` v5 apis to respond with `RFC3339` date/time Format.
 - [#7621](https://github.com/apache/trafficcontrol/pull/7621) *Traffic Ops* Use ID token for OAuth authentication, not Access Token
 - [#7694](https://github.com/apache/trafficcontrol/pull/7694) *t3c*, *Traffic Control Health Client* Upgrade to ATS 9.2
+- [#7966](https://github.com/apache/trafficcontrol/pull/7696) *t3c* will no longer clear update flag when config failure occurs and will also give a cache config error msg on exit. 
 
 ### Fixed
 - [#4393](https://github.com/apache/trafficcontrol/issues/4393) *Traffic Ops* Fixed the error code and alert structure when TO is queried for a delivery service with no ssl keys.
@@ -145,6 +146,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - [#7628](https://github.com/apache/trafficcontrol/pull/7628) *Traffic Ops* Fixes an issue where certificate chain validation failed based on leading or trailing whitespace.
 - [#7596](https://github.com/apache/trafficcontrol/pull/7596) *Traffic Ops* Fixes `federation_resolvers` v5 apis to respond with `RFC3339` date/time Format.
 - [#7660](https://github.com/apache/trafficcontrol/pull/7660) *Traffic Ops* Fixes `deliveryServices` v5 apis to respond with `RFC3339` date/time Format.
+- [#7698](https://github.com/apache/trafficcontrol/pull/7698) *Traffic Ops* Fixes `region` v5 apis to respond with `RFC3339` date/time Format.
+- [#7686](https://github.com/apache/trafficcontrol/pull/7686) *Traffic Ops* Fixes secured parameters being visible when role has proper permissions.
+- [#7697](https://github.com/apache/trafficcontrol/pull/7697) *Traffic Ops* Fixes `iloPassword` and `xmppPassword` checking for priv-level instead of using permissions.
 
 ### Removed
 - [#7271](https://github.com/apache/trafficcontrol/pull/7271) Remove components in `infrastructre/docker/`, not in use as cdn-in-a-box performs the same functionality.
diff --git a/cache-config/t3c-apply/t3c-apply.go b/cache-config/t3c-apply/t3c-apply.go
index 24e1e8e91..32692822b 100644
--- a/cache-config/t3c-apply/t3c-apply.go
+++ b/cache-config/t3c-apply/t3c-apply.go
@@ -22,7 +22,6 @@ package main
 import (
 	"encoding/json"
 	"errors"
-	"io/ioutil"
 	"os"
 	"path/filepath"
 	"strings"
@@ -75,6 +74,7 @@ const LockFilePath = "/var/run/t3c.lock"
 const LockFileRetryInterval = time.Second
 const LockFileRetryTimeout = time.Minute
 
+const CacheConfigFailureExitMsg = `CACHE CONFIG FAILURE`
 const FailureExitMsg = `CRITICAL FAILURE, ABORTING`
 const PostConfigFailureExitMsg = `CRITICAL FAILURE AFTER SETTING CONFIG, ABORTING`
 const SuccessExitMsg = `SUCCESS`
@@ -346,7 +346,11 @@ func Main() int {
 	}
 
 	metaData.Succeeded = true
-	return GitCommitAndExit(ExitCodeSuccess, SuccessExitMsg, cfg, metaData, oldMetaData)
+	if syncdsUpdate == torequest.UpdateTropsFailed {
+		return GitCommitAndExit(ExitCodeSuccess, CacheConfigFailureExitMsg, cfg, metaData, oldMetaData)
+	} else {
+		return GitCommitAndExit(ExitCodeSuccess, SuccessExitMsg, cfg, metaData, oldMetaData)
+	}
 }
 
 func LogPanic(f func() int) (exitCode int) {
@@ -450,7 +454,7 @@ func WriteMetaData(cfg config.Cfg, metaData *t3cutil.ApplyMetaData) {
 
 	metaDataFilePath := GetMetaDataFilePath(cfg)
 
-	if err := ioutil.WriteFile(metaDataFilePath, bts, MetaDataFileMode); err != nil {
+	if err := os.WriteFile(metaDataFilePath, bts, MetaDataFileMode); err != nil {
 		log.Errorln("writing metadata file '" + metaDataFilePath + "': " + err.Error())
 		return
 	}
@@ -459,7 +463,7 @@ func WriteMetaData(cfg config.Cfg, metaData *t3cutil.ApplyMetaData) {
 func LoadMetaData(cfg config.Cfg) (*t3cutil.ApplyMetaData, error) {
 	metaDataFilePath := GetMetaDataFilePath(cfg)
 
-	bts, err := ioutil.ReadFile(metaDataFilePath)
+	bts, err := os.ReadFile(metaDataFilePath)
 	if err != nil {
 		return nil, errors.New("reading metadata file '" + metaDataFilePath + "': " + err.Error())
 	}
diff --git a/cache-config/t3c-apply/torequest/torequest.go b/cache-config/t3c-apply/torequest/torequest.go
index 5298091f6..3d6d7e34c 100644
--- a/cache-config/t3c-apply/torequest/torequest.go
+++ b/cache-config/t3c-apply/torequest/torequest.go
@@ -22,7 +22,6 @@ package torequest
 import (
 	"errors"
 	"fmt"
-	"io/ioutil"
 	"os"
 	"os/user"
 	"path/filepath"
@@ -102,6 +101,7 @@ type ConfigFile struct {
 	TropsBackup       string // location to backup the TrafficOps Version
 	AuditComplete     bool   // audit is complete
 	AuditFailed       bool   // audit failed
+	AuditError        string // Error generated when AuditFailed is true
 	ChangeApplied     bool   // a change has been applied
 	ChangeNeeded      bool   // change required
 	PreReqFailed      bool   // failed plugin prerequiste check
@@ -208,22 +208,33 @@ func (r *TrafficOpsReq) checkConfigFile(cfg *ConfigFile, filesAdding []string) e
 	}
 
 	if cfg.Dir == "" {
+		cfg.AuditFailed = true
 		return errors.New("No location information for " + cfg.Name)
 	}
 	// return if audit has already been done.
-	if cfg.AuditComplete == true {
+	if cfg.AuditComplete {
 		return nil
 	}
 
 	if !util.MkDirWithOwner(cfg.Dir, r.Cfg.ReportOnly, &cfg.Uid, &cfg.Gid) {
+		cfg.AuditFailed = true
 		return errors.New("Unable to create the directory '" + cfg.Dir + " for " + "'" + cfg.Name + "'")
 	}
 
 	log.Debugf("======== Start processing config file: %s ========\n", cfg.Name)
 
+	if cfg.Name == "50-ats.rules" {
+		err := r.processUdevRules(cfg)
+		if err != nil {
+			cfg.AuditFailed = true
+			return errors.New("unable to process udev rules in '" + cfg.Name + "': " + err.Error())
+		}
+	}
+
 	if cfg.Name == "remap.config" {
 		err := r.processRemapOverrides(cfg)
 		if err != nil {
+			cfg.AuditFailed = true
 			return err
 		}
 	}
@@ -232,6 +243,7 @@ func (r *TrafficOpsReq) checkConfigFile(cfg *ConfigFile, filesAdding []string) e
 	if cfg.Name == "remap.config" || cfg.Name == "plugin.config" {
 		if err := checkRefs(r.Cfg, cfg.Body, filesAdding); err != nil {
 			r.configFileWarnings[cfg.Name] = append(r.configFileWarnings[cfg.Name], "failed to verify '"+cfg.Name+"': "+err.Error())
+			cfg.AuditFailed = true
 			return errors.New("failed to verify '" + cfg.Name + "': " + err.Error())
 		}
 		log.Infoln("Successfully verified plugins used by '" + cfg.Name + "'")
@@ -251,18 +263,12 @@ func (r *TrafficOpsReq) checkConfigFile(cfg *ConfigFile, filesAdding []string) e
 	changeNeeded, err := diff(r.Cfg, cfg.Body, cfg.Path, r.Cfg.ReportOnly, cfg.Perm, cfg.Uid, cfg.Gid)
 
 	if err != nil {
+		cfg.AuditFailed = true
 		return errors.New("getting diff: " + err.Error())
 	}
 	cfg.ChangeNeeded = changeNeeded
 	cfg.AuditComplete = true
 
-	if cfg.Name == "50-ats.rules" {
-		err := r.processUdevRules(cfg)
-		if err != nil {
-			return errors.New("unable to process udev rules in '" + cfg.Name + "': " + err.Error())
-		}
-	}
-
 	log.Infof("======== End processing config file: %s for service: %s ========\n", cfg.Name, cfg.Service)
 	return nil
 }
@@ -418,7 +424,7 @@ func (r *TrafficOpsReq) processUdevRules(cfg *ConfigFile) error {
 			}
 		}
 	}
-	fs, err := ioutil.ReadDir("/proc/fs/ext4")
+	fs, err := os.ReadDir("/proc/fs/ext4")
 	if err != nil {
 		log.Errorln("unable to read /proc/fs/ext4, cannot audit disks for filesystem usage.")
 	} else {
@@ -811,11 +817,12 @@ func (r *TrafficOpsReq) CheckReloadRestart(data []FileRestartData) RestartData {
 // ProcessConfigFiles processes all config files retrieved from Traffic Ops.
 func (r *TrafficOpsReq) ProcessConfigFiles(metaData *t3cutil.ApplyMetaData) (UpdateStatus, error) {
 	var updateStatus UpdateStatus = UpdateTropsNotNeeded
+	var auditErrors []string
 
 	log.Infoln(" ======== Start processing config files ========")
 
 	filesAdding := []string{} // list of file names being added, needed for verification.
-	for fileName, _ := range r.configFiles {
+	for fileName := range r.configFiles {
 		filesAdding = append(filesAdding, fileName)
 	}
 
@@ -841,6 +848,7 @@ func (r *TrafficOpsReq) ProcessConfigFiles(metaData *t3cutil.ApplyMetaData) (Upd
 		err := r.checkConfigFile(cfg, filesAdding)
 		if err != nil {
 			log.Errorln(err)
+			r.configFiles[cfg.Name].AuditError = err.Error()
 		}
 	}
 
@@ -857,11 +865,11 @@ func (r *TrafficOpsReq) ProcessConfigFiles(metaData *t3cutil.ApplyMetaData) (Upd
 			!cfg.AuditFailed {
 
 			changesRequired++
-			if cfg.Name == "plugin.config" && r.configFiles["remap.config"].PreReqFailed == true {
+			if cfg.Name == "plugin.config" && r.configFiles["remap.config"].PreReqFailed {
 				updateStatus = UpdateTropsFailed
 				log.Errorln("plugin.config changed however, prereqs failed for remap.config so I am skipping updates for plugin.config")
 				continue
-			} else if cfg.Name == "remap.config" && r.configFiles["plugin.config"].PreReqFailed == true {
+			} else if cfg.Name == "remap.config" && r.configFiles["plugin.config"].PreReqFailed {
 				updateStatus = UpdateTropsFailed
 				log.Errorln("remap.config changed however, prereqs failed for plugin.config so I am skipping updates for remap.config")
 				continue
@@ -876,9 +884,17 @@ func (r *TrafficOpsReq) ProcessConfigFiles(metaData *t3cutil.ApplyMetaData) (Upd
 				}
 				shouldRestartReload.ReloadRestart = append(shouldRestartReload.ReloadRestart, *reData)
 			}
+		} else if cfg.AuditFailed {
+			auditErrors = append(auditErrors, cfg.AuditError)
+			log.Warnf("audit failed for config file: %v Error: %s", cfg.Name, cfg.AuditError)
+			updateStatus = UpdateTropsFailed
 		}
 	}
 
+	if updateStatus == UpdateTropsFailed {
+		return UpdateTropsFailed, errors.New(strings.Join(auditErrors, "\n"))
+	}
+
 	r.RestartData = r.CheckReloadRestart(shouldRestartReload.ReloadRestart)
 
 	if 0 < len(r.changedFiles) {
diff --git a/cache-config/t3c-check-refs/t3c-check-refs.go b/cache-config/t3c-check-refs/t3c-check-refs.go
index 92c11426a..835719bca 100644
--- a/cache-config/t3c-check-refs/t3c-check-refs.go
+++ b/cache-config/t3c-check-refs/t3c-check-refs.go
@@ -111,11 +111,11 @@ func checkConfigLine(line string, lineNumber int, filesAdding map[string]struct{
 					}
 				}
 			} else if strings.HasPrefix(fields[ii], "@pparam") {
-				// any plugin parameters that end in '.config | .cfg | .txt | yml | .yaml'
+				// any plugin parameters that end in '.config | .cfg | .txt | yml | .yaml | .lua'
 				// are assumed to be configuration files and are checked that they
 				// exist in the filesystem at the absolute location in the name
 				// or relative to the ATS configuration files directory.
-				m := regexp.MustCompile(`^*(\.config|\.cfg|\.txt|\.yml|\.yaml)+`)
+				m := regexp.MustCompile(`^*(\.config|\.cfg|\.txt|\.yml|\.yaml|\.lua)+`)
 				sa := strings.Split(fields[ii], "=")
 				if len(sa) != 2 && len(sa) != 3 {
 					log.Errorf("malformed @pparam definition in remap.config on line '%d': %v\n", lineNumber, fields)
@@ -159,11 +159,11 @@ func checkConfigLine(line string, lineNumber int, filesAdding map[string]struct{
 			}
 		}
 		// Check the arguments in a plugin.config file for possible plugin config files.
-		// Any plugin argument that ends in '.config | .cfg | .txt | .yml | .yaml' are
+		// Any plugin argument that ends in '.config | .cfg | .txt | .yml | .yaml | .lua' are
 		// assumed to be configuration files and are checked that they
 		// exist in the filesystem at the absolute location in the name
 		// or relative to the ATS configuration files directory.
-		m := regexp.MustCompile(`([^=]+\.config$|[^=]\.cfg$|[^=]+\.txt$|[^=]+\.yml$|[^=]+\.yaml$)`)
+		m := regexp.MustCompile(`([^=]+\.config$|[^=]\.cfg$|[^=]+\.txt$|[^=]+\.yml$|[^=]+\.yaml$|[^=]+\.lua$)`)
 		for ii := 1; ii < length; ii++ {
 			param := strings.TrimSpace(fields[ii])
 			cfg := m.FindStringSubmatch(param)
diff --git a/docs/source/api/v5/regions.rst b/docs/source/api/v5/regions.rst
index 6f1323487..13a898416 100644
--- a/docs/source/api/v5/regions.rst
+++ b/docs/source/api/v5/regions.rst
@@ -41,8 +41,7 @@ Request Structure
 	+-----------+----------+---------------------------------------------------------------------------------------------------------------+
 	| name      | no       | Filter :term:`Regions` by name                                                                                |
 	+-----------+----------+---------------------------------------------------------------------------------------------------------------+
-	| orderby   | no       | Choose the ordering of the results - must be the name of one of the fields of the objects in the ``response`` |
-	|           |          | array                                                                                                         |
+	| orderby   | no       | Choose the ordering of the results - either one of them "division", "id", "name"                              |
 	+-----------+----------+---------------------------------------------------------------------------------------------------------------+
 	| sortOrder | no       | Changes the order of sorting. Either ascending (default or "asc") or descending ("desc")                      |
 	+-----------+----------+---------------------------------------------------------------------------------------------------------------+
@@ -67,9 +66,9 @@ Request Structure
 Response Structure
 -------------------
 :divisionName: The name of the division which contains this region
-:divisionId:   The integral, unique identifier of the division which contains this region
+:division:   The integral, unique identifier of the division which contains this region
 :id:           An integral, unique identifier for this region
-:lastUpdated:  The date and time at which this region was last updated, in :ref:`non-rfc-datetime`
+:lastUpdated:  The date and time at which this region was last updated in :rfc:`3339` Format
 :name:         The region name
 
 .. code-block:: http
@@ -92,7 +91,7 @@ Response Structure
 			"divisionName": "Quebec",
 			"division": 1,
 			"id": 2,
-			"lastUpdated": "2018-12-05 17:50:58+00",
+			"lastUpdated": "2023-05-25T15:59:33.7096-06:00",
 			"name": "Montreal"
 		}
 	]}
@@ -110,8 +109,7 @@ Creates a new region
 
 Request Structure
 -----------------
-:division:     The integral, unique identifier of the division which shall contain the new region\ [1]_
-:divisionName: The name of the division which shall contain the new region\ [1]_
+:division:     The integral, unique identifier of the division which shall contain the new region
 :name:         The name of the region
 
 .. code-block:: http
@@ -126,19 +124,16 @@ Request Structure
 	Content-Type: application/json
 
 	{
-		"name": "Manchester",
-		"division": "4",
-		"divisionName": "England"
+		"division": 4,
+		"name": "Manchester"
 	}
 
-.. [1] The only "division" key that actually matters in the request body is ``division``; ``divisionName`` is not validated and has no effect - particularly not the effect of re-naming the division - beyond changing the name in the API response to this request. Subsequent requests will reveal the true name of the division. Note that if ``divisionName`` is not present in the request body it will be ``null`` in the response, but again further requests will show the true division name (provided it has been assigned to a division).
-
 Response Structure
 ------------------
 :divisionName: The name of the division which contains this region
-:divisionId:   The integral, unique identifier of the division which contains this region
+:division:   The integral, unique identifier of the division which contains this region
 :id:           An integral, unique identifier for this region
-:lastUpdated:  The date and time at which this region was last updated, in :ref:`non-rfc-datetime`
+:lastUpdated:  The date and time at which this region was last updated in :rfc:`3339` Format
 :name:         The region name
 
 .. code-block:: http
@@ -166,7 +161,7 @@ Response Structure
 		"divisionName": "England",
 		"division": 3,
 		"id": 5,
-		"lastUpdated": "2018-12-06 02:14:45+00",
+		"lastUpdated": "2023-05-25T15:59:33.7096-06:00",
 		"name": "Manchester"
 	}}
 
diff --git a/docs/source/api/v5/regions_id.rst b/docs/source/api/v5/regions_id.rst
index ed92a29d8..ca8e8a81d 100644
--- a/docs/source/api/v5/regions_id.rst
+++ b/docs/source/api/v5/regions_id.rst
@@ -38,8 +38,7 @@ Request Structure
 	|  ID  | The integral, unique identifier of the region to update |
 	+------+---------------------------------------------------------+
 
-:division:     The new integral, unique identifier of the division which shall contain the region\ [1]_
-:divisionName: The new name of the division which shall contain the region\ [1]_
+:division:     The new integral, unique identifier of the division which shall contain the region
 :name:         The new name of the region
 
 .. code-block:: http
@@ -54,20 +53,16 @@ Request Structure
 	Content-Type: application/json
 
 	{
-		"name": "Leeds",
 		"division": 3,
-		"divisionName": "England"
+		"name": "Leeds"
 	}
 
-.. [1] The only "division" key that actually matters in the request body is ``division``; ``divisionName`` is not validated and has no effect - particularly not the effect of re-naming the division - beyond changing the name in the API response to this request. Subsequent requests will reveal the true name of the division. Note that if ``divisionName`` is not present in the request body it will be ``null`` in the response, but again further requests will show the true division name (provided it has been assigned to a division).
-
-
 Response Structure
 ------------------
 :divisionName: The name of the division which contains this region
-:divisionId:   The integral, unique identifier of the division which contains this region
+:division:   The integral, unique identifier of the division which contains this region
 :id:           An integral, unique identifier for this region
-:lastUpdated:  The date and time at which this region was last updated, in :ref:`non-rfc-datetime`
+:lastUpdated:  The date and time at which this region was last updated in :rfc:`3339` Format
 :name:         The region name
 
 .. code-block:: http
@@ -95,6 +90,6 @@ Response Structure
 		"divisionName": "England",
 		"division": 3,
 		"id": 5,
-		"lastUpdated": "2018-12-06 02:23:40+00",
+		"lastUpdated": "2023-05-26T15:59:33.7096-06:00",
 		"name": "Leeds"
 	}}
diff --git a/docs/source/api/v5/servers.rst b/docs/source/api/v5/servers.rst
index ed9024fe6..a612c1add 100644
--- a/docs/source/api/v5/servers.rst
+++ b/docs/source/api/v5/servers.rst
@@ -98,7 +98,7 @@ Response Structure
 :iloIpAddress: The IPv4 address of the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
 :iloIpGateway: The IPv4 gateway address of the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
 :iloIpNetmask: The IPv4 subnet mask of the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
-:iloPassword:  The password of the of the server's :abbr:`ILO (Integrated Lights-Out)` service user\ [#ilo]_ - displays as simply ``******`` if the currently logged-in user does not have the 'admin' or 'operations' :term:`Role(s) <Role>`
+:iloPassword:  The password of the of the server's :abbr:`ILO (Integrated Lights-Out)` service user\ [#ilo]_ - displays as simply ``******`` if the currently logged-in user does not have the SECURE-SERVER:READ permission.
 :iloUsername:  The user name for the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
 :interfaces:   A set of the network interfaces in use by the server. In most scenarios, only one will be present, but it is illegal for this set to be an empty collection.
 
@@ -158,7 +158,7 @@ Response Structure
 :typeId:     The integral, unique identifier of the 'type' of this server
 :updPending: A boolean value which, if ``true``, indicates that the server has updates of some kind pending, typically to be acted upon by Traffic Control Cache Config (:term:`t3c`, formerly ORT)
 :xmppId:     A system-generated UUID used to generate a server hashId for use in Traffic Router's consistent hashing algorithm. This value is set when a server is created and cannot be changed afterwards.
-:xmppPasswd: The password used in XMPP communications with the server
+:xmppPasswd: The password used in XMPP communications with the server - displays as simply ``******`` if the currently logged-in user does not have the SECURE-SERVER:READ permission.
 
 .. code-block:: http
 	:caption: Response Example
@@ -254,7 +254,7 @@ Request Structure
 :iloIpAddress: An optional IPv4 address of the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
 :iloIpGateway: An optional IPv4 gateway address of the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
 :iloIpNetmask: An optional IPv4 subnet mask of the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
-:iloPassword:  An optional string containing the password of the of the server's :abbr:`ILO (Integrated Lights-Out)` service user\ [#ilo]_ - displays as simply ``******`` if the currently logged-in user does not have the 'admin' or 'operations' :term:`Role(s) <Role>`
+:iloPassword:  An optional string containing the password of the of the server's :abbr:`ILO (Integrated Lights-Out)` service user\ [#ilo]_ - displays as simply ``******`` if the currently logged-in user does not have the SECURE-SERVER:READ permission.
 :iloUsername:  An optional string containing the user name for the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
 :interfaces:   A set of the network interfaces in use by the server. In most scenarios, only one will be necessary, but it is illegal for this set to be an empty collection.
 
@@ -302,7 +302,7 @@ Request Structure
 
 :typeId:     The integral, unique identifier of the 'type' of this server
 :xmppId:     A system-generated UUID used to generate a server hashId for use in Traffic Router's consistent hashing algorithm. This value is set when a server is created and cannot be changed afterwards.
-:xmppPasswd: An optional password used in XMPP communications with the server
+:xmppPasswd: An optional password used in XMPP communications with the server - displays as simply ``******`` if the currently logged-in user does not have the SECURE-SERVER:READ permission.
 
 .. code-block:: http
 	:caption: Request Example
@@ -385,7 +385,7 @@ Response Structure
 :iloIpAddress: The IPv4 address of the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
 :iloIpGateway: The IPv4 gateway address of the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
 :iloIpNetmask: The IPv4 subnet mask of the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
-:iloPassword:  The password of the of the server's :abbr:`ILO (Integrated Lights-Out)` service user\ [#ilo]_ - displays as simply ``******`` if the currently logged-in user does not have the 'admin' or 'operations' :abbr:`Role(s) <Role>`
+:iloPassword:  The password of the of the server's :abbr:`ILO (Integrated Lights-Out)` service user\ [#ilo]_ - displays as simply ``******`` if the currently logged-in user does not have the SECURE-SERVER:READ permission.
 :iloUsername:  The user name for the server's :abbr:`ILO (Integrated Lights-Out)` service\ [#ilo]_
 :interfaces:   A set of the network interfaces in use by the server. In most scenarios, only one will be present, but it is illegal for this set to be an empty collection.
 
@@ -445,7 +445,7 @@ Response Structure
 :typeId:     The integral, unique identifier of the 'type' of this server
 :updPending: A boolean value which, if ``true``, indicates that the server has updates of some kind pending, typically to be acted upon by Traffic Control Cache Config (T3C, formerly ORT)
 :xmppId:     A system-generated UUID used to generate a server hashId for use in Traffic Router's consistent hashing algorithm. This value is set when a server is created and cannot be changed afterwards.
-:xmppPasswd: The password used in XMPP communications with the server
+:xmppPasswd: The password used in XMPP communications with the server - displays as simply ``******`` if the currently logged-in user does not have the SECURE-SERVER:READ permission.
 
 .. code-block:: http
 	:caption: Response Example
diff --git a/experimental/traffic-portal/.eslintrc.json b/experimental/traffic-portal/.eslintrc.json
index b3b69ce77..620d62888 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 5b2c52a68..a2fa69abf 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 000000000..28e0645e6
--- /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 700e86672..382602e23 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 ce552ca83..83657b7f2 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 78702d2dd..853a1ae45 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 714dfe9f0..186e045bf 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 3ff154e2f..03f334b19 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 4b1afea5b..be5c66a06 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/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 10f2a3492..278140921 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 cb0643729..cc80a543f 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 6524f51ba..12ce08366 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 598abe147..1ab0c3714 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 cc4b5280e..7842b7b81 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 0ca1331ca..2bba71171 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 fe18e4638..91b46961a 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 d9d742f0c..4a9081e59 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 c195bd475..042f7fadd 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 4a31e6277..e4e966f89 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 fc8d14c48..53fe74473 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 a5d6d07cb..8bf05e019 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 8f7a6c748..30cdd50c7 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 0276e73f7..4f25fbb91 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 acc912ae8..4a5f1a96a 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 3a1d06995..00ddec2d2 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 d524b76c1..392326904 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 676d0e230..51d3e65e7 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 c10ac704f..cbf2481af 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 ccef99e4b..0480879fc 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 fdb768b69..8bf52574e 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 6102ba30e..048f9b29c 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 dfed761f4..429b7c1c7 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 a8882a6d1..828164dbf 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 cc79127ec..a78d4e11a 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 78e0c362f..cfc438139 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 5fe946ec4..88a0e30aa 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 b93e84ccb..51b946161 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 3f78be514..9b1f25b13 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 4f8d0627b..57c89bb31 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 81089fedd..de41c4178 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 2a88f7913..ef1ec5250 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 2454abae8..c6203eecc 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 5e66bff8b..dfc9228aa 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 89b0dc593..43d9cb8e0 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 2a73e5d88..a30216c66 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 511143e62..21b14cff6 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 b02294a6d..a0ca6e1a2 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 593c7d32a..aac736a15 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 a70a5b0b5..24083015d 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 f0b977967..03f0caafd 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 5e88b1851..266a5572b 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 2871b1e5a..3545d6404 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 1eb119880..6f97264d0 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/generic-table/generic-table.component.ts b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
index ec47410d8..61b7c69c6 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 6a20435ac..270b6663e 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 55bc33993..de36720f8 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 6c1709ba6..a424ad75d 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 000000000..ba844b49a
--- /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 000000000..73b6bb751
--- /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 fc762b706..faa1d87a9 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 d7fee1e2f..0ec720c42 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 b9572489e..b84ef8bf7 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 61fd89d8f..28a2e8e7d 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 b52520be2..d271e98a2 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 cf4f95f30..959f5398f 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 ff7cb1e51..8338df1ad 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 605c9818c..af85a1f6f 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 000000000..4a7808152
--- /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 000000000..7efafa8c3
--- /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 e62997da0..f0100d045 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 ca62c10ae..fee4beeb8 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));
 });
diff --git a/infrastructure/cdn-in-a-box/health/tc-health-client-ReadMe.md b/infrastructure/cdn-in-a-box/health/tc-health-client-ReadMe.md
index 231efbd09..04c3cf1a2 100644
--- a/infrastructure/cdn-in-a-box/health/tc-health-client-ReadMe.md
+++ b/infrastructure/cdn-in-a-box/health/tc-health-client-ReadMe.md
@@ -23,6 +23,14 @@
 
 Build and run cdn-in-a-box `docker-compose -f docker-compose.yml -f docker-compose.expose-ports.yml up`, once up and running, using docker desktop, navigate to terminal tab of an edge or mid. cd into `/var/log/trafficcontrol` and run `tail -f tc-health-client.log`. Click on the `Open in external terminal` on upper right side and cd into `/usr/bin` and run `./tc-health-client`. Wait for the dispersion time to pass and then logs will start in the window where the tail command was ran. After that you may interact with it via Traffic Portal.
 
+## Enable Debug instructions [Different from Production]
+
+Debug is currently going to `/dev/null` to avoid filling up the logs. However, it can be redirect to show in the logs when debugging is needed. Systemd doesn't work well in Docker. Therefore the debbuging can be enabled in CDN-in-a-Box with the following steps:
+
+If `tc-health-client` is already running `control + c` to stop it
+Then run `/usr/bin/tc-health-client -vvv` in the `external terminal` this enables debug messages
+Watch logs in the docker desktop tab where `tail -f tc-health-client.log` was ran
+
 ## Config files for Testing Only
 
 For testing only the `tc-health-client.json` are the settings used to run it locally and can be changed. If changed `purge` all containers and run `docker-compose -f docker-compose.yml -f docker-compose.expose-ports.yml up` in the `infrastructure/cdn-in-a-box/` folder. Same applies if the `tc-health-client.service` and `to-creds` files are changed. The `tc-health-client.service` is set for `Debug` mode with `vvv` which is different from Production which is `vv`.
diff --git a/infrastructure/cdn-in-a-box/health/tc-health-client.service b/infrastructure/cdn-in-a-box/health/tc-health-client.service
index 854075b0b..e8868f806 100644
--- a/infrastructure/cdn-in-a-box/health/tc-health-client.service
+++ b/infrastructure/cdn-in-a-box/health/tc-health-client.service
@@ -22,7 +22,7 @@ After=trafficserver.service
 Requires=trafficserver.service
 
 [Service]
-ExecStart=/usr/bin/tc-health-client -vvv
+ExecStart=/usr/bin/tc-health-client -vv
 ExecReload=/bin/kill -HUP $MAINPID
 Restart=always
 RestartSec=5
diff --git a/lib/go-tc/regions.go b/lib/go-tc/regions.go
index fd3808233..5d3a29050 100644
--- a/lib/go-tc/regions.go
+++ b/lib/go-tc/regions.go
@@ -21,6 +21,9 @@ package tc
 
 // RegionsResponse is the type of responses from Traffic Ops to GET requests
 // made to its /regions API endpoint.
+
+import "time"
+
 type RegionsResponse struct {
 	Response []Region `json:"response"`
 	Alerts
@@ -35,6 +38,27 @@ type Region struct {
 	Name         string    `json:"name" db:"name"`
 }
 
+// RegionsResponseV5 is an alias for the latest minor version of the major version 5.
+type RegionsResponseV5 = RegionsResponseV50
+
+// RegionsResponseV50 is response made from /regions API endpoint - in the latest minor version APIv50.
+type RegionsResponseV50 struct {
+	Response []RegionV5 `json:"response"`
+	Alerts
+}
+
+// RegionV5 is an alias for the latest minor version of the major version 5.
+type RegionV5 = RegionV50
+
+// RegionV50 is a named collection of Physical Locations within a Division in the latest minor version APIv50.
+type RegionV50 struct {
+	DivisionName string    `json:"divisionName"`
+	Division     int       `json:"division" db:"division"`
+	ID           int       `json:"id" db:"id"`
+	LastUpdated  time.Time `json:"lastUpdated" db:"last_updated"`
+	Name         string    `json:"name" db:"name"`
+}
+
 // RegionName is a response to a request to get a region by its name. It
 // includes the division that the region is in.
 type RegionName struct {
diff --git a/tc-health-client/README.md b/tc-health-client/README.md
index 1b15c10e2..55930fad7 100644
--- a/tc-health-client/README.md
+++ b/tc-health-client/README.md
@@ -131,6 +131,18 @@ Sample configuarion file:
 
 The name of the CDN that the Traffic Server host is a member of.
 
+### Enable Debug instructions
+
+Debug is currently going to `/dev/null` to avoid filling up the logs. However, it can be redirect to show in the logs when debugging is needed. In the desired machine, please add a 3rd `v` to `tc-health-client.service` resulting in `-vvv` which enables debugging live to trobleshoot an issue and **change it back to `-vv` once debugging is no londer needed**. 
+
+Steps:
+Cd into `/usr/lib/systemd/system/`
+To edit run `sudo vi tc-health-client.service`
+Save and quit with `wq!`
+Reload with `sudo systemctl daemon-reload`
+Run `sudo systemctl stop tc-health-client` and wait for the next prompt to show
+Then run `sudo systemctl start tc-health-client`
+
 ### enable-active-markdowns
 
 When enabled, the client will actively mark down Traffic Server parents.
diff --git a/tc-health-client/config/config.go b/tc-health-client/config/config.go
index 5a210b1cf..dc2f00c45 100644
--- a/tc-health-client/config/config.go
+++ b/tc-health-client/config/config.go
@@ -229,10 +229,10 @@ func GetConfig() (*Cfg, error, bool) {
 	var err error
 	var configFile string
 	var logLocationErr = log.LogLocationStderr
-	var logLocationDebug = log.LogLocationFile
+	var logLocationDebug = log.LogLocationNull
 	var logLocationInfo = log.LogLocationFile
-	var logLocationWarn = log.LogLocationFile
-	var logLocationEvent = log.LogLocationFile
+	var logLocationWarn = log.LogLocationNull
+	var logLocationEvent = log.LogLocationNull
 
 	configFilePtr := getopt.StringLong("config-file", 'f', DefaultConfigFile, "full path to the json config file")
 	logdirPtr := getopt.StringLong("logging-dir", 'l', DefaultLogDirectory, "directory location for log files")
diff --git a/traffic_control/clients/python/trafficops/tosession.py b/traffic_control/clients/python/trafficops/tosession.py
index fbb5565e3..2f038d182 100644
--- a/traffic_control/clients/python/trafficops/tosession.py
+++ b/traffic_control/clients/python/trafficops/tosession.py
@@ -1617,6 +1617,39 @@ class TOSession(RestApiSession):
 		:raises: Union[LoginError, OperationError]
 		"""
 
+	#
+	# Server_server_capabilities
+	#
+	@api_request('get', 'server_server_capabilities', ('3.0', '4.0', '4.1', '5.0'))
+	def get_server_server_capabilities(self, query_params=None):
+		"""
+		Gets all associations of Server Capabilities to cache servers.
+		:ref:`to-api-server_server_capabilities`
+		:rtype: Tuple[Dict[str, Any], requests.Response]
+		:raises: Union[LoginError, OperationError]
+		"""
+
+
+	@api_request('post', 'server_server_capabilities', ('3.0', '4.0', '4.1', '5.0'))
+	def associate_server_capability_to_server(self, data=None):
+		"""
+		Associate server capability to server.
+		:ref:`to-api-server_server_capabilities`
+		:param query_params: 'serverId' is a required parameter, identifying which server to associate server capability.
+		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], requests.Response]
+		:raises: Union[LoginError, OperationError]
+		"""
+
+	@api_request('delete', 'server_server_capabilities', ('3.0', '4.0', '4.1', '5.0'))
+	def delete_server_capability_association_to_server(self, query_params=None):
+		"""
+		Deletes an association of server capability to a server.
+		:ref:`to-api-server_server_capabilities`
+		:param query_params: 'serverId' is a required parameter, identifying where server capability association is being deleted.
+		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], requests.Response]
+		:raises: Union[LoginError, OperationError]
+		"""
+
 
 	#
 	# Physical Location
@@ -2028,6 +2061,49 @@ class TOSession(RestApiSession):
 		:rtype: Tuple[Dict[str, Any], requests.Response]
 		:raises: Union[LoginError, OperationError]
 		"""
+	
+	#
+	# Service categories
+	#
+	@api_request('get', 'service_categories', ('3.0', '4.0', '4.1', '5.0'))
+	def get_service_categories(self, query_params=None):
+		"""
+		Get service categories.
+		:ref:`to-api-service_categories`
+		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], requests.Response]
+		:raises: Union[LoginError, OperationError]
+		"""
+
+
+	@api_request('post', 'service_categories', ('3.0', '4.0', '4.1', '5.0'))
+	def create_service_category(self, data=None):
+		"""
+		Create a service category.
+		:param data: The update action. QueueUpdateRequest() can be used for this argument also.
+		:type data: Dict[str, Any]
+		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], requests.Response]
+		:raises: Union[LoginError, OperationError]
+		"""
+	
+	@api_request('put', 'service_categories/{service_category_name:s}', ('3.0', '4.0', '4.1', '5.0'))
+	def update_service_category(self, service_category_name=None, data=None):
+		"""
+		Update a service category.
+		:param service_category_name: The current name of the Service Category.
+		:param data: The update action. QueueUpdateRequest() can be used for this argument also.
+		:type data: Dict[str, Any]
+		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], requests.Response]
+		:raises: Union[LoginError, OperationError]
+		"""
+
+	@api_request('delete', 'service_categories/{service_category_name:s}', ('3.0', '4.0', '4.1', '5.0'))
+	def delete_service_category(self, service_category_name=None):
+		"""
+		Delete a service category.
+		:param service_category_name: The current name of the Service Category to be deleted.
+		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], requests.Response]
+		:raises: Union[LoginError, OperationError]
+		"""
 
 	#
 	# Static DNS Entries
diff --git a/traffic_ops/traffic_ops_golang/parameter/parameters.go b/traffic_ops/traffic_ops_golang/parameter/parameters.go
index 167019fbc..3595ced55 100644
--- a/traffic_ops/traffic_ops_golang/parameter/parameters.go
+++ b/traffic_ops/traffic_ops_golang/parameter/parameters.go
@@ -176,10 +176,18 @@ func (param *TOParameter) Read(h http.Header, useIMS bool) ([]interface{}, error
 			return nil, nil, errors.New("scanning " + param.GetType() + ": " + err.Error()), http.StatusInternalServerError, nil
 		}
 		if p.Secure != nil && *p.Secure {
-			if param.ReqInfo.Version.Major >= 4 &&
-				param.ReqInfo.Config.RoleBasedPermissions &&
-				!param.ReqInfo.User.Can("PARAMETER-SECURE:READ") {
-				p.Value = &HiddenField
+			if param.ReqInfo.Version.Major >= 5 {
+				if !param.ReqInfo.User.Can("PARAMETER-SECURE:READ") {
+					p.Value = &HiddenField
+				}
+			} else if param.ReqInfo.Version.Major == 4 {
+				if param.ReqInfo.Config.RoleBasedPermissions {
+					if !param.ReqInfo.User.Can("PARAMETER-SECURE:READ") {
+						p.Value = &HiddenField
+					}
+				} else if param.ReqInfo.User.PrivLevel < auth.PrivLevelAdmin {
+					p.Value = &HiddenField
+				}
 			} else if param.ReqInfo.User.PrivLevel < auth.PrivLevelAdmin {
 				p.Value = &HiddenField
 			}
diff --git a/traffic_ops/traffic_ops_golang/region/regions.go b/traffic_ops/traffic_ops_golang/region/regions.go
index 79fd9ccbe..5ce193057 100644
--- a/traffic_ops/traffic_ops_golang/region/regions.go
+++ b/traffic_ops/traffic_ops_golang/region/regions.go
@@ -20,14 +20,22 @@ package region
  */
 
 import (
+	"database/sql"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"net/http"
 	"time"
 
+	"github.com/apache/trafficcontrol/lib/go-log"
 	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-tc/tovalidate"
+	"github.com/apache/trafficcontrol/lib/go-util"
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/util/ims"
+
+	validation "github.com/go-ozzo/ozzo-validation"
 )
 
 // we need a type alias to define functions on
@@ -192,3 +200,259 @@ func deleteQuery() string {
 	query := deleteQueryBase() + ` WHERE id=:id`
 	return query
 }
+
+// Read is the handler for GET requests to Regions of APIv5
+func Read(w http.ResponseWriter, r *http.Request) {
+	var maxTime time.Time
+	var runSecond bool
+
+	inf, sysErr, userErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx
+	if sysErr != nil || userErr != nil {
+		api.HandleErr(w, r, tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	// Query Parameters to Database Query column mappings
+	queryParamsToQueryCols := map[string]dbhelpers.WhereColumnInfo{
+		"division": {Column: "r.division"},
+		"id":       {Column: "r.id", Checker: api.IsInt},
+		"name":     {Column: "r.name"},
+	}
+	if _, ok := inf.Params["orderby"]; !ok {
+		inf.Params["orderby"] = "name"
+	}
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, queryParamsToQueryCols)
+	if len(errs) > 0 {
+		api.HandleErr(w, r, tx.Tx, http.StatusBadRequest, util.JoinErrs(errs), nil)
+		return
+	}
+	if inf.Config.UseIMS {
+		runSecond, maxTime = ims.TryIfModifiedSinceQuery(tx, r.Header, queryValues, SelectMaxLastUpdatedQuery(where))
+		if !runSecond {
+			log.Debugln("IMS HIT")
+			api.AddLastModifiedHdr(w, maxTime)
+			w.WriteHeader(http.StatusNotModified)
+			return
+		}
+		log.Debugln("IMS MISS")
+	} else {
+		log.Debugln("Non IMS request")
+	}
+
+	query := `SELECT r.division, d.name as divisionname, r.id, r.last_updated, r.name FROM region r
+				JOIN division d ON r.division = d.id` + where + orderBy + pagination
+	rows, err := tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx.Tx, http.StatusInternalServerError, nil, fmt.Errorf("region get: error getting region(s): %w", err))
+	}
+	defer log.Close(rows, "unable to close DB connection")
+
+	typ := tc.RegionV5{}
+	regionList := []tc.RegionV5{}
+	for rows.Next() {
+		if err = rows.Scan(&typ.Division, &typ.DivisionName, &typ.ID, &typ.LastUpdated, &typ.Name); err != nil {
+			api.HandleErr(w, r, tx.Tx, http.StatusInternalServerError, nil, fmt.Errorf("error getting region(s): %w", err))
+		}
+		regionList = append(regionList, typ)
+	}
+
+	api.WriteResp(w, r, regionList)
+	return
+}
+
+// Create region with the passed data for APIv5.
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	rg, readValErr := readAndValidateJsonStruct(r)
+	if readValErr != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, readValErr, nil)
+		return
+	}
+
+	var exists bool
+	existErr := tx.QueryRow(`SELECT EXISTS(SELECT * from region where name = $1)`, rg.Name).Scan(&exists)
+
+	if existErr != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("error: %w, when checking if region with name %s exists", existErr, rg.Name))
+		return
+	}
+	if exists {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, fmt.Errorf("region name '%s' already exists", rg.Name), nil)
+		return
+	}
+
+	query := `INSERT INTO region ( division, name) VALUES ( $1, $2 ) 
+				RETURNING 
+					id,last_updated,
+					(SELECT d.name FROM division d WHERE d.id = region.division)`
+	err := tx.QueryRow(query, rg.Division, rg.Name).Scan(&rg.ID, &rg.LastUpdated, &rg.DivisionName)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, fmt.Errorf("error: %w in creating region with name: %s", err, rg.Name), nil)
+			return
+		}
+		usrErr, sysErr, code := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, code, usrErr, sysErr)
+		return
+	}
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "region is created.")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, rg)
+	return
+}
+
+// Update a Region for APIv5
+func Update(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"id"}, []string{"id"})
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+	rg, readValErr := readAndValidateJsonStruct(r)
+	if readValErr != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, readValErr, nil)
+		return
+	}
+
+	requestedRegionId := inf.IntParams["id"]
+	// check if the entity was already updated
+	userErr, sysErr, errCode = api.CheckIfUnModified(r.Header, inf.Tx, requestedRegionId, "region")
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+
+	query := `UPDATE region SET division= $1, name= $2 WHERE id= $3 
+				RETURNING 
+					id,last_updated,
+					(SELECT d.name FROM division d WHERE d.id = region.division)`
+
+	err := tx.QueryRow(query, rg.Division, rg.Name, requestedRegionId).Scan(&rg.ID, &rg.LastUpdated, &rg.DivisionName)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			api.HandleErr(w, r, tx, http.StatusBadRequest, fmt.Errorf("region: %d not found", requestedRegionId), nil)
+			return
+		}
+		usrErr, sysErr, code := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, code, usrErr, sysErr)
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "region was updated")
+	api.WriteAlertsObj(w, r, http.StatusOK, alerts, rg)
+	return
+}
+
+// Delete an Region for APIv5
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	requestedRegionId := inf.Params["id"]
+	requestedRegionName := inf.Params["name"]
+
+	var exists bool
+	var noExistsErrMsg string
+
+	if requestedRegionId == "" && requestedRegionName == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, fmt.Errorf("refusing to delete all resources of type Region"), nil)
+		return
+	} else if requestedRegionId != "" && requestedRegionName != "" { // checking if both id and name are passed
+		existErr := tx.QueryRow(`SELECT EXISTS(SELECT * from region where id = $1 AND name = $2)`, requestedRegionId, requestedRegionName).Scan(&exists)
+		noExistsErrMsg = fmt.Sprintf("no region exists by id: %s and name: %s", requestedRegionId, requestedRegionName)
+		if existErr != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("error: %w, when checking if region with id %s exists", existErr, requestedRegionId))
+			return
+		}
+	} else if requestedRegionId != "" {
+		existErr := tx.QueryRow(`SELECT EXISTS(SELECT * from region where id = $1)`, requestedRegionId).Scan(&exists)
+		noExistsErrMsg = fmt.Sprintf("no region exists by id: %s", requestedRegionId)
+		if existErr != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("error: %w, when checking if region with id %s exists", existErr, requestedRegionId))
+			return
+		}
+	} else if requestedRegionName != "" {
+		existErr := tx.QueryRow(`SELECT EXISTS(SELECT * from region where name = $1)`, requestedRegionName).Scan(&exists)
+		noExistsErrMsg = fmt.Sprintf("no region exists by name: %s", requestedRegionName)
+		if existErr != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("error: %w, when checking if region with name %s exists", existErr, requestedRegionName))
+			return
+		}
+		if exists {
+			existErr := tx.QueryRow(`SELECT id from region where name = $1`, requestedRegionName).Scan(&requestedRegionId)
+			if existErr != nil {
+				api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("error: %w, when checking if region with name %s exists", existErr, requestedRegionName))
+				return
+			}
+		}
+	}
+
+	if !exists {
+		api.HandleErr(w, r, tx, http.StatusNotFound, fmt.Errorf(noExistsErrMsg), nil)
+		return
+	}
+
+	res, err := tx.Exec("DELETE FROM region WHERE id = $1", requestedRegionId)
+
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	rowsAffected, err := res.RowsAffected()
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("determining rows affected for delete region: %w", err))
+		return
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, fmt.Errorf("no rows deleted for region"), nil)
+		return
+	}
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "region was deleted.")
+	api.WriteAlerts(w, r, http.StatusOK, alerts)
+	return
+}
+
+// readAndValidateJsonStruct reads json body and validates json fields
+func readAndValidateJsonStruct(r *http.Request) (tc.RegionV5, error) {
+	var region tc.RegionV5
+	if err := json.NewDecoder(r.Body).Decode(&region); err != nil {
+		userErr := fmt.Errorf("error decoding POST request body into RegionV5 struct %w", err)
+		return region, userErr
+	}
+
+	// validate JSON body
+	errs := tovalidate.ToErrors(validation.Errors{
+		"division": validation.Validate(region.Division, validation.NotNil, validation.Min(0)),
+		"name":     validation.Validate(region.Name, validation.Required),
+	})
+	if len(errs) > 0 {
+		userErr := util.JoinErrs(errs)
+		return region, userErr
+	}
+	return region, nil
+}
+
+// SelectMaxLastUpdatedQuery used for TryIfModifiedSinceQuery()
+func SelectMaxLastUpdatedQuery(where string) string {
+	return `SELECT max(t) from (
+		SELECT max(r.last_updated) as t FROM region r
+JOIN division d ON r.division = d.id ` + where +
+		` UNION ALL
+	select max(last_updated) as t from last_deleted l where l.table_name='region') as res`
+}
diff --git a/traffic_ops/traffic_ops_golang/routing/routes.go b/traffic_ops/traffic_ops_golang/routing/routes.go
index f62a77db4..26b5a0c17 100644
--- a/traffic_ops/traffic_ops_golang/routing/routes.go
+++ b/traffic_ops/traffic_ops_golang/routing/routes.go
@@ -278,10 +278,10 @@ func Routes(d ServerData) ([]Route, http.Handler, error) {
 		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodPost, Path: `profiles/name/{new_profile}/copy/{existing_profile}`, Handler: profile.CopyProfileHandler, RequiredPrivLevel: auth.PrivLevelOperations, RequiredPermissions: []string{"PROFILE:CREATE", "PROFILE:READ", "PARAMETER:READ"}, Authenticated: Authenticated, Middlewares: nil, ID: 40614320931},
 
 		//Region: CRUDs
-		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodGet, Path: `regions/?$`, Handler: api.ReadHandler(&region.TORegion{}), RequiredPrivLevel: auth.PrivLevelReadOnly, RequiredPermissions: []string{"REGION:READ"}, Authenticated: Authenticated, Middlewares: nil, ID: 41003708531},
-		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodPut, Path: `regions/{id}$`, Handler: api.UpdateHandler(&region.TORegion{}), RequiredPrivLevel: auth.PrivLevelOperations, RequiredPermissions: []string{"REGION:UPDATE", "REGION:READ"}, Authenticated: Authenticated, Middlewares: nil, ID: 42230822431},
-		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodPost, Path: `regions/?$`, Handler: api.CreateHandler(&region.TORegion{}), RequiredPrivLevel: auth.PrivLevelOperations, RequiredPermissions: []string{"REGION:CREATE", "REGION:READ"}, Authenticated: Authenticated, Middlewares: nil, ID: 428833448831},
-		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodDelete, Path: `regions/?$`, Handler: api.DeleteHandler(&region.TORegion{}), RequiredPrivLevel: auth.PrivLevelOperations, RequiredPermissions: []string{"REGION:DELETE"}, Authenticated: Authenticated, Middlewares: nil, ID: 423262675831},
+		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodGet, Path: `regions/?$`, Handler: region.Read, RequiredPrivLevel: auth.PrivLevelReadOnly, RequiredPermissions: []string{"REGION:READ"}, Authenticated: Authenticated, Middlewares: nil, ID: 41003708531},
+		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodPut, Path: `regions/{id}$`, Handler: region.Update, RequiredPrivLevel: auth.PrivLevelOperations, RequiredPermissions: []string{"REGION:UPDATE", "REGION:READ"}, Authenticated: Authenticated, Middlewares: nil, ID: 42230822431},
+		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodPost, Path: `regions/?$`, Handler: region.Create, RequiredPrivLevel: auth.PrivLevelOperations, RequiredPermissions: []string{"REGION:CREATE", "REGION:READ"}, Authenticated: Authenticated, Middlewares: nil, ID: 428833448831},
+		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodDelete, Path: `regions/?$`, Handler: region.Delete, RequiredPrivLevel: auth.PrivLevelOperations, RequiredPermissions: []string{"REGION:DELETE"}, Authenticated: Authenticated, Middlewares: nil, ID: 423262675831},
 
 		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodPost, Path: `topologies/?$`, Handler: api.CreateHandler(&topology.TOTopology{}), RequiredPrivLevel: auth.PrivLevelOperations, RequiredPermissions: []string{"TOPOLOGY:CREATE", "TOPOLOGY:READ", "CACHE-GROUP:READ"}, Authenticated: Authenticated, Middlewares: nil, ID: 48714522211},
 		{Version: api.Version{Major: 5, Minor: 0}, Method: http.MethodGet, Path: `topologies/?$`, Handler: api.ReadHandler(&topology.TOTopology{}), RequiredPrivLevel: auth.PrivLevelReadOnly, RequiredPermissions: []string{"TOPOLOGY:READ", "CACHE-GROUP:READ"}, Authenticated: Authenticated, Middlewares: nil, ID: 48714522221},
diff --git a/traffic_ops/traffic_ops_golang/server/servers.go b/traffic_ops/traffic_ops_golang/server/servers.go
index a2d2461ce..4253514d5 100644
--- a/traffic_ops/traffic_ops_golang/server/servers.go
+++ b/traffic_ops/traffic_ops_golang/server/servers.go
@@ -698,7 +698,7 @@ func Read(w http.ResponseWriter, r *http.Request) {
 		log.Warnf("Couldn't get config %v", e)
 	}
 
-	servers, serverCount, userErr, sysErr, errCode, maxTime = getServers(r.Header, inf.Params, inf.Tx, inf.User, useIMS, *version)
+	servers, serverCount, userErr, sysErr, errCode, maxTime = getServers(r.Header, inf.Params, inf.Tx, inf.User, useIMS, *version, inf.Config.RoleBasedPermissions)
 	if maxTime != nil && api.SetLastModifiedHeader(r, useIMS) {
 		api.AddLastModifiedHdr(w, *maxTime)
 	}
@@ -767,7 +767,7 @@ func getServerCount(tx *sqlx.Tx, query string, queryValues map[string]interface{
 	return serverCount, nil
 }
 
-func getServers(h http.Header, params map[string]string, tx *sqlx.Tx, user *auth.CurrentUser, useIMS bool, version api.Version) ([]tc.ServerV40, uint64, error, error, int, *time.Time) {
+func getServers(h http.Header, params map[string]string, tx *sqlx.Tx, user *auth.CurrentUser, useIMS bool, version api.Version, roleBasedPerms bool) ([]tc.ServerV40, uint64, error, error, int, *time.Time) {
 	var maxTime time.Time
 	var runSecond bool
 	// Query Parameters to Database Query column mappings
@@ -956,7 +956,12 @@ JOIN server_profile sp ON s.id = sp.server`
 		if err != nil {
 			return nil, serverCount, nil, errors.New("getting servers: " + err.Error()), http.StatusInternalServerError, nil
 		}
-		if user.PrivLevel < auth.PrivLevelOperations {
+		if (version.GreaterThanOrEqualTo(&api.Version{Major: 4}) && roleBasedPerms) || version.GreaterThanOrEqualTo(&api.Version{Major: 5}) {
+			if !user.Can("SECURE-SERVER:READ") {
+				s.ILOPassword = &HiddenField
+				s.XMPPPasswd = &HiddenField
+			}
+		} else if user.PrivLevel < auth.PrivLevelOperations {
 			s.ILOPassword = &HiddenField
 			s.XMPPPasswd = &HiddenField
 		}
@@ -1322,7 +1327,7 @@ func Update(w http.ResponseWriter, r *http.Request) {
 	id := inf.IntParams["id"]
 
 	// Get original server
-	originals, _, userErr, sysErr, errCode, _ := getServers(r.Header, inf.Params, inf.Tx, inf.User, false, *version)
+	originals, _, userErr, sysErr, errCode, _ := getServers(r.Header, inf.Params, inf.Tx, inf.User, false, *version, inf.Config.RoleBasedPermissions)
 	if userErr != nil || sysErr != nil {
 		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
 		return
@@ -2185,7 +2190,7 @@ func Delete(w http.ResponseWriter, r *http.Request) {
 	}
 
 	var servers []tc.ServerV40
-	servers, _, userErr, sysErr, errCode, _ = getServers(r.Header, map[string]string{"id": inf.Params["id"]}, inf.Tx, inf.User, false, *version)
+	servers, _, userErr, sysErr, errCode, _ = getServers(r.Header, map[string]string{"id": inf.Params["id"]}, inf.Tx, inf.User, false, *version, inf.Config.RoleBasedPermissions)
 	if userErr != nil || sysErr != nil {
 		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
 		return
diff --git a/traffic_ops/v5-client/region.go b/traffic_ops/v5-client/region.go
index 7ef85216b..e7c2edaa1 100644
--- a/traffic_ops/v5-client/region.go
+++ b/traffic_ops/v5-client/region.go
@@ -28,7 +28,7 @@ import (
 const apiRegions = "/regions"
 
 // CreateRegion creates the given Region.
-func (to *Session) CreateRegion(region tc.Region, opts RequestOptions) (tc.Alerts, toclientlib.ReqInf, error) {
+func (to *Session) CreateRegion(region tc.RegionV5, opts RequestOptions) (tc.Alerts, toclientlib.ReqInf, error) {
 	if region.Division == 0 && region.DivisionName != "" {
 		divisionOpts := NewRequestOptions()
 		divisionOpts.QueryParameters.Set("name", region.DivisionName)
@@ -47,7 +47,7 @@ func (to *Session) CreateRegion(region tc.Region, opts RequestOptions) (tc.Alert
 }
 
 // UpdateRegion replaces the Region identified by ID with the one provided.
-func (to *Session) UpdateRegion(id int, region tc.Region, opts RequestOptions) (tc.Alerts, toclientlib.ReqInf, error) {
+func (to *Session) UpdateRegion(id int, region tc.RegionV5, opts RequestOptions) (tc.Alerts, toclientlib.ReqInf, error) {
 	route := fmt.Sprintf("%s/%d", apiRegions, id)
 	var alerts tc.Alerts
 	reqInf, err := to.put(route, opts, region, &alerts)
@@ -55,8 +55,8 @@ func (to *Session) UpdateRegion(id int, region tc.Region, opts RequestOptions) (
 }
 
 // GetRegions returns all Regions in Traffic Ops.
-func (to *Session) GetRegions(opts RequestOptions) (tc.RegionsResponse, toclientlib.ReqInf, error) {
-	var data tc.RegionsResponse
+func (to *Session) GetRegions(opts RequestOptions) (tc.RegionsResponseV5, toclientlib.ReqInf, error) {
+	var data tc.RegionsResponseV5
 	reqInf, err := to.get(apiRegions, opts, &data)
 	return data, reqInf, err
 }


Test patch:
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 66f35f971..607e9f3eb 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/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 4d2d631cf..52ab6eb70 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/traffic_ops/testing/api/v5/regions_test.go b/traffic_ops/testing/api/v5/regions_test.go
index e30e7b86f..f031e542f 100644
--- a/traffic_ops/testing/api/v5/regions_test.go
+++ b/traffic_ops/testing/api/v5/regions_test.go
@@ -38,7 +38,7 @@ func TestRegions(t *testing.T) {
 		currentTimeRFC := currentTime.Format(time.RFC1123)
 		tomorrow := currentTime.AddDate(0, 0, 1).Format(time.RFC1123)
 
-		methodTests := utils.TestCase[client.Session, client.RequestOptions, tc.Region]{
+		methodTests := utils.TestCase[client.Session, client.RequestOptions, tc.RegionV5]{
 			"GET": {
 				"NOT MODIFIED when NO CHANGES made": {
 					ClientSession: TOSession,
@@ -121,10 +121,9 @@ func TestRegions(t *testing.T) {
 			"POST": {
 				"NOT FOUND when DIVISION DOESNT EXIST": {
 					ClientSession: TOSession,
-					RequestBody: tc.Region{
-						Name:         "invalidDivision",
-						Division:     99999999,
-						DivisionName: "doesntexist",
+					RequestBody: tc.RegionV5{
+						Name:     "invalidDivision",
+						Division: 99999999,
 					},
 					Expectations: utils.CkRequest(utils.HasError(), utils.HasStatus(http.StatusNotFound)),
 				},
@@ -133,10 +132,9 @@ func TestRegions(t *testing.T) {
 				"OK when VALID request": {
 					EndpointID:    GetRegionID(t, "cdn-region2"),
 					ClientSession: TOSession,
-					RequestBody: tc.Region{
-						Name:         "newName",
-						Division:     GetDivisionID(t, "cdn-div2")(),
-						DivisionName: "cdn-div2",
+					RequestBody: tc.RegionV5{
+						Name:     "newName",
+						Division: GetDivisionID(t, "cdn-div2")(),
 					},
 					Expectations: utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK),
 						validateRegionsUpdateCreateFields("newName", map[string]interface{}{"Name": "newName"})),
@@ -145,20 +143,18 @@ func TestRegions(t *testing.T) {
 					EndpointID:    GetRegionID(t, "region1"),
 					ClientSession: TOSession,
 					RequestOpts:   client.RequestOptions{Header: http.Header{rfc.IfUnmodifiedSince: {currentTimeRFC}}},
-					RequestBody: tc.Region{
-						Name:         "newName",
-						Division:     GetDivisionID(t, "division1")(),
-						DivisionName: "division1",
+					RequestBody: tc.RegionV5{
+						Name:     "newName",
+						Division: GetDivisionID(t, "division1")(),
 					},
 					Expectations: utils.CkRequest(utils.HasError(), utils.HasStatus(http.StatusPreconditionFailed)),
 				},
 				"PRECONDITION FAILED when updating with IFMATCH ETAG Header": {
 					EndpointID:    GetRegionID(t, "region1"),
 					ClientSession: TOSession,
-					RequestBody: tc.Region{
-						Name:         "newName",
-						Division:     GetDivisionID(t, "division1")(),
-						DivisionName: "division1",
+					RequestBody: tc.RegionV5{
+						Name:     "newName",
+						Division: GetDivisionID(t, "division1")(),
 					},
 					RequestOpts:  client.RequestOptions{Header: http.Header{rfc.IfMatch: {rfc.ETag(currentTime)}}},
 					Expectations: utils.CkRequest(utils.HasError(), utils.HasStatus(http.StatusPreconditionFailed)),
@@ -229,7 +225,7 @@ func TestRegions(t *testing.T) {
 func validateRegionsFields(expectedResp map[string]interface{}) utils.CkReqFunc {
 	return func(t *testing.T, _ toclientlib.ReqInf, resp interface{}, _ tc.Alerts, _ error) {
 		assert.RequireNotNil(t, resp, "Expected Regions response to not be nil.")
-		regionResp := resp.([]tc.Region)
+		regionResp := resp.([]tc.RegionV5)
 		for field, expected := range expectedResp {
 			for _, region := range regionResp {
 				switch field {
@@ -264,7 +260,7 @@ func validateRegionsSort() utils.CkReqFunc {
 	return func(t *testing.T, _ toclientlib.ReqInf, resp interface{}, alerts tc.Alerts, _ error) {
 		assert.RequireNotNil(t, resp, "Expected Regions response to not be nil.")
 		var regionNames []string
-		regionResp := resp.([]tc.Region)
+		regionResp := resp.([]tc.RegionV5)
 		for _, region := range regionResp {
 			regionNames = append(regionNames, region.Name)
 		}
@@ -275,7 +271,7 @@ func validateRegionsSort() utils.CkReqFunc {
 func validateRegionsDescSort() utils.CkReqFunc {
 	return func(t *testing.T, _ toclientlib.ReqInf, resp interface{}, alerts tc.Alerts, _ error) {
 		assert.RequireNotNil(t, resp, "Expected Regions response to not be nil.")
-		regionDescResp := resp.([]tc.Region)
+		regionDescResp := resp.([]tc.RegionV5)
 		var descSortedList []string
 		var ascSortedList []string
 		assert.RequireGreaterOrEqual(t, len(regionDescResp), 2, "Need at least 2 Regions in Traffic Ops to test desc sort, found: %d", len(regionDescResp))
@@ -298,7 +294,7 @@ func validateRegionsDescSort() utils.CkReqFunc {
 
 func validateRegionsPagination(paginationParam string) utils.CkReqFunc {
 	return func(t *testing.T, _ toclientlib.ReqInf, resp interface{}, _ tc.Alerts, _ error) {
-		paginationResp := resp.([]tc.Region)
+		paginationResp := resp.([]tc.RegionV5)
 
 		opts := client.NewRequestOptions()
 		opts.QueryParameters.Set("orderby", "id")
diff --git a/traffic_ops/testing/api/v5/servers_test.go b/traffic_ops/testing/api/v5/servers_test.go
index 6e2b24283..5b488e772 100644
--- a/traffic_ops/testing/api/v5/servers_test.go
+++ b/traffic_ops/testing/api/v5/servers_test.go
@@ -39,6 +39,8 @@ func TestServers(t *testing.T) {
 		currentTimeRFC := currentTime.Format(time.RFC1123)
 		tomorrow := currentTime.AddDate(0, 0, 1).Format(time.RFC1123)
 
+		opsUserSession := utils.CreateV5Session(t, Config.TrafficOps.URL, "operations", Config.TrafficOps.UserPassword, Config.Default.Session.TimeoutInSecs)
+
 		methodTests := utils.V5TestCase{
 			"GET": {
 				"NOT MODIFIED when NO CHANGES made": {
@@ -117,6 +119,12 @@ func TestServers(t *testing.T) {
 					RequestOpts:   client.RequestOptions{QueryParameters: url.Values{"dsId": {strconv.Itoa(GetDeliveryServiceId(t, "ds-based-top-with-no-mids")())}}},
 					Expectations:  utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK), utils.ResponseLengthGreaterOrEqual(1), validateServerTypeIsNotMid()),
 				},
+				"VALUE HIDDEN when OPERATIONS USER views SERVER": {
+					ClientSession: opsUserSession,
+					RequestOpts:   client.RequestOptions{QueryParameters: url.Values{"iloPassword": {"testSecure"}, "xmppPasswd": {"testSecure"}}},
+					Expectations: utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK), utils.ResponseLengthGreaterOrEqual(1),
+						validateServerFields(map[string]interface{}{"ILOPassword": "********", "XMPPPasswd": "********"})),
+				},
 				"EMPTY RESPONSE when INVALID DSID parameter": {
 					ClientSession: TOSession,
 					RequestOpts:   client.RequestOptions{QueryParameters: url.Values{"dsId": {"999999"}}},
@@ -438,6 +446,12 @@ func validateServerFields(expectedResp map[string]interface{}) utils.CkReqFunc {
 				case "TypeID":
 					assert.RequireNotNil(t, server.TypeID, "Expected TypeID to not be nil")
 					assert.Equal(t, expected, *server.TypeID, "Expected Type to be %d, but got %d", expected, *server.TypeID)
+				case "XMPPPasswd":
+					assert.RequireNotNil(t, server.XMPPPasswd, "Expected XMPPPasswd to not be nil")
+					assert.Equal(t, expected, *server.XMPPPasswd, "Expected XMPPPasswd to be %s, but got %s", expected, *server.XMPPPasswd)
+				case "ILOPassword":
+					assert.RequireNotNil(t, server.ILOPassword, "Expected ILOPassword to not be nil")
+					assert.Equal(t, expected, *server.ILOPassword, "Expected ILOPassword to be %s, but got %s", expected, *server.ILOPassword)
 				default:
 					t.Errorf("Expected field: %v, does not exist in response", field)
 				}
diff --git a/traffic_ops/testing/api/v5/traffic_control_test.go b/traffic_ops/testing/api/v5/traffic_control_test.go
index 1ed3ad0b0..4263f50fa 100644
--- a/traffic_ops/testing/api/v5/traffic_control_test.go
+++ b/traffic_ops/testing/api/v5/traffic_control_test.go
@@ -43,7 +43,7 @@ type TrafficControl struct {
 	Parameters                                        []tc.Parameter                          `json:"parameters"`
 	ProfileParameters                                 []tc.ProfileParameter                   `json:"profileParameters"`
 	PhysLocations                                     []tc.PhysLocationV5                     `json:"physLocations"`
-	Regions                                           []tc.Region                             `json:"regions"`
+	Regions                                           []tc.RegionV5                           `json:"regions"`
 	Roles                                             []tc.RoleV4                             `json:"roles"`
 	Servers                                           []tc.ServerV4                           `json:"servers"`
 	ServerServerCapabilities                          []tc.ServerServerCapability             `json:"serverServerCapabilities"`
diff --git a/traffic_ops/testing/api_contract/v4/conftest.py b/traffic_ops/testing/api_contract/v4/conftest.py
index 07d1d3eb1..6d64200ee 100644
--- a/traffic_ops/testing/api_contract/v4/conftest.py
+++ b/traffic_ops/testing/api_contract/v4/conftest.py
@@ -1093,10 +1093,15 @@ def server_data_post(to_session: TOSession, request_template_data: list[JSONData
 	type_object = create_or_get_existing(to_session, "types", "type", type_data,
 				      {"useInTable": "server"})
 	type_id = type_object["id"]
-	server["typeId"] = type_id
+	edge_type_id = pytestconfig.cache.get("edgeTypeId", default=None)
 
-	pytestconfig.cache.set("typeId", type_id)
+	if edge_type_id:
+		server["typeId"] = edge_type_id
+	else:
+		server["typeId"] = type_id
 
+	pytestconfig.cache.set("typeId", type_id)
+	
 	server["cachegroupId"]= cache_group_post_data["id"]
 
 	# Check if cdn already exists, otherwise create it
@@ -1648,6 +1653,7 @@ def delivery_services_regex_data_post(to_session: TOSession, request_template_da
 @pytest.fixture(name="cdn_federation_post_data")
 def cdn_federation_data_post(to_session: TOSession, request_template_data: list[JSONData],
 		  cdn_post_data:dict[str, object], user_post_data:dict[str, object],
+		  federation_resolver_post_data:dict[str, object],
 		  delivery_services_post_data: dict[str, object]) -> dict[str, object]:
 	"""
 	PyTest Fixture to create POST data for cdn_name_federations endpoint.
@@ -1680,6 +1686,13 @@ def cdn_federation_data_post(to_session: TOSession, request_template_data: list[
 	response: tuple[JSONData, requests.Response] = to_session.assign_delivery_services_to_federations(federation_id=federation_id, data=delivery_service_federation)
 	delivery_service_federation_resp_obj = check_template_data(response, "delivery_service_federation")
 
+	#Assign a federation resolver to created federation
+	federation_resolver_id = federation_resolver_post_data["id"]
+	federation_federation_resolver = check_template_data(request_template_data["federation_federation_resolver"], "federation_federation_resolver")
+	federation_federation_resolver["fedResolverIds"][0] = federation_resolver_id
+	response: tuple[JSONData, requests.Response] = to_session.assign_federation_resolver_to_federations(federation_id=federation_id, data=federation_federation_resolver)
+	federation_federation_resolver_resp_obj = check_template_data(response, "federation_federation_resolver")
+
 	yield [cdn_name, cdn_federation_resp_obj, cdn_federation, federation_id, delivery_service_federation_resp_obj]
 	
 	msg = to_session.delete_federation_in_cdn(cdn_name=cdn_name, federation_id=federation_id)
@@ -1794,6 +1807,37 @@ def profile_parameters_post_data(to_session: TOSession, request_template_data: l
 		pytest.fail("Response from delete request is empty, Failing test_case")
 
 
+@pytest.fixture(name="service_category_post_data")
+def service_category_data_post(to_session: TOSession,
+		request_template_data: list[JSONData]) -> dict[str, object]:
+	"""
+	PyTest Fixture to create POST data for service_category endpoint.
+	:param to_session: Fixture to get Traffic Ops session.
+	:param request_template_data: Fixture to get service_category request template.
+	:returns: Sample POST data and the actual API response.
+	"""
+
+	service_category = check_template_data(
+		request_template_data["service_category"], "service_category")
+
+	# Return new post data and post response from service_category POST request
+	service_category_name = service_category["name"]
+	service_category["name"] = service_category_name + str(randint(0,1000))
+
+	logger.info("New service_category data to hit POST method %s", service_category)
+	# Hitting service_category POST methed
+	response: tuple[JSONData, requests.Response] = to_session.create_service_category(
+		data=service_category)
+	resp_obj = check_template_data(response, "service_category")
+	yield resp_obj
+	service_category_name = resp_obj.get("name")
+	msg = to_session.delete_service_category(service_category_name=service_category_name)
+	logger.info("Deleting service_category data... %s", msg)
+	if msg is None:
+		logger.error("service_category returned by Traffic Ops is missing an 'name' property")
+		pytest.fail("Response from delete request is empty, Failing test_case")
+
+
 @pytest.fixture(name="federation_resolver_post_data")
 def federation_resolver_data_post(to_session: TOSession, request_template_data: list[JSONData]
 				  ) -> dict[str, object]:
@@ -1812,8 +1856,8 @@ def federation_resolver_data_post(to_session: TOSession, request_template_data:
 	type_object = create_or_get_existing(to_session, "types", "type", type_data,
 				      {"useInTable": "federation"})
 	federation_resolver["typeId"] = type_object["id"]
-	ipaddress = federation_resolver["ipAddress"]
-	federation_resolver["ipAddress"] = ipaddress + randstr
+	federation_resolver["ipAddress"] = ".".join(map(str, (randint(0, 255) 
+                        for _ in range(4))))
 
 	logger.info("New federation_resolver data to hit POST method %s", federation_resolver)
 	# Hitting federation_resolver POST methed
@@ -1859,3 +1903,45 @@ def static_dns_entries_data_post(to_session: TOSession, request_template_data: l
 	if msg is None:
 		logger.error("static_dns_entries returned by Traffic Ops is missing an 'id' property")
 		pytest.fail("Response from delete request is empty, Failing test_case")
+
+@pytest.fixture(name="edge_type_data")
+def edge_data_type(pytestconfig: pytest.Config, request_template_data: list[JSONData], to_session: TOSession):
+	type_data = check_template_data(request_template_data["types"], "types")
+	type_object = create_or_get_existing(to_session, "types", "type", type_data,
+				      {"name":"EDGE" , "useInTable":"server"})
+	type_id = type_object["id"]
+	pytestconfig.cache.set("edgeTypeId", type_id)
+
+@pytest.fixture(name="server_server_capabilities_post_data")
+def server_server_capabilities_data_post(to_session: TOSession, edge_type_data:None, request_template_data: list[JSONData],
+		server_post_data:dict[str, object], server_capabilities_post_data:dict[str, object], pytestconfig: pytest.Config
+		) -> dict[str, object]:
+	"""
+	PyTest Fixture to create POST data for server server capabilities endpoint.
+	:param to_session: Fixture to get Traffic Ops session.
+	:param request_template_data: Fixture to get Server Server Capabilities request template from a prerequisites file.
+	:returns: Sample POST data and the actual API response.
+	"""
+
+	server_server_capabilities = check_template_data(request_template_data["server_server_capabilities"], "server_server_capabilities")
+
+	# Return new post data and post response from server server capabilities POST request
+
+	server_id = server_post_data.get("id")
+	serverCapability = server_capabilities_post_data.get("name")
+	server_server_capabilities["serverId"] = server_id
+	server_server_capabilities["serverCapability"] = serverCapability
+
+
+	logger.info("New server_server_capabilities data to hit POST method %s", server_server_capabilities)
+
+	# Hitting server server capabilities POST method
+	response: tuple[JSONData, requests.Response] = to_session.associate_server_capability_to_server(server_id=server_id, data=server_server_capabilities)
+	resp_obj = check_template_data(response, "server_server_capabilities")
+	yield resp_obj
+	server_id = resp_obj.get("serverId")
+	msg = to_session.delete_server_capability_association_to_server(query_params={"serverId":server_id, "serverCapability":serverCapability})
+	logger.info("Deleting Server Server Capability data... %s", msg)
+	if msg is None:
+		logger.error("Server Server Capability returned by Traffic Ops is missing a 'server_id' property")
+		pytest.fail("Response from delete request is empty, Failing test_case")
diff --git a/traffic_ops/testing/api_contract/v4/data/request_template.json b/traffic_ops/testing/api_contract/v4/data/request_template.json
index ba1364518..25a838ecf 100644
--- a/traffic_ops/testing/api_contract/v4/data/request_template.json
+++ b/traffic_ops/testing/api_contract/v4/data/request_template.json
@@ -419,6 +419,12 @@
 			"replace": true
 		}
 	],
+	"federation_federation_resolver":[
+		{
+			"fedResolverIds": [39],
+			"replace": true
+		}
+	],
 	"delivery_service_required_capabilities": [
 		{
 			"deliveryServiceID": 1,
@@ -441,6 +447,11 @@
 			"parameterId": 3
 		}
 	],
+	"service_category": [
+		{
+			"name": "SERVICE-CATEGORY-NAME"
+      }
+	],
 	"federation_resolver": [
 		{
 			"ipAddress": "::1/1",
@@ -455,5 +466,11 @@
 			"ttl": 300,
 			"typeId": 41
 		}
-	]
+	],
+  "server_server_capabilities":[
+		{
+			"serverId": 1,
+			"serverCapability": "disk"
+    	}
+  	]
 }
diff --git a/traffic_ops/testing/api_contract/v4/data/response_template.json b/traffic_ops/testing/api_contract/v4/data/response_template.json
index 439dbca29..73d93492b 100644
--- a/traffic_ops/testing/api_contract/v4/data/response_template.json
+++ b/traffic_ops/testing/api_contract/v4/data/response_template.json
@@ -1875,6 +1875,21 @@
             }
         }
     },
+    "service_category": {
+        "type": "object",
+        "required": [
+            "lastUpdated",
+            "name"
+        ],
+        "properties": {
+            "lastUpdated": {
+                "type": "string"
+            },
+            "name": {
+                "type": "string"
+            }
+        }
+    },
     "federation_resolver": {
         "type": "object",
         "required": [
@@ -1898,6 +1913,25 @@
             }
         }
     },
+    "federation_federation_resolver": {
+        "type": "object",
+        "required": [
+            "id",
+            "ipAddress",
+            "type"
+        ],
+        "properties": {
+            "id": {
+                "type": "integer"
+            },
+            "ipAddress": {
+                "type": "string"
+            },
+            "type": {
+                "type": "string"
+              }
+          }
+    },
     "delivery_service_capacity": {
         "type": "object",
         "required": [
@@ -2065,5 +2099,28 @@
                 "type": "integer"
             }
         }
+    },
+    "server_server_capabilities":{
+        "type": "object",
+        "required": [
+            "lastUpdated",
+            "serverHostName",
+            "serverId",
+            "serverCapability"
+        ],
+        "properties": {
+            "lastUpdated": {
+                "type": "string"
+            },
+            "serverHostName": {
+                "type": "string"
+            },
+            "serverId": {
+                "type": "integer"
+            },
+            "serverCapability": {
+                "type": "string"
+            }
+        }
     }
 }
diff --git a/traffic_ops/testing/api_contract/v4/test_cdn_federation.py b/traffic_ops/testing/api_contract/v4/test_cdn_federation.py
index 4aa423794..07413a415 100644
--- a/traffic_ops/testing/api_contract/v4/test_cdn_federation.py
+++ b/traffic_ops/testing/api_contract/v4/test_cdn_federation.py
@@ -64,6 +64,11 @@ def test_cdn_federation_contract(to_session: TOSession,
 		Union[dict[str, object], list[Union[dict[str, object], list[object], Primitive]], Primitive],
 		requests.Response
 	] = to_session.get_federation_delivery_services(federation_id=federation_id)
+
+	federation_resolvers_get_response: tuple[
+		Union[dict[str, object], list[Union[dict[str, object], list[object], Primitive]], Primitive],
+		requests.Response
+	] = to_session.get_federation_resolvers_by_id(federation_id=federation_id)
 	
     # Hitting cdn_federation PUT method
 	cdn_federation_sample_data["description"] = "test" + str(randint(0, 1000)) 
@@ -96,6 +101,12 @@ def test_cdn_federation_contract(to_session: TOSession,
 		if not isinstance(cdn_federation_put_data, dict):
 			raise TypeError(
 				"malformed API response; first cdn_federation_put in response is not an dict")
+		
+		federation_federation_resolver= federation_resolvers_get_response[0]
+		if not isinstance(federation_federation_resolver, list):
+			raise TypeError(
+				"malformed API response; first federation_federation_resolver in response is not an dict")
+		first_federation_federation_resolver = federation_federation_resolver[0]
 
 		cdn_federation_response_template = response_template_data.get(
 			"cdn_federation")
@@ -114,6 +125,11 @@ def test_cdn_federation_contract(to_session: TOSession,
 		if not isinstance(delivery_service_federation_response_template, dict):
 			raise TypeError(f"delivery_service_federation response template data must be a dict, not '"
 							f"{type(user_federation_response_template)}'")
+		
+		federation_federation_resolver_response_template = response_template_data.get("federation_federation_resolver")
+		if not isinstance(federation_federation_resolver_response_template, dict):
+			raise TypeError(f"delivery_service_federation response template data must be a dict, not '"
+							f"{type(user_federation_response_template)}'")
 
 		keys = ["cname", "ttl"]
 		prereq_values = [cdn_federation_data_post[key] for key in keys]
@@ -123,6 +139,7 @@ def test_cdn_federation_contract(to_session: TOSession,
 		assert validate(instance=first_user_federation, schema=user_federation_response_template) is None
 		assert validate(instance=first_delivery_service_federation, schema=delivery_service_federation_response_template) is None
 		assert validate(instance=cdn_federation_put_data, schema=cdn_federation_response_template) is None
+		assert validate(instance=first_federation_federation_resolver, schema=federation_federation_resolver_response_template) is None
 		
 		assert get_values == prereq_values
 	except IndexError:
diff --git a/traffic_ops/testing/api_contract/v4/test_server_server_capabilities.py b/traffic_ops/testing/api_contract/v4/test_server_server_capabilities.py
new file mode 100644
index 000000000..898111492
--- /dev/null
+++ b/traffic_ops/testing/api_contract/v4/test_server_server_capabilities.py
@@ -0,0 +1,79 @@
+#
+# 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.
+#
+
+"""API Contract Test Case for Server Server Capabilities endpoint."""
+import logging
+from typing import Union
+
+import pytest
+import requests
+from jsonschema import validate
+
+from trafficops.tosession import TOSession
+
+# Create and configure logger
+logger = logging.getLogger()
+
+Primitive = Union[bool, int, float, str, None]
+
+
+def test_server_server_capabilities_contract(
+	to_session: TOSession,
+	response_template_data: dict[str, Union[Primitive, list[Union[Primitive, dict[str, object],
+	list[object]]], dict[object, object]]], server_server_capabilities_post_data: dict[str, object]) -> None:
+	"""
+	Test step to validate keys, values and data types from server server capabilities endpoint
+	response.
+	:param to_session: Fixture to get Traffic Ops session.
+	:param response_template_data: Fixture to get response template data from a prerequisites file.
+	:param server_server_capabilities_post_data: Fixture to get sample server server capabilities data 
+	and actual Server Server Capabilities response.
+	"""
+	# validate Server Server Capabilities keys from server server capabilities get response
+	logger.info(
+		"Accessing /server server capabilities endpoint through Traffic ops session.")
+
+	serverCapability = server_server_capabilities_post_data.get("serverCapability")
+	if not isinstance(serverCapability, str):
+		raise TypeError(
+			"malformed server server capabilities in prerequisite data; 'serverId' not an integer")
+
+	server_server_capabilities_get_response: tuple[Union[dict[str, object], list[Union[dict[str, object], list[object], Primitive]], Primitive], requests.Response
+	] = to_session.get_server_server_capabilities(query_params={"serverCapability": serverCapability})
+	try:
+		server_server_capabilities_data = server_server_capabilities_get_response[0]
+		if not isinstance(server_server_capabilities_data, list):
+			raise TypeError("malformed API response; 'response' property not an array")
+
+		first_server_server_capability = server_server_capabilities_data[0]
+		if not isinstance(first_server_server_capability, dict):
+			raise TypeError(
+				"malformed API response; first Server Server Capability in response is not an object")
+		logger.info("Server Server Capabilities Api get response %s", first_server_server_capability)
+		server_server_capabilities_response_template = response_template_data.get("server_server_capabilities")
+		if not isinstance(server_server_capabilities_response_template, dict):
+			raise TypeError(
+				f"Server Server Capability response template data must be a dict, not '{type(server_server_capabilities_response_template)}'")
+
+		# validate server server capabilities values from prereq data in server server capabilities get response.
+		keys = ["serverId","serverCapability"]
+		prereq_values = [server_server_capabilities_post_data[key] for key in keys]
+		get_values = [first_server_server_capability[key] for key in keys]
+
+		assert validate(instance=first_server_server_capability,
+						schema=server_server_capabilities_response_template) is None
+		assert get_values == prereq_values
+	except IndexError:
+		logger.error("Either prerequisite data or API response was malformed")
+		pytest.fail("API contract test failed for server_server_capabilities endpoint: API response was malformed")
diff --git a/traffic_ops/testing/api_contract/v4/test_service_categories.py b/traffic_ops/testing/api_contract/v4/test_service_categories.py
new file mode 100644
index 000000000..6e7679ac9
--- /dev/null
+++ b/traffic_ops/testing/api_contract/v4/test_service_categories.py
@@ -0,0 +1,72 @@
+#
+# 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.
+#
+
+"""API Contract Test Case for service_categories endpoint."""
+import logging
+from typing import Union
+import pytest
+import requests
+from jsonschema import validate
+
+from trafficops.tosession import TOSession
+
+# Create and configure logger
+logger = logging.getLogger()
+
+Primitive = Union[bool, int, float, str, None]
+
+
+def test_service_categories_contract(to_session: TOSession,
+	response_template_data: dict[str, Union[Primitive, list[Union[Primitive,
+							dict[str, object], list[object]]], dict[object, object]]],
+	service_category_post_data: dict[str, object]
+) -> None:
+	"""
+	Test step to validate keys, values and data types from service_category endpoint
+	response.
+	:param to_session: Fixture to get Traffic Ops session.
+	:param response_template_data: Fixture to get response template data from a prerequisites file.
+	:param service_category_post_data: Fixture to get service categories data.
+	"""
+	# validate service_category keys from api get response
+	logger.info("Accessing /service_category endpoint through Traffic ops session.")
+
+	service_category_name = service_category_post_data["name"]
+	if not isinstance(service_category_name, str):
+		raise TypeError("malformed API response; 'service_category_name' property not a string")
+
+	service_category_get_response: tuple[
+		Union[dict[str, object], list[Union[dict[str, object], list[object], Primitive]], Primitive],
+		requests.Response
+	] = to_session.get_service_categories(query_params={"name":service_category_name})
+    
+	try:
+		service_category = service_category_get_response[0]
+		if not isinstance(service_category, list):
+			raise TypeError(
+				"malformed API response; first service_category in response is not an array")
+		first_service_category = service_category[0]
+
+		service_category_response_template = response_template_data.get(
+			"service_category")
+		if not isinstance(service_category_response_template, dict):
+			raise TypeError(f"service_category response template data must be a dict, not'"
+							f"{type(service_category_response_template)}'")
+
+		assert first_service_category["name"] == service_category_post_data["name"]
+		assert validate(instance=first_service_category, schema=service_category_response_template) is None
+		
+	except IndexError:
+		logger.error("Either prerequisite data or API response was malformed")
+		pytest.fail("API contract test failed for service_category endpoint: API response was malformed")
diff --git a/traffic_ops/traffic_ops_golang/server/servers_test.go b/traffic_ops/traffic_ops_golang/server/servers_test.go
index bc15429d9..56199998b 100644
--- a/traffic_ops/traffic_ops_golang/server/servers_test.go
+++ b/traffic_ops/traffic_ops_golang/server/servers_test.go
@@ -265,7 +265,7 @@ func TestGetServersByCachegroup(t *testing.T) {
 
 	version := api.Version{Major: 4, Minor: 0}
 
-	servers, _, userErr, sysErr, errCode, _ := getServers(nil, v, db.MustBegin(), &user, false, version)
+	servers, _, userErr, sysErr, errCode, _ := getServers(nil, v, db.MustBegin(), &user, false, version, false)
 	if userErr != nil || sysErr != nil {
 		t.Errorf("getServers expected: no errors, actual: %v %v with status: %s", userErr, sysErr, http.StatusText(errCode))
 	}
@@ -380,7 +380,7 @@ func TestGetMidServers(t *testing.T) {
 
 	user := auth.CurrentUser{}
 	version := api.Version{Major: 4, Minor: 0}
-	servers, _, userErr, sysErr, errCode, _ := getServers(nil, v, db.MustBegin(), &user, false, version)
+	servers, _, userErr, sysErr, errCode, _ := getServers(nil, v, db.MustBegin(), &user, false, version, false)
 
 	if userErr != nil || sysErr != nil {
 		t.Errorf("getServers expected: no errors, actual: %v %v with status: %s", userErr, sysErr, http.StatusText(errCode))