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
8 changes: 8 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@ BACKUP_S3_SECRET_ACCESS_KEY=""
BACKUP_S3_ENDPOINT=""

# ============================================
# Metrics / Monitoring (see METRICS_DOCUMENTATION.md)
# ============================================
# Shared secret monitoring agents send as X-Metrics-Token or Authorization: Bearer.
# Required in production: without it the metrics endpoints return 503.
METRICS_AUTH_TOKEN=""

# Max metrics scrapes per minute per identity
METRICS_RATE_LIMIT=120
# Payload Encryption Key Rotation
# ============================================
# AES-256-GCM keys used to encrypt sensitive payload fields at rest.
Expand Down
68 changes: 68 additions & 0 deletions backend/API_ERROR_CONTRACT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# API Error Contract

Every handled error returned by the backend uses one envelope. The shape is
declared in `src/utils/apiError.ts` and published in OpenAPI as
`components.schemas.ErrorEnvelope` (see `/api-docs`).

## Envelope

```json
{
"error": {
"version": "1",
"code": "VALIDATION_FAILED",
"message": "Request validation failed",
"requestId": "9f1c2e3a-6b74-4c0f-9a5c-7b1d2e3f4a5b",
"timestamp": "2026-01-01T12:00:00.000Z",
"fieldErrors": [{ "field": "tokenId", "message": "tokenId must be alphanumeric" }]
}
}
```

| Field | Always present | Notes |
| --- | --- | --- |
| `version` | yes | Envelope schema version. Bumped only on a breaking change. |
| `code` | yes | Stable machine-readable code — branch on this, never on `message`. |
| `message` | yes | Client-safe text. Server faults collapse to a generic sentence. |
| `requestId` | yes | Correlation ID; also returned as the `X-Correlation-ID` header. |
| `timestamp` | yes | ISO 8601, server clock. |
| `fieldErrors` | no | Present on validation failures. Field path + reason only — never the submitted value. |

## Codes

`BAD_REQUEST`, `VALIDATION_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`,
`CONFLICT`, `UNPROCESSABLE_ENTITY`, `RATE_LIMITED`, `INTERNAL_ERROR`,
`SERVICE_UNAVAILABLE`.

## Raising errors

```ts
import { ApiError } from '../utils/apiError.js';

throw ApiError.notFound('Certificate not found');
throw ApiError.validationFailed('Request validation failed', [
{ field: 'grade', message: 'grade must be one of A–F' },
]);
throw ApiError.internal(); // message is replaced with the generic sentence
```

Anything else that reaches the global handler (`src/middleware/errorHandler.ts`)
becomes a 500 `INTERNAL_ERROR`. Zod failures raised by
`src/middleware/validation.ts` become 400 `VALIDATION_FAILED` with `fieldErrors`.

## Client messages vs server logs

Stack traces and raw error messages never leave the process. For every error the
handler writes a log entry containing `requestId`, `code`, `statusCode`, the raw
message, the stack and the request method/path — 5xx at `error` level, 4xx at
`warn`. To investigate a report, take the `requestId` the client saw and search
the logs for it.

`requestId` resolution order: the ID assigned by `detailedRequestLogger`, then an
inbound `X-Correlation-ID` or `X-Request-ID` header, then a freshly generated
UUID — so an error response is never returned without one.

## Tests

- `tests/errorEnvelope.routes.test.ts` — route-level contract (validation, 404, 500, correlation ID echo).
- `tests/sentry.errorHandler.test.ts` — global handler unit behaviour.
131 changes: 131 additions & 0 deletions backend/METRICS_DOCUMENTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Metrics & Monitoring

The backend exports aggregated in-process metrics in a form monitoring systems
can consume without manual parsing.

## Endpoints

