6. Guided Project 1: Building a Flexible Logger Service
This project will guide you through creating a flexible logging system using Injection-JS. The goal is to design a logger that can easily swap between different output destinations (e.g., console, file) and support multiple log levels, all managed by dependency injection.
We’ll start with the basics and incrementally add features, applying the core concepts you’ve learned.
Project Objective: Create a logging infrastructure that allows:
- Injectable
Loggerservice. - Different log “transports” (e.g.,
ConsoleTransport,FileTransport). - Configurable log level (e.g.,
INFO,WARN,ERROR). - Easy swapping of transports and configuration using Injection-JS providers.
Project Setup
Ensure you are in your injection-js-tutorial project directory. We’ll organize our code in src/logger-project.
mkdir -p src/logger-project
touch src/logger-project/tokens.ts
touch src/logger-project/logger.interface.ts
touch src/logger-project/transports.ts
touch src/logger-project/logger.service.ts
touch src/logger-project/config.ts
touch src/logger-project/main.ts # Our entry point for this project
Step 1: Define Logging Abstractions (Interface and Token)
It’s good practice to define an abstraction for your logger so that different implementations can adhere to a contract. Since TypeScript interfaces are erased at runtime, we’ll use an InjectionToken to refer to our Logger abstraction.
src/logger-project/logger.interface.ts:
// src/logger-project/logger.interface.ts
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
NONE = 4
}
export interface ILogger {
debug(message: string, context?: string): void;
info(message: string, context?: string): void;
warn(message: string, context?: string): void;
error(message: string, context?: string): void;
}
src/logger-project/tokens.ts:
// src/logger-project/tokens.ts
import { InjectionToken } from 'injection-js';
import { LogLevel } from './logger.interface';
// Token for the logger implementation
export const APP_LOGGER = new InjectionToken<ILogger>('APP_LOGGER');
// Token for logger configuration
export interface LoggerConfig {
minimumLogLevel: LogLevel;
// Could add more options like date format, etc.
}
export const LOGGER_CONFIG = new InjectionToken<LoggerConfig>('LOGGER_CONFIG');
// Token for logging transports (multi-provider)
export interface LogTransport {
log(level: LogLevel, message: string, context?: string): void;
}
export const LOG_TRANSPORTS = new InjectionToken<LogTransport[]>('LOG_TRANSPORTS');
Step 2: Create Log Transports
Log transports are responsible for the output of log messages (e.g., to console, to a file, to a remote server). We’ll create two simple transports.
src/logger-project/transports.ts:
// src/logger-project/transports.ts
import { Injectable } from 'injection-js';
import { LogLevel, LogTransport } from './tokens'; // Note: importing from tokens for convenience
@Injectable()
export class ConsoleTransport implements LogTransport {
log(level: LogLevel, message: string, context?: string): void {
const timestamp = new Date().toISOString();
const prefix = context ? `[${context}]` : '';
switch (level) {
case LogLevel.DEBUG:
console.debug(`${timestamp} ${prefix} [DEBUG]: ${message}`);
break;
case LogLevel.INFO:
console.info(`${timestamp} ${prefix} [INFO]: ${message}`);
break;
case LogLevel.WARN:
console.warn(`${timestamp} ${prefix} [WARN]: ${message}`);
break;
case LogLevel.ERROR:
console.error(`${timestamp} ${prefix} [ERROR]: ${message}`);
break;
default:
console.log(`${timestamp} ${prefix}: ${message}`);
}
}
}
// For FileTransport, we'll need Node's fs module.
// In a browser environment, this would be different (e.g., sending to a logging service).
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class FileTransport implements LogTransport {
private logFilePath: string;
constructor() {
this.logFilePath = path.join(process.cwd(), 'app.log');
// Ensure the log file exists
if (!fs.existsSync(this.logFilePath)) {
fs.writeFileSync(this.logFilePath, '', 'utf8');
}
}
log(level: LogLevel, message: string, context?: string): void {
const timestamp = new Date().toISOString();
const prefix = context ? `[${context}]` : '';
const levelStr = LogLevel[level];
const logEntry = `${timestamp} ${prefix} [${levelStr}]: ${message}\n`;
fs.appendFileSync(this.logFilePath, logEntry, 'utf8');
}
}
Step 3: Implement the Logger Service
This service will implement ILogger and inject the LoggerConfig and LogTransports. It will be responsible for filtering messages based on minimumLogLevel and dispatching them to all registered transports.
src/logger-project/logger.service.ts:
// src/logger-project/logger.service.ts
import { Injectable, Inject, Optional } from 'injection-js';
import {
ILogger, LogLevel, LoggerConfig, LOGGER_CONFIG, LogTransport, LOG_TRANSPORTS
} from './tokens';
@Injectable()
export class AppLogger implements ILogger {
private minimumLogLevel: LogLevel;
private transports: LogTransport[];
constructor(
@Inject(LOGGER_CONFIG) @Optional() config: LoggerConfig | null,
@Inject(LOG_TRANSPORTS) @Optional() transports: LogTransport[] | null
) {
this.minimumLogLevel = config?.minimumLogLevel ?? LogLevel.INFO; // Default to INFO if no config
this.transports = transports || [];
if (this.transports.length === 0) {
console.warn("AppLogger: No log transports provided. Logs will not be displayed anywhere.");
}
}
private shouldLog(level: LogLevel): boolean {
return level >= this.minimumLogLevel;
}
private dispatchLog(level: LogLevel, message: string, context?: string): void {
if (this.shouldLog(level)) {
this.transports.forEach(transport => {
transport.log(level, message, context);
});
}
}
debug(message: string, context?: string): void {
this.dispatchLog(LogLevel.DEBUG, message, context);
}
info(message: string, context?: string): void {
this.dispatchLog(LogLevel.INFO, message, context);
}
warn(message: string, context?: string): void {
this.dispatchLog(LogLevel.WARN, message, context);
}
error(message: string, context?: string): void {
this.dispatchLog(LogLevel.ERROR, message, context);
}
}
Notice the use of @Optional() for config and transports. This makes the logger more resilient if no config or transports are explicitly provided.
Step 4: Configure the Logger (Example Configuration)
Let’s create a default configuration.
src/logger-project/config.ts:
// src/logger-project/config.ts
import { LogLevel, LoggerConfig } from './tokens';
export const DEFAULT_LOGGER_CONFIG: LoggerConfig = {
minimumLogLevel: LogLevel.INFO
};
Step 5: Put It All Together (Main Application File)
Now, let’s create our main file (main.ts) to set up the injector and use our flexible logger.
src/logger-project/main.ts:
// src/logger-project/main.ts
import "reflect-metadata"; // KEEP THIS AT THE TOP
import { ReflectiveInjector, Injectable, Inject } from 'injection-js';
import { ILogger, APP_LOGGER, LoggerConfig, LOGGER_CONFIG, LogLevel, LOG_TRANSPORTS } from './tokens';
import { ConsoleTransport, FileTransport } from './transports';
import { AppLogger } from './logger.service';
import { DEFAULT_LOGGER_CONFIG } from './config';
// --- Example Service that uses the Logger ---
@Injectable()
class DataService {
constructor(@Inject(APP_LOGGER) private logger: ILogger) {}
fetchData(id: string): any {
this.logger.debug(`Attempting to fetch data for ID: ${id}`, 'DataService');
// Simulate some logic
if (id === 'error') {
this.logger.error(`Failed to fetch data for ID: ${id}`, 'DataService');
throw new Error('Data not found');
}
this.logger.info(`Successfully fetched data for ID: ${id}`, 'DataService');
return { id, data: `Sample data for ${id}` };
}
}
@Injectable()
class AuthService {
constructor(@Inject(APP_LOGGER) private logger: ILogger) {}
login(username: string): boolean {
this.logger.info(`User '${username}' attempting to log in.`, 'AuthService');
if (username === 'admin') {
this.logger.warn(`Admin login attempt detected.`, 'AuthService');
return true;
}
this.logger.debug(`User '${username}' login successful.`, 'AuthService');
return true;
}
}
// --- Setup Injector Scenarios ---
// Scenario 1: Console Logging, INFO level
console.log("\n--- Scenario 1: Console Logging, INFO Level ---");
const config1: LoggerConfig = { minimumLogLevel: LogLevel.INFO };
const injector1 = ReflectiveInjector.resolveAndCreate([
{ provide: LOGGER_CONFIG, useValue: config1 },
{ provide: LOG_TRANSPORTS, useClass: ConsoleTransport, multi: true },
{ provide: APP_LOGGER, useClass: AppLogger }, // Provide AppLogger as ILogger
DataService,
AuthService
]);
const dataService1 = injector1.get(DataService);
const authService1 = injector1.get(AuthService);
dataService1.fetchData('user-123');
authService1.login('guest');
try {
dataService1.fetchData('error');
} catch (e: any) {
console.error(`Caught error in main: ${e.message}`);
}
authService1.login('admin');
// Scenario 2: File Logging, DEBUG level
console.log("\n--- Scenario 2: File Logging, DEBUG Level ---");
// Clear existing log file for this scenario
import * as fs from 'fs';
import * as path from 'path';
fs.writeFileSync(path.join(process.cwd(), 'app.log'), '', 'utf8');
const config2: LoggerConfig = { minimumLogLevel: LogLevel.DEBUG };
const injector2 = ReflectiveInjector.resolveAndCreate([
{ provide: LOGGER_CONFIG, useValue: config2 },
{ provide: LOG_TRANSPORTS, useClass: FileTransport, multi: true }, // Only FileTransport
{ provide: APP_LOGGER, useClass: AppLogger },
DataService,
AuthService
]);
const dataService2 = injector2.get(DataService);
const authService2 = injector2.get(AuthService);
dataService2.fetchData('user-456');
authService2.login('debug-user');
dataService2.fetchData('another-data'); // This will log debug messages to file
To run this project:
- Open your
package.jsonand add a new script:"scripts": { "start": "tsc && node dist/app.js", "logger-project": "tsc && node dist/logger-project/main.js", // Add this line "test": "echo \"Error: no test specified\" && exit 1" }, - Now, compile and run:Observe the console output for Scenario 1. Then, check your project root for
npm run logger-projectapp.logfile, which should contain logs from Scenario 2.
Encouraging Independent Problem-Solving
You’ve built a solid flexible logging system. Here are some challenges to extend it:
Add a
RemoteTransport:- Create a new class
RemoteTransportthat implementsLogTransport. - Instead of
console.logorfs.appendFileSync, simulate sending logs to a remote API endpoint (e.g., usingconsole.logto represent network activity). - Update
main.tsto includeRemoteTransportas amulti: trueprovider, potentially alongsideConsoleTransport. Run and observe.
- Create a new class
Dynamic Log Level per Service:
- Currently,
minimumLogLevelis global. How could you provide a differentminimumLogLevelfor a specific service (e.g.,AuthServicealways logs DEBUG, even if the global is INFO)? - Hint: You’ll need an
InjectionTokenforAuthServiceLogLeveland possibly use a child injector or a factory provider that checks for this token.@Self()could be useful here.
- Currently,
Custom Log Message Formatting:
- Introduce a new
InjectionTokencalledLOG_FORMATTERthat expects a function(level: LogLevel, message: string, context?: string) => string. - Modify
ConsoleTransportandFileTransportto inject and use this formatter function instead of directly constructing the log string. - Provide different formatter functions in
main.tsto see how you can change the log output format without touching the transports themselves.
- Introduce a new
These challenges will push you to combine different Injection-JS concepts (multi-providers, custom tokens, factories, hierarchies) in practical ways, making your logging system even more robust and configurable.