Error Log Details

I’ll continue on from yesterday designing the front-end interface to the PHP error log database. I had stylized a grid listing all of the errors to make it easier to review the information. Today’s end-goal is to view the details of a specific error.

Error Log Details Prompt: Microsoft Image Creator

A programmer is viewing a dialog providing intricate details about an error. They can see the time, how often the error occurred, as well as the type of error. The error message is displayed along with a stack trace. A chart demonstrates how often the error occurs. An icon demonstrates what kind of error it it.

SAR Chapter Meeting

I had just gotten back from the SAR chapter meeting. I signed an application and wrote out four checks. The meeting was interesting. Lot’s of focus on the Color Guard and various events. Apparently the chapter tops all 500+ chapters in the country with the number of events that they attend to supporting veterans. In addition, there were plenty of awards and medals handed out. People start getting awards after being a member for five years. There were a few educational talks. One was in regards to the formation of our constitution and the seventh amendment. Another talk covered George Washington as a boy. And last, I learned about the man that Warren County was named after, and the circumstances of when he was shot, and by whom.

Revisiting Hashed Text Image

I noticed on the third page of logs, the hash icons for common/Secrets.php and errors/view.php looked too similar. I needed more entropy. I reduced the number of unique hues by increasing the step count from 4 to 15. This reduced the number of unique colors from 90 to 25 colors, and makes them more distinguishable.

25 colors
90 colors

Looking at the gradient, some of the colors are still hard to distinguish between their sibling colors. Reducing down to 12 colors can give better contrast. Although colors 3, 4, and 5 are still closely aligned, as well as colors 0 and 11. Reducing further down to 6 colors gives us a good range of contrasting hues.

12 colors
6 colors

So we’ve got a unique color palette. What do our matrix values look like now?

Secrets and view are different
Secrets and database are similar

We have the same problem. All of this color reduction isn’t helping. I’m increasing my collision rates as I reduce the colors. This is because each value ranges from 0 to 5 instead of 0 to 90. In addition, I think my sum of ASCII values is making the final values too similar. If I was able to apply a SHA-256 hash, I could get more entropy, and I get the feeling that the number of unique colors wouldn’t matter so much.

Okay, let’s stop this nonsense. We need a hash. Since the service is asynchronous, maybe we can map the data to include a hash value. Bingo!

Map http get results to asynchronously hash values
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { map } from 'rxjs/operators';

interface PaginatedResponse<T> {
  data: T[];
  total: number;
}

interface LogResult {
  id: number,
  scope: string,
  last_at: number,
  type: string,
  message: string,
  path: string,
  line: number,
  count: number,
  message_hash?: string,
  path_hash?: string,
  scope_hash?: string
}

@Injectable({
  providedIn: 'root'
})
export class LogsService {
  baseUrl = 'https://dev-api.periplux.io/errors/logs';

  constructor(private http: HttpClient) { }

