Refactor codebase to improve output flexibility

This commit is contained in:
Clayton Wilson 2024-03-12 01:06:33 -05:00
parent d4e976c6c4
commit abb548f5ec
7 changed files with 413 additions and 413 deletions

View File

@ -1,287 +1,63 @@
import {
LogLabel,
LogLevel,
StrictLogConfig,
StrictLogOutputs,
} from "./types/logger";
import { gzip, randomString, stringifyInstance, ungzip } from "./lib/utilities";
import { LogContext, BucketInfo, LogMeta, LogConfig } from "./types/logger";
const MESSAGE_STYLE = "background: inherit; color: inherit;";
function getLabel(level: number) {
if (level <= LogLevel.TRACE) {
return LogLabel.TRACE;
} else if (level <= LogLevel.DEBUG) {
return LogLabel.DEBUG;
} else if (level <= LogLevel.INFO) {
return LogLabel.INFO;
} else if (level <= LogLevel.WARN) {
return LogLabel.WARN;
} else {
return LogLabel.FATAL;
}
}
import { LogContext, LogLevel, LogMeta } from "./types/logger";
import { Loggable, Storable } from "./interface/output";
export class Logger {
private buffer: string[];
private bufferLength: number;
private bucketIndex: BucketInfo[];
private outputs: StrictLogOutputs;
private bufferCapacity: number;
private outputs: Loggable[] = [];
private storage: Storable | undefined;
constructor() {}
constructor(config: LogConfig = {}) {
this.buffer = [];
this.bufferLength = 0;
const parsedConfig = StrictLogConfig.parse(config);
this.bufferCapacity = parsedConfig.bufferCapacity;
this.outputs = parsedConfig.outputs;
if (this.outputs.tampermonkey.enabled) {
this.bucketIndex = JSON.parse(
GM_getValue(this.outputs.tampermonkey.bucketIndexKey, "[]")
);
} else {
this.bucketIndex = [];
}
addOutput(output: Loggable) {
this.outputs.push(output);
return this;
}
log(message: string, context: LogContext = {}) {
const level = context.level ? context.level : LogLevel.FATAL;
const label = getLabel(level);
setStorage(storage: Storable) {
this.storage = storage;
return this;
}
ok() {
// TODO: Check all outputs and storage for required props
return this;
}
log(message: string, level: LogLevel, context: LogContext) {
// const label = getLabel(level);
const meta: LogMeta = {
context,
time: new Date().valueOf(),
level,
time: new Date(),
};
if (this.outputs.console.enabled) {
this.consolePrint(label, message, meta);
for (const output of this.outputs) {
output.write(message, meta, context);
}
const textOutput = `${new Date(
meta.time
).toISOString()} [${label}] ${message} - ${stringifyInstance(
meta.context
)}`;
this.buffer.push(textOutput);
this.bufferLength += textOutput.length;
if (this.outputs.callback) {
this.outputs.callback(textOutput);
}
if (this.bufferLength >= this.bufferCapacity) {
if (this.outputs.tampermonkey.enabled) {
this.flush();
} else {
while (this.bufferLength >= this.bufferCapacity) {
const stale = this.buffer.shift();
const offset = stale ? stale.length : 0;
this.bufferLength -= offset;
}
}
if (this.storage) {
this.storage.write(message, meta, context);
}
}
trace(message: string, context?: Object) {
this.log(message, {
level: LogLevel.TRACE,
trace(message: string, context: LogContext = {}) {
this.log(message, LogLevel.TRACE, {
stacktrace: new Error().stack?.slice(13), // Remove the "Error\n at "
...context,
});
}
debug(message: string, context?: Object) {
this.log(message, { level: LogLevel.DEBUG, ...context });
debug(message: string, context: LogContext = {}) {
this.log(message, LogLevel.DEBUG, context);
}
info(message: string, context?: Object) {
this.log(message, { level: LogLevel.INFO, ...context });
info(message: string, context: LogContext = {}) {
this.log(message, LogLevel.INFO, context);
}
warn(message: string, context?: Object) {
this.log(message, { level: LogLevel.WARN, ...context });
warn(message: string, context: LogContext = {}) {
this.log(message, LogLevel.WARN, context);
}
fatal(message: string, context?: Object) {
this.log(message, { level: LogLevel.FATAL, ...context });
}
private consolePrint(label: LogLabel, message: string, meta: LogMeta) {
const styleFormatter = `background: ${this.outputs.console.style[label].backgroundColor}; color: ${this.outputs.console.style[label].textColor}; font-weight: bold; border-radius: 4px;`;
switch (label) {
case LogLabel.TRACE:
console.trace(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
meta
);
break;
case LogLabel.DEBUG:
console.debug(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
meta
);
break;
case LogLabel.INFO:
console.info(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
meta
);
break;
case LogLabel.WARN:
console.warn(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
meta
);
break;
case LogLabel.FATAL:
console.error(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
meta
);
break;
default:
console.log(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
meta
);
break;
}
}
async flush() {
// Clear buffer
const stringifiedBuffer = JSON.stringify(this.buffer);
this.buffer = [];
this.bufferLength = 0;
// Don't flush unless tampermonkey output is enabled
if (!this.outputs.tampermonkey.enabled) {
return;
}
// Generate non-clashing name
let newBucketName = randomString(10).toLowerCase();
while (GM_getValue(newBucketName, undefined) !== undefined) {
newBucketName = randomString(10);
}
// GZip data
const gzipped = await gzip(stringifiedBuffer);
// Update bucketIndex with info
const newBucket: BucketInfo = {
name: newBucketName,
size: gzipped.length,
createdAt: new Date().valueOf(),
};
// Write bucketIndex to disk
this.bucketIndex.push(newBucket);
GM_setValue(
this.outputs.tampermonkey.bucketIndexKey,
JSON.stringify(this.bucketIndex)
);
// Write gzipped data to new bucket
GM_setValue(newBucketName, gzipped);
if (this.bucketIndex.length <= this.outputs.tampermonkey.maxBuckets) {
return;
}
// Delete old buckets if the number is too large
let oldBuckets = this.bucketIndex
.sort((a, b) => a.createdAt - b.createdAt)
.slice(0, -this.bufferCapacity);
oldBuckets.forEach((oldBucket) => {
GM_deleteValue(oldBucket.name);
let deleteIndex = this.bucketIndex.findIndex(
(indexBucket) => indexBucket.name === oldBucket.name
);
if (deleteIndex === -1) {
console.error("Invalid index for bucket");
return;
}
this.bucketIndex.splice(deleteIndex, 1);
});
// Update tampermonkey bucket index
GM_setValue(
this.outputs.tampermonkey.bucketIndexKey,
JSON.stringify(this.bucketIndex)
);
}
async export(amount: number) {
// Check if the buffer has the requested amount
if (this.buffer.length >= amount) {
return this.buffer.slice(this.buffer.length - amount);
}
// Only return buffer if tamppermonkey is disabled
if (!this.outputs.tampermonkey.enabled) {
return [...this.buffer];
}
let logs = [...this.buffer];
for (const bucket of this.bucketIndex) {
// Get data from bucket
const gzipped = GM_getValue(bucket.name, undefined);
if (gzipped === undefined) {
console.error("Bucket does not exist on disk", bucket);
continue;
}
// Ungzip and parse
const ungzipped = await ungzip(gzipped);
let lines: string[] = [];
try {
lines = JSON.parse(ungzipped) as string[];
} catch (err) {
// Bucket has invalid or empty data
lines = [];
}
// prepend to logs up to amount
if (logs.length + lines.length < amount) {
// Need to grab more from storage
logs.unshift(...lines);
} else if (logs.length + lines.length == amount) {
// Have the exact amount
logs.unshift(...lines);
break;
} else {
// Grab a slice of the exact amount needed
logs.unshift(...lines.slice(0, amount - logs.length));
break;
}
}
return logs;
}
async exportGzipped(amount: number) {
const lines = await this.export(amount);
const gzipped = await gzip(JSON.stringify(lines));
return gzipped;
fatal(message: string, context: LogContext = {}) {
this.log(message, LogLevel.FATAL, context);
}
}

11
src/interface/output.ts Normal file
View File

@ -0,0 +1,11 @@
import { LogContext, LogMeta } from "../types/logger";
export abstract class Loggable {
abstract write(message: string, meta: LogMeta, context: LogContext): void;
}
export abstract class Storable extends Loggable {
abstract read(
entries: number
): Promise<{ message: string; meta: LogMeta; context: LogContext }[]>;
}

View File

@ -1,3 +1,5 @@
import { LogLabel, LogLevel } from "logger";
export function blobToBase64(blob: Blob) {
return new Promise<string | ArrayBuffer>((resolve, _) => {
const reader = new FileReader();
@ -71,3 +73,17 @@ export function randomString(length: number) {
}
return result;
}
export function getLabel(level: number) {
if (level <= LogLevel.TRACE) {
return LogLabel.TRACE;
} else if (level <= LogLevel.DEBUG) {
return LogLabel.DEBUG;
} else if (level <= LogLevel.INFO) {
return LogLabel.INFO;
} else if (level <= LogLevel.WARN) {
return LogLabel.WARN;
} else {
return LogLabel.FATAL;
}
}

76
src/outputs/Console.ts Normal file
View File

@ -0,0 +1,76 @@
import { LogContext, LogLabel, LogMeta } from "../types/logger";
import { Loggable } from "../interface/output";
import {
ConsoleOutputConfig,
StrictConsoleOutputConfig,
} from "../types/output";
import { getLabel } from "../lib/utilities";
const MESSAGE_STYLE = "background: inherit; color: inherit;";
export default class Console implements Loggable {
private style;
enabled: boolean;
constructor(config: ConsoleOutputConfig = {}) {
const parsedConfig = StrictConsoleOutputConfig.parse(config);
this.style = parsedConfig.style;
this.enabled = parsedConfig.enabled;
}
write(message: string, meta: LogMeta, context: LogContext): void {
if (!this.enabled) return;
const label = getLabel(meta.level);
const styleFormatter = `background: ${this.style[label].backgroundColor}; color: ${this.style[label].textColor}; font-weight: bold; border-radius: 4px;`;
switch (label) {
case LogLabel.TRACE:
console.trace(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
context
);
break;
case LogLabel.DEBUG:
console.debug(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
context
);
break;
case LogLabel.INFO:
console.info(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
context
);
break;
case LogLabel.WARN:
console.warn(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
context
);
break;
case LogLabel.FATAL:
console.error(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
context
);
break;
default:
console.log(
`%c ${label} ` + `%c ${message}`,
styleFormatter,
MESSAGE_STYLE,
context
);
break;
}
}
}

142
src/outputs/Tampermonkey.ts Normal file
View File

@ -0,0 +1,142 @@
import { Storable } from "../interface/output";
import {
StrictTampermonkeyOutputConfig,
TampermonkeyBucketInfo,
TampermonkeyOutputConfig,
} from "../types/output";
import { gzip, randomString, ungzip } from "../lib/utilities";
import { LogContext, LogData, LogMeta } from "../types/logger";
export default class Tampermonkey implements Storable {
private buffer: LogData[];
private bucketList: TampermonkeyBucketInfo[];
private bufferCapacity: number;
private bucketListKey: string;
private maxBuckets: number;
enabled: boolean;
constructor(config: TampermonkeyOutputConfig = {}) {
this.buffer = [];
const parsedConfig = StrictTampermonkeyOutputConfig.parse(config);
this.bucketListKey = parsedConfig.bucketListKey;
this.enabled = parsedConfig.enabled;
this.bufferCapacity = parsedConfig.bufferCapacity;
this.maxBuckets = parsedConfig.maxBuckets;
this.bucketList = GM_getValue(this.bucketListKey, []);
}
write(message: string, meta: LogMeta, context: LogContext): void {
if (!this.enabled) return;
this.buffer.push({ message, meta, context });
if (this.buffer.length > this.bufferCapacity) {
this.flush();
}
}
async read(
entries: number
): Promise<{ message: string; meta: LogMeta; context: LogContext }[]> {
if (!this.enabled) return Promise.resolve([]);
// Check if the buffer has the requested amount
if (this.buffer.length >= entries) {
return Promise.resolve(this.buffer.slice(this.buffer.length - entries));
}
let logs = [...this.buffer];
for (const bucket of this.bucketList) {
// Get data from bucket
const gzipped = <string | undefined>GM_getValue(bucket.name, undefined);
if (gzipped === undefined) {
console.error("Bucket does not exist on disk", bucket);
continue;
}
// Ungzip and parse
const ungzipped = await ungzip(gzipped);
let lines: LogData[];
try {
lines = JSON.parse(ungzipped) as LogData[];
} catch (err) {
// Bucket has invalid or empty data
lines = [];
}
// prepend to logs up to amount
if (logs.length + lines.length < entries) {
// TODO: Need to grab more from storage
logs.unshift(...lines);
} else if (logs.length + lines.length == entries) {
// Have the exact amount
logs.unshift(...lines);
break;
} else {
// Grab a slice of the exact amount needed
logs.unshift(...lines.slice(0, entries - logs.length));
break;
}
}
return logs;
}
async flush() {
// Clear buffer
const stringifiedBuffer = JSON.stringify(this.buffer);
this.buffer = [];
// Don't flush unless tampermonkey output is enabled
if (!this.enabled) return;
// Generate non-clashing name
let newBucketName = randomString(10).toLowerCase();
while (GM_getValue(newBucketName, undefined) !== undefined) {
newBucketName = randomString(10);
}
// GZip data
const gzipped = await gzip(stringifiedBuffer);
// Update bucketList with info
const newBucket: TampermonkeyBucketInfo = {
name: newBucketName,
size: gzipped.length,
createdAt: new Date().valueOf(),
};
// Write bucketList to disk
this.bucketList.push(newBucket);
GM_setValue(this.bucketListKey, this.bucketList);
// Write gzipped data to new bucket
GM_setValue(newBucketName, gzipped);
if (this.bucketList.length <= this.maxBuckets) {
return;
}
// Delete old buckets if the number is too large
let oldBuckets = this.bucketList
.sort((a, b) => a.createdAt - b.createdAt)
.slice(0, -this.bufferCapacity);
oldBuckets.forEach((oldBucket) => {
GM_deleteValue(oldBucket.name);
let deleteIndex = this.bucketList.findIndex(
(indexBucket) => indexBucket.name === oldBucket.name
);
if (deleteIndex === -1) {
console.error("Invalid index for bucket");
return;
}
this.bucketList.splice(deleteIndex, 1);
});
// Update tampermonkey bucket index
GM_setValue(this.bucketListKey, this.bucketList);
}
}

View File

@ -1,5 +1,3 @@
import { z } from "zod";
export enum LogLevel {
TRACE = 10,
DEBUG = 20,
@ -16,161 +14,17 @@ export enum LogLabel {
FATAL = "fatal",
}
export interface TampermonkeyOutputOpts {
enabled: boolean;
maxBuckets?: number;
bucketIndexKey?: string;
}
export interface ConsoleOutputOpts {
enabled: boolean;
}
export const ConsoleStyles = z
.object({
trace: z
.object({
backgroundColor: z.string().default("#949494").optional(),
textColor: z.string().default("#fff").optional(),
})
.default({})
.optional(),
debug: z
.object({
backgroundColor: z.string().default("#fe7bf3").optional(),
textColor: z.string().default("#fff").optional(),
})
.default({})
.optional(),
info: z
.object({
backgroundColor: z.string().default("#65f10e").optional(),
textColor: z.string().default("#fff").optional(),
})
.default({})
.optional(),
warn: z
.object({
backgroundColor: z.string().default("#faf200").optional(),
textColor: z.string().default("#000").optional(),
})
.default({})
.optional(),
fatal: z
.object({
backgroundColor: z.string().default("#cc0018").optional(),
textColor: z.string().default("#fff").optional(),
})
.default({})
.optional(),
})
.optional();
export type ConsoleStyles = z.infer<typeof ConsoleStyles>;
export const StrictConsoleStyles = z
.object({
trace: z
.object({
backgroundColor: z.string().default("#949494"),
textColor: z.string().default("#fff"),
})
.default({}),
debug: z
.object({
backgroundColor: z.string().default("#fe7bf3"),
textColor: z.string().default("#fff"),
})
.default({}),
info: z
.object({
backgroundColor: z.string().default("#65f10e"),
textColor: z.string().default("#fff"),
})
.default({}),
warn: z
.object({
backgroundColor: z.string().default("#faf200"),
textColor: z.string().default("#000"),
})
.default({}),
fatal: z
.object({
backgroundColor: z.string().default("#cc0018"),
textColor: z.string().default("#fff"),
})
.default({}),
})
.default({});
export const LogOutputs = z.object({
console: z
.object({
enabled: z.boolean().default(true).optional(),
style: ConsoleStyles,
})
.default({})
.optional(),
tampermonkey: z
.object({
enabled: z.boolean().default(false).optional(),
maxBuckets: z.number().default(10).optional(),
bucketIndexKey: z.string().default("bucket_index").optional(),
})
.default({})
.optional(),
callback: z.function().args(z.string()).optional(),
});
export type LogOutputs = z.infer<typeof LogOutputs>;
export const LogConfig = z.object({
outputs: LogOutputs.default({}).optional(),
bufferCapacity: z.number().default(100000).optional(),
});
export type LogConfig = z.infer<typeof LogConfig>;
export const StrictLogOutputs = z.object({
console: z
.object({
enabled: z.boolean().default(true),
style: StrictConsoleStyles,
})
.default({}),
tampermonkey: z
.object({
enabled: z.boolean().default(false),
maxBuckets: z.number().default(10),
bucketIndexKey: z.string().default("bucket_index"),
})
.default({}),
callback: z.function().args(z.string()).optional(),
});
export type StrictLogOutputs = z.infer<typeof StrictLogOutputs>;
export const StrictLogConfig = z.object({
outputs: StrictLogOutputs.default({}),
bufferCapacity: z.number().default(100000),
});
export type StrictLogConfig = z.infer<typeof StrictLogConfig>;
export interface LogContext {
level?: number;
[key: string]: any;
}
export interface LogMeta {
level: LogLevel;
time: Date;
}
export interface LogData {
message: string;
meta: LogMeta;
context: LogContext;
time: number;
}
export interface BucketInfo {
name: string;
size: number;
createdAt: number;
}
export type StrictConsoleStyles = z.infer<typeof StrictConsoleStyles>;

125
src/types/output.ts Normal file
View File

@ -0,0 +1,125 @@
import { z } from "zod";
export const ConsoleStyles = z
.object({
trace: z
.object({
backgroundColor: z.string().default("#949494").optional(),
textColor: z.string().default("#fff").optional(),
})
.default({})
.optional(),
debug: z
.object({
backgroundColor: z.string().default("#fe7bf3").optional(),
textColor: z.string().default("#fff").optional(),
})
.default({})
.optional(),
info: z
.object({
backgroundColor: z.string().default("#65f10e").optional(),
textColor: z.string().default("#fff").optional(),
})
.default({})
.optional(),
warn: z
.object({
backgroundColor: z.string().default("#faf200").optional(),
textColor: z.string().default("#000").optional(),
})
.default({})
.optional(),
fatal: z
.object({
backgroundColor: z.string().default("#cc0018").optional(),
textColor: z.string().default("#fff").optional(),
})
.default({})
.optional(),
})
.optional();
export type ConsoleStyles = z.infer<typeof ConsoleStyles>;
export const StrictConsoleStyles = z
.object({
trace: z
.object({
backgroundColor: z.string().default("#949494"),
textColor: z.string().default("#fff"),
})
.default({}),
debug: z
.object({
backgroundColor: z.string().default("#fe7bf3"),
textColor: z.string().default("#fff"),
})
.default({}),
info: z
.object({
backgroundColor: z.string().default("#65f10e"),
textColor: z.string().default("#fff"),
})
.default({}),
warn: z
.object({
backgroundColor: z.string().default("#faf200"),
textColor: z.string().default("#000"),
})
.default({}),
fatal: z
.object({
backgroundColor: z.string().default("#cc0018"),
textColor: z.string().default("#fff"),
})
.default({}),
})
.default({});
export const ConsoleOutputConfig = z.object({
enabled: z.boolean().default(true).optional(),
style: ConsoleStyles,
});
export type ConsoleOutputConfig = z.infer<typeof ConsoleOutputConfig>;
export const StrictConsoleOutputConfig = z.object({
enabled: z.boolean().default(true),
style: StrictConsoleStyles,
});
export type StrictConsoleOutputConfig = z.infer<
typeof StrictConsoleOutputConfig
>;
export const TampermonkeyOutputConfig = z
.object({
enabled: z.boolean().default(false).optional(),
maxBuckets: z.number().default(10).optional(),
bufferCapacity: z.number().default(10000).optional(),
bucketListKey: z.string().default("bucket_index").optional(),
})
.default({})
.optional();
export type TampermonkeyOutputConfig = z.infer<typeof TampermonkeyOutputConfig>;
export const StrictTampermonkeyOutputConfig = z
.object({
enabled: z.boolean().default(true),
maxBuckets: z.number().default(10),
bufferCapacity: z.number().default(10000),
bucketListKey: z.string().default("bucket_index"),
})
.default({});
export type StrictTampermonkeyOutputConfig = z.infer<
typeof StrictTampermonkeyOutputConfig
>;
export interface TampermonkeyBucketInfo {
name: string;
size: number;
createdAt: number;
}