|
| 1 | +# Design: Structured Logging + Correlation IDs |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +This feature adds structured JSON logging and correlation ID propagation to the YieldVault-RWA off-chain layer (the React/TypeScript frontend and its API client). The goal is to make every API call traceable end-to-end: each request carries a unique `X-Correlation-ID` header, that ID flows through telemetry events and error objects, and all log output is machine-readable JSON with a consistent field schema. |
| 6 | + |
| 7 | +The scope is intentionally limited to the off-chain layer — the frontend API client (`frontend/src/lib/api/`), the telemetry bus, and the React context that exposes the current correlation ID to components. On-chain Soroban contracts are out of scope. |
| 8 | + |
| 9 | +### Key Design Decisions |
| 10 | + |
| 11 | +- **No new runtime dependencies for logging**: The existing telemetry pub/sub system (`telemetry.ts`) is extended rather than replaced. A thin `logger` module subscribes to telemetry events and writes structured JSON to `console`. |
| 12 | +- **Interceptor-based correlation ID injection**: The existing `ApiClient` interceptor API is used to attach `X-Correlation-ID` on every request and read it back from responses, keeping the concern isolated. |
| 13 | +- **React Context for correlation ID**: A `CorrelationIdContext` makes the active ID available to any component without prop drilling, consistent with how `ThemeContext` and `ToastContext` are already structured. |
| 14 | +- **UUID v4 via `crypto.randomUUID()`**: Available in all modern browsers and Node 14.17+; no library needed. |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Architecture |
| 19 | + |
| 20 | +```mermaid |
| 21 | +flowchart TD |
| 22 | + subgraph React App |
| 23 | + A[Component] -->|reads| B[CorrelationIdContext] |
| 24 | + B -->|provides correlationId| C[ApiClient] |
| 25 | + end |
| 26 | +
|
| 27 | + subgraph ApiClient |
| 28 | + C -->|request interceptor| D[attach X-Correlation-ID header] |
| 29 | + D -->|fetch| E[Backend / Mock API] |
| 30 | + E -->|response| F[response interceptor] |
| 31 | + F -->|extract X-Correlation-ID| G[emitApiTelemetry] |
| 32 | + end |
| 33 | +
|
| 34 | + subgraph Logging |
| 35 | + G -->|ApiTelemetryEvent + correlationId| H[logger subscriber] |
| 36 | + H -->|JSON.stringify| I[console output] |
| 37 | + end |
| 38 | +
|
| 39 | + subgraph Error Path |
| 40 | + F -->|HTTP error| J[ApiError with correlationId] |
| 41 | + J --> H |
| 42 | + end |
| 43 | +``` |
| 44 | + |
| 45 | +The data flow is: |
| 46 | + |
| 47 | +1. A component (or script) obtains or generates a correlation ID. |
| 48 | +2. The `ApiClient` request interceptor reads the ID from context and sets the header. |
| 49 | +3. On response, the response interceptor reads the echoed header (if present) and attaches it to the telemetry event. |
| 50 | +4. The logger subscriber formats the event as a JSON log entry and writes it. |
| 51 | +5. Errors carry the correlation ID so they can be correlated with the request log line. |
| 52 | + |
| 53 | +--- |
| 54 | + |
| 55 | +## Components and Interfaces |
| 56 | + |
| 57 | +### `logger` module (`frontend/src/lib/logger.ts`) |
| 58 | + |
| 59 | +Thin structured-logging facade. Subscribes to the telemetry bus and also exposes a direct `log()` function for non-telemetry log sites. |
| 60 | + |
| 61 | +```typescript |
| 62 | +export type LogLevel = "debug" | "info" | "warn" | "error"; |
| 63 | + |
| 64 | +export interface LogEntry { |
| 65 | + timestamp: string; // ISO 8601 |
| 66 | + level: LogLevel; |
| 67 | + message: string; |
| 68 | + correlationId?: string; |
| 69 | + method?: string; |
| 70 | + url?: string; |
| 71 | + durationMs?: number; |
| 72 | + attempt?: number; |
| 73 | + status?: number; |
| 74 | + errorCode?: string; |
| 75 | + [key: string]: unknown; // extensible for future fields |
| 76 | +} |
| 77 | + |
| 78 | +export interface LoggerConfig { |
| 79 | + minLevel: LogLevel; |
| 80 | + output?: (entry: LogEntry) => void; // defaults to console.log(JSON.stringify(entry)) |
| 81 | +} |
| 82 | + |
| 83 | +export function configureLogger(config: LoggerConfig): void; |
| 84 | +export function log(level: LogLevel, message: string, fields?: Partial<LogEntry>): void; |
| 85 | +``` |
| 86 | + |
| 87 | +Log level ordering: `debug < info < warn < error`. Entries below `minLevel` are silently dropped. |
| 88 | + |
| 89 | +### `CorrelationIdContext` (`frontend/src/context/CorrelationIdContext.tsx`) |
| 90 | + |
| 91 | +React context that holds the active correlation ID for the current "session" or user interaction. |
| 92 | + |
| 93 | +```typescript |
| 94 | +export interface CorrelationIdContextValue { |
| 95 | + correlationId: string; |
| 96 | + /** Replace the active ID (e.g. when starting a new user action). */ |
| 97 | + refreshCorrelationId: () => void; |
| 98 | +} |
| 99 | + |
| 100 | +export const CorrelationIdContext: React.Context<CorrelationIdContextValue>; |
| 101 | +export function CorrelationIdProvider({ children }: { children: React.ReactNode }): JSX.Element; |
| 102 | +export function useCorrelationId(): CorrelationIdContextValue; |
| 103 | +``` |
| 104 | + |
| 105 | +The provider generates a UUID v4 on mount and exposes `refreshCorrelationId` to rotate it. |
| 106 | + |
| 107 | +### `correlationInterceptors` (`frontend/src/lib/api/correlationInterceptors.ts`) |
| 108 | + |
| 109 | +Two `ApiClient` interceptors that handle ID injection and extraction. |
| 110 | + |
| 111 | +```typescript |
| 112 | +/** |
| 113 | + * Request interceptor: reads correlationId from the provided getter and |
| 114 | + * sets the X-Correlation-ID header on every outgoing request. |
| 115 | + */ |
| 116 | +export function createCorrelationRequestInterceptor( |
| 117 | + getCorrelationId: () => string, |
| 118 | +): RequestInterceptor; |
| 119 | + |
| 120 | +/** |
| 121 | + * Response interceptor: reads X-Correlation-ID from the response headers |
| 122 | + * and attaches it to the telemetry context for downstream logging. |
| 123 | + */ |
| 124 | +export function createCorrelationResponseInterceptor(): ResponseInterceptor; |
| 125 | +``` |
| 126 | + |
| 127 | +### Extended `ApiTelemetryEvent` (`frontend/src/lib/api/telemetry.ts`) |
| 128 | + |
| 129 | +Each event variant gains an optional `correlationId` field: |
| 130 | + |
| 131 | +```typescript |
| 132 | +// Added to every event variant: |
| 133 | +correlationId?: string; |
| 134 | +``` |
| 135 | + |
| 136 | +### Extended `ApiError` (`frontend/src/lib/api/error.ts`) |
| 137 | + |
| 138 | +`ApiErrorMetadata` and `ApiError` gain: |
| 139 | + |
| 140 | +```typescript |
| 141 | +correlationId?: string; |
| 142 | +``` |
| 143 | + |
| 144 | +--- |
| 145 | + |
| 146 | +## Data Models |
| 147 | + |
| 148 | +### Log Entry Schema |
| 149 | + |
| 150 | +Every log line written to `console` is a single-line JSON object conforming to `LogEntry`: |
| 151 | + |
| 152 | +| Field | Type | Required | Description | |
| 153 | +|---|---|---|---| |
| 154 | +| `timestamp` | string (ISO 8601) | yes | When the entry was created | |
| 155 | +| `level` | `"debug"\|"info"\|"warn"\|"error"` | yes | Severity | |
| 156 | +| `message` | string | yes | Human-readable summary | |
| 157 | +| `correlationId` | string (UUID v4) | no | Active correlation ID | |
| 158 | +| `method` | string | no | HTTP method (GET, POST, …) | |
| 159 | +| `url` | string | no | Full request URL | |
| 160 | +| `durationMs` | number | no | Round-trip time in milliseconds | |
| 161 | +| `attempt` | number | no | Retry attempt number (1-based) | |
| 162 | +| `status` | number | no | HTTP response status code | |
| 163 | +| `errorCode` | string | no | `ApiErrorCode` value on error | |
| 164 | + |
| 165 | +Example output: |
| 166 | + |
| 167 | +```json |
| 168 | +{"timestamp":"2026-01-15T10:23:45.123Z","level":"info","message":"API request succeeded","correlationId":"f47ac10b-58cc-4372-a567-0e02b2c3d479","method":"GET","url":"https://api.example.com/mock-api/vault-summary.json","durationMs":142,"attempt":1,"status":200} |
| 169 | +``` |
| 170 | + |
| 171 | +### Correlation ID Format |
| 172 | + |
| 173 | +UUID v4 as produced by `crypto.randomUUID()`. Example: `f47ac10b-58cc-4372-a567-0e02b2c3d479`. |
| 174 | + |
| 175 | +Pattern: `/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i` |
| 176 | + |
| 177 | +### Header Convention |
| 178 | + |
| 179 | +| Header | Direction | Description | |
| 180 | +|---|---|---| |
| 181 | +| `X-Correlation-ID` | Request (client → server) | Client-generated or client-propagated ID | |
| 182 | +| `X-Correlation-ID` | Response (server → client) | Server echoes or overrides the ID | |
| 183 | +| `X-Trace-ID` | Response (server → client) | Already read by `ApiClient`; stored on `ApiError.traceId` (unchanged) | |
| 184 | + |
| 185 | +--- |
| 186 | + |
| 187 | +## Correctness Properties |
| 188 | + |
| 189 | +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* |
| 190 | + |
| 191 | +### Property 1: Every outgoing request carries a correlation ID header |
| 192 | + |
| 193 | +*For any* API request made through `ApiClient` when the correlation request interceptor is registered, the outgoing `Headers` object must contain an `X-Correlation-ID` entry with a non-empty string value. |
| 194 | + |
| 195 | +**Validates: Requirements 1.1** |
| 196 | + |
| 197 | +--- |
| 198 | + |
| 199 | +### Property 2: Client-supplied correlation ID takes precedence |
| 200 | + |
| 201 | +*For any* API request where the caller provides a specific correlation ID via the context getter, the `X-Correlation-ID` header on the outgoing request must equal that provided ID, not a freshly generated one. |
| 202 | + |
| 203 | +**Validates: Requirements 1.3** |
| 204 | + |
| 205 | +--- |
| 206 | + |
| 207 | +### Property 3: Generated correlation IDs are valid UUID v4 |
| 208 | + |
| 209 | +*For any* call to the correlation ID generation function, the returned string must match the UUID v4 format pattern `/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i`. |
| 210 | + |
| 211 | +**Validates: Requirements 1.4** |
| 212 | + |
| 213 | +--- |
| 214 | + |
| 215 | +### Property 4: All observable outputs carry the correlation ID |
| 216 | + |
| 217 | +*For any* API request that completes (successfully or with an error), the resulting telemetry event and any resulting `ApiError` must both carry the same `correlationId` value that was sent on the request. |
| 218 | + |
| 219 | +**Validates: Requirements 2.1, 2.2** |
| 220 | + |
| 221 | +--- |
| 222 | + |
| 223 | +### Property 5: Log entries serialize to valid JSON with all required fields |
| 224 | + |
| 225 | +*For any* log entry produced by the logger (at any level), calling `JSON.stringify` on it must produce valid JSON, and the parsed object must contain `timestamp`, `level`, and `message` fields with the correct types. |
| 226 | + |
| 227 | +**Validates: Requirements 2.3, 3.1** |
| 228 | + |
| 229 | +--- |
| 230 | + |
| 231 | +### Property 6: Log level filtering excludes entries below threshold |
| 232 | + |
| 233 | +*For any* logger configuration with `minLevel` set to level L, and *for any* log entry with a level below L, that entry must not appear in the output (the `output` function must not be called for it). |
| 234 | + |
| 235 | +**Validates: Requirements 3.2** |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +### Property 7: Correlation ID is accessible throughout the React component tree |
| 240 | + |
| 241 | +*For any* component rendered as a descendant of `CorrelationIdProvider`, calling `useCorrelationId()` must return the same `correlationId` value that the provider holds, regardless of nesting depth. |
| 242 | + |
| 243 | +**Validates: Requirements 4.2** |
| 244 | + |
| 245 | +--- |
| 246 | + |
| 247 | +## Error Handling |
| 248 | + |
| 249 | +### Missing or malformed `X-Correlation-ID` in response |
| 250 | + |
| 251 | +If the server does not echo `X-Correlation-ID`, the client retains the ID it generated for the request. No error is thrown; the field is simply absent from the response-side telemetry. |
| 252 | + |
| 253 | +### `crypto.randomUUID()` unavailability |
| 254 | + |
| 255 | +`crypto.randomUUID()` is available in all browsers that support the Web Crypto API (Chrome 92+, Firefox 95+, Safari 15.4+). If unavailable (e.g., non-secure context in very old browsers), the interceptor falls back to a timestamp-based pseudo-ID prefixed with `fallback-` and logs a `warn` entry. This is a graceful degradation path, not a hard failure. |
| 256 | + |
| 257 | +### Logger output errors |
| 258 | + |
| 259 | +If the `output` function throws (e.g., a custom output sink fails), the error is caught and silently swallowed to prevent logging from disrupting the application. A single `console.error` is written as a last resort. |
| 260 | + |
| 261 | +### Correlation ID context outside provider |
| 262 | + |
| 263 | +If `useCorrelationId()` is called outside `CorrelationIdProvider`, it throws a descriptive error: `"useCorrelationId must be used within a CorrelationIdProvider"`. This matches the pattern used by `ToastContext`. |
| 264 | + |
| 265 | +--- |
| 266 | + |
| 267 | +## Testing Strategy |
| 268 | + |
| 269 | +### Dual Testing Approach |
| 270 | + |
| 271 | +Both unit tests and property-based tests are used. Unit tests cover specific examples, integration points, and error conditions. Property tests verify universal invariants across many generated inputs. |
| 272 | + |
| 273 | +### Property-Based Testing Library |
| 274 | + |
| 275 | +**fast-check** is the chosen PBT library for TypeScript/Vitest. It integrates cleanly with Vitest via `fc.assert(fc.property(...))` and supports async properties. |
| 276 | + |
| 277 | +Install: `npm install --save-dev fast-check` |
| 278 | + |
| 279 | +Each property test runs a minimum of **100 iterations** (fast-check default is 100; set explicitly via `{ numRuns: 100 }`). |
| 280 | + |
| 281 | +Each property test is tagged with a comment in the format: |
| 282 | +`// Feature: structured-logging-correlation-ids, Property N: <property_text>` |
| 283 | + |
| 284 | +### Property Tests |
| 285 | + |
| 286 | +Each correctness property maps to exactly one property-based test: |
| 287 | + |
| 288 | +**Property 1** — `correlationInterceptors.test.ts` |
| 289 | +``` |
| 290 | +// Feature: structured-logging-correlation-ids, Property 1: Every outgoing request carries a correlation ID header |
| 291 | +fc.assert(fc.asyncProperty(fc.record({ method: fc.constantFrom('GET','POST'), path: fc.string() }), async ({ method, path }) => { |
| 292 | + // build request context, run interceptor, assert headers.get('X-Correlation-ID') is non-empty |
| 293 | +})) |
| 294 | +``` |
| 295 | + |
| 296 | +**Property 2** — `correlationInterceptors.test.ts` |
| 297 | +``` |
| 298 | +// Feature: structured-logging-correlation-ids, Property 2: Client-supplied correlation ID takes precedence |
| 299 | +fc.assert(fc.asyncProperty(fc.uuid(), async (suppliedId) => { |
| 300 | + // provide suppliedId via getter, run interceptor, assert header === suppliedId |
| 301 | +})) |
| 302 | +``` |
| 303 | + |
| 304 | +**Property 3** — `correlationId.test.ts` |
| 305 | +``` |
| 306 | +// Feature: structured-logging-correlation-ids, Property 3: Generated correlation IDs are valid UUID v4 |
| 307 | +fc.assert(fc.property(fc.integer({ min: 1, max: 1000 }), (_n) => { |
| 308 | + const id = generateCorrelationId(); |
| 309 | + return UUID_V4_PATTERN.test(id); |
| 310 | +})) |
| 311 | +``` |
| 312 | + |
| 313 | +**Property 4** — `logger.test.ts` |
| 314 | +``` |
| 315 | +// Feature: structured-logging-correlation-ids, Property 4: All observable outputs carry the correlation ID |
| 316 | +fc.assert(fc.asyncProperty(fc.uuid(), fc.constantFrom('success','error'), async (correlationId, outcome) => { |
| 317 | + // simulate telemetry event with correlationId, assert logger output and ApiError carry same ID |
| 318 | +})) |
| 319 | +``` |
| 320 | + |
| 321 | +**Property 5** — `logger.test.ts` |
| 322 | +``` |
| 323 | +// Feature: structured-logging-correlation-ids, Property 5: Log entries serialize to valid JSON with required fields |
| 324 | +fc.assert(fc.property(fc.record({ level: fc.constantFrom('debug','info','warn','error'), message: fc.string() }), ({ level, message }) => { |
| 325 | + const entry = buildLogEntry(level, message, {}); |
| 326 | + const parsed = JSON.parse(JSON.stringify(entry)); |
| 327 | + return typeof parsed.timestamp === 'string' && typeof parsed.level === 'string' && typeof parsed.message === 'string'; |
| 328 | +})) |
| 329 | +``` |
| 330 | + |
| 331 | +**Property 6** — `logger.test.ts` |
| 332 | +``` |
| 333 | +// Feature: structured-logging-correlation-ids, Property 6: Log level filtering excludes entries below threshold |
| 334 | +fc.assert(fc.property( |
| 335 | + fc.constantFrom('debug','info','warn','error'), |
| 336 | + fc.constantFrom('debug','info','warn','error'), |
| 337 | + (minLevel, entryLevel) => { |
| 338 | + // configure logger with minLevel, emit entry at entryLevel |
| 339 | + // assert output was called iff levelOrder[entryLevel] >= levelOrder[minLevel] |
| 340 | + } |
| 341 | +)) |
| 342 | +``` |
| 343 | + |
| 344 | +**Property 7** — `CorrelationIdContext.test.tsx` |
| 345 | +``` |
| 346 | +// Feature: structured-logging-correlation-ids, Property 7: Correlation ID accessible throughout React tree |
| 347 | +fc.assert(fc.property(fc.integer({ min: 1, max: 10 }), (depth) => { |
| 348 | + // render CorrelationIdProvider with a component nested `depth` levels deep |
| 349 | + // assert useCorrelationId() returns the provider's ID |
| 350 | +})) |
| 351 | +``` |
| 352 | + |
| 353 | +### Unit Tests |
| 354 | + |
| 355 | +- `correlationInterceptors.test.ts`: missing header fallback, non-secure context fallback to `fallback-` prefix |
| 356 | +- `logger.test.ts`: `configureLogger` reads `VITE_LOG_LEVEL` env var (example for Requirement 4.1), output-function error is swallowed |
| 357 | +- `CorrelationIdContext.test.tsx`: `refreshCorrelationId` produces a new UUID v4, calling hook outside provider throws descriptive error |
| 358 | +- `client.test.ts` (existing): verify interceptors are invoked in order; extend existing tests to assert `correlationId` on `ApiError` |
0 commit comments