  getPage(pageNumber: number, pageSize: number) {
    let params = new HttpParams()
      .set('page', pageNumber.toString())
      .set('size', pageSize.toString());
    return this.http.get<PaginatedResponse<LogResult>>(this.baseUrl, { params })
      .pipe(
        map(data => {
          data.data.forEach(async item => {
            item.message_hash = await this.hash(item.message);
            item.scope_hash = await this.hash(item.scope);
            item.path_hash = await this.hash(item.path);
          })
          return data;
        }));
  }
  async hash(text: string): Promise<string> {
    const data = new TextEncoder().encode(text);
    const buffer: ArrayBuffer = await crypto.subtle.digest({ name: 'SHA-256' }, data);
    const hex = Array.from(new Uint8Array(buffer)).map(b => b.toString(16).padStart(2, '0')).join('');
    return hex;
  }
}
Generate Matrix Image from Hash
generateMatrixImage(
    hash?: string,
    horizontalCells: number = 4,
    verticalCells: number = 4,
    border: boolean = true
  ) {
    if (hash === undefined) return "";

    if (hash in this.hashImages) {
      return this.hashImages[hash];
    }

    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    if (ctx === null) return '';
    // No less than 16x16 image
    // ensure each cell is at least 4 pixels wide/high
    canvas.width = Math.max(16, horizontalCells * 4);
    canvas.height = Math.max(16, verticalCells * 4);

    const cellWidth = canvas.width / horizontalCells;
    const cellHeight = canvas.height / verticalCells;

    let i = 0;
    const hexSize = 3; // 0 to 4095
    const hues = 360;
    const step = 360 / hues;
    for (let y = 0; y < verticalCells; y++) {
      for (let x = 0; x < horizontalCells; x++) {
        const value = parseInt(hash.substring(i, i + hexSize), 16);
        const hue = Math.floor((value % hues) * step);
        ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;
        ctx.fillRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight);
        i += hexSize;
        if ((i + hexSize) >= hash.length) {
          i = (i + 1) % hash.length;
        }
      }
    }
    if (border) {
      ctx.lineWidth = .25;
      ctx.strokeStyle = 'black';
      for (let y = 0; y <= verticalCells; y++) {
        ctx.beginPath();
        ctx.moveTo(0, y * cellHeight);
        ctx.lineTo(canvas.width, y * cellHeight);
        ctx.stroke();
      }
      for (let x = 0; x <= horizontalCells; x++) {
        ctx.beginPath();
        ctx.moveTo(x * cellWidth, 0);
        ctx.lineTo(x * cellWidth, canvas.height);
        ctx.stroke();
      }
    }
    const url = canvas.toDataURL();
    this.hashImages[hash] = url;
    return url;
  }

Our hashed text images now have quite a bit of entropy that allow them to be fairly distinct compared to each other visually. Using the hash, I’m parsing the hex characters into integers. Rather than reading two characters at a time to get a byte of 0 to 255, I’m reading three nibbles to get 12 bit numbers (0 to 4095) so that I can use all 360 hues with a modulos 360 on the parsed value. Using all 360 hues doesn’t have much influence over the chaos. The hash introduces enough randomness that cells appear to be scattered with random colors.

Taking a brake to sleep

I’m currently running on two hours of sleep. In last nights dream, I dreamt that I became blind due to being tired. I slowed down and parked the car in a ditch until I could see again. I saw an officers car next to me waiting at the light. I was able to get back on the road, but pulled over once I saw his lights turn on. Fortunately he was pulling someone else over. Just a weird dream. I’m running on fumes at the moment and a couple cups of coffee. The effects of coffee is wearing down. Although I’m alert, I’m still feeling the effects of sleepiness. Typing is a bit difficult as I’m skipping letters a lot. Let’s try and get some more rest before we carry on with the form.

I awoke at 7pm. Sleep was desperately needed. I was unable to find my phone. It started to rain as I let the dogs play out in the yard. I found my phone sitting in the hot car. I must have been more tired than I realized. Thankfully it still works. Shortly after coming back inside, we heard the ice cream truck and chased it down. The dogs got giant dog treats and did a bunch of tricks for the community while getting little bits of ice cream as a reward. Anyhow, back to work.

Weekday Colors

I noticed that the colors for Friday and Saturday are fairly close together. Initially, I just picked the named colors of the rainbow of red, orange, yellow, green, blue, indigo, and violet. Since I was able to equally distribute the hues for the hash images earlier, let’s do the same for weekday colors.

Rainbow
One Based
Rainbow
Zero Based
Equal Hues
Manually Adjusted

I had assumed that the weekday values were one-based and found that Sundays didn’t have a color. I changed the values accordingly. I then played around with generating seven distinct colors by evenly spacing out the hue values. I kept running into trouble with some of the colors (greens and reds) being too close together. Sure, I could tell them apart, but I wanted their perceived contrast with sibling colors to be much easier to distinguish.