| Endpoint | Format | Purpose |
| --- | --- | --- |
| `GET /api/v1/metrics/prometheus` | `text/plain; version=0.0.4` | Scrape target. Stable names, units in the name. |
| `GET /api/v1/metrics/snapshot` | JSON (`schemaVersion: "1"`) | Same aggregation for JSON-only tooling. |
| `GET /api/v1/metrics` | JSON | Legacy summary shape. |
| `GET /api/v1/metrics/performance` | JSON | Retained per-request samples (ring buffer). |
| `GET /api/v1/metrics/errors` | JSON | Error entries with **messages redacted**. |
| `GET /api/v1/metrics/business` | JSON | Domain event entries. |
| `POST /api/v1/metrics/reset` | JSON | Clears counters (admin/manual use). |
| `GET /api/v1/cache/metrics` | JSON | Cache hit/miss plus backend reachability. |

### Authorization and rate control

All of the above require the monitoring secret `METRICS_AUTH_TOKEN`, sent as
either header:

```
X-Metrics-Token: <token>
Authorization: Bearer <token>
```

Comparison is constant-time. If `METRICS_AUTH_TOKEN` is unset the endpoints stay
open in development and test but return `503 SERVICE_UNAVAILABLE` in production,
so a deployed instance is never unprotected. Scrapes are rate limited to
`METRICS_RATE_LIMIT` requests/minute per identity (default 120). Failures use the
standard [error envelope](./API_ERROR_CONTRACT.md).

Example Prometheus scrape config:

```yaml
scrape_configs:
- job_name: web3-student-lab-api
scrape_interval: 30s
metrics_path: /api/v1/metrics/prometheus
static_configs:
- targets: ['api.internal:8080']
authorization:
type: Bearer
credentials_file: /etc/prometheus/w3sl-metrics-token
```

## Exported metrics

All names are prefixed `w3sl_`. Counters are monotonic per process lifetime.

| Metric | Type | Unit | Description |
| --- | --- | --- | --- |
| `w3sl_cache_backend_up{mode}` | gauge | boolean | 1 = cache backend reachable, 0 = unreachable. `mode` is `standalone`/`cluster`/`sentinel`. |
| `w3sl_cache_hits_total` | counter | lookups | Lookups served from cache. |
| `w3sl_cache_misses_total` | counter | lookups | Lookups that missed. |
| `w3sl_cache_hit_ratio` | gauge | ratio 0–1 | Hit ratio over the process lifetime. |
| `w3sl_http_requests_total{method,route}` | counter | requests | Requests by method and **normalised** route. |
| `w3sl_http_responses_total{status_class}` | counter | responses | Responses by `2xx`/`4xx`/`5xx`. |
| `w3sl_http_request_duration_milliseconds_avg` | gauge | ms | Mean duration over retained samples. |
| `w3sl_errors_total{type}` | counter | errors | Errors by type/class name. `type="all"` is the total. |
| `w3sl_business_events_total{event}` | counter | events | Domain events, e.g. `certificate.minted`. |
| `w3sl_worker_up{worker,state}` | gauge | boolean | 1 = running, 0 = stopped/degraded. |
| `w3sl_worker_jobs_completed_total{worker}` | counter | jobs | Jobs completed per worker. |
| `w3sl_worker_jobs_failed_total{worker}` | counter | jobs | Jobs failed per worker. |
| `w3sl_process_uptime_seconds` | gauge | seconds | Process uptime. |
| `w3sl_process_resident_memory_bytes` | gauge | bytes | Node heap usage. |
| `w3sl_process_cpu_user_seconds_total` | counter | seconds | User CPU time. |

Known `worker` labels: `storage-pin`, `storage-gc`, `webhook-delivery`.

### What is deliberately excluded

- Request and response bodies, query strings and headers.
- User, student and wallet identifiers — route labels have identifier-looking
segments rewritten to `:id` (`/certificates/4242` → `/certificates/:id`), which
also keeps label cardinality bounded.
- Error *messages*. Only the error type is exported; the full message and stack
live in the logs, correlated by the `requestId` from the error envelope.
- Business event metadata (only the event name and count are exported).

## Dashboards

