diff --git a/lerna.json b/lerna.json index 4663f680..f9e24dcd 100644 --- a/lerna.json +++ b/lerna.json @@ -1,7 +1,7 @@ { "useNx": false, "packages": ["libs/*", "apps/*"], - "version": "13.0.0", + "version": "14.0.0-pr76.5", "command": { "version": { "allowBranch": "*", diff --git a/libs/components/package.json b/libs/components/package.json index 0dcebe58..46e0e977 100644 --- a/libs/components/package.json +++ b/libs/components/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/ngx-components", - "version": "13.0.0", + "version": "14.0.0-pr76.5", "repository": "https://github.com/shiftcode/sc-ng-commons-public", "license": "MIT", "author": "shiftcode GmbH ", @@ -20,8 +20,8 @@ "@angular/core": "^21.0.3", "@angular/forms": "^21.0.3", "@angular/router": "^21.0.3", - "@shiftcode/logger": "^3.0.0", - "@shiftcode/ngx-core": "^13.0.0 || ^13.0.0-pr63", + "@shiftcode/logger": "^4.0.0-pr250", + "@shiftcode/ngx-core": "^14.0.0 || ^14.0.0-pr76", "rxjs": "^6.5.3 || ^7.4.0" } } diff --git a/libs/core/package.json b/libs/core/package.json index ab530636..cbebc8b2 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/ngx-core", - "version": "13.0.0", + "version": "14.0.0-pr76.5", "repository": "https://github.com/shiftcode/sc-ng-commons-public", "license": "MIT", "author": "shiftcode GmbH ", @@ -18,7 +18,7 @@ "@angular/core": "^21.0.3", "@angular/platform-browser": "^21.0.3", "@angular/router": "^21.0.3", - "@shiftcode/logger": "^3.0.0", + "@shiftcode/logger": "^4.0.0-pr250", "@shiftcode/utilities": "^4.0.0", "rxjs": "^6.5.3 || ^7.4.0" } diff --git a/libs/core/src/lib/logger/console/console-log-transport.service.ts b/libs/core/src/lib/logger/browser-console/browser-console-log-transport.service.ts similarity index 61% rename from libs/core/src/lib/logger/console/console-log-transport.service.ts rename to libs/core/src/lib/logger/browser-console/browser-console-log-transport.service.ts index d7b1ee4f..ebeab7da 100644 --- a/libs/core/src/lib/logger/console/console-log-transport.service.ts +++ b/libs/core/src/lib/logger/browser-console/browser-console-log-transport.service.ts @@ -1,15 +1,22 @@ -/* eslint-disable no-console */ import { isPlatformServer } from '@angular/common' -import { inject, Injectable, PLATFORM_ID } from '@angular/core' +import { inject, Injectable, InjectionToken, PLATFORM_ID } from '@angular/core' import { LogLevel, LogTransport } from '@shiftcode/logger' -import { leadingZero } from '../helper/leading-zero.function' -import { CONSOLE_LOG_TRANSPORT_CONFIG } from './console-log-transport-config.injection-token' +import { loggingTimeFormat } from '../helper/logging-time-format.const' + +export interface BrowserConsoleLogTransportConfig { + logLevel: LogLevel +} + +export const BROWSER_CONSOLE_LOG_TRANSPORT_CONFIG = new InjectionToken( + 'BROWSER_CONSOLE_LOG_TRANSPORT_CONFIG', + { factory: () => ({ logLevel: LogLevel.DEBUG }) }, +) @Injectable({ providedIn: 'root' }) -export class ConsoleLogTransport extends LogTransport { +export class BrowserConsoleLogTransportService extends LogTransport { constructor() { - super(inject(CONSOLE_LOG_TRANSPORT_CONFIG).logLevel) + super(inject(BROWSER_CONSOLE_LOG_TRANSPORT_CONFIG).logLevel) if (isPlatformServer(inject(PLATFORM_ID))) { throw new Error('This log transport is only for client side use - consider using "NodeConsoleLogTransport"') } @@ -17,12 +24,8 @@ export class ConsoleLogTransport extends LogTransport { log(level: LogLevel, clazzName: string, color: string, timestamp: Date, args: any[]) { if (this.isLevelEnabled(level)) { - const now = [ - leadingZero(2, timestamp.getHours()), - leadingZero(2, timestamp.getMinutes()), - leadingZero(2, timestamp.getSeconds()), - leadingZero(3, timestamp.getMilliseconds()), - ].join(':') // 'HH:mm:ss:SSS' + const now = loggingTimeFormat.format(timestamp) + const firstArgument = args.splice(0, 1)[0] if (typeof firstArgument === 'string') { @@ -32,6 +35,7 @@ export class ConsoleLogTransport extends LogTransport { args.splice(0, 0, `%c${now} - ${clazzName} ::`, `color:${color}`, firstArgument) } + /* eslint-disable no-console */ switch (level) { case LogLevel.DEBUG: console.debug(...args) @@ -47,7 +51,10 @@ export class ConsoleLogTransport extends LogTransport { break case LogLevel.OFF: break + default: + return level // exhaustive check } + /* eslint-enable no-console */ } } } diff --git a/libs/core/src/lib/logger/browser-console/with-browser-console-transport.function.ts b/libs/core/src/lib/logger/browser-console/with-browser-console-transport.function.ts new file mode 100644 index 00000000..e6a4fdd8 --- /dev/null +++ b/libs/core/src/lib/logger/browser-console/with-browser-console-transport.function.ts @@ -0,0 +1,25 @@ +import { LogTransport } from '@shiftcode/logger' + +import { LoggerFeature } from '../logger-feature.type' +import { LoggerFeatureKind } from '../logger-feature-kind.enum' +import { + BROWSER_CONSOLE_LOG_TRANSPORT_CONFIG, + BrowserConsoleLogTransportConfig, + BrowserConsoleLogTransportService, +} from './browser-console-log-transport.service' + +export function withBrowserConsoleTransport( + browserConsoleLogTransportConfigOrFactory: + | BrowserConsoleLogTransportConfig + | (() => BrowserConsoleLogTransportConfig), +): LoggerFeature { + const configProvider = + typeof browserConsoleLogTransportConfigOrFactory === 'function' + ? { provide: BROWSER_CONSOLE_LOG_TRANSPORT_CONFIG, useFactory: browserConsoleLogTransportConfigOrFactory } + : { provide: BROWSER_CONSOLE_LOG_TRANSPORT_CONFIG, useValue: browserConsoleLogTransportConfigOrFactory } + + return { + kind: LoggerFeatureKind.TRANSPORT, + providers: [configProvider, { provide: LogTransport, useClass: BrowserConsoleLogTransportService, multi: true }], + } +} diff --git a/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-api.service.ts b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-api.service.ts new file mode 100644 index 00000000..1fe513c8 --- /dev/null +++ b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-api.service.ts @@ -0,0 +1,98 @@ +// eslint-disable-next-line max-classes-per-file +import { inject, Injectable } from '@angular/core' +import { ContentType } from '@shiftcode/utilities' +import { CommonHttpHeader } from '@shiftcode/utilities' + +import { CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2 } from './cloud-watch-log-transport-config.injection-token' + +export interface LogStream { + logStreamName: string + creationTime: number + lastIngestionTime: number | null +} + +export interface LogEvent { + message: string + timestamp: number +} + +export interface WriteLogEvents { + logEvents: LogEvent[] +} + +export class HttpError extends Error { + override readonly name: string = 'HttpError' + + constructor( + readonly statusCode: number, + message: string, + ) { + super(`${statusCode} - ${message}`) + } +} + +export class HttpApiError extends HttpError { + override readonly name: string = 'HttpApiError' + + constructor( + statusCode: number, + readonly errorCode: string, + message: string, + ) { + super(statusCode, `${message} (${errorCode})`) + } +} + +enum ApiPath { + STREAMS = 'streams', + STREAM_LOGS = 'logs', +} + +@Injectable({ providedIn: 'root' }) +export class CloudWatchLogApiService { + private readonly apiUrl = inject(CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2).apiUrl + + async createLogStream(logStreamName: string): Promise { + const resp = await fetch(new URL(ApiPath.STREAMS, this.apiUrl), { + method: 'POST', + headers: { [CommonHttpHeader.CONTENT_TYPE]: ContentType.JSON }, + body: JSON.stringify({ logStreamName }), + }) + await this.handleError(resp) + } + + async describeLogStream(logStreamName: string): Promise { + const result = await fetch(new URL(`${ApiPath.STREAMS}/${logStreamName}`, this.apiUrl), { + method: 'GET', + headers: { [CommonHttpHeader.CONTENT_TYPE]: ContentType.JSON }, + }) + await this.handleError(result) + return (await result.json()) as LogStream + } + + async writeLogs(logStreamName: string, logs: LogEvent[]): Promise { + // todo: use beaconApi ? + const resp = await fetch(new URL(`${ApiPath.STREAMS}/${logStreamName}/${ApiPath.STREAM_LOGS}`, this.apiUrl), { + method: 'POST', + headers: { [CommonHttpHeader.CONTENT_TYPE]: ContentType.JSON }, + body: JSON.stringify({ logEvents: logs } satisfies WriteLogEvents), + }) + await this.handleError(resp) + } + + private async handleError(resp: Response): Promise { + if (!resp.ok) { + const errorResponse: object = (await resp.json().catch(() => ({}))) ?? {} + if ( + 'message' in errorResponse && + typeof errorResponse.message === 'string' && + 'code' in errorResponse && + typeof errorResponse.code === 'string' + ) { + throw new HttpApiError(resp.status, errorResponse.code, errorResponse.message) + } else { + throw new HttpError(resp.status, 'unknown error') + } + } + } +} diff --git a/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-error-handler.service.ts b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-error-handler.service.ts new file mode 100644 index 00000000..5ee2632b --- /dev/null +++ b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-error-handler.service.ts @@ -0,0 +1,22 @@ +import { ErrorHandler, inject, Injectable, Injector } from '@angular/core' +import { LogLevel } from '@shiftcode/logger' + +import { CloudWatchLogServiceV2 } from './cloud-watch-log.service' + +/** + * Angular ErrorHandler to send uncaught Errors to AWS CloudWatch Logs + * requires the {@link CloudWatchLogServiceV2} + */ +@Injectable({ providedIn: 'root' }) +export class CloudWatchLogErrorHandlerV2 extends ErrorHandler { + private readonly injector = inject(Injector) + + override handleError(error: any): void { + // prevent cyclic dependencies (eg. when CLOUD_WATCH_LOG_TRANSPORT_CONFIG needs config from httpClient request) + const cws = this.injector.get(CloudWatchLogServiceV2) + cws.addMessage(LogLevel.ERROR, 'BrowserJsException', new Date(), [error]) + + // call super.handleError to print error the angular way to the console + super.handleError(error) + } +} diff --git a/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-transport-config.injection-token.ts b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-transport-config.injection-token.ts new file mode 100644 index 00000000..2afaafca --- /dev/null +++ b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-transport-config.injection-token.ts @@ -0,0 +1,18 @@ +import { InjectionToken } from '@angular/core' +import { LogLevel } from '@shiftcode/logger' + +export interface CloudWatchLogTransportConfigV2 { + logLevel: LogLevel + apiUrl: string + + /** milliseconds until logs are flushed to aws */ + flushInterval: number + jsonStringifyReplacer?: (key: string, value: any) => any + + /** max number of sub-threshold log events to buffer before dropping oldest. default 100 */ + bufferSize?: number +} + +export const CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2 = new InjectionToken( + 'CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2', +) diff --git a/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-transport.service.ts b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-transport.service.ts new file mode 100644 index 00000000..bf28849e --- /dev/null +++ b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-transport.service.ts @@ -0,0 +1,24 @@ +import { inject, Injectable } from '@angular/core' +import { LogLevel, LogTransport } from '@shiftcode/logger' + +import { CloudWatchLogServiceV2 } from './cloud-watch-log.service' +import { CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2 } from './cloud-watch-log-transport-config.injection-token' + +/** + * The LogTransport implementation using {@link CloudWatchLogServiceV2}. + * Delegates all logging logic to the CloudWatchLogger. + * Requires the {@link CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2} to be provided. + */ +@Injectable({ providedIn: 'root' }) +export class CloudWatchLogTransportServiceV2 extends LogTransport { + private readonly cloudWatchLogger = inject(CloudWatchLogServiceV2) + + constructor() { + super(inject(CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2).logLevel) + } + + log(level: LogLevel, clazzName: string, _color: string, timestamp: Date, args: unknown[]): void { + // checking the log level is done in the cloudWatchLogger. important. + this.cloudWatchLogger.addMessage(level, clazzName, timestamp, args) + } +} diff --git a/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-utils.ts b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-utils.ts new file mode 100644 index 00000000..5740e2cb --- /dev/null +++ b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log-utils.ts @@ -0,0 +1,10 @@ +/** + * Push an item to a ring buffer array. If the buffer exceeds maxSize, the oldest entry is removed. + * todo: use from @shiftcode/logger when available + */ +export function pushToRingBuffer(buffer: T[], item: T, maxSize: number): void { + buffer.push(item) + if (buffer.length > maxSize) { + buffer.shift() + } +} diff --git a/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log.service.ts b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log.service.ts new file mode 100644 index 00000000..af0bc69a --- /dev/null +++ b/libs/core/src/lib/logger/cloud-watch-log-v2/cloud-watch-log.service.ts @@ -0,0 +1,148 @@ +/* eslint-disable */ +import { inject, Injectable } from '@angular/core' +import { createJsonLogObjectData, getJsonStringifyReplacer, JsonLogObjectData, LogLevel } from '@shiftcode/logger' +import { jsonMapSetStringifyReplacer } from '@shiftcode/utilities' +import { buffer, catchError, defer, filter, first, mergeMap, Observable, of, Subject, timer } from 'rxjs' + +import { CloudWatchLogApiService, HttpApiError, LogEvent } from './cloud-watch-log-api.service' +import { CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2 } from './cloud-watch-log-transport-config.injection-token' +import { pushToRingBuffer } from './cloud-watch-log-utils' +import { LOG_REQUEST_INFO_FN } from '../log-request-info-fn.token' +import { ClientIdService } from '../../client-id/client-id.service' + +/** + * Service to send messages to the integration api for CloudWatch Logs + * requires the {@link CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2} to be provided + */ +@Injectable({ providedIn: 'root' }) +export class CloudWatchLogServiceV2 { + // max request per second per log stream + private static readonly CLOUD_WATCH_RATE_LIMIT = 1000 / 5 + + private readonly api = inject(CloudWatchLogApiService) + private readonly logsSubject = new Subject() + private readonly clientId: string + + private readonly logRequestInfoFn = inject(LOG_REQUEST_INFO_FN, { optional: true }) + private readonly config = inject(CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2) + private readonly jsonStringifyReplacer = this.config.jsonStringifyReplacer || jsonMapSetStringifyReplacer + private readonly bufferSize = this.config.bufferSize || 100 + + /** Ring buffer for log events below the configured level, flushed on ERROR */ + private pendingBuffer: LogEvent[] = [] + + constructor() { + const clientIdService = inject(ClientIdService) + + this.clientId = clientIdService.clientId + + // no instantiation if LogLevel === OFF + if (this.config.logLevel !== LogLevel.OFF) { + // validation + if (this.config.flushInterval <= CloudWatchLogServiceV2.CLOUD_WATCH_RATE_LIMIT) { + throw new Error( + `Flush interval must be greater than ${CloudWatchLogServiceV2.CLOUD_WATCH_RATE_LIMIT}ms --> CloudWatch Rate Limit`, + ) + } + + const ready = this.getOrCreateLogStream(clientIdService) + this.setupLogStream(ready) + } + } + + /** + * add message to the queue to send to CloudWatch Service + * only send when {@link CloudWatchLogTransportConfig#logLevel} is not {@link LogLevel#OFF } + */ + addMessage(level: LogLevel, context: string, timestamp: Date, args: unknown[]) { + if (this.config.logLevel === LogLevel.OFF) { + return + } + const logEvent = this.buildLogEvent(level, context, timestamp, args) + + // if level is below threshold, buffer the event + if (level < this.config.logLevel) { + // we use `structuredClone` to prevent potential mutations of the logEvent before it is actually sent + pushToRingBuffer(this.pendingBuffer, structuredClone(logEvent), this.bufferSize) + return + } + + // on ERROR: flush buffered events first, then clear buffer + if (level === LogLevel.ERROR) { + for (const bufferedEvent of this.pendingBuffer) { + this.logsSubject.next(bufferedEvent) + } + this.pendingBuffer = [] + } + + this.logsSubject.next(logEvent) + } + + private buildLogEvent(level: LogLevel, context: string, timestamp: Date, args: unknown[]): LogEvent { + // Clone args to avoid mutating the original array + let logData: JsonLogObjectData & { requestInfo?: unknown } = createJsonLogObjectData(level, context, timestamp, [ + ...args, + ]) + + if (this.logRequestInfoFn) { + logData.requestInfo = this.logRequestInfoFn() + } + + return { + message: JSON.stringify(logData, getJsonStringifyReplacer(this.jsonStringifyReplacer)), + // time it is sent. not the time of the log (that one is included in the message object) + timestamp: new Date().getTime(), + } + } + + private getOrCreateLogStream({ createdInThisSession, clientId }: ClientIdService): Observable { + // return observable to keep it cold + return defer(async () => { + // if the clientId was created in this session, no LogStream can exist of it. + if (createdInThisSession) { + await this.api.createLogStream(clientId) + return + } + try { + await this.api.describeLogStream(clientId) + } catch (error) { + if (error instanceof HttpApiError && error.statusCode === 404) { + await this.api.createLogStream(clientId) + return + } + throw error + } + }) + } + + private setupLogStream(ready$: Observable): void { + // actual log subscription + this.logsSubject + .pipe( + // buffer logs --> wait for ready$ to complete - then setup the time based interval scheduler + buffer( + ready$.pipe( + first(), + mergeMap(() => timer(0, this.config.flushInterval)), + ), + ), + + // only none empty arrays + filter((logEvents) => logEvents?.length > 0), + + // put logs to cloudwatch + mergeMap(this.putLogEvents), + ) + .subscribe({ error: console.error.bind(console) }) + } + + private readonly putLogEvents = (events: LogEvent[]): Observable => { + // outer observable only for retry logic -- see below + return defer(() => this.api.writeLogs(this.clientId, events)).pipe( + catchError((err) => { + console.warn('unable to put logs to CloudWatch --> we try again with the next batch', err) + return of(void 0) + }), + ) + } +} diff --git a/libs/core/src/lib/logger/cloud-watch-log-v2/with-cloud-watch-log-transport.function.ts b/libs/core/src/lib/logger/cloud-watch-log-v2/with-cloud-watch-log-transport.function.ts new file mode 100644 index 00000000..51a15dea --- /dev/null +++ b/libs/core/src/lib/logger/cloud-watch-log-v2/with-cloud-watch-log-transport.function.ts @@ -0,0 +1,23 @@ +import { LogTransport } from '@shiftcode/logger' + +import { ValueOrFactory } from '../helper/value-or-factory.type' +import { LoggerFeature } from '../logger-feature.type' +import { LoggerFeatureKind } from '../logger-feature-kind.enum' +import { CloudWatchLogTransportServiceV2 } from './cloud-watch-log-transport.service' +import { + CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2, + CloudWatchLogTransportConfigV2, +} from './cloud-watch-log-transport-config.injection-token' + +export function withCloudWatchLogTransportV2(config: ValueOrFactory): LoggerFeature { + return { + kind: LoggerFeatureKind.TRANSPORT, + providers: [ + { + provide: CLOUD_WATCH_LOG_TRANSPORT_CONFIG_V2, + ...(typeof config === 'function' ? { useFactory: config } : { useValue: config }), + }, + { provide: LogTransport, useClass: CloudWatchLogTransportServiceV2, multi: true }, + ], + } +} diff --git a/libs/core/src/lib/logger/console-json/console-json-log-transport.service.ts b/libs/core/src/lib/logger/console-json/console-json-log-transport.service.ts new file mode 100644 index 00000000..2761d907 --- /dev/null +++ b/libs/core/src/lib/logger/console-json/console-json-log-transport.service.ts @@ -0,0 +1,17 @@ +import { inject, Injectable, InjectionToken } from '@angular/core' +import { LogLevel } from '@shiftcode/logger' +import { ConsoleJsonLogTransport, ConsoleJsonLogTransportConfig } from '@shiftcode/logger' + +export { ConsoleJsonLogTransportConfig } from '@shiftcode/logger' + +export const CONSOLE_JSON_LOG_TRANSPORT_CONFIG = new InjectionToken( + 'CONSOLE_JSON_LOG_TRANSPORT_CONFIG', + { factory: () => ({ logLevel: LogLevel.DEBUG }) }, +) + +@Injectable({ providedIn: 'root' }) +export class ConsoleJsonLogTransportService extends ConsoleJsonLogTransport { + constructor() { + super(inject(CONSOLE_JSON_LOG_TRANSPORT_CONFIG)) + } +} diff --git a/libs/core/src/lib/logger/console-json/with-console-json-log-transport.function.ts b/libs/core/src/lib/logger/console-json/with-console-json-log-transport.function.ts new file mode 100644 index 00000000..125c148f --- /dev/null +++ b/libs/core/src/lib/logger/console-json/with-console-json-log-transport.function.ts @@ -0,0 +1,27 @@ +import { LogTransport } from '@shiftcode/logger' + +import { ValueOrFactory } from '../helper/value-or-factory.type' +import { LoggerFeature } from '../logger-feature.type' +import { LoggerFeatureKind } from '../logger-feature-kind.enum' +import { + CONSOLE_JSON_LOG_TRANSPORT_CONFIG, + ConsoleJsonLogTransportConfig, + ConsoleJsonLogTransportService, +} from './console-json-log-transport.service' + +export function withConsoleJsonLogTransport( + consoleJsonLogTransportConfig: ValueOrFactory, +): LoggerFeature { + return { + kind: LoggerFeatureKind.TRANSPORT, + providers: [ + { + provide: CONSOLE_JSON_LOG_TRANSPORT_CONFIG, + ...(typeof consoleJsonLogTransportConfig === 'function' + ? { useFactory: consoleJsonLogTransportConfig } + : { useValue: consoleJsonLogTransportConfig }), + }, + { provide: LogTransport, useClass: ConsoleJsonLogTransportService, multi: true }, + ], + } +} diff --git a/libs/core/src/lib/logger/console/console-log-transport-config.injection-token.ts b/libs/core/src/lib/logger/console/console-log-transport-config.injection-token.ts deleted file mode 100644 index f014d9ac..00000000 --- a/libs/core/src/lib/logger/console/console-log-transport-config.injection-token.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { InjectionToken } from '@angular/core' - -import { ConsoleLogTransportConfig } from './console-log-transport-config' - -export const CONSOLE_LOG_TRANSPORT_CONFIG = new InjectionToken('consoleLogTransportConfig') diff --git a/libs/core/src/lib/logger/console/console-log-transport-config.ts b/libs/core/src/lib/logger/console/console-log-transport-config.ts deleted file mode 100644 index f6cf4f06..00000000 --- a/libs/core/src/lib/logger/console/console-log-transport-config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { LogLevel } from '@shiftcode/logger' - -export interface ConsoleLogTransportConfig { - logLevel: LogLevel -} diff --git a/libs/core/src/lib/logger/console/node-console-log-transport.service.ts b/libs/core/src/lib/logger/console/node-console-log-transport.service.ts deleted file mode 100644 index 9cedf5ae..00000000 --- a/libs/core/src/lib/logger/console/node-console-log-transport.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* eslint-disable no-console */ -import { isPlatformBrowser } from '@angular/common' -import { inject, Injectable, PLATFORM_ID } from '@angular/core' -import { LogLevel, LogTransport } from '@shiftcode/logger' -import { colorizeForConsole } from '@shiftcode/utilities' - -import { leadingZero } from '../helper/leading-zero.function' -import { CONSOLE_LOG_TRANSPORT_CONFIG } from './console-log-transport-config.injection-token' - -@Injectable({ providedIn: 'root' }) -export class NodeConsoleLogTransport extends LogTransport { - constructor() { - super(inject(CONSOLE_LOG_TRANSPORT_CONFIG).logLevel) - if (isPlatformBrowser(inject(PLATFORM_ID))) { - throw new Error('This log transport is only for server side use - consider using "ConsoleLogTransport"') - } - } - - log(level: LogLevel, clazzName: string, hexColor: string, timestamp: Date, args: any[]) { - if (this.isLevelEnabled(level)) { - const now = [ - leadingZero(2, timestamp.getHours()), - leadingZero(2, timestamp.getMinutes()), - leadingZero(2, timestamp.getSeconds()), - leadingZero(3, timestamp.getMilliseconds()), - ].join(':') // 'HH:mm:ss:SSS' - const firstArgument = args.splice(0, 1)[0] - - if (typeof firstArgument === 'string') { - // we have a string with potential message format - args.splice(0, 0, colorizeForConsole(`${now} - ${clazzName} :: ${firstArgument}`, hexColor)) - } else { - args.splice(0, 0, colorizeForConsole(`${now} - ${clazzName} ::`, hexColor), firstArgument) - } - - switch (level) { - case LogLevel.DEBUG: - console.debug(...args) - break - case LogLevel.ERROR: - console.error(...args) - break - case LogLevel.INFO: - console.info(...args) - break - case LogLevel.WARN: - console.warn(...args) - break - case LogLevel.OFF: - break - } - } - } -} diff --git a/libs/core/src/lib/logger/console/with-browser-console-transport.function.ts b/libs/core/src/lib/logger/console/with-browser-console-transport.function.ts deleted file mode 100644 index e2694160..00000000 --- a/libs/core/src/lib/logger/console/with-browser-console-transport.function.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { LogTransport } from '@shiftcode/logger' - -import { LoggerFeature } from '../logger-feature.type' -import { LoggerFeatureKind } from '../logger-feature-kind.enum' -import { ConsoleLogTransport } from './console-log-transport.service' -import { ConsoleLogTransportConfig } from './console-log-transport-config' -import { CONSOLE_LOG_TRANSPORT_CONFIG } from './console-log-transport-config.injection-token' - -export function withBrowserConsoleTransport( - consoleLoggerConfigOrFactory: ConsoleLogTransportConfig | (() => ConsoleLogTransportConfig), -): LoggerFeature { - const configProvider = - typeof consoleLoggerConfigOrFactory === 'function' - ? { provide: CONSOLE_LOG_TRANSPORT_CONFIG, useFactory: consoleLoggerConfigOrFactory } - : { provide: CONSOLE_LOG_TRANSPORT_CONFIG, useValue: consoleLoggerConfigOrFactory } - - return { - kind: LoggerFeatureKind.TRANSPORT, - providers: [configProvider, { provide: LogTransport, useClass: ConsoleLogTransport, multi: true }], - } -} diff --git a/libs/core/src/lib/logger/console/with-node-console-transport.function.ts b/libs/core/src/lib/logger/console/with-node-console-transport.function.ts deleted file mode 100644 index 33729321..00000000 --- a/libs/core/src/lib/logger/console/with-node-console-transport.function.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { LogTransport } from '@shiftcode/logger' - -import { LoggerFeature } from '../logger-feature.type' -import { LoggerFeatureKind } from '../logger-feature-kind.enum' -import { ConsoleLogTransportConfig } from './console-log-transport-config' -import { CONSOLE_LOG_TRANSPORT_CONFIG } from './console-log-transport-config.injection-token' -import { NodeConsoleLogTransport } from './node-console-log-transport.service' - -export function withNodeConsoleTransport( - consoleLoggerConfigOrFactory: ConsoleLogTransportConfig | (() => ConsoleLogTransportConfig), -): LoggerFeature { - const configProvider = - typeof consoleLoggerConfigOrFactory === 'function' - ? { provide: CONSOLE_LOG_TRANSPORT_CONFIG, useFactory: consoleLoggerConfigOrFactory } - : { provide: CONSOLE_LOG_TRANSPORT_CONFIG, useValue: consoleLoggerConfigOrFactory } - - return { - kind: LoggerFeatureKind.TRANSPORT, - providers: [configProvider, { provide: LogTransport, useClass: NodeConsoleLogTransport, multi: true }], - } -} diff --git a/libs/core/src/lib/logger/helper/logging-time-format.const.spec.ts b/libs/core/src/lib/logger/helper/logging-time-format.const.spec.ts new file mode 100644 index 00000000..e926b236 --- /dev/null +++ b/libs/core/src/lib/logger/helper/logging-time-format.const.spec.ts @@ -0,0 +1,18 @@ +import { loggingTimeFormat } from './logging-time-format.const' + +describe('timeFormat', () => { + it('should format time as HH:mm:ss.SSS', () => { + const timestamp = new Date('2024-01-15T14:30:45.123Z') + expect(loggingTimeFormat.format(timestamp)).toBe(`14:30:45.123`) + }) + + it('should format another time as HH:mm:ss.SSS', () => { + const timestamp = new Date('2024-01-01T23:59:59.999Z') + expect(loggingTimeFormat.format(timestamp)).toBe(`23:59:59.999`) + }) + + it('should format time with leading zeros as HH:mm:ss.SSS', () => { + const timestamp = new Date('2024-01-01T01:02:03.004Z') + expect(loggingTimeFormat.format(timestamp)).toBe(`01:02:03.004`) + }) +}) diff --git a/libs/core/src/lib/logger/helper/logging-time-format.const.ts b/libs/core/src/lib/logger/helper/logging-time-format.const.ts new file mode 100644 index 00000000..8f442380 --- /dev/null +++ b/libs/core/src/lib/logger/helper/logging-time-format.const.ts @@ -0,0 +1,10 @@ +/** + * Time formatter for logging timestamps in 'HH:MM:SS.sss' format. + */ +export const loggingTimeFormat = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + hour12: false, +}) diff --git a/libs/core/src/lib/logger/helper/value-or-factory.type.ts b/libs/core/src/lib/logger/helper/value-or-factory.type.ts new file mode 100644 index 00000000..795035b4 --- /dev/null +++ b/libs/core/src/lib/logger/helper/value-or-factory.type.ts @@ -0,0 +1 @@ +export type ValueOrFactory = T | (() => T) diff --git a/libs/core/src/lib/logger/logger.service.spec.ts b/libs/core/src/lib/logger/logger.service.spec.ts index 3faba019..2b2c0ed4 100644 --- a/libs/core/src/lib/logger/logger.service.spec.ts +++ b/libs/core/src/lib/logger/logger.service.spec.ts @@ -2,7 +2,8 @@ import { Component, Directive, inject, Injectable, InjectionToken, PLATFORM_ID } import { TestBed } from '@angular/core/testing' import { By } from '@angular/platform-browser' import { Logger, LogLevel, LogTransport } from '@shiftcode/logger' -import { LoggerService } from '@shiftcode/ngx-core' + +import { LoggerService } from './logger.service' interface MockLogTransportConfig { logLevel: LogLevel diff --git a/libs/core/src/lib/logger/node-console/log-level-emoji.const.ts b/libs/core/src/lib/logger/node-console/log-level-emoji.const.ts new file mode 100644 index 00000000..7efad25e --- /dev/null +++ b/libs/core/src/lib/logger/node-console/log-level-emoji.const.ts @@ -0,0 +1,3 @@ +import { LogLevel } from '@shiftcode/logger' + +export const logLevelEmoji: Record = ['🐞', '💬', '💣', '🔥', ''] diff --git a/libs/core/src/lib/logger/node-console/node-console-log-transport.service.ts b/libs/core/src/lib/logger/node-console/node-console-log-transport.service.ts new file mode 100644 index 00000000..af4757d4 --- /dev/null +++ b/libs/core/src/lib/logger/node-console/node-console-log-transport.service.ts @@ -0,0 +1,120 @@ +import { isPlatformBrowser } from '@angular/common' +import { inject, Injectable, InjectionToken, PLATFORM_ID } from '@angular/core' +import { getJsonStringifyReplacer, LogLevel, LogTransport } from '@shiftcode/logger' +import { colorizeForConsole, jsonMapSetStringifyReplacer } from '@shiftcode/utilities' + +import { loggingTimeFormat } from '../helper/logging-time-format.const' +import { logLevelEmoji } from './log-level-emoji.const' + +export interface NodeConsoleLogTransportConfig { + logLevel: LogLevel + + /** + * custom replacer function for JSON serialization, default to @shiftcode/utilities jsonMapSetStringifyReplacer + */ + jsonStringifyReplacer?: (key: string, value: unknown) => unknown + + /** max depth for object serialization, default is 5 */ + maxDepth?: number +} + +export const NODE_CONSOLE_LOG_TRANSPORT_CONFIG = new InjectionToken( + 'NODE_CONSOLE_LOG_TRANSPORT_CONFIG', + { factory: () => ({ logLevel: LogLevel.DEBUG }) }, +) + +// we do not extend the NodeConsoleLogTransport from @shiftcode/logger since it uses `node:utils` which would break ts here without further changes +@Injectable({ providedIn: 'root' }) +export class NodeConsoleLogTransportService extends LogTransport { + private readonly config: NodeConsoleLogTransportConfig + + constructor() { + const config = inject(NODE_CONSOLE_LOG_TRANSPORT_CONFIG) + super(config.logLevel) + this.config = config + + if (isPlatformBrowser(inject(PLATFORM_ID))) { + throw new Error('This log transport is only for server side use - consider using "ConsoleLogTransport"') + } + } + + log(level: LogLevel, clazzName: string, hexColor: string, timestamp: Date, args: any[]) { + if (this.isLevelEnabled(level)) { + const now = loggingTimeFormat.format(timestamp) + // make sure to not alter the input args array + if (typeof args[0] === 'string') { + // if first arg is string, also colorize it + args = [ + logLevelEmoji[level], + colorizeForConsole(`${now} - ${clazzName} :: ${args[0]}`, hexColor), + ...args.slice(1).map(this.stringifyJson), + ] + } else { + args = [ + logLevelEmoji[level], + colorizeForConsole(`${now} - ${clazzName} ::`, hexColor), + ...args.map(this.stringifyJson), + ] + } + + /* eslint-disable no-console */ + switch (level) { + case LogLevel.DEBUG: + console.debug(...args) + break + case LogLevel.ERROR: + console.error(...args) + break + case LogLevel.INFO: + console.info(...args) + break + case LogLevel.WARN: + console.warn(...args) + break + case LogLevel.OFF: + break + default: + return level // exhaustive check + } + /* eslint-enable no-console */ + } + } + + private readonly stringifyJson = (data: unknown) => { + const maxDepth = this.config.maxDepth ?? 5 + const jsonStringifyReplacer = getJsonStringifyReplacer( + this.config.jsonStringifyReplacer ?? jsonMapSetStringifyReplacer, + ) + + const seen = new WeakMap() + let refCounter = 0 + const handleDepthAndCircularRefs = (value: unknown, depth: number = 0): unknown => { + if (depth > maxDepth) { + return '[max depth reached]' + } + + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return `[circular ref #${seen.get(value)}]` + } else { + seen.set(value, ++refCounter) + } + + if (Array.isArray(value)) { + return value.map((v) => handleDepthAndCircularRefs(v, depth + 1)) + } + + const result: any = {} + for (const [k, v] of Object.entries(value)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + result[k] = handleDepthAndCircularRefs(v, depth + 1) + } + return result + } + + return value + } + + return JSON.stringify(handleDepthAndCircularRefs(data), jsonStringifyReplacer) + } +} diff --git a/libs/core/src/lib/logger/node-console/with-node-console-transport.function.ts b/libs/core/src/lib/logger/node-console/with-node-console-transport.function.ts new file mode 100644 index 00000000..e74fd813 --- /dev/null +++ b/libs/core/src/lib/logger/node-console/with-node-console-transport.function.ts @@ -0,0 +1,21 @@ +import { LogTransport } from '@shiftcode/logger' + +import { ValueOrFactory } from '../helper/value-or-factory.type' +import { LoggerFeature } from '../logger-feature.type' +import { LoggerFeatureKind } from '../logger-feature-kind.enum' +import { NODE_CONSOLE_LOG_TRANSPORT_CONFIG, NodeConsoleLogTransportService } from './node-console-log-transport.service' + +export function withNodeConsoleTransport(nodeConsoleLogTransportConfig: ValueOrFactory): LoggerFeature { + return { + kind: LoggerFeatureKind.TRANSPORT, + providers: [ + { + provide: NODE_CONSOLE_LOG_TRANSPORT_CONFIG, + ...(typeof nodeConsoleLogTransportConfig === 'function' + ? { useFactory: nodeConsoleLogTransportConfig } + : { useValue: nodeConsoleLogTransportConfig }), + }, + { provide: LogTransport, useClass: NodeConsoleLogTransportService, multi: true }, + ], + } +} diff --git a/libs/core/src/public-api.ts b/libs/core/src/public-api.ts index 8fbbc565..ec44da36 100644 --- a/libs/core/src/public-api.ts +++ b/libs/core/src/public-api.ts @@ -28,13 +28,20 @@ export * from './lib/logger/cloudwatch/cloud-watch-log-transport-config.model' export * from './lib/logger/cloudwatch/is-error.function' export * from './lib/logger/cloudwatch/with-cloudwatch-transport.function' +// cloudwatch v2 logger +export * from './lib/logger/cloud-watch-log-v2/cloud-watch-log.service' +export * from './lib/logger/cloud-watch-log-v2/cloud-watch-log-error-handler.service' +export * from './lib/logger/cloud-watch-log-v2/cloud-watch-log-transport.service' +export * from './lib/logger/cloud-watch-log-v2/cloud-watch-log-transport-config.injection-token' +export * from './lib/logger/cloud-watch-log-v2/with-cloud-watch-log-transport.function' + // console logger -export * from './lib/logger/console/console-log-transport.service' -export * from './lib/logger/console/console-log-transport-config' -export * from './lib/logger/console/console-log-transport-config.injection-token' -export * from './lib/logger/console/node-console-log-transport.service' -export * from './lib/logger/console/with-browser-console-transport.function' -export * from './lib/logger/console/with-node-console-transport.function' +export * from './lib/logger/browser-console/browser-console-log-transport.service' +export * from './lib/logger/browser-console/with-browser-console-transport.function' +export * from './lib/logger/console-json/console-json-log-transport.service' +export * from './lib/logger/console-json/with-console-json-log-transport.function' +export * from './lib/logger/node-console/node-console-log-transport.service' +export * from './lib/logger/node-console/with-node-console-transport.function' // remote logger export * from './lib/logger/remote/remote-log.service' diff --git a/package-lock.json b/package-lock.json index baaf1584..92991984 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "~21.0.3", "@angular/router": "~21.0.3", "@angular/ssr": "~21.0.2", - "@shiftcode/logger": "^3.0.0", + "@shiftcode/logger": "^4.0.0-pr250.6", "@shiftcode/utilities": "^4.0.0", "rxjs": "^6.5.3 || ^7.4.0", "tslib": "^2.5.0" @@ -57,7 +57,7 @@ "zone.js": "~0.15.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || ^24.0.0" + "node": "^22.12.0 || ^24.0.0" }, "optionalDependencies": { "@nx/nx-linux-x64-gnu": "^19.4.2", @@ -11361,15 +11361,15 @@ } }, "node_modules/@shiftcode/logger": { - "version": "3.0.0", - "resolved": "https://npm.pkg.github.com/download/@shiftcode/logger/3.0.0/da9b7d65a0545ebb5499b5f7ffd2e86ef90c6ce2", - "integrity": "sha512-UAUiG9iWHfXQbumY2y1Z0JjZoPJ+Ebq9FOd/+aFnp9zJrt7+zRTUPDdGj4sQ7vf8Hvis3dzRUdEmW4swgomxlg==", + "version": "4.0.0-pr250.6", + "resolved": "https://npm.pkg.github.com/download/@shiftcode/logger/4.0.0-pr250.6/c9160a219cc16982a7c1e2e430be929f0247e7de", + "integrity": "sha512-S4RrWQC1yFRev32IqUGDINjG0K1YP+/Qafeb7QaO5yvp97dbeNuUe3vKLIkbJ8g/8gu5C4cj1gH0/OWNq0R/qg==", "license": "UNLICENSED", "engines": { - "node": "^20.0.0 || ^22.0.0" + "node": "^22.0.0 || ^24.0.0" }, "peerDependencies": { - "@shiftcode/utilities": "^4.0.0 || ^4.0.0-pr45" + "@shiftcode/utilities": "^4.0.0" } }, "node_modules/@shiftcode/publish-helper": { diff --git a/package.json b/package.json index bacd629d..27462698 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@angular/platform-browser": "~21.0.3", "@angular/router": "~21.0.3", "@angular/ssr": "~21.0.2", - "@shiftcode/logger": "^3.0.0", + "@shiftcode/logger": "^4.0.0-pr250.6", "@shiftcode/utilities": "^4.0.0", "rxjs": "^6.5.3 || ^7.4.0", "tslib": "^2.5.0" @@ -73,6 +73,6 @@ "@rollup/rollup-linux-x64-gnu": "^4.20.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || ^24.0.0" + "node": "^22.12.0 || ^24.0.0" } }