[← Back to Root](../../README.md) # Enterprise API Engine (`@repo/core-api`) The platform-agnostic API engine for the monorepo. Provides an isolated HTTP client factory, a unified observability pipeline (Grafana Faro + OpenTelemetry), and a generic data services engine. **This package enforces App Autonomy (IoC).** The core provides the engine and interceptor pipelines, but the consuming applications (`apps/web`, `apps/landing`) inject their own specific configurations, authentication tokens, and error handling behaviors. --- ## Architecture Overview ```mermaid graph TD %% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ─── classDef appEntity fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a classDef coreEngine fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff classDef dataService fill:#10b981,stroke:#047857,stroke-width:2px,color:#ffffff classDef observability fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#ffffff classDef errorNode fill:#f43f5e,stroke:#be123c,stroke-width:2px,color:#ffffff %% ─── Subgraphs ─── subgraph Apps ["apps/* (App Autonomy)"] WEB([apps/web]) LAND([apps/landing]) DESK([apps/desktop]) end subgraph Core ["@repo/core-api (Engine)"] subgraph HTTP ["http-client"] FACTORY[createHttpClient] end subgraph OBS ["observability"] FARO[faroAdapter] end subgraph DATA ["data-services"] BASE[BaseRemoteDataServices] COMMON[CommonRemoteDataServices] end subgraph ERRORS ["errors"] API_ERR[ApiError] end end %% ─── Flow & Relationships ─── WEB & LAND & DESK ===>|instantiates| FACTORY WEB & LAND & DESK ===>|extends| COMMON COMMON --->|executes via| FACTORY FACTORY -.->|reports via| FARO FACTORY -.->|throws| API_ERR %% ─── Apply Styles ─── class WEB,LAND,DESK appEntity; class FACTORY coreEngine; class BASE,COMMON dataService; class FARO observability; class API_ERR errorNode; %% ─── Subgraph Backgrounds (Transparent for Native GitHub Support) ─── style Apps fill:transparent,stroke:#3b82f6,stroke-width:2px,stroke-dasharray: 5 5 style Core fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 %% Nested subgraphs also need transparent backgrounds to prevent glaring white boxes in dark mode style HTTP fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5 style OBS fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5 style DATA fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5 style ERRORS fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5 ``` ### Data Flow Lifecycle Every HTTP request flows through this precise interceptor pipeline: ```mermaid sequenceDiagram autonumber %% ─── Dark-Mode Friendly RGBA Boxes ─── box rgba(59, 130, 246, 0.1) App Layer (Consumers) participant C as UI Component end box rgba(148, 163, 184, 0.1) Core Engine (@repo/core-api) participant S as Data Service participant H as HTTP Client participant F as Faro Adapter end box rgba(16, 185, 129, 0.1) App Logic (IoC) participant A as App Hooks end box rgba(245, 158, 11, 0.1) External participant N as Network end %% ─── Execution Flow ─── C->>S: getMany() S->>H: request() H->>F: onRequestStart() (Log + Span) H->>A: hooks.onRequest() (Inject Token) A->>N: fetch/XHR alt Success (2xx) N-->>A: return Response A->>F: onRequestEnd() (Close Span) F->>A: hooks.onResponse() A-->>S: return data else Error (4xx / 5xx) N-->>A: return Rejection A->>F: onRequestError() (Log Error) F->>A: hooks.onResponseError() (Redirect/Refresh) A-->>S: throw ApiError end ``` > [!IMPORTANT] > Observability adapter errors are **caught internally** via try-catch in the interceptor chain. An adapter crash will never swallow or replace the original API error — the UI always receives the correct rejection. --- ## HTTP Client ### `createHttpClient(config, hooks?)` Creates an **isolated** Axios instance. Each app receives its own interceptor chain — no globals are shared or mutated. ```typescript import { createHttpClient } from '@repo/core-api/http-client'; import { faroAdapter } from '@repo/core-api/observability'; export const apiClient = createHttpClient( { baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1', timeout: 15000, observability: faroAdapter, }, { onRequest: async (config) => { const token = localStorage.getItem('access_token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }, onResponseError: async (error) => { if (error.response?.status === 401) { localStorage.removeItem('access_token'); window.location.href = '/auth/login'; } throw error; }, }, ); ``` ### Configuration | Property | Type | Default | Description | |---|---|---|---| | `baseURL` | `string` | *required* | Base URL for all requests | | `timeout` | `number` | `15000` | Default request timeout (ms) | | `defaultHeaders` | `Record` | `{}` | Headers applied to every request | | `observability` | `IObservabilityAdapter` | `noopAdapter` | Observability adapter (Faro or no-op) | ### Interceptor Hooks | Hook | Signature | Purpose | |---|---|---| | `onRequest` | `(config) => config` | Inject auth tokens, tenant headers | | `onResponse` | `(response) => response` | Transform response shapes | | `onResponseError` | `(error) => never` | App-specific error handling (e.g., 401 redirect) | --- ## Observability ### Strategy: Opt-In Custom Spans + Faro/Loki Baseline The observability layer operates in two complementary modes: | Mode | Activation | What it does | |---|---|---| | **Baseline** (always on) | Automatic | Pushes structured logs to Faro/Loki on every request with `module.key`, `module.action`, HTTP method, and URL | | **Custom Span** (opt-in) | Via `telemetryContext.customSpanName` | Creates an explicit OTel span with custom tags, visible in Grafana Tempo | > [!NOTE] > `trace.getActiveSpan()` returns `undefined` inside Axios interceptors due to browser XHR/Fetch lifecycle race conditions with Faro's `TracingInstrumentation`. The adapter does **not** attempt to enrich auto-instrumented spans. HTTP span capture is handled entirely by `TracingInstrumentation` auto-instrumentation. ### Initialization Call `initTelemetry()` **once** at the top of your app's entry point, before any React code: ```typescript import { initTelemetry } from '@repo/core-api/observability/setup'; initTelemetry({ appName: 'fe-monorepo-web', appVersion: '1.0.0', telemetryUrl: '[https://telemetry.eigen.co.id/collect](https://telemetry.eigen.co.id/collect)', environment: 'production', // Optional: direct OTLP export to Grafana Tempo otlpTraceUrl: '[https://telemetry.eigen.co.id/v1/traces](https://telemetry.eigen.co.id/v1/traces)', }); ``` ### `TelemetryConfig` | Property | Type | Required | Description | |---|---|---|---| | `appName` | `string` | ✅ | Application name for Faro + OTel resource attributes | | `appVersion` | `string` | ✅ | SemVer version | | `telemetryUrl` | `string` | ✅ | Grafana Faro collector URL | | `environment` | `string` | ✅ | Deployment environment (`production`, `staging`, `development`) | | `otlpTraceUrl` | `string` | — | Separate OTLP trace endpoint for direct Tempo ingestion | | `propagateTraceHeaderCorsUrls` | `Array` | — | CORS patterns for W3C trace context propagation (default: `[/.*/]`) | ### Audit Headers Every request dispatched through `BaseRemoteDataServices` automatically attaches two business audit headers: | Header | Source | Purpose | |---|---|---| | `ex-module-key` | `DataServicesConfig.moduleKey` | Identifies the business module (e.g., `BOOKING`) | | `ex-module-action` | `RequestDescriptor.action` | Identifies the operation (e.g., `READ`, `CREATE`) | These headers are extracted by the `faroAdapter` and included in all Faro `pushLog`, `pushError`, and `pushEvent` calls as top-level context — making them directly queryable in **LogQL (Loki)**. ### Span Safety Guarantees | Guarantee | Mechanism | |---|---| | **No span leaks** | `safeEndSpan()` always closes the span and detaches the reference from config | | **No double-close on retry** | Span reference is deleted from config after `span.end()` | | **No error swallowing** | All adapter calls are wrapped in try-catch in `create-http-client.ts` | | **No crash on timeout** | `null`/`undefined` config guards on all `error.config` access | --- ## Data Services ### `CommonRemoteDataServices` A concrete, ready-to-use data services class that provides full CRUD and lifecycle operations. Extends `BaseRemoteDataServices`. ```typescript import { CommonRemoteDataServices } from '@repo/core-api/data-services'; import type { BaseEntity } from '@repo/core-api/data-services'; import { apiClient } from '@/lib/api-client'; interface BookingEntity extends BaseEntity { bookingCode: string; customerName: string; status: 'pending' | 'confirmed' | 'cancelled'; } export const bookingServices = new CommonRemoteDataServices( apiClient, { apiUrl: '/bookings', moduleKey: 'BOOKING', }, ); ``` ### Available Operations | Method | HTTP | URL Template | Description | |---|---|---|---| | `getMany(config?)` | GET | `/bookings` | Fetch paginated list | | `getOne(id, config?)` | GET | `/bookings/:id` | Fetch single entity | | `create(data, config?)` | POST | `/bookings` | Create new entity | | `edit(id, data, config?)` | PUT | `/bookings/:id` | Update entity | | `delete(id, config?)` | DELETE | `/bookings/:id` | Delete entity | | `batchDelete(ids, config?)` | DELETE | `/bookings/batch` | Delete multiple | | `activate(id)` | PATCH | `/bookings/:id/activate` | Activate entity | | `deactivate(id)` | PATCH | `/bookings/:id/deactivate` | Deactivate entity | | `confirmProcessData(id)` | PATCH | `/bookings/:id/confirm-process-data` | Confirm data processing | | `confirmProcessTransaction(id)` | PATCH | `/bookings/:id/confirm-process-transaction` | Confirm transaction | | `cancelProcessTransaction(id)` | PATCH | `/bookings/:id/cancel-process-transaction` | Cancel transaction | | `rollbackProcessTransaction(id)` | PATCH | `/bookings/:id/rollback-process-transaction` | Rollback transaction | | `holdProcessTransaction(id)` | PATCH | `/bookings/:id/hold-process-transaction` | Hold transaction | All batch variants (`batchActivate`, `batchDeactivate`, etc.) are also available. ### Escape Hatch: `customRequest(config)` For non-standard endpoints that don't fit the CRUD pattern: ```typescript const taxResult = await bookingServices.customRequest({ url: '/bookings/42/calculate-tax', method: 'POST', data: { items: [...] }, }); ``` --- ## Application Setup Guide ### 1. Initialize Telemetry (Entry Point) ```typescript // apps/web/src/main.tsx — MUST be the first import import { initTelemetry } from '@repo/core-api/observability/setup'; initTelemetry({ appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web', appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0', telemetryUrl: import.meta.env.VITE_FARO_URL || '[https://telemetry.eigen.co.id/collect](https://telemetry.eigen.co.id/collect)', otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || '[https://telemetry.eigen.co.id/v1/traces](https://telemetry.eigen.co.id/v1/traces)', environment: import.meta.env.VITE_ENV || 'development', }); // ... rest of React bootstrap ``` ### 2. Create the HTTP Client ```typescript // apps/web/src/lib/api-client.ts import { createHttpClient } from '@repo/core-api/http-client'; import { faroAdapter } from '@repo/core-api/observability'; export const apiClient = createHttpClient({ baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1', timeout: 15000, observability: faroAdapter, }); ``` ### 3. Create a Data Service ```typescript // features/booking/data/booking.data-services.ts import { CommonRemoteDataServices } from '@repo/core-api/data-services'; import type { BaseEntity } from '@repo/core-api/data-services'; import { apiClient } from '@/lib/api-client'; export interface BookingEntity extends BaseEntity { bookingCode: string; customerName: string; status: 'pending' | 'confirmed' | 'cancelled'; totalAmount: number; } export const bookingServices = new CommonRemoteDataServices( apiClient, { apiUrl: '/bookings', moduleKey: 'BOOKING' }, ); ``` ### 4. Consume in a React Component ```tsx import { useState } from 'react'; import { bookingServices } from '../data/booking.data-services'; import type { BookingEntity } from '../data/booking.data-services'; import type { ApiResponse } from '@repo/core-api/http-client'; import { ApiError } from '@repo/core-api/errors'; export default function BookingSample() { const [result, setResult] = useState | null>(null); const [error, setError] = useState(null); const handleFetch = async () => { try { const response = await bookingServices.getMany({ params: { page: 1, limit: 20 }, // Optional: Per-request telemetry escape hatch telemetryContext: { customSpanName: 'booking.list.fetch', tags: { feature: 'booking', page: 1 }, pushEventOnSuccess: 'booking_list_loaded', }, }); setResult(response); } catch (err) { if (err instanceof ApiError) { setError(`[${err.code}] ${err.message} (HTTP ${err.status})`); } } }; return ; } ``` --- ## Per-Request Telemetry (Escape Hatch) ### `TelemetryContext` Attach to any request via the `telemetryContext` property to push custom spans and business events: ```typescript interface TelemetryContext { /** Creates a custom OTel span wrapping this request (visible in Grafana Tempo). */ customSpanName?: string; /** Custom tags enriching the span and Faro logs (prefixed with `custom.` on spans). */ tags?: Record; /** Pushes a named Faro event on success (visible in Grafana Faro dashboard). */ pushEventOnSuccess?: string; } ``` ### Precedence `telemetryContext` can be provided at two levels. The top-level `ExecuteOptions.telemetryContext` takes precedence over `config.telemetryContext`: ```typescript // Top-level (preferred) await bookingServices.getMany({ telemetryContext: { customSpanName: 'booking.list.fetch' }, }); // Nested in config (also works) await bookingServices.getMany({ params: { page: 1 }, telemetryContext: { customSpanName: 'booking.list.fetch' }, }); ``` ### What Happens at Each Stage | Stage | Baseline (no telemetryContext) | With `customSpanName` | |---|---|---| | **Request Start** | Faro `pushLog` (DEBUG) with `module.key`, `module.action`, URL | + Creates OTel span with `http.method`, `http.url`, `custom.*` tags | | **Request Success** | — | Closes span (OK). If `pushEventOnSuccess`, pushes Faro event | | **Request Error** | Faro `pushError` + `pushLog` (ERROR) | + Closes span (ERROR), records exception | --- ## Error Handling ### `ApiError` All non-2xx responses are normalized into structured `ApiError` instances: ```typescript try { await bookingServices.getOne('42'); } catch (err) { if (err instanceof ApiError) { err.code; // ApiErrorCode.NOT_FOUND err.status; // 404 err.message; // "Booking not found" err.data; // Raw server response body err.toJSON(); // Serializable for logging } } ``` ### Error Codes | Code | HTTP Status | Description | |---|---|---| | `BAD_REQUEST` | 400 | Invalid request parameters | | `UNAUTHORIZED` | 401 | Missing or expired token | | `FORBIDDEN` | 403 | Insufficient permissions | | `NOT_FOUND` | 404 | Resource not found | | `TIMEOUT` | — | Request timed out (`ECONNABORTED`) | | `CANCELLED` | — | Request was cancelled (`ERR_CANCELED`) | | `NETWORK_ERROR` | — | No response received | | `SERVER_ERROR` | 500+ | Internal server error | --- ## Package Exports | Import Path | Contents | |---|---| | `@repo/core-api/http-client` | `createHttpClient`, `ApiResponse`, `TelemetryContext`, Axios type re-exports | | `@repo/core-api/observability` | `faroAdapter`, `noopObservabilityAdapter`, `IObservabilityAdapter`, `initTelemetry`, `getFaro`, `TelemetryConfig` | | `@repo/core-api/observability/setup` | `initTelemetry`, `getFaro`, `TelemetryConfig` | | `@repo/core-api/data-services` | `BaseRemoteDataServices`, `CommonRemoteDataServices`, types, constants | | `@repo/core-api/errors` | `ApiError`, `ApiErrorCode` |