Is my color vision fading? Usually it’s the blue values that are too close together. I took a quick color blind test. Nope. 100%. Although the hue is evenly spaced, I think I’m running into a problem with the perception of color with human vision in general. Mathematically the hues are evenly spaced – but physically, humans are not equipped with an evenly distributed number of cones to discern the difference of blues as we are able to differentiate between reds and greens.

I started experimenting to alternating luminance since we have an overwhelming majority of rods that detect brightness. Better, but bland. I went back to the original named colors of the rainbow that I had. The hues were mostly fine. I decided to just manually adjust the luminance and saturation one by one. In the end, I pulled out light brown/orange and settled for colors that I would describe as being red, yellow, lime, green, light blue, blue, and pink.

Enough with the colors

Yes, yes. We need to move onto the actual form to display the error details. Let’s wire up the behavior to click on an error. Let’s start with highlighting the row as we hover over it. Currently, we have alternating colors on the rows background color on tr:nth-child(odd|event). This is going to override background colors assigned to the row since nth child is more explicit. Instead, let’s move the alternating background colors to the table cells and introduce transparency so that the table row background color can appear behind the table cells background color. In addition, our path truncation colors need to be updated to appear similar to the other cells in the highlighted row.

Sassy CSS for highlighting hovered table row
tbody tr {

  &:nth-child(odd) td {
    background-color: rgba(0, 0, 0, 4%);
  }

  &:nth-child(even) td {
    background-color: rgba(0, 0, 0, 13%);
  }

  .path {
    width: 200px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    direction: rtl;
    position: relative;
    text-align: right;
    color: grey;

    &::after {
      content: attr(data-file);
      color: black;
      position: absolute;
      right: 0;
    }
  }

  &:hover {
    cursor: default;
    background-color: rgb(149, 197, 255);
    color: white;

    .path {
      color: rgb(33, 133, 255);

      &::after {
        color: white;
      }
    }
  }
}

And now for the (click). I decided to use @angular/material/dialog. My first attempt got a bunch of log components to display below the table.

Can we pass the error log data to the dialog?

Can we limit the dialog to only display one at a time? Yes, but we need a button to close the dialog before we can open another.

Can the dialog overlay the table?

Display formatting and hashed text images?

Display first at, age, and duration?

Duration Pipe
import { Pipe, PipeTransform } from '@angular/core';
import { Duration } from 'luxon';

@Pipe({
  name: 'duration',
  standalone: true
})
export class DurationPipe implements PipeTransform {
  transform(ms: number) {
    if (ms === 0) return "Instant";
    return Duration.fromMillis(ms).rescale().toHuman();
  }
}
Age Pipe
import { Pipe, PipeTransform } from '@angular/core';
import { Duration, DurationObjectUnits } from 'luxon';

type unitType = keyof DurationObjectUnits;
const unitTypes: unitType[] = [
  'years',
  'quarters',
  'months',
  'weeks',
  'days',
  'hours',
  'minutes',
  'seconds',
  'milliseconds'
];

@Pipe({
  name: 'age',
  standalone: true
})
export class AgePipe implements PipeTransform {
  transform(ms: number) {
    const now = new Date().valueOf();
    if (ms === now) return "Now";
    const diff = Duration.fromMillis(now - ms).rescale().toObject();
    let first = -1;
    unitTypes.forEach((unit, i) => {
      if (diff[unit] && diff[unit] !== 0) {
        if (first === -1) {
          first = i;
        } else if (i > first + 2) {
          delete diff[unit];
        }
      }
    });

    return Duration.fromDurationLike(diff).toHuman();
  }
}

Display the dates

That just looks like noise. Can we graph that over time to see how time relates? Maybe we can see spikes in frequency or a consistent pattern.

I need three or more dates in order to have some kind of relativity. For two dates, I would just be drawing a line with two dots at either end. For one date, the question is – where would I draw the dot, and would I bother drawing a line? And for no dates… well, for starters, that shouldn’t be possible – but the database relationships do not guarantee the log will have a date.

What if there are many occurrences within the same time span? Can we make the dots bigger?

Graph Dates
import { LogDateData } from "./LogDateData";

