Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/LOGGING_STRATEGY.md
Original file line number Diff line number Diff line change
@@ -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).

Comment on lines +18 to +21
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This strategy doc states that all off-chain services MUST emit structured JSON logs in production, but the PR doesn’t actually integrate the provided LoggerService into any service (e.g., the Nest indexer still uses @nestjs/common Logger). Either update the documentation to describe the current state, or wire the implementation into the runtime services so the doc is accurate.

Copilot uses AI. Check for mistakes.
### 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.
11 changes: 11 additions & 0 deletions docs/logger.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { LoggerService } from './logger.service';

Comment on lines +1 to +4
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These NestJS LoggerModule/LoggerService implementations live under docs/ and are not imported anywhere, so they won’t actually standardize runtime logging for services like the indexer. If this is intended for production use, move it into the Nest app source (e.g., indexer/src/logger/) and wire it into DI / NestFactory.create({ logger: ... }).

Copilot uses AI. Check for mistakes.
@Global()
@Module({
imports: [ConfigModule],
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
101 changes: 101 additions & 0 deletions docs/logger.service.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +4 to +6
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new provider is named LoggerService, which is easy to confuse with Nest’s LoggerService interface and with other logger implementations. Consider renaming this class to something more specific (e.g., StructuredLoggerService / AppLogger) to avoid ambiguous imports and improve clarity.

Copilot uses AI. Check for mistakes.
private readonly isProduction: boolean;
private readonly logLevel: string;

constructor(private configService: ConfigService) {
this.isProduction = this.configService.get<string>('app.nodeEnv') === 'production';
this.logLevel = this.configService.get<string>('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());
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldLog() behaves incorrectly when the configured logLevel is not one of the supported values: levels.indexOf(...) returns -1, which makes targetIndex >= configuredIndex always true and logs everything. Add a fallback (e.g., treat unknown configured levels as info) before comparing indices.

Suggested change
const configuredIndex = levels.indexOf(this.logLevel.toLowerCase());
const normalizedLogLevel = this.logLevel.toLowerCase();
const effectiveLogLevel = levels.includes(normalizedLogLevel) ? normalizedLogLevel : 'info';
const configuredIndex = levels.indexOf(effectiveLogLevel);

Copilot uses AI. Check for mistakes.
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') {
Comment on lines +33 to +34
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatMessage() assumes the trailing string in optionalParams is always a Nest context and pops it. For error(message, trace) calls (a common Nest signature), the stack trace is a string and will be incorrectly treated as the context, losing the trace and corrupting the context field. Handle error() separately (accept trace?: string, context?: string), or only treat the last string as context when there are 2+ trailing strings / a known context is provided.

Suggested change
// Extract trailing string arguments (usually used as the context internally by NestJS)
if (typeof optionalParams[optionalParams.length - 1] === 'string') {
// Only treat the final string as context when there are multiple trailing strings.
// This preserves Nest's common error(message, trace) signature, where the single
// trailing string is a stack trace rather than a context value.
let trailingStringCount = 0;
for (let i = optionalParams.length - 1; i >= 0; i--) {
if (typeof optionalParams[i] === 'string') {
trailingStringCount++;
} else {
break;
}
}
if (trailingStringCount >= 2) {

Copilot uses AI. Check for mistakes.
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 } : {}),
};
Comment on lines +49 to +53
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.stringify(message) can throw (e.g., circular references, BigInt) and would cause the logger to throw during normal operation. Wrap serialization in a try/catch and fall back to a safe serializer so logging is best-effort and never crashes the process.

Copilot uses AI. Check for mistakes.

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` : '';

Comment on lines +60 to +63
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.stringify(metadata, null, 2) can also throw on non-serializable values (circular refs, BigInt). Since this is in the log path, ensure formatting cannot throw—use a safe serializer/fallback for dev output too.

Copilot uses AI. Check for mistakes.
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));
}
}
138 changes: 89 additions & 49 deletions recommendation-system/src/explainability/explainability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +38 to +42
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is titled/positioned as implementing consistent logging patterns, but this refactor in the recommendation explainability layer is unrelated to logging. Consider splitting these changes into a separate PR (or updating the PR description) so the logging work and its review/risk are isolated.

Copilot uses AI. Check for mistakes.
);

// Add modality preference explanation
if (userProfile.features.preferredModality === Types.ContentModality.VIDEO) {
Expand Down Expand Up @@ -105,20 +69,88 @@ export class ExplanationGenerator {
};
}

/**
* Extracts the explanation based on the dominant ranking signal
*/
private extractDominantSignalExplanation(
dominantSignal: string,
rankingSignal: any,
userProfile: Types.UserProfile,
similarUsers?: string[]
Comment on lines +75 to +79
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractDominantSignalExplanation widens rankingSignal to any, even though generateExplanation already has a concrete { collaborativeSignal/contentSignal/... } type. Keeping the same explicit type (or a shared alias) will prevent accidental missing/typoed signal keys and improve editor support.

Copilot uses AI. Check for mistakes.
): {
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
*/
private generateRuleBasedExplanation(
userProfile: Types.UserProfile,
signals: string[]
): string {
if (!userProfile) return ''; // Guard clause

Comment on lines +142 to +143
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userProfile is a required parameter in the method signature, so if (!userProfile) return '' is unreachable under TypeScript typing and can silently hide programming errors at runtime. Either remove this guard to fail fast, or change the signature to accept userProfile?: Types.UserProfile and handle the undefined case intentionally.

Suggested change
if (!userProfile) return ''; // Guard clause

Copilot uses AI. Check for mistakes.
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
Expand Down Expand Up @@ -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';
Expand Down
Loading