Skip to content
Open
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
29 changes: 27 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ The Orivex API provides endpoints for user management, learning modules, rewards

**Base URL:** `https://api.orivex.io/v1` (production) or `http://localhost:3001/v1` (development)

## Request correlation (`X-Request-Id`)

Every response includes an `X-Request-Id` header so clients and operators can
correlate a single HTTP call with server logs.

```txt
X-Request-Id: 550e8400-e29b-41d4-a716-446655440000
```

Behavior:

- If the client sends a valid `X-Request-Id` (1–128 characters of
`[A-Za-z0-9_.:-]`), the server **honors** it and echoes it back.
- Missing, oversized, or malformed values are **replaced** with a newly
generated UUID v4. Invalid headers never cause the request to fail.
- Error responses also include the same value as `error.requestId` in the JSON
envelope (see [Error Handling](#error-handling)).

Browser clients can read the header: CORS exposes `X-Request-Id` via
`Access-Control-Expose-Headers`. Clients that ignore unknown headers or fields
remain compatible.

## Authentication

Most endpoints require authentication using a JWT token.
Expand Down Expand Up @@ -453,17 +475,20 @@ Error response format:

```json
{
"status": "error",
"success": false,
"error": {
"code": "RESOURCE_NOT_FOUND",
"code": 404,
"message": "The requested module was not found",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"details": {
"moduleId": "mod_invalid"
}
}
}
```

The `requestId` matches the `X-Request-Id` response header for the same call.

## Rate Limiting

- Public endpoints: 60 requests per minute
Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ versioned JSON API under `/api/v1` and is responsible for:
| Validation | Zod schemas in `src/schemas`, applied by `validation.middleware.ts` |
| Rate limiting | `src/middleware/rate-limit.middleware.ts` backed by Redis (production) or in-memory Map (development / test) |
| Error response shape | `src/utils/errors.ts`, formatted by `error.middleware.ts` |
| Logging | `src/config/logger.ts` (Winston) + `morgan` request logs |
| Logging | `src/config/logger.ts` (Winston) + `morgan` request logs; correlated via `X-Request-Id` / AsyncLocalStorage |
| Request correlation | `src/middleware/request-id.middleware.ts` — UUID v4 (or honored inbound ID), echoed as `X-Request-Id` |
| Webhook delivery | `src/services/webhook.service.ts` with HMAC `X-Orivex-Signature` |
| Push notifications | `src/services/notification.service.ts` via Firebase Admin |
| Crypto / Stellar | `src/services/stellar.service.ts`, `src/services/soroban.service.ts` |
Expand Down
4 changes: 3 additions & 1 deletion docs/ERROR_HANDLING.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ app.use(errorHandler)
"success": false,
"error": {
"message": "User not found",
"code": 404
"code": 404,
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
Expand All @@ -168,6 +169,7 @@ app.use(errorHandler)
"error": {
"message": "User not found",
"code": 404,
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"stack": [
"NotFoundError: User not found",
"at Array.getUserById [as handler] (/path/to/controller.ts:25:11)",
Expand Down
28 changes: 26 additions & 2 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,32 @@ This document is for operators of the **Orivex-Backend** service.
## Logs

- Production logs are emitted via `winston` (`src/config/logger.ts`).
- Request logs go through `morgan('dev')` in development — disable in
production by setting `NODE_ENV=production`.
- Request access logs go through `morgan` in `src/app.ts` and include the
request ID as the first token on each line.
- Every request is assigned a correlation ID (UUID v4 by default). The ID is
stored in AsyncLocalStorage and attached to Winston log lines as
`requestId=…` structured metadata.

### Querying logs by request ID

1. Capture the `X-Request-Id` header from the HTTP response (or from the
`error.requestId` field on error envelopes). Browser JavaScript can read
the header because CORS exposes it via `Access-Control-Expose-Headers`.
2. Filter application logs for that value, for example:

```bash
# Example: stream container logs and filter by ID
grep 'requestId=550e8400-e29b-41d4-a716-446655440000' /var/log/orivex/*.log

# Example: kubectl / cloud log query (adjust for your provider)
kubectl logs -l app=orivex-backend --since=1h | grep '550e8400-e29b-41d4-a716-446655440000'
```

Morgan access lines also start with the same ID, so a single grep covers
access logs, Winston service logs, and error-handler output for that request.

Valid client-supplied `X-Request-Id` values (≤ 128 chars, `[A-Za-z0-9_.:-]`)
are honored; oversized or malformed values are overwritten with a new UUID.

## Secrets management

Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ service. It is intentionally short — execution happens in feature branches.

- ✅ Replace in-memory stores in `src/services/reward.service.ts` with Prisma
calls, removing the implicit in-test singletons (completed in #15).
- Add structured request IDs and propagate them across logs and HTTP
- Add structured request IDs and propagate them across logs and HTTP
responses.
- Add OpenTelemetry traces for outbound Stellar RPC and webhook delivery.
- Background job queue (BullMQ or Inngest) for module reward payouts.
Expand Down
18 changes: 15 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,27 @@ import swaggerUi from 'swagger-ui-express'
import { specs } from './config/swagger'
import routes from './routes'
import { errorHandler, notFoundHandler } from './middleware/error.middleware'
import { requestIdMiddleware } from './middleware/request-id.middleware'

const app: express.Application = express()

// Request ID must run before access logs and routes so every downstream
// log line and response can correlate on the same identifier.
app.use(requestIdMiddleware)

app.use(express.json())
app.use(cors())
app.use(
cors({
// Browser clients cannot read X-Request-Id unless it is explicitly exposed.
exposedHeaders: ['X-Request-Id'],
}),
)
app.use(helmet({
contentSecurityPolicy: false, // Disable CSP for Swagger UI to work correctly
}))
app.use(morgan('dev'))

morgan.token('id', (req) => (req as express.Request).requestId ?? '-')
app.use(morgan(':id :method :url :status :response-time ms - :res[content-length]'))

// API routes
app.use('/api', routes)
Expand All @@ -37,4 +49,4 @@ app.use(notFoundHandler)
// Global error handler - must be last
app.use(errorHandler)

export default app
export default app
33 changes: 30 additions & 3 deletions src/config/logger.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
import winston from 'winston'

import { getRequestId } from './request-context'

/**
* Inject the active request ID (from AsyncLocalStorage) into every log record
* as structured metadata so operators can filter by correlation ID.
*/
const requestIdFormat = winston.format((info) => {
const requestId = getRequestId()
if (requestId) {
info.requestId = requestId
}

return info
})

const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.simple()
requestIdFormat(),
winston.format.printf((info) => {
const { timestamp, level, message, requestId, ...meta } = info
const idPart = requestId ? ` requestId=${requestId}` : ''
// Drop Symbol keys Winston attaches (e.g. Symbol(level)) from meta dump
const printable = Object.fromEntries(
Object.entries(meta).filter(([key]) => typeof key === 'string'),
)
const metaPart =
Object.keys(printable).length > 0 ? ` ${JSON.stringify(printable)}` : ''

return `${timestamp} ${level}:${idPart} ${message}${metaPart}`
}),
),
transports: [new winston.transports.Console()]
transports: [new winston.transports.Console()],
})

export default logger
export default logger
16 changes: 16 additions & 0 deletions src/config/request-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { AsyncLocalStorage } from 'node:async_hooks'

/**
* Per-request context propagated via AsyncLocalStorage so Winston (and any
* other code) can read the active request ID without threading it through
* every call site.
*/
export interface RequestContext {
requestId: string
}

export const requestContext = new AsyncLocalStorage<RequestContext>()

export function getRequestId(): string | undefined {
return requestContext.getStore()?.requestId
}
41 changes: 37 additions & 4 deletions src/middleware/error.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { NextFunction, Request, Response } from 'express'

import { env } from '../config/env'
import logger from '../config/logger'
import { getRequestId } from '../config/request-context'

function requestIdFrom(req: Request): string | undefined {
return req.requestId ?? getRequestId()
}

/**
* Global error handler middleware
Expand All @@ -13,14 +18,21 @@ export const errorHandler = (
err: Error | AppError,
req: Request,
res: Response,
_next: NextFunction,
): void => {
let error = err
const requestId = requestIdFrom(req)

if (requestId) {
res.setHeader('X-Request-Id', requestId)
}

logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
requestId,
timestamp: new Date().toISOString(),
})

Expand All @@ -33,20 +45,38 @@ export const errorHandler = (
const statusCode = (error as AppError).statusCode || 500
const isDevelopment = env.NODE_ENV === 'development'

const errorResponse: any = {
const errorResponse: {
success: false
error: {
message: string
code: number | string
requestId?: string
stack?: string[]
details?: unknown
request?: {
method: string
path: string
headers: Request['headers']
}
}
} = {
success: false,
error: {
message: (error as AppError).message,
code: (error as AppError).statusCode || 'INTERNAL_SERVER_ERROR',
},
}

if (requestId) {
errorResponse.error.requestId = requestId
}

if (isDevelopment && err.stack) {
errorResponse.error.stack = err.stack.split('\n')
}

if ('errors' in error && (error as any).errors) {
errorResponse.error.details = (error as any).errors
if ('errors' in error && (error as AppError & { errors?: unknown }).errors) {
errorResponse.error.details = (error as AppError & { errors?: unknown }).errors
}

if (isDevelopment) {
Expand All @@ -70,11 +100,13 @@ export const notFoundHandler = (
next: NextFunction
): void => {
const notFound = new NotFoundError(`Cannot ${req.method} ${req.path}`)
const requestId = requestIdFrom(req)

logger.warn({
message: 'Not Found',
path: req.path,
method: req.method,
requestId,
timestamp: new Date().toISOString(),
})

Expand All @@ -96,10 +128,11 @@ export const asyncHandler = (
stack: error.stack,
path: req.path,
method: req.method,
requestId: requestIdFrom(req),
timestamp: new Date().toISOString(),
})

next(error)
})
}
}
}
Loading
Loading