const minDateReducer = (min: number, { first_at }: LogDateData) => Math.min(first_at, min);
const maxDateReducer = (max: number, { last_at }: LogDateData) => Math.max(last_at, max);
const minCountReducer = (min: number, { count }: LogDateData) => Math.min(count, min);
const maxCountReducer = (max: number, { count }: LogDateData) => Math.max(count, max);

export const graphDates = (data: LogDateData[]) => {
  if (data.length < 3) return "";

  const first = data[0];

  const minTime = data.reduce(minDateReducer, first.first_at);
  const maxTime = data.reduce(maxDateReducer, first.last_at);
  const timeRange = maxTime - minTime;

  let maxCount = data.reduce(maxCountReducer, first.count);
  let minCount = data.reduce(minCountReducer, first.count);
  let countRange = maxCount - minCount;

  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  if (ctx === null) return "";
  const width = 400;
  const gap = 10;
  const height = gap * 2;
  canvas.width = width;
  canvas.height = height;

  const getX = (date: number) => gap + (((date - minTime) / timeRange) * (width - gap * 2));
  const getLineWidth = (count: number) => {
    const minSize = gap / 6;
    const maxSize = gap / 2;
    const avgSize = (minSize + maxSize) / 2;
    const sizeRange = maxSize - minSize;
    if (countRange === 0) return avgSize;
    const scale = (count - minCount) / countRange;
    return (minSize + (sizeRange * scale));
  };
  ctx.strokeStyle = "lightblue";
  ctx.lineWidth = 1;

  ctx.beginPath();
  ctx.moveTo(getX(minTime), gap);
  ctx.lineTo(getX(maxTime), gap);
  ctx.stroke();

  ctx.strokeStyle = "red";
  ctx.fillStyle = "red";
  data.forEach(({
    first_at,
    last_at,
    count
  }) => {
    const x1 = getX(first_at);
    const x2 = getX(last_at);
    ctx.lineWidth = getLineWidth(count);

    if (x1 === x2) {
      ctx.beginPath();
      ctx.arc(x1, gap, ctx.lineWidth, 0, Math.PI * 2, true);
      ctx.fill();
    } else {
      ctx.beginPath();
      ctx.moveTo(x1, gap);
      ctx.lineTo(x2, gap);
      ctx.stroke();
    }
  });

  return canvas.toDataURL();
}

Let’s display the log details such as the stack trace.

Our line breaks, tabs, and spaces are not being rendered properly. Let’s change the white space to pre-wrap.

Let’s drop the page navigation if we don’t have any content, or no more than one page of data.

I think that about wraps everything up. Let’s start to see if there are better UI components to interact with. First is pagination. We have a material paginator component that we can wire up.

Nice. I no longer have to modify the code in order to change the page size.

I changed the dates around. Rather than showing two separate dates, I changed the second one to be the duration. Most of the time the two dates are duplicates. Rather than comparing numbers, I can simply see that all occurrences happened at the same time, or within a short period of time.

I’m finding that the grid keeps going back to page one every time I make a change to a file. I implemented the router to remember what page I was viewing, and the number of records per page.

I ran into a bit of a hiccup since the component was loading before the router was able to tell it what page the user was viewing. Initially I subscribed to the activated route, but the parameters were always empty when first loading the page. Instead, I watch for the NavigationEnd event of the router and make my changes accordingly.

Log Pagination with Query String parameters
import { Component, OnInit, ViewChild } from '@angular/core';
import { NgFor, CommonModule } from '@angular/common';
import { MatDialogModule, MatDialog, MatDialogConfig } from '@angular/material/dialog';
import { MatButtonModule } from "@angular/material/button";
import { MatPaginatorModule, MatPaginator, PageEvent } from '@angular/material/paginator';
import { ActivatedRoute, Router, NavigationEnd } from '@angular/router';

import { LogsService } from './logs.service';
import { LogComponent } from './log.component';
import { LogData } from './LogData';
import { generateMatrixImage } from './generateMatrixImage';
import { errorTypeAsEmoji } from './errorTypeAsEmoji';

