Files
trackgo-fe/packages/core-api

Enterprise API Engine (@repo/core-api)

← Back to Root

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

graph TD
    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

    WEB & LAND & DESK -->|instantiates| FACTORY
    WEB & LAND & DESK -->|extends| COMMON
    COMMON -->|executes via| FACTORY
    FACTORY -.->|reports via| FARO
    FACTORY -.->|throws| API_ERR

    style Core fill:#f8f9fa,stroke:#ced4da
    style Apps fill:#e9ecef,stroke:#adb5bd

Data Flow Lifecycle

Every HTTP request flows through this precise interceptor pipeline:

sequenceDiagram
    participant C as UI Component
    participant S as Data Service
    participant H as HTTP Client
    participant F as Faro Adapter
    participant A as App Hooks (IoC)
    participant N as Network

    C->>S: getMany()
    S->>H: request()
    H->>F: onRequestStart() (Log + Span)
    H->>A: hooks.onRequest() (Inject Token)
    A->>N: fetch/XHR
    
    alt Success
        N-->>A: 200 OK
        A->>F: onRequestEnd() (Close Span)
        F->>A: hooks.onResponse()
        A-->>S: return data
    else Error
        N-->>A: 401 / 500
        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.

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

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:

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 bootstrap

2. 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