Enterprise API Engine (@repo/core-api)
Architectural Foundation: Axios · Grafana Faro · OpenTelemetry
Description: Platform-agnostic API engine providing isolated Axios HTTP client factories, a Grafana Faro + OpenTelemetry observability pipeline, and a generic CRUD data services layer.
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
Data Flow Lifecycle
Every HTTP request flows through this precise interceptor pipeline:
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.
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<string, string> | {} | 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 |
> `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:
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<string | RegExp> | — | 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<E>
A concrete, ready-to-use data services class that provides full CRUD and lifecycle operations. Extends BaseRemoteDataServices<E>.
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<BookingEntity>(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<T>(config)
For non-standard endpoints that don't fit the CRUD pattern:
const taxResult = await bookingServices.customRequest<TaxCalculation>({
url: '/bookings/42/calculate-tax',
method: 'POST',
data: { items: [...] },
});Application Setup Guide
1. Initialize Telemetry (Entry Point)
// 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 bootstrap2. Create the HTTP Client
// 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
// 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<BookingEntity>(apiClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
});4. Consume in a React Component
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<ApiResponse<BookingEntity[]> | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFetch = async () => {
try {
const response = await bookingServices.getMany<BookingEntity[]>({
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 <button onClick={handleFetch}>Fetch Bookings</button>;
}Per-Request Telemetry (Escape Hatch)
TelemetryContext
Attach to any request via the telemetryContext property to push custom spans and business events:
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<string, string | number | boolean>;
/** 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:
// 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:
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 |