const defaultPageSize = 25;

@Component({
  selector: 'app-logs',
  templateUrl: './logs.component.html',
  styleUrls: ['./logs.component.scss'],
  imports: [
    MatPaginatorModule,
    NgFor,
    CommonModule,
    MatDialogModule,
    LogComponent,
    MatButtonModule
  ],
  standalone: true
})
export class LogsComponent implements OnInit {
  @ViewChild('paginator') paginator!: MatPaginator
  pageSizeOptions = [5, 10, 25, 50, 100];
  totalItems: number = 0;
  pageSize: number = defaultPageSize;
  pageIndex: number = -1;
  data: LogData[] = [];
  isDialogOpen: boolean = false;
  errorTypeAsEmoji = errorTypeAsEmoji;
  generateMatrixImage = generateMatrixImage;

  constructor(
    private logsService: LogsService,
    public dialog: MatDialog,
    private activatedRoute: ActivatedRoute,
    private router: Router
  ) {
  }

  ngOnInit() {
    this.router.events.subscribe(event => {
      if (event instanceof NavigationEnd) {
        const page = this.activatedRoute.snapshot.queryParamMap.get('page');
        const size = this.activatedRoute.snapshot.queryParamMap.get('size');
        let pageIndex = page ? parseInt(page, 10) - 1 : 0;
        if (pageIndex < 0) pageIndex = 0;
        let pageSize = size ? parseInt(size, 10) : defaultPageSize;
        if (!this.pageSizeOptions.includes(pageSize)) {
          pageSize = defaultPageSize;
        }
        this.loadData(pageIndex, pageSize);
      }
    });
  }

  loadData(pageIndex: number, pageSize: number) {
    if (pageIndex === this.pageIndex && pageSize === this.pageSize) {
      return;
    }
    this.logsService.getPage(pageIndex + 1, pageSize)
      .subscribe(response => {
        this.pageSize = pageSize;
        this.pageIndex = pageIndex;
        this.data = response.data;
        this.totalItems = response.total;
        this.router.navigate([''], {
          queryParams: {
            page: pageIndex + 1,
            size: pageSize
          }
        })
      });
  }
  handlePageEvent(event: PageEvent) {
    this.loadData(event.pageIndex, event.pageSize);
  }
  onRowClick(item: LogData) {
    if (this.isDialogOpen) return;
    this.isDialogOpen = true;
    const config = new MatDialogConfig<LogData>();
    config.data = item;
    config.disableClose = false;
    config.hasBackdrop = true;

    const dialogRef = this.dialog.open(LogComponent, config);
    dialogRef.afterClosed().subscribe(dialogResult => {
      this.isDialogOpen = false;
    });
  }
  parseFile(path: string) {
    const i = path.lastIndexOf('/');
    if (i === -1) return '';
    return path.substring(i + 1);
  }
}

export class LogsModule { };

Error Log File

For the past two days, it seems like nothing is being logged to the error log any longer since I wired up an error and exception handler.

Wrap Up

  • Continued learning Angular 2
  • Improved color contrast for weekday markers
  • Highlighted table row on hover
  • Implement SHA-256 hashing with Web Crypto API
  • Open a material dialog to display a log entry
  • Display details / stack traces associated with the log
  • Display dates associated with the log
  • Graph out dates on a timeline and show larger markers if more errors were reported for the given date compared to others
  • Create a few pipes
    • Duration – show humanized description of time
    • Age – humanize the top-two unit sizes
  • Change pagination controls to use material paginator
  • Hide pagination controls if no more than one page is available
  • Created an environment file for development vs production settings
  • Wired up a route and used QueryString parameters to jump to a specific page

Tomorrow I’d like to setup a subdomain and deploy the app over to errors.periplux.io. At that point, we can move onto the next thing.

One response to “Error Log Details”

Discover more from Lewis Moten

Subscribe now to keep reading and get access to the full archive.

Continue reading