**API health**
1. Request rate — `sum(rate(w3sl_http_requests_total[5m]))`
2. Error ratio — `sum(rate(w3sl_http_responses_total{status_class="5xx"}[5m])) / sum(rate(w3sl_http_responses_total[5m]))`
3. Mean latency — `w3sl_http_request_duration_milliseconds_avg`
4. Top routes — `topk(10, sum by (route) (rate(w3sl_http_requests_total[5m])))`

**Cache health**
1. `w3sl_cache_backend_up` as a status tile
2. `w3sl_cache_hit_ratio` trend
3. Lookup rate — `rate(w3sl_cache_hits_total[5m])` vs `rate(w3sl_cache_misses_total[5m])`

**Workers**
1. `w3sl_worker_up` per worker as status tiles
2. Failure rate — `rate(w3sl_worker_jobs_failed_total[15m])`
3. Throughput — `rate(w3sl_worker_jobs_completed_total[15m])`

**Process**: uptime (restart detection), resident memory, CPU seconds.

## Alert-worthy signals

| Alert | Condition | Severity |
| --- | --- | --- |
| Cache backend down | `w3sl_cache_backend_up == 0` for 2m | critical |
| Elevated 5xx | 5xx ratio > 2% for 5m | critical |
| Latency regression | `w3sl_http_request_duration_milliseconds_avg > 1000` for 10m | warning |
| Cache hit ratio collapse | `w3sl_cache_hit_ratio < 0.5` for 15m (with non-trivial lookup rate) | warning |
| Worker down | `w3sl_worker_up == 0` for 5m while the app is up | critical |
| Worker failures | `rate(w3sl_worker_jobs_failed_total[15m]) > 0.1` | warning |
| Memory growth | `w3sl_process_resident_memory_bytes` up >50% over 1h with flat traffic | warning |
| Frequent restarts | `w3sl_process_uptime_seconds` resets more than twice in 30m | warning |
| Scrape failure | target down for 5m | warning |

When an alert fires, take the correlation ID from the affected request's error
envelope (or the log entry) and search the logs — metrics intentionally carry no
request detail.

## Implementation notes

Counters are per-process and in memory: they reset on restart, and with multiple
instances each one must be scraped separately (aggregate in the monitoring
system). Retained raw samples are bounded to 10,000 entries per category
(ring buffer) in `src/metrics/MetricsCollector.ts`.

- `src/metrics/MetricsExporter.ts` — snapshot + Prometheus rendering
- `src/metrics/WorkerRegistry.ts` — worker liveness and job counters
- `src/middleware/metricsAuth.ts` — monitoring token check
- `tests/metricsExporter.test.ts` — schema, label safety and auth tests
4 changes: 4 additions & 0 deletions backend/src/cache/CacheMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Router } from 'express';
import cacheService from './CacheService.js';
import redisClient from './RedisClient.js';
import { requireMetricsAuth } from '../middleware/metricsAuth.js';

const router: ReturnType<typeof Router> = Router();

// Cache metrics are operational data — same authorization as /api/v1/metrics.
router.use(requireMetricsAuth);

router.get('/metrics', (_req, res) => {
const metrics = cacheService.getMetrics();
const isHealthy = redisClient.isHealthy();
Expand Down
57 changes: 36 additions & 21 deletions backend/src/certificates/CertificateService.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
/**
* CertificateService — issuance, verification and reporting for course
* certificates. Fully type-checked: request, response and domain shapes are
* declared explicitly instead of being suppressed.
*/


import prisma from '../db/index.js';
import { storageService } from '../services/storage/index.js';
import { certificateBlockchainService } from '../blockchain/CertificateBlockchainService.js';
import {
Certificate,
CertificateMetadata,
MintCertificateRequest,
VerificationResult,
Certificate,
CertificateMetadata,
CertificateStatus,
MintCertificateRequest,
VerificationResult,
} from '../types/certificate.types.js';
import { certificateImageGenerator } from '../utils/certificateImageGenerator.js';
import logger from '../utils/logger.js';
Expand Down Expand Up @@ -90,8 +98,6 @@ export class CertificateService {
},
});

