From a766534b970da34d136e58fdb665b859c6e6b586 Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Fri, 11 Apr 2025 14:58:34 +0200 Subject: [PATCH] feat(logging): implement core logging library with structured logging service - Added Core Logging library providing centralized logging functionality. - Implemented LoggingService with multiple log levels and configurable sinks. - Created ConsoleLogSink for logging to the browser console. - Introduced LoggerApi for context-aware logging. - Added support for custom sinks and logging configuration during app initialization. - Enhanced FilterService and FilterMenuButtonComponent with logging capabilities. - Updated ESLint and Jest configurations for the new logging library. - Documented the logging library API and usage in README. --- apps/isa-app/src/app/app.module.ts | 44 +- docs/guidelines/code-style.md | 383 ++++++++++++++++-- libs/core/logging/README.md | 151 +++++++ libs/core/logging/eslint.config.mjs | 34 ++ libs/core/logging/jest.config.ts | 21 + libs/core/logging/project.json | 20 + libs/core/logging/src/index.ts | 19 + libs/core/logging/src/lib/console-log.sink.ts | 68 ++++ libs/core/logging/src/lib/log-level.enum.ts | 11 + libs/core/logging/src/lib/logger.factory.ts | 82 ++++ .../core/logging/src/lib/logging.providers.ts | 118 ++++++ libs/core/logging/src/lib/logging.service.ts | 173 ++++++++ libs/core/logging/src/lib/logging.types.ts | 46 +++ libs/core/logging/src/test-setup.ts | 6 + libs/core/logging/tsconfig.json | 28 ++ libs/core/logging/tsconfig.lib.json | 17 + libs/core/logging/tsconfig.spec.json | 16 + .../filter/src/lib/core/filter.service.ts | 7 +- .../filter-menu-button.component.ts | 16 +- tsconfig.base.json | 1 + 20 files changed, 1215 insertions(+), 46 deletions(-) create mode 100644 libs/core/logging/README.md create mode 100644 libs/core/logging/eslint.config.mjs create mode 100644 libs/core/logging/jest.config.ts create mode 100644 libs/core/logging/project.json create mode 100644 libs/core/logging/src/index.ts create mode 100644 libs/core/logging/src/lib/console-log.sink.ts create mode 100644 libs/core/logging/src/lib/log-level.enum.ts create mode 100644 libs/core/logging/src/lib/logger.factory.ts create mode 100644 libs/core/logging/src/lib/logging.providers.ts create mode 100644 libs/core/logging/src/lib/logging.service.ts create mode 100644 libs/core/logging/src/lib/logging.types.ts create mode 100644 libs/core/logging/src/test-setup.ts create mode 100644 libs/core/logging/tsconfig.json create mode 100644 libs/core/logging/tsconfig.lib.json create mode 100644 libs/core/logging/tsconfig.spec.json diff --git a/apps/isa-app/src/app/app.module.ts b/apps/isa-app/src/app/app.module.ts index 04021c29f..e2be36db4 100644 --- a/apps/isa-app/src/app/app.module.ts +++ b/apps/isa-app/src/app/app.module.ts @@ -1,4 +1,8 @@ -import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + HTTP_INTERCEPTORS, + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; import { ErrorHandler, Injector, @@ -24,7 +28,10 @@ import { environment } from '../environments/environment'; import { AppSwaggerModule } from './app-swagger.module'; import { AppDomainModule } from './app-domain.module'; import { UiModalModule } from '@ui/modal'; -import { NotificationsHubModule, NOTIFICATIONS_HUB_OPTIONS } from '@hub/notifications'; +import { + NotificationsHubModule, + NOTIFICATIONS_HUB_OPTIONS, +} from '@hub/notifications'; import { SignalRHubOptions } from '@core/signalr'; import { CoreBreadcrumbModule } from '@core/breadcrumb'; import { UiCommonModule } from '@ui/common'; @@ -36,7 +43,11 @@ import { HttpErrorInterceptor } from './interceptors'; import { CoreLoggerModule, LOG_PROVIDER } from '@core/logger'; import { IsaLogProvider } from './providers'; import { IsaErrorHandler } from './providers/isa.error-handler'; -import { ScanAdapterModule, ScanAdapterService, ScanditScanAdapterModule } from '@adapter/scan'; +import { + ScanAdapterModule, + ScanAdapterService, + ScanditScanAdapterModule, +} from '@adapter/scan'; import { RootStateService } from './store/root-state.service'; import * as Commands from './commands'; import { PreviewComponent } from './preview'; @@ -45,11 +56,22 @@ import { ShellModule } from '@shared/shell'; import { MainComponent } from './main.component'; import { IconModule } from '@shared/components/icon'; import { NgIconsModule } from '@ng-icons/core'; -import { matClose, matWifi, matWifiOff } from '@ng-icons/material-icons/baseline'; +import { + matClose, + matWifi, + matWifiOff, +} from '@ng-icons/material-icons/baseline'; import { NetworkStatusService } from './services/network-status.service'; import { firstValueFrom } from 'rxjs'; import { provideMatomo } from 'ngx-matomo-client'; import { withRouter, withRouteData } from 'ngx-matomo-client'; +import { + provideLogging, + withLogLevel, + LogLevel, + withSink, + ConsoleLogSink, +} from '@isa/core/logging'; registerLocaleData(localeDe, localeDeExtra); registerLocaleData(localeDe, 'de', localeDeExtra); @@ -103,7 +125,13 @@ export function _appInitializerFactory(config: Config, injector: Injector) { '⚡

Fehler bei der Initialisierung

Bitte prüfen Sie die Netzwerkverbindung (WLAN).

'; const reload = document.createElement('button'); - reload.classList.add('bg-brand', 'text-white', 'p-2', 'rounded', 'cursor-pointer'); + reload.classList.add( + 'bg-brand', + 'text-white', + 'p-2', + 'rounded', + 'cursor-pointer', + ); reload.innerHTML = 'App neu laden'; reload.onclick = () => window.location.reload(); statusElement.appendChild(reload); @@ -167,7 +195,10 @@ export function _notificationsHubOptionsFactory( ], providers: [ provideAppInitializer(() => { - const initializerFn = _appInitializerFactory(inject(Config), inject(Injector)); + const initializerFn = _appInitializerFactory( + inject(Config), + inject(Injector), + ); return initializerFn(); }), { @@ -196,6 +227,7 @@ export function _notificationsHubOptionsFactory( withRouter(), withRouteData(), ), + provideLogging(withLogLevel(LogLevel.Debug), withSink(ConsoleLogSink)), ], }) export class AppModule {} diff --git a/docs/guidelines/code-style.md b/docs/guidelines/code-style.md index f2751121b..dc6a70456 100644 --- a/docs/guidelines/code-style.md +++ b/docs/guidelines/code-style.md @@ -5,6 +5,186 @@ - **Readability First**: Write code that is easy to read and understand. - **Consistency**: Follow the same patterns and conventions throughout the codebase. - **Clean Code**: Avoid unnecessary complexity and keep functions small and focused. +- **SOLID Principles**: Follow SOLID design principles to create more maintainable, flexible, and scalable code. + +## SOLID Design Principles + +SOLID is an acronym for five design principles that help make software designs more understandable, flexible, and maintainable: + +- **Single Responsibility Principle (SRP)**: A class should have only one reason to change, meaning it should have only one job or responsibility. + + ```typescript + // Good - Each class has a single responsibility + class UserAuthentication { + authenticate(username: string, password: string): boolean { + // Authentication logic + } + } + + class UserRepository { + findById(id: string): User { + // Database access logic + } + } + + // Bad - Class has multiple responsibilities + class UserManager { + authenticate(username: string, password: string): boolean { + // Authentication logic + } + + findById(id: string): User { + // Database access logic + } + + sendEmail(user: User, message: string): void { + // Email sending logic + } + } + ``` + +- **Open/Closed Principle (OCP)**: Software entities should be open for extension but closed for modification. + + ```typescript + // Good - Open for extension + interface PaymentProcessor { + processPayment(amount: number): void; + } + + class CreditCardProcessor implements PaymentProcessor { + processPayment(amount: number): void { + // Credit card processing logic + } + } + + class PayPalProcessor implements PaymentProcessor { + processPayment(amount: number): void { + // PayPal processing logic + } + } + + // New payment methods can be added without modifying existing code + ``` + +- **Liskov Substitution Principle (LSP)**: Objects of a superclass should be replaceable with objects of subclasses without affecting the correctness of the program. + + ```typescript + // Good - Derived classes can substitute base class + class Rectangle { + constructor( + protected width: number, + protected height: number, + ) {} + + setWidth(width: number): void { + this.width = width; + } + + setHeight(height: number): void { + this.height = height; + } + + getArea(): number { + return this.width * this.height; + } + } + + class Square extends Rectangle { + constructor(size: number) { + super(size, size); + } + + // Preserve behavior when overriding methods + setWidth(width: number): void { + super.setWidth(width); + super.setHeight(width); + } + + setHeight(height: number): void { + super.setWidth(height); + super.setHeight(height); + } + } + ``` + +- **Interface Segregation Principle (ISP)**: Clients should not be forced to depend on interfaces they do not use. + + ```typescript + // Good - Segregated interfaces + interface Printable { + print(): void; + } + + interface Scannable { + scan(): void; + } + + class AllInOnePrinter implements Printable, Scannable { + print(): void { + // Printing logic + } + + scan(): void { + // Scanning logic + } + } + + class BasicPrinter implements Printable { + print(): void { + // Printing logic + } + } + + // Bad - Fat interface + interface OfficeMachine { + print(): void; + scan(): void; + fax(): void; + staple(): void; + } + + // Classes must implement methods they don't need + ``` + +- **Dependency Inversion Principle (DIP)**: High-level modules should not depend on low-level modules. Both should depend on abstractions. + + ```typescript + // Good - Depending on abstractions + interface Logger { + log(message: string): void; + } + + class ConsoleLogger implements Logger { + log(message: string): void { + console.log(message); + } + } + + class FileLogger implements Logger { + log(message: string): void { + // File logging logic + } + } + + class UserService { + constructor(private logger: Logger) {} + + createUser(user: User): void { + // Create user logic + this.logger.log(`User created: ${user.name}`); + } + } + + // The UserService depends on the abstraction (Logger interface) + // not on concrete implementations + ``` + +Following these principles improves code quality by: + +- Reducing coupling between components +- Making the system more modular and easier to maintain +- Facilitating testing and extension +- Promoting code reuse ## Extended Guidelines for Angular and TypeScript @@ -13,16 +193,24 @@ This section extends the core code style principles with Angular-specific and ad ### Angular Enhancements - **Change Detection**: Use the OnPush strategy by default for better performance. -- **Lifecycle Hooks**: Explicitly implement Angular lifecycle interfaces. +- **Lifecycle Hooks**: Explicitly implement Angular lifecycle interfaces (OnInit, OnDestroy, etc.). - **Template Management**: Keep templates concise and use the async pipe to handle observables. - **Component Structure**: Follow best practices for component modularization to enhance readability and testability. +- **Naming Conventions**: Follow Angular's official naming conventions for selectors, files, and component classes. +- **File Organization**: Structure files according to features and follow the recommended folder structure. +- **Control Flow**: Use modern control flow syntax (@if, @for) instead of structural directives (*ngIf, *ngFor). +- **Signals**: Prefer signals over RxJS for simpler state management within components. ### TypeScript Enhancements - **Strict Type Checking**: Enable strict mode (`strict: true`) and avoid excessive use of `any`. - **Interfaces vs. Types**: Prefer interfaces for object definitions and use type aliases for unions and intersections. - **Generics**: Use meaningful type parameter names and constrain generics when applicable. -- **Documentation**: Employ JSDoc comments functions and generic parameters to improve code clarity. +- **Documentation**: Employ JSDoc comments for functions and generic parameters to improve code clarity. +- **Non-Nullability**: Use the non-null assertion operator (!) sparingly and only when you're certain a value cannot be null. +- **Type Guards**: Implement custom type guards to handle type narrowing safely. +- **Immutability**: Favor immutable data structures and use readonly modifiers when applicable. +- **Exhaustiveness Checking**: Use exhaustiveness checking for switch statements handling union types. ## TypeScript Guidelines @@ -48,18 +236,18 @@ This section extends the core code style principles with Angular-specific and ad - Prefer `interface` over `type` for object definitions - Use `type` for unions, intersections, and mapped types - - Follow Angular's naming convention: `IComponentProps` for props interfaces + - Follow Angular's naming convention: Don't prefix interfaces with 'I' (use `ComponentProps` not `IComponentProps`) - Extend interfaces instead of repeating properties - Use readonly modifiers where appropriate ```typescript // Good - interface IBaseProps { + interface BaseProps { readonly id: string; name: string; } - interface IUserProps extends IBaseProps { + interface UserProps extends BaseProps { email: string; } @@ -75,9 +263,49 @@ This section extends the core code style principles with Angular-specific and ad - **Enums and Constants**: - - Use `const enum` for better performance - - Only use regular `enum` when runtime access is required - - Prefer union types for simple string literals + - Prefer this order of implementation (from most to least preferred): + + 1. `const enum` for better compile-time performance + 2. Object literals with `as const` for runtime flexibility + 3. Regular `enum` only when necessary for runtime access + + - **When to use each approach**: + - Use `const enum` for internal application enumerations that don't need runtime access + - Use `const object as const` when values need to be inspected at runtime or exported in an API + - Use regular `enum` only when runtime enumeration object access is required + + ```typescript + // Good - const enum (preferred for most cases) + // Advantages: Tree-shakable, type-safe, disappears at compile time + export const enum ConstEnumStates { + NotSet = 'not-set', + Success = 'success', + } + + // Good - const object with 'as const' assertion + // Advantages: Runtime accessible, works well with API boundaries + export const ConstStates = { + NotSet: 'not-set', + Success: 'success', + } as const; + + // Types can be extracted from const objects + type ConstStatesType = (typeof ConstStates)[keyof typeof ConstStates]; + + // Least preferred - regular enum + // Only use when you need the enum object at runtime + export enum States { + NotSet = 'not-set', + Success = 'success', + } + ``` + + - Use union types as an alternative for simple string literals + + ```typescript + // Alternative approach using union types + export type StatusType = 'not-set' | 'success'; + ``` - **Functions and Methods**: @@ -94,7 +322,7 @@ This section extends the core code style principles with Angular-specific and ad * @param id - The user's unique identifier * @param includeDetails - Whether to include additional user details */ - const getUser = (id: string, includeDetails = false): Promise => { + const getUser = (id: string, includeDetails = false): Promise => { // ...implementation }; @@ -113,12 +341,12 @@ Example: ```typescript // Good -interface IUserProps { +interface UserProps { id: string; name: string; } -interface IAdminProps extends IUserProps { +interface AdminProps extends UserProps { permissions: string[]; } @@ -127,7 +355,7 @@ const enum UserRole { User = 'USER', } -const getUser = (id: string): Promise => { +const getUser = (id: string): Promise => { // ...implementation }; @@ -170,20 +398,116 @@ function getUser(id) { subscription: Subscription; ngOnInit() { - this.subscription = this.userService.getUsers().subscribe((users) => (this.users = users)); + this.subscription = this.userService + .getUsers() + .subscribe((users) => (this.users = users)); } } ``` -- **Templates** +- **Templates and Control Flow**: - - Use new control flow syntax - instead if \*ngIf use the @if syntax + - Use modern control flow syntax (`@if`, `@for`, `@switch`) instead of structural directives (`*ngIf`, `*ngFor`, `*ngSwitch`). + + ```html + +
+ @if (user) { +

Welcome, {{ user.name }}!

+ } @else if (isLoading) { +

Loading user data...

+ } @else { +

Please log in

+ } + +
    + @for (item of items; track item.id) { +
  • {{ item.name }}
  • + } @empty { +
  • No items available
  • + } +
+ + @switch (userRole) { @case ('admin') { + + } @case ('manager') { + + } @default { + + } } +
+ + +
+

Welcome, {{ user.name }}!

+

Loading user data...

+

Please log in

+ +
    +
  • {{ item.name }}
  • +
  • No items available
  • +
+ + + + +
+ ``` + + - When using `@for`, always specify the `track` expression to optimize rendering performance: + - Use a unique identifier property (like `id` or `uuid`) when available + - Only use `$index` for static collections that never change + - Avoid using non-unique properties that could result in DOM mismatches + - Leverage contextual variables in `@for` blocks: + - `$index` - Current item index + - `$first` - Boolean indicating if this is the first item + - `$last` - Boolean indicating if this is the last item + - `$even` - Boolean indicating if this index is even + - `$odd` - Boolean indicating if this index is odd + - `$count` - Total number of items in the collection + + ```html + + @for (item of items; track item.id; let i = $index, isLast = $last) { +
  • {{ i + 1 }}. {{ item.name }}
  • + } + ``` + + - Use the `@empty` block with `@for` to handle empty collections gracefully + - Store conditional expression results in variables for clearer templates: + + ```html + + @if (user.permissions.canEditSettings; as canEdit) { + + } + ``` ## Project-Specific Preferences -- **Frameworks**: Follow best practices for Nx, Hono, and Zod. +- **Frameworks**: Follow best practices for Nx, Angular, date-fns, Ngrx, RxJs and Zod. - **Testing**: Use Jest with Spectator for unit tests and follow the Arrange-Act-Assert pattern. -- **File Naming**: Use kebab-case for filenames (e.g., `my-component.ts`). +- **File Naming**: + + - Use kebab-case for filenames (e.g., `my-component.ts`). + - Follow a pattern that describes the symbol's feature then its type: `feature.type.ts` + + ``` + // Good examples + user.service.ts + auth.guard.ts + product-list.component.ts + order.model.ts + + // Bad examples + service-user.ts + userService.ts + ``` + - **Comments**: Use JSDoc for documenting functions, classes, and modules. ## Formatting @@ -198,25 +522,10 @@ function getUser(id) { - Use ESLint with the recommended TypeScript and Nx configurations. - Prettier should be used for consistent formatting. -## Example - -```typescript -// Good Example -interface User { - id: string; - name: string; -} - -const getUser = (id: string): User => { - // ...function logic... -}; - -// Bad Example -function getUser(id) { - // ...function logic... -} -``` - ## References -- [Angular Style Guide](https://angular.dev/style-guide#) +- [Angular Style Guide](https://angular.dev/style-guide) - Official Angular style guide with best practices for Angular development +- [Angular Control Flow](https://angular.dev/guide/templates/control-flow) - Official Angular documentation on the new control flow syntax (@if, @for, @switch) +- [TypeScript Style Guide](https://ts.dev/style/) - TypeScript community style guide with patterns and practices +- [SOLID Design Principles](https://en.wikipedia.org/wiki/SOLID) - Wikipedia article explaining the SOLID principles in object-oriented design +- [Clean Code](https://www.amazon.com/Clean-Code-Handbook-Software-Craftsmanship/dp/0132350882) - Robert C. Martin's seminal book on writing clean, maintainable code diff --git a/libs/core/logging/README.md b/libs/core/logging/README.md new file mode 100644 index 000000000..b3d08cacc --- /dev/null +++ b/libs/core/logging/README.md @@ -0,0 +1,151 @@ +# Core Logging + +## Overview + +The Core Logging library provides a centralized logging service for the ISA Frontend application. It offers a structured way to log messages, errors, and other information across the application, with support for different log levels and output destinations. + +## Features + +- Multiple log levels (trace, debug, info, warn, error) +- Configurable logging targets (console, remote server, etc.) +- Context-aware logging with metadata support +- Production/development mode detection +- Filtering capabilities based on log level or context + +## Core Concepts + +### Log Sinks + +Log sinks are destinations where log messages are sent. The library supports multiple sinks operating simultaneously, allowing you to: + +- Display logs in the console during development +- Send critical errors to a monitoring service +- Store logs for later analysis + +## API + +### LoggingService + +The main service for logging functionality. + +#### Methods + +| Method | Description | +| ---------------------------------------------------------- | --------------------------- | +| `trace(message: string, context?: unknown)` | Logs trace information | +| `debug(message: string, context?: unknown)` | Logs debug information | +| `info(message: string, context?: unknown)` | Logs informational messages | +| `warn(message: string, context?: unknown)` | Logs warning messages | +| `error(message: string, error?: Error, context?: unknown)` | Logs error messages | + +### Create Custom Sink + +#### Custom Sink Class + +```typescript +class MyCustomSink implements Sink { + #loggerAPI = inject(LoggerApi); + log( + level: LogLevel, + message: string, + context?: unknown, + error?: Error, + ): void { + // ... Do Stuff + } +} +``` + +### Custom Sink Function + +```typescript +const myCustomSinkFn: SinkFn = () => { + const loggerAPI = inject(LoggerApi); + return ( + level: LogLevel, + message: string, + context?: unknown, + error?: Error, + ) => { + // ... Do Stuff + }; +}; +``` + +### LogLevel Enum + +```typescript +export const enum LogLevel { + Trace = 'trace', + Debug = 'debug', + Info = 'info', + Warn = 'warn', + Error = 'error', + Off = 'off', +} +``` + +## Configuration + +### Global Configuration + +Configure the logging service globally during application initialization: + +```typescript +// In your app.config.ts or similar initialization file + +import { provideLogging, withSink, ConsoleLogSink } from '@isa/core/logging'; + +const isProduction = environment.production; +export const appConfig: ApplicationConfig = { + providers: [ + // ...other providers + provideLogging( + withLogLevel(isProduction ? LogLevel.Warn : LogLevel.Debug), + withSink(ConsoleLogSink), + withSink(MyCustomSink), + withSinkFn(myCustomSinkFn), + ), + ], +}; +``` + +### Context Configuration + +Configure the logging service for a specific context + +```typescript +@Component({ + providers: [provideLoggerContext({ component: 'MyComponent', ... })] +}) +export class MyComponent {} +#logger = logger(); +``` + +## Usage + +```typescript +import { logger, LogLevel } from '@isa/core/logging'; + +@Component({ +// ... +}) +export class MyComponent implements OnInit { + + #logger = logger(); + + ngOnInit() { + this.logger.info('Component initialized', { componentName: 'MyComponent' }); + } + + processData(data: any) { + try { + // Process data + this.logger.debug('Data processed successfully', { dataId: data.id }); + } catch (error) { + this.logger.error('Failed to process data', error as Error, { + dataId: data.id, + }); + } +} +``` diff --git a/libs/core/logging/eslint.config.mjs b/libs/core/logging/eslint.config.mjs new file mode 100644 index 000000000..7f933fa33 --- /dev/null +++ b/libs/core/logging/eslint.config.mjs @@ -0,0 +1,34 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../../eslint.config.mjs'; + +export default [ + ...baseConfig, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'core', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'core', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/core/logging/jest.config.ts b/libs/core/logging/jest.config.ts new file mode 100644 index 000000000..b07c8bbc9 --- /dev/null +++ b/libs/core/logging/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'core-logging', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/core/logging', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/core/logging/project.json b/libs/core/logging/project.json new file mode 100644 index 000000000..5ac734ee3 --- /dev/null +++ b/libs/core/logging/project.json @@ -0,0 +1,20 @@ +{ + "name": "core-logging", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/core/logging/src", + "prefix": "core", + "projectType": "library", + "tags": [], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/core/logging/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/core/logging/src/index.ts b/libs/core/logging/src/index.ts new file mode 100644 index 000000000..4fe74a1dc --- /dev/null +++ b/libs/core/logging/src/index.ts @@ -0,0 +1,19 @@ +/** + * Core Logging Library + * + * A centralized logging service for the ISA Frontend application. + */ + +// Export core logging functionality +export { LogLevel } from './lib/log-level.enum'; +export { LoggerApi, Sink, SinkFn, LoggerContext } from './lib/logging.types'; +export { LoggingService } from './lib/logging.service'; +export { ConsoleLogSink } from './lib/console-log.sink'; +export { + provideLogging, + provideLoggerContext, + withLogLevel, + withSink, + withSinkFn, +} from './lib/logging.providers'; +export { logger } from './lib/logger.factory'; diff --git a/libs/core/logging/src/lib/console-log.sink.ts b/libs/core/logging/src/lib/console-log.sink.ts new file mode 100644 index 000000000..69df00221 --- /dev/null +++ b/libs/core/logging/src/lib/console-log.sink.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { LogLevel } from './log-level.enum'; +import { Sink } from './logging.types'; + +/** + * A sink implementation that outputs logs to the browser console. + */ +@Injectable() +export class ConsoleLogSink implements Sink { + /** + * Logs a message to the browser console with the appropriate log level. + * @param level The log level. + * @param message The log message. + * @param context Optional context data. + * @param error Optional error object. + */ + log( + level: LogLevel, + message: string, + context?: unknown, + error?: Error, + ): void { + switch (level) { + case LogLevel.Trace: + if (context) { + console.trace(message, context); + } else { + console.trace(message); + } + break; + case LogLevel.Debug: + if (context) { + console.debug(message, context); + } else { + console.debug(message); + } + break; + case LogLevel.Info: + if (context) { + console.info(message, context); + } else { + console.info(message); + } + break; + case LogLevel.Warn: + if (context) { + console.warn(message, context); + } else { + console.warn(message); + } + break; + case LogLevel.Error: + if (error) { + if (context) { + console.error(message, error, context); + } else { + console.error(message, error); + } + } else if (context) { + console.error(message, context); + } else { + console.error(message); + } + break; + // LogLevel.Off - no logs will be sent to this sink + } + } +} diff --git a/libs/core/logging/src/lib/log-level.enum.ts b/libs/core/logging/src/lib/log-level.enum.ts new file mode 100644 index 000000000..d12e4c411 --- /dev/null +++ b/libs/core/logging/src/lib/log-level.enum.ts @@ -0,0 +1,11 @@ +/** + * Defines the available log levels for the logging system. + */ +export const enum LogLevel { + Trace = 'trace', + Debug = 'debug', + Info = 'info', + Warn = 'warn', + Error = 'error', + Off = 'off', +} diff --git a/libs/core/logging/src/lib/logger.factory.ts b/libs/core/logging/src/lib/logger.factory.ts new file mode 100644 index 000000000..776bc7072 --- /dev/null +++ b/libs/core/logging/src/lib/logger.factory.ts @@ -0,0 +1,82 @@ +import { inject } from '@angular/core'; +import { LoggingService } from './logging.service'; +import { LoggerApi } from './logging.types'; +import { LOGGER_CONTEXT } from './logging.providers'; + +/** + * Factory function to create a logger instance with optional context. + * + * This is the primary way for components and services to access the logging + * functionality in the application. + * + * @returns Logger API interface that can be used to log messages. + */ +export function logger(): LoggerApi { + const loggingService = inject(LoggingService); + + // Try to inject context if available + const context = inject(LOGGER_CONTEXT, { optional: true }); + + // Return an object with methods that forward to the logging service + // with the provided context + return { + trace: (message: string, additionalContext?: unknown): void => { + loggingService.trace(message, mergeContexts(context, additionalContext)); + }, + debug: (message: string, additionalContext?: unknown): void => { + loggingService.debug(message, mergeContexts(context, additionalContext)); + }, + info: (message: string, additionalContext?: unknown): void => { + loggingService.info(message, mergeContexts(context, additionalContext)); + }, + warn: (message: string, additionalContext?: unknown): void => { + loggingService.warn(message, mergeContexts(context, additionalContext)); + }, + error: ( + message: string, + error?: Error, + additionalContext?: unknown, + ): void => { + loggingService.error( + message, + error, + mergeContexts(context, additionalContext), + ); + }, + }; +} + +/** + * Merges component-level context with message-specific context. + * @param baseContext The component-level context. + * @param additionalContext The message-specific context. + * @returns The merged context. + */ +function mergeContexts( + baseContext?: unknown, + additionalContext?: unknown, +): unknown { + if (!baseContext) { + return additionalContext; + } + + if (!additionalContext) { + return baseContext; + } + + // If both contexts are objects, merge them + if ( + typeof baseContext === 'object' && + baseContext !== null && + typeof additionalContext === 'object' && + additionalContext !== null + ) { + return { ...baseContext, ...additionalContext }; + } + + // If not both objects, return them separately + return { + baseContext, + additionalContext, + }; +} diff --git a/libs/core/logging/src/lib/logging.providers.ts b/libs/core/logging/src/lib/logging.providers.ts new file mode 100644 index 000000000..027c12e62 --- /dev/null +++ b/libs/core/logging/src/lib/logging.providers.ts @@ -0,0 +1,118 @@ +import { + APP_INITIALIZER, + EnvironmentProviders, + InjectionToken, + Provider, + Type, + makeEnvironmentProviders, +} from '@angular/core'; +import { LogLevel } from './log-level.enum'; +import { LoggingService } from './logging.service'; +import { LoggerContext, LoggingConfig, Sink, SinkFn } from './logging.types'; + +// Injection tokens for the Logger API +export const LOGGER_CONFIG = new InjectionToken('LOGGER_CONFIG'); +export const LOGGER_CONTEXT = new InjectionToken( + 'LOGGER_CONTEXT', +); + +/** + * Configuration data for the logging system + */ +interface LoggingConfigData { + level?: LogLevel; + sinks?: (Sink | SinkFn | Type)[]; + context?: LoggerContext; +} + +/** + * Configures the LoggingService during app initialization + */ +export function configureLogger( + loggingService: LoggingService, + config: LoggingConfig, +): () => void { + return () => { + loggingService.configure(config); + }; +} + +/** + * Helper function to set the log level + * @param level The log level to set + */ +export function withLogLevel(level: LogLevel): LoggingConfigData { + return { level }; +} + +/** + * Helper function to add a sink to the configuration + * @param sink The sink to add (instance or class reference) + */ +export function withSink(sink: Sink | Type): LoggingConfigData { + return { sinks: [sink] }; +} + +/** + * Helper function to add a sink function to the configuration + * @param sinkFn The sink function to add + */ +export function withSinkFn(sinkFn: SinkFn): LoggingConfigData { + return { sinks: [sinkFn] }; +} + +/** + * Provides logging functionality with the given configuration + */ +export function provideLogging( + ...configs: LoggingConfigData[] +): EnvironmentProviders { + // Merge all configurations + const mergedConfig: LoggingConfig = { + level: LogLevel.Info, // Default level + sinks: [], + }; + + for (const config of configs) { + if (config.level !== undefined) { + mergedConfig.level = config.level; + } + + if (config.sinks) { + mergedConfig.sinks.push(...config.sinks); + } + + if (config.context) { + mergedConfig.context = { + ...mergedConfig.context, + ...config.context, + }; + } + } + + return makeEnvironmentProviders([ + { + provide: LOGGER_CONFIG, + useValue: mergedConfig, + }, + { + provide: APP_INITIALIZER, + useFactory: configureLogger, + deps: [LoggingService, LOGGER_CONFIG], + multi: true, + }, + ]); +} + +/** + * Provides a context object for logging within a specific component or module + * @param context The context object to provide + */ +export function provideLoggerContext(context: LoggerContext): Provider[] { + return [ + { + provide: LOGGER_CONTEXT, + useValue: context, + }, + ]; +} diff --git a/libs/core/logging/src/lib/logging.service.ts b/libs/core/logging/src/lib/logging.service.ts new file mode 100644 index 000000000..e9cdf82e7 --- /dev/null +++ b/libs/core/logging/src/lib/logging.service.ts @@ -0,0 +1,173 @@ +import { Injectable, inject } from '@angular/core'; +import { LogLevel } from './log-level.enum'; +import { LoggerApi, LoggingConfig, Sink, SinkFn } from './logging.types'; + +/** + * The main service for logging functionality. + * Implements the LoggerApi interface to provide logging methods. + */ +@Injectable({ providedIn: 'root' }) +export class LoggingService implements LoggerApi { + private level: LogLevel; + private sinks: (( + level: LogLevel, + message: string, + context?: unknown, + error?: Error, + ) => void)[] = []; + private globalContext?: Record; + + constructor() { + this.level = LogLevel.Info; // Default level + } + + /** + * Configures the logging service with the provided options. + * @param config The logging configuration options. + */ + configure(config: LoggingConfig): void { + this.level = config.level; + this.globalContext = config.context; + this.sinks = []; + + // Initialize all sinks + for (const sink of config.sinks) { + if (typeof sink === 'function') { + // Check if it's a constructor function (class) or a sink function + if (sink.prototype && sink.prototype.log) { + // It's a constructor function (class), instantiate it + const instance = new (sink as any)(); + this.sinks.push(instance.log.bind(instance)); + } else { + // It's a SinkFn - explicitly cast to SinkFn + this.sinks.push((sink as SinkFn)()); + } + } else { + // It's a Sink object + this.sinks.push(sink.log.bind(sink)); + } + } + } + + /** + * Logs trace information. + * @param message The log message. + * @param context Optional context data. + */ + trace(message: string, context?: unknown): void { + this.log(LogLevel.Trace, message, context); + } + + /** + * Logs debug information. + * @param message The log message. + * @param context Optional context data. + */ + debug(message: string, context?: unknown): void { + this.log(LogLevel.Debug, message, context); + } + + /** + * Logs informational messages. + * @param message The log message. + * @param context Optional context data. + */ + info(message: string, context?: unknown): void { + this.log(LogLevel.Info, message, context); + } + + /** + * Logs warning messages. + * @param message The log message. + * @param context Optional context data. + */ + warn(message: string, context?: unknown): void { + this.log(LogLevel.Warn, message, context); + } + + /** + * Logs error messages. + * @param message The log message. + * @param error Optional error object. + * @param context Optional context data. + */ + error(message: string, error?: Error, context?: unknown): void { + this.log(LogLevel.Error, message, context, error); + } + + /** + * Internal method to handle logging with level filtering. + * @param level The log level. + * @param message The log message. + * @param context Optional context data. + * @param error Optional error object. + */ + private log( + level: LogLevel, + message: string, + context?: unknown, + error?: Error, + ): void { + // Check if the current log level should be processed + if (!this.shouldLog(level)) { + return; + } + + // Merge global context with the provided context + const mergedContext = this.mergeContext(context); + + // Send to all sinks + for (const sink of this.sinks) { + sink(level, message, mergedContext, error); + } + } + + /** + * Determines if a message at the specified level should be logged. + * @param level The log level to check. + * @returns True if the message should be logged, false otherwise. + */ + private shouldLog(level: LogLevel): boolean { + const levels: LogLevel[] = [ + LogLevel.Trace, + LogLevel.Debug, + LogLevel.Info, + LogLevel.Warn, + LogLevel.Error, + ]; + + const currentLevelIndex = levels.indexOf(this.level); + const messageLevelIndex = levels.indexOf(level); + + // Log the message if its level is higher or equal to the current level + return ( + messageLevelIndex >= currentLevelIndex && this.level !== LogLevel.Off + ); + } + + /** + * Merges the global context with the provided context. + * @param context The context provided in the log call. + * @returns The merged context object. + */ + private mergeContext(context?: unknown): unknown { + if (!this.globalContext) { + return context; + } + + if (!context) { + return this.globalContext; + } + + // Both contexts exist, merge them + if (typeof context === 'object' && context !== null) { + return { ...this.globalContext, ...context }; + } + + // Context is not an object, return both separately + return { + globalContext: this.globalContext, + logContext: context, + }; + } +} diff --git a/libs/core/logging/src/lib/logging.types.ts b/libs/core/logging/src/lib/logging.types.ts new file mode 100644 index 000000000..fde5d0d2a --- /dev/null +++ b/libs/core/logging/src/lib/logging.types.ts @@ -0,0 +1,46 @@ +import { LogLevel } from './log-level.enum'; +import { Type } from '@angular/core'; + +/** + * Represents a destination where log messages are sent. + */ +export interface Sink { + log(level: LogLevel, message: string, context?: unknown, error?: Error): void; +} + +/** + * Function implementation of a Sink. + */ +export type SinkFn = () => ( + level: LogLevel, + message: string, + context?: unknown, + error?: Error, +) => void; + +/** + * Configuration options for the logging service. + */ +export interface LoggingConfig { + level: LogLevel; + sinks: (Sink | SinkFn | Type)[]; + context?: Record; +} + +/** + * Represents the logger API for logging operations. + */ +export interface LoggerApi { + trace(message: string, context?: unknown): void; + debug(message: string, context?: unknown): void; + info(message: string, context?: unknown): void; + warn(message: string, context?: unknown): void; + error(message: string, error?: Error, context?: unknown): void; +} + +/** + * Logger context for context-aware logging. + */ +export interface LoggerContext { + [key: string]: unknown; +} diff --git a/libs/core/logging/src/test-setup.ts b/libs/core/logging/src/test-setup.ts new file mode 100644 index 000000000..ea414013f --- /dev/null +++ b/libs/core/logging/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/core/logging/tsconfig.json b/libs/core/logging/tsconfig.json new file mode 100644 index 000000000..fde35eab0 --- /dev/null +++ b/libs/core/logging/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es2022", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/core/logging/tsconfig.lib.json b/libs/core/logging/tsconfig.lib.json new file mode 100644 index 000000000..9b49be758 --- /dev/null +++ b/libs/core/logging/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/core/logging/tsconfig.spec.json b/libs/core/logging/tsconfig.spec.json new file mode 100644 index 000000000..f858ef78c --- /dev/null +++ b/libs/core/logging/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/shared/filter/src/lib/core/filter.service.ts b/libs/shared/filter/src/lib/core/filter.service.ts index 4a8bd88f7..2e4bed68f 100644 --- a/libs/shared/filter/src/lib/core/filter.service.ts +++ b/libs/shared/filter/src/lib/core/filter.service.ts @@ -1,8 +1,9 @@ -import { computed, inject, Injectable, Input, signal } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; import { InputType } from '../types'; import { getState, patchState, signalState } from '@ngrx/signals'; import { mapToFilter } from './mappings'; import { isEqual } from 'lodash'; +import { logger } from '@isa/core/logging'; import { FilterInput, OrderByDirectionSchema, @@ -13,6 +14,8 @@ import { FILTER_ON_COMMIT, FILTER_ON_INIT, QUERY_SETTINGS } from './tokens'; @Injectable() export class FilterService { + #logger = logger(); + #onInit = inject(FILTER_ON_INIT, { optional: true })?.map((fn) => fn(this)); #onCommit = inject(FILTER_ON_COMMIT, { optional: true })?.map((fn) => fn(this), @@ -33,7 +36,9 @@ export class FilterService { orderBy = this.#state.orderBy; constructor() { + this.#logger.info('FilterService initialized with default state:'); this.#onInit?.forEach((initFn) => { + this.#logger.info('Executing init function:', initFn); initFn(); }); } diff --git a/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.ts b/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.ts index 67410a2ee..14b5b1f67 100644 --- a/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.ts +++ b/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.ts @@ -13,6 +13,7 @@ import { NgIconComponent, provideIcons } from '@ng-icons/core'; import { FilterMenuComponent } from './filter-menu.component'; import { isaActionFilter } from '@isa/icons'; import { FilterService } from '../../core'; +import { logger, provideLoggerContext } from '@isa/core/logging'; /** * A button component that toggles the visibility of a filter menu. @@ -23,10 +24,20 @@ import { FilterService } from '../../core'; templateUrl: './filter-menu-button.component.html', styleUrls: ['./filter-menu-button.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [IconButtonComponent, OverlayModule, NgIconComponent, FilterMenuComponent], - providers: [provideIcons({ isaActionFilter })], + imports: [ + IconButtonComponent, + OverlayModule, + NgIconComponent, + FilterMenuComponent, + ], + providers: [ + provideIcons({ isaActionFilter }), + provideLoggerContext({ component: FilterMenuButtonComponent }), + ], }) export class FilterMenuButtonComponent { + #logger = logger(); + scrollStrategy = inject(Overlay).scrollStrategies.block(); #filter = inject(FilterService); @@ -68,6 +79,7 @@ export class FilterMenuButtonComponent { * Emits `opened` or `closed` events based on the new state. */ toggle() { + this.#logger.debug('toggle', this.open()); const open = this.open(); this.open.set(!open); diff --git a/tsconfig.base.json b/tsconfig.base.json index 1dfeea307..a4a8aebda 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -42,6 +42,7 @@ "@isa/catalogue/data-access": ["libs/catalogue/data-access/src/index.ts"], "@isa/common/result": ["libs/common/result/src/index.ts"], "@isa/core/config": ["libs/core/config/src/index.ts"], + "@isa/core/logging": ["libs/core/logging/src/index.ts"], "@isa/core/notifications": ["libs/core/notifications/src/index.ts"], "@isa/core/process": ["libs/core/process/src/index.ts"], "@isa/core/scanner": ["libs/core/scanner/src/index.ts"],