diff --git a/docs/LOGGING_STRATEGY.md b/docs/LOGGING_STRATEGY.md new file mode 100644 index 00000000..ca5c9884 --- /dev/null +++ b/docs/LOGGING_STRATEGY.md @@ -0,0 +1,58 @@ +# TeachLink Logging Strategy + +## Overview + +This document defines the standard logging patterns, formats, and levels across the TeachLink ecosystem. Consistent, structured logging is critical for observability, debugging, and security auditing for both off-chain infrastructure (e.g., the Indexer) and on-chain smart contracts. + +## 1. Log Levels + +The following standard log levels MUST be used across all off-chain services: + +- **`error`**: System failures, critical errors requiring immediate attention, uncaught exceptions, and transaction failures. +- **`warn`**: Non-critical errors, anomalous but recoverable states, deprecated API usage, and retries. +- **`info`**: Normal system operations, business logic milestones (e.g., indexer block processing), startup/shutdown events. +- **`debug`**: Detailed diagnostic information, granular state transitions, payload dumps, and tracing for local development. + +*Note: The environment variable `LOG_LEVEL` dictates the minimum severity level output by the application.* + +## 2. Structured Logging Format + +All off-chain services MUST output logs in **Structured JSON format** when running in production (`NODE_ENV=production`). This ensures seamless ingestion by modern log aggregators and monitoring tools (e.g., Datadog, ELK, CloudWatch). + +### Standard JSON Schema + +- **`timestamp`**: ISO 8601 UTC string (e.g., `2023-11-20T12:00:00.000Z`) +- **`level`**: The log severity level (e.g., `info`, `error`) +- **`context`**: The module, class, or domain emitting the log (e.g., `EventProcessorService`) +- **`message`**: A concise, human-readable description of the event +- **`data`**: (Optional) Additional contextual structured metadata (e.g., `txHash`, `userId`, `contractId`) + +### Example Production Log Entry + +```json +{ + "timestamp": "2023-11-20T12:00:05.123Z", + "level": "info", + "context": "HorizonService", + "message": "Successfully processed bridge transaction", + "data": { + "txHash": "0xabc123...", + "ledger": 456789 + } +} +``` + +In non-production environments, logs may fallback to a human-readable, color-coded console output for better developer experience (DX). + +## 3. Smart Contract Logging + +Soroban smart contracts operate natively on-chain, where traditional `stdout` logging is not viable for production. + +1. **Production Auditing (Events):** Use Soroban `#[contractevent]` structs as the primary mechanism for emitting structured logs. Events inherently provide structured, tamper-evident logging that off-chain indexers consume. +2. **Local Debugging:** Use `env.logs().print(...)` exclusively for local testing and development. These statements should ideally be removed or disabled in production deployments. + +## 4. Best Practices + +- **Do not log sensitive data:** PII (Personally Identifiable Information), private keys, secrets, and auth tokens MUST NOT be logged. +- **Include correlation IDs:** When processing user requests or multi-step asynchronous tasks, include a correlation identifier in the `data` field to trace the execution path. +- **Keep messages static:** The `message` field should be a static string (e.g., `"User login failed"`), while dynamic variables should be placed in the `data` object to facilitate aggregations and log metrics. \ No newline at end of file diff --git a/docs/logger.module.ts b/docs/logger.module.ts new file mode 100644 index 00000000..b39d2631 --- /dev/null +++ b/docs/logger.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { LoggerService } from './logger.service'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [LoggerService], + exports: [LoggerService], +}) +export class LoggerModule {} \ No newline at end of file diff --git a/docs/logger.service.ts b/docs/logger.service.ts new file mode 100644 index 00000000..aad1e465 --- /dev/null +++ b/docs/logger.service.ts @@ -0,0 +1,101 @@ +import { LoggerService as NestLoggerService, Injectable, Scope } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable({ scope: Scope.TRANSIENT }) +export class LoggerService implements NestLoggerService { + private context?: string; + private readonly isProduction: boolean; + private readonly logLevel: string; + + constructor(private configService: ConfigService) { + this.isProduction = this.configService.get('app.nodeEnv') === 'production'; + this.logLevel = this.configService.get('app.logLevel') || 'info'; + } + + setContext(context: string) { + this.context = context; + } + + private shouldLog(level: string): boolean { + const levels = ['debug', 'info', 'warn', 'error']; + const configuredIndex = levels.indexOf(this.logLevel.toLowerCase()); + const targetIndex = levels.indexOf(level); + return targetIndex >= configuredIndex; + } + + private formatMessage(level: string, message: any, optionalParams: any[]) { + const timestamp = new Date().toISOString(); + + let metadata = {}; + let currentContext = this.context || 'Application'; + + if (optionalParams.length > 0) { + // Extract trailing string arguments (usually used as the context internally by NestJS) + if (typeof optionalParams[optionalParams.length - 1] === 'string') { + currentContext = optionalParams.pop(); + } + + // Next trailing param logic checks if it's an object intended for structured metadata logging + if (optionalParams.length > 0) { + const lastParam = optionalParams[optionalParams.length - 1]; + if (typeof lastParam === 'object' && lastParam !== null) { + metadata = optionalParams.pop(); + } + } + } + + const logEntry = { + timestamp, + level, + context: currentContext, + message: typeof message === 'object' ? JSON.stringify(message) : message, + ...(Object.keys(metadata).length > 0 ? { data: metadata } : {}), + }; + + if (this.isProduction) { + return JSON.stringify(logEntry); + } + + // Human-readable format output tailored for development environments + const colorCode = this.getColorCode(level); + const resetCode = '\x1b[0m'; + const metadataStr = Object.keys(metadata).length > 0 ? `\n\x1b[33m[Data]: ${JSON.stringify(metadata, null, 2)}\x1b[0m` : ''; + + return `${colorCode}[${timestamp}] [${level.toUpperCase()}] [${currentContext}] ${logEntry.message}${resetCode}${metadataStr}`; + } + + private getColorCode(level: string): string { + switch (level) { + case 'error': return '\x1b[31m'; // Red + case 'warn': return '\x1b[33m'; // Yellow + case 'info': return '\x1b[32m'; // Green + case 'debug': return '\x1b[36m'; // Cyan + default: return '\x1b[37m'; // White + } + } + + log(message: any, ...optionalParams: any[]) { + if (!this.shouldLog('info')) return; + console.log(this.formatMessage('info', message, optionalParams)); + } + + error(message: any, ...optionalParams: any[]) { + if (!this.shouldLog('error')) return; + console.error(this.formatMessage('error', message, optionalParams)); + } + + warn(message: any, ...optionalParams: any[]) { + if (!this.shouldLog('warn')) return; + console.warn(this.formatMessage('warn', message, optionalParams)); + } + + debug(message: any, ...optionalParams: any[]) { + if (!this.shouldLog('debug')) return; + console.debug(this.formatMessage('debug', message, optionalParams)); + } + + verbose?(message: any, ...optionalParams: any[]) { + if (!this.shouldLog('debug')) return; + console.debug(this.formatMessage('debug', message, optionalParams)); + } +} \ No newline at end of file diff --git a/recommendation-system/src/explainability/explainability.ts b/recommendation-system/src/explainability/explainability.ts index 866fdb42..f71378e4 100644 --- a/recommendation-system/src/explainability/explainability.ts +++ b/recommendation-system/src/explainability/explainability.ts @@ -30,53 +30,17 @@ export class ExplanationGenerator { similarContent?: Array<[string, number]>, similarUsers?: string[] ): Types.RecommendationExplanation { - let primaryReason = ''; - const supportingSignals: string[] = []; - const featureAttribution: Array<{ - feature: string; - importance: number; - contribution: string; - }> = []; - // Determine dominant signal const signals = Object.entries(rankingSignal); const [dominantSignal] = signals.reduce((a, b) => (a[1] > b[1] ? a : b)); // Generate primary reason and supporting signals - if (dominantSignal === 'collaborativeSignal' && similarUsers && similarUsers.length > 0) { - primaryReason = `Users like you enjoyed this content`; - supportingSignals.push(`Liked by ${similarUsers.length} similar learners`); - featureAttribution.push({ - feature: 'user_similarity', - importance: rankingSignal.collaborativeSignal, - contribution: `Based on similar learning patterns`, - }); - } else if (dominantSignal === 'contentSignal') { - primaryReason = `Matches your interests`; - const topics = Array.from(userProfile.features.topicAffinities.keys()).slice(0, 2); - supportingSignals.push(`Related to your interest in ${topics.join(' and ')}`); - featureAttribution.push({ - feature: 'topic_match', - importance: rankingSignal.contentSignal, - contribution: `Content topic alignment with your profile`, - }); - } else if (dominantSignal === 'learningPathSignal') { - primaryReason = `Recommended based on your learning path`; - supportingSignals.push(`Prerequisite for your next goal`); - featureAttribution.push({ - feature: 'learning_path_fit', - importance: rankingSignal.learningPathSignal, - contribution: `Aligns with recommended progression`, - }); - } else if (dominantSignal === 'qualitySignal') { - primaryReason = `High-quality content`; - supportingSignals.push(`Highly rated by other learners`); - featureAttribution.push({ - feature: 'content_quality', - importance: rankingSignal.qualitySignal, - contribution: `Strong engagement and completion metrics`, - }); - } + const { primaryReason, supportingSignals, featureAttribution } = this.extractDominantSignalExplanation( + dominantSignal, + rankingSignal, + userProfile, + similarUsers + ); // Add modality preference explanation if (userProfile.features.preferredModality === Types.ContentModality.VIDEO) { @@ -105,6 +69,69 @@ export class ExplanationGenerator { }; } + /** + * Extracts the explanation based on the dominant ranking signal + */ + private extractDominantSignalExplanation( + dominantSignal: string, + rankingSignal: any, + userProfile: Types.UserProfile, + similarUsers?: string[] + ): { + primaryReason: string; + supportingSignals: string[]; + featureAttribution: Array<{ feature: string; importance: number; contribution: string }>; + } { + const result = { + primaryReason: '', + supportingSignals: [] as string[], + featureAttribution: [] as Array<{ feature: string; importance: number; contribution: string }>, + }; + + switch (dominantSignal) { + case 'collaborativeSignal': + if (!similarUsers || similarUsers.length === 0) break; // Guard clause + result.primaryReason = `Users like you enjoyed this content`; + result.supportingSignals.push(`Liked by ${similarUsers.length} similar learners`); + result.featureAttribution.push({ + feature: 'user_similarity', + importance: rankingSignal.collaborativeSignal, + contribution: `Based on similar learning patterns`, + }); + break; + case 'contentSignal': + result.primaryReason = `Matches your interests`; + const topics = Array.from(userProfile.features.topicAffinities.keys()).slice(0, 2); + result.supportingSignals.push(`Related to your interest in ${topics.join(' and ')}`); + result.featureAttribution.push({ + feature: 'topic_match', + importance: rankingSignal.contentSignal, + contribution: `Content topic alignment with your profile`, + }); + break; + case 'learningPathSignal': + result.primaryReason = `Recommended based on your learning path`; + result.supportingSignals.push(`Prerequisite for your next goal`); + result.featureAttribution.push({ + feature: 'learning_path_fit', + importance: rankingSignal.learningPathSignal, + contribution: `Aligns with recommended progression`, + }); + break; + case 'qualitySignal': + result.primaryReason = `High-quality content`; + result.supportingSignals.push(`Highly rated by other learners`); + result.featureAttribution.push({ + feature: 'content_quality', + importance: rankingSignal.qualitySignal, + contribution: `Strong engagement and completion metrics`, + }); + break; + } + + return result; + } + /** * Rule-based explanation generator */ @@ -112,13 +139,18 @@ export class ExplanationGenerator { userProfile: Types.UserProfile, signals: string[] ): string { + if (!userProfile) return ''; // Guard clause + const rules: string[] = []; // Learning pattern rules - if (userProfile.behavior.pattern === Types.UserBehaviorPattern.FAST_TRACK) { - rules.push('You are a fast learner, so we prioritize advanced content'); - } else if (userProfile.behavior.pattern === Types.UserBehaviorPattern.STRUGGLING) { - rules.push('We detected you need support in this area, recommending foundational content'); + switch (userProfile.behavior.pattern) { + case Types.UserBehaviorPattern.FAST_TRACK: + rules.push('You are a fast learner, so we prioritize advanced content'); + break; + case Types.UserBehaviorPattern.STRUGGLING: + rules.push('We detected you need support in this area, recommending foundational content'); + break; } // Engagement rules @@ -380,9 +412,17 @@ export class TransparencyDashboard { for (const rec of recommendations) { const modality = rec.metadata.modality; - if (modality === Types.ContentModality.VIDEO) videoCount++; - else if (modality === Types.ContentModality.TEXT) textCount++; - else if (modality === Types.ContentModality.INTERACTIVE) interactiveCount++; + switch (modality) { + case Types.ContentModality.VIDEO: + videoCount++; + break; + case Types.ContentModality.TEXT: + textCount++; + break; + case Types.ContentModality.INTERACTIVE: + interactiveCount++; + break; + } const difficulty = rec.metadata.difficulty; const diffKey = Object.keys(Types.DifficultyLevel)[difficulty - 1]?.toLowerCase() || 'unknown';