let metadata: CertificateMetadata | undefined;

try {
// Generate and pin the certificate image and metadata to decentralized storage
const imageBuffer = await certificateImageGenerator.generateCertificateImage({
Expand All @@ -110,7 +116,7 @@ export class CertificateService {
mimeType: 'image/svg+xml',
});

metadata = this.metadataGenerator.generate(certificate, course, student, {
const metadata = this.metadataGenerator.generate(certificate, course, student, {
imageUri: imageAsset.ipfsUri,
externalUrl: `${process.env.API_BASE_URL || 'http://localhost:8080'}/api/v1/certificates/${
certificate.tokenId || tokenIdValue
Expand All @@ -124,7 +130,7 @@ export class CertificateService {

const metadataAsset = await storageService.pinCertificateMetadata({
certificateId: certificateId,
content: metadata,
content: { ...metadata },
});

// Call blockchain service to mint actual NFT
Expand Down Expand Up @@ -163,15 +169,17 @@ export class CertificateService {
// Update returned certificate
certificate.certificateHash = mintResult.transactionHash;
certificate.contractAddress = mintResult.contractAddress;
certificate.status = 'ACTIVE' as any;
certificate.tokenId = finalTokenId;
certificate.contentHash = contentHash;
certificate.status = CertificateStatus.ACTIVE;
certificate.tokenId = mintResult.tokenId || tokenIdValue;

logger.info(`Certificate minted on-chain: ${certificateId} -> token ${mintResult.tokenId}`, {
certificateId,
tokenId: mintResult.tokenId,
txHash: mintResult.transactionHash,
});

// Return certificate with metadata
return { ...certificate, metadata };
} catch (error) {
logger.error(`Certificate issuance failed for ${certificateId}:`, error);
await prisma.certificate.update({
Expand All @@ -185,9 +193,6 @@ export class CertificateService {
`Failed to mint certificate: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}

// Return certificate with metadata
return { ...certificate, metadata };
}

/**
Expand Down Expand Up @@ -250,7 +255,7 @@ export class CertificateService {
return {
isValid: false,
certificate: null,
status: 'invalid' as any,
status: CertificateStatus.INVALID,
onChainData: null,
message: 'Certificate not found',
};
Expand Down Expand Up @@ -307,13 +312,13 @@ export class CertificateService {
};

const result: VerificationResult = {
isValid: certificate.status === 'ACTIVE',
isValid: certificate.status === CertificateStatus.ACTIVE,
certificate: metadata,
status: certificate.status as any,
status: this.toCertificateStatus(certificate.status),
onChainData,
};

if (certificate.status === 'REVOKED') {
if (certificate.status === CertificateStatus.REVOKED) {
result.revocationInfo = {
revokedAt: certificate.revokedAt!,
reason: certificate.revocationReason!,
Expand Down Expand Up @@ -369,7 +374,7 @@ export class CertificateService {
results.push({
isValid: false,
certificate: null,
status: 'invalid' as any,
status: CertificateStatus.INVALID,
onChainData: null,
message: 'Certificate not found',
});
Expand All @@ -390,9 +395,9 @@ export class CertificateService {
};

results.push({
isValid: cert.status === 'ACTIVE',
isValid: cert.status === CertificateStatus.ACTIVE,
certificate: metadata,
status: cert.status as any,
status: this.toCertificateStatus(cert.status),
onChainData,
});
}
Expand Down Expand Up @@ -627,6 +632,16 @@ export class CertificateService {
});
}

/**
* Narrows a persisted status string to the CertificateStatus union.
* Unknown values (legacy rows, manual edits) resolve to INVALID rather
* than being cast blindly.
*/
private toCertificateStatus(status: string): CertificateStatus {
const known = Object.values(CertificateStatus) as string[];
return known.includes(status) ? (status as CertificateStatus) : CertificateStatus.INVALID;
}

/**
* Extracts wallet address from DID string
*/
Expand Down
Loading