pubnub
Version:
Publish & Subscribe Real-time Messaging with PubNub
376 lines (331 loc) • 14.5 kB
text/typescript
/**
* Default console-based logger.
*
* **Important:** This logger is always added as part of {@link LoggerManager} instance configuration and can't be
* removed.
*
* @internal
*/
import {
NetworkResponseLogMessage,
NetworkRequestLogMessage,
ObjectLogMessage,
ErrorLogMessage,
LogLevelString,
LogMessage,
LogLevel,
Logger,
} from '../core/interfaces/logger';
import { queryStringFromObject } from '../core/utils';
import { Payload } from '../core/types/api';
/**
* Custom {@link Logger} implementation to show a message in the native console.
*/
export class ConsoleLogger implements Logger {
/**
* Binary data decoder.
*/
private static readonly decoder = new TextDecoder();
/**
* Process a `debug` level message.
*
* @param message - Message which should be handled by custom logger implementation.
*/
debug(message: LogMessage): void {
this.log(message);
}
/**
* Process a `error` level message.
*
* @param message - Message which should be handled by custom logger implementation.
*/
error(message: LogMessage): void {
this.log(message);
}
/**
* Process an `info` level message.
*
* @param message - Message which should be handled by custom logger implementation.
*/
info(message: LogMessage): void {
this.log(message);
}
/**
* Process a `trace` level message.
*
* @param message - Message which should be handled by custom logger implementation.
*/
trace(message: LogMessage): void {
this.log(message);
}
/**
* Process an `warn` level message.
*
* @param message - Message which should be handled by custom logger implementation.
*/
warn(message: LogMessage): void {
this.log(message);
}
/**
* Stringify logger object.
*
* @returns Serialized logger object.
*/
toString(): string {
return `ConsoleLogger {}`;
}
/**
* Process log message object.
*
* @param message - Object with information which can be used to identify level and prepare log entry payload.
*/
private log(message: LogMessage) {
const logLevelString = LogLevel[message.level];
const level = logLevelString.toLowerCase() as LogLevelString;
console[level === 'trace' ? 'debug' : level](
`${message.timestamp.toISOString()} PubNub-${message.pubNubId} ${logLevelString.padEnd(5, ' ')}${
message.location ? ` ${message.location}` : ''
} ${this.logMessage(message)}`,
);
}
/**
* Get a pre-formatted log message.
*
* @param message - Log message which should be stringified.
*
* @returns String formatted for log entry in console.
*/
private logMessage(message: LogMessage): string {
if (message.messageType === 'text') return message.message;
else if (message.messageType === 'object')
return `${message.details ? `${message.details}\n` : ''}${this.formattedObject(message)}`;
else if (message.messageType === 'network-request') {
const showOnlyBasicInfo = !!message.canceled || !!message.failed;
const headersList =
message.minimumLevel === LogLevel.Trace && !showOnlyBasicInfo ? this.formattedHeaders(message) : undefined;
const request = message.message;
const queryString =
request.queryParameters && Object.keys(request.queryParameters).length > 0
? queryStringFromObject(request.queryParameters)
: undefined;
const url = `${request.origin}${request.path}${queryString ? `?${queryString}` : ''}`;
const formattedBody = !showOnlyBasicInfo ? this.formattedBody(message) : undefined;
let action = 'Sending';
if (showOnlyBasicInfo)
action = `${!!message.canceled ? 'Canceled' : 'Failed'}${message.details ? ` (${message.details})` : ''}`;
const padding = (formattedBody?.formData ? 'FormData' : 'Method').length;
return `${action} HTTP request:\n ${this.paddedString('Method', padding)}: ${
request.method
}\n ${this.paddedString('URL', padding)}: ${url}${
headersList ? `\n ${this.paddedString('Headers', padding)}:\n${headersList}` : ''
}${formattedBody?.formData ? `\n ${this.paddedString('FormData', padding)}:\n${formattedBody.formData}` : ''}${
formattedBody?.body ? `\n ${this.paddedString('Body', padding)}:\n${formattedBody.body}` : ''
}`;
} else if (message.messageType === 'network-response') {
const headersList = message.minimumLevel === LogLevel.Trace ? this.formattedHeaders(message) : undefined;
const formattedBody = this.formattedBody(message);
const padding = (formattedBody?.formData ? 'Headers' : 'Status').length;
const response = message.message;
return `Received HTTP response:\n ${this.paddedString('URL', padding)}: ${
response.url
}\n ${this.paddedString('Status', padding)}: ${response.status}${
headersList ? `\n ${this.paddedString('Headers', padding)}:\n${headersList}` : ''
}${formattedBody?.body ? `\n ${this.paddedString('Body', padding)}:\n${formattedBody.body}` : ''}`;
} else if (message.messageType === 'error') {
const formattedStatus = this.formattedErrorStatus(message);
const error = message.message;
return `${error.name}: ${error.message}${formattedStatus ? `\n${formattedStatus}` : ''}`;
}
return '<unknown log message data>';
}
/**
* Get a pre-formatted object (dictionary / array).
*
* @param message - Log message which may contain an object for formatting.
*
* @returns String formatted for log entry in console or `undefined` if a log message doesn't have suitable data.
*/
private formattedObject(message: ObjectLogMessage): string | undefined {
const stringify = (
obj: Record<string, unknown> | unknown[] | unknown,
level: number = 1,
skipIndentOnce: boolean = false,
) => {
const maxIndentReached = level === 10;
const targetIndent = ' '.repeat(level * 2);
const lines: string[] = [];
const isIgnored = (key: string, obj: Record<string, unknown>) => {
if (!message.ignoredKeys) return false;
if (typeof message.ignoredKeys === 'function') return message.ignoredKeys(key, obj);
return message.ignoredKeys.includes(key);
};
if (typeof obj === 'string') lines.push(`${targetIndent}- ${obj}`);
else if (typeof obj === 'number') lines.push(`${targetIndent}- ${obj}`);
else if (typeof obj === 'boolean') lines.push(`${targetIndent}- ${obj}`);
else if (obj === null) lines.push(`${targetIndent}- null`);
else if (obj === undefined) lines.push(`${targetIndent}- undefined`);
else if (typeof obj === 'function') lines.push(`${targetIndent}- <function>`);
else if (typeof obj === 'object') {
if (!Array.isArray(obj) && typeof obj.toString === 'function' && obj.toString().indexOf('[object') !== 0) {
lines.push(`${skipIndentOnce ? '' : targetIndent}${obj.toString()}`);
skipIndentOnce = false;
} else if (Array.isArray(obj)) {
for (const element of obj) {
const indent = skipIndentOnce ? '' : targetIndent;
if (element === null) lines.push(`${indent}- null`);
else if (element === undefined) lines.push(`${indent}- undefined`);
else if (typeof element === 'function') lines.push(`${indent}- <function>`);
else if (typeof element === 'object') {
const isArray = Array.isArray(element);
const entry = maxIndentReached ? '...' : stringify(element, level + 1, !isArray);
lines.push(`${indent}-${isArray && !maxIndentReached ? '\n' : ' '}${entry}`);
} else lines.push(`${indent}- ${element}`);
skipIndentOnce = false;
}
} else {
const object = obj as Record<string, unknown>;
const keys = Object.keys(object);
const maxKeyLen = keys.reduce((max, key) => Math.max(max, isIgnored(key, object) ? max : key.length), 0);
for (const key of keys) {
if (isIgnored(key, object)) continue;
const indent = skipIndentOnce ? '' : targetIndent;
const raw = object[key];
const paddedKey = key.padEnd(maxKeyLen, ' ');
if (raw === null) lines.push(`${indent}${paddedKey}: null`);
else if (raw === undefined) lines.push(`${indent}${paddedKey}: undefined`);
else if (typeof raw === 'function') lines.push(`${indent}${paddedKey}: <function>`);
else if (typeof raw === 'object') {
const isArray = Array.isArray(raw);
const isEmptyArray = isArray && raw.length === 0;
const isEmptyObject = !isArray && !(raw instanceof String) && Object.keys(raw).length === 0;
const hasToString =
!isArray && typeof raw.toString === 'function' && raw.toString().indexOf('[object') !== 0;
const entry = maxIndentReached
? '...'
: isEmptyArray
? '[]'
: isEmptyObject
? '{}'
: stringify(raw, level + 1, hasToString);
lines.push(
`${indent}${paddedKey}:${
maxIndentReached || hasToString || isEmptyArray || isEmptyObject ? ' ' : '\n'
}${entry}`,
);
} else lines.push(`${indent}${paddedKey}: ${raw}`);
skipIndentOnce = false;
}
}
}
return lines.join('\n');
};
return stringify(message.message);
}
/**
* Get a pre-formatted headers list.
*
* @param message - Log message which may contain an object with headers to be used for formatting.
*
* @returns String formatted for log entry in console or `undefined` if a log message not related to the network data.
*/
private formattedHeaders(message: NetworkRequestLogMessage | NetworkResponseLogMessage) {
if (!message.message.headers) return undefined;
const headers = message.message.headers;
const maxHeaderLength = Object.keys(headers).reduce((max, key) => Math.max(max, key.length), 0);
return Object.keys(headers)
.map((key) => ` - ${key.toLowerCase().padEnd(maxHeaderLength, ' ')}: ${headers[key]}`)
.join('\n');
}
/**
* Get a pre-formatted body.
*
* @param message - Log message which may contain an object with `body` (request or response).
*
* @returns Object with formatted string of form data and / or body for log entry in console or `undefined` if a log
* message not related to the network data.
*/
private formattedBody(
message: NetworkRequestLogMessage | NetworkResponseLogMessage,
): { body?: string; formData?: string } | undefined {
if (!message.message.headers) return undefined;
let stringifiedFormData: string | undefined;
let stringifiedBody: string | undefined;
const headers = message.message.headers;
const contentType = headers['content-type'] ?? headers['Content-Type'];
const formData = 'formData' in message.message ? message.message.formData : undefined;
const body = message.message.body;
// The presence of this object means that we are sending `multipart/form-data` (potentially uploading a file).
if (formData) {
const maxFieldLength = formData.reduce((max, { key }) => Math.max(max, key.length), 0);
stringifiedFormData = formData
.map(({ key, value }) => ` - ${key.padEnd(maxFieldLength, ' ')}: ${value}`)
.join('\n');
}
if (!body) return { formData: stringifiedFormData };
if (typeof body === 'string') {
stringifiedBody = ` ${body}`;
} else if (body instanceof ArrayBuffer || Object.prototype.toString.call(body) === '[object ArrayBuffer]') {
if (contentType && (contentType.indexOf('javascript') !== -1 || contentType.indexOf('json') !== -1))
stringifiedBody = ` ${ConsoleLogger.decoder.decode(body as ArrayBuffer)}`;
else stringifiedBody = ` ArrayBuffer { byteLength: ${(body as ArrayBuffer).byteLength} }`;
} else {
stringifiedBody = ` File { name: ${body.name}${
body.contentLength ? `, contentLength: ${body.contentLength}` : ''
}${body.mimeType ? `, mimeType: ${body.mimeType}` : ''} }`;
}
return { body: stringifiedBody, formData: stringifiedFormData };
}
/**
* Get a pre-formatted status object.
*
* @param message - Log message which may contain a {@link Status} object.
*
* @returns String formatted for log entry in console or `undefined` if a log message doesn't have {@link Status}
* object.
*/
private formattedErrorStatus(message: ErrorLogMessage) {
if (!message.message.status) return undefined;
const status = message.message.status;
const errorData = status.errorData;
let stringifiedErrorData: string | undefined;
if (ConsoleLogger.isError(errorData)) {
stringifiedErrorData = ` ${errorData.name}: ${errorData.message}`;
if (errorData.stack) {
stringifiedErrorData += `\n${errorData.stack
.split('\n')
.map((line) => ` ${line}`)
.join('\n')}`;
}
} else if (errorData) {
try {
stringifiedErrorData = ` ${JSON.stringify(errorData)}`;
} catch (_) {
stringifiedErrorData = ` ${errorData}`;
}
}
return ` Category : ${status.category}\n Operation : ${status.operation}\n Status : ${
status.statusCode
}${stringifiedErrorData ? `\n Error data:\n${stringifiedErrorData}` : ''}`;
}
/**
* Append the required amount of space to provide proper padding.
*
* @param str - Source string which should be appended with necessary number of spaces.
* @param maxLength - Maximum length of the string to which source string should be padded.
* @returns End-padded string.
*/
private paddedString(str: string, maxLength: number) {
return str.padEnd(maxLength - str.length, ' ');
}
/**
* Check whether passed object is {@link Error} instance.
*
* @param errorData - Object which should be checked.
*
* @returns `true` in case if an object actually {@link Error}.
*/
private static isError(errorData?: Error | Payload): errorData is Error {
if (!errorData) return false;
return errorData instanceof Error || Object.prototype.toString.call(errorData) === '[object Error]';
}
}