Refactor code structure for improved readability and maintainability

This commit is contained in:
Firman Ramdhani
2026-05-22 17:53:35 +07:00
parent af0fe70ac6
commit 4260e240a0
37 changed files with 2915 additions and 74 deletions
@@ -0,0 +1,314 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import type {
BaseEntity,
ApiURLMap,
RequestMethodMap,
RequestDescriptor,
ExecuteOptions,
DataServicesConfig,
} from './types';
import type { ApiResponse } from '../http-client/types';
import { interpolateUrl } from './url-builder';
import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
/**
* Abstract base class for remote data services.
*
* Provides a generic `execute()` method that eliminates the 22
* near-identical methods from the legacy `BaseRemoteDataServices`.
* Each operation is reduced to a one-liner calling `execute()`
* with the appropriate descriptor.
*
* **Key architectural differences from legacy:**
* - Receives an **injected** AxiosInstance (no global axios import)
* - Returns `Promise<ApiResponse<T>>` (no callback-based onSuccess/onFailed)
* - All operations are fully typed end-to-end
* - Includes `customRequest<T>()` as an escape hatch for non-standard endpoints
*
* @typeParam E - The domain entity type (must extend BaseEntity)
*
* @example
* ```ts
* class BookingDataServices extends BaseRemoteDataServices<BookingEntity> {}
*
* const services = new BookingDataServices(apiClient, {
* apiUrl: '/bookings',
* moduleKey: 'BOOKING',
* });
*
* const { data, status } = await services.getOne<BookingEntity>('42');
* ```
*/
export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity> {
/** The injected, isolated HTTP client instance. */
protected readonly httpClient: AxiosInstance;
/** Resolved URL map for all operations. */
protected readonly urls: ApiURLMap;
/** Resolved HTTP method map for all operations. */
protected readonly methods: RequestMethodMap;
/** Module key for the 'ex-module-key' audit header. */
protected readonly moduleKey: string | undefined;
constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
this.httpClient = httpClient;
this.moduleKey = config.moduleKey;
this.urls = {
...makeDefaultURLs(config.apiUrl ?? ''),
...(config.urls ?? {}),
};
this.methods = {
...DEFAULT_METHODS,
...(config.methods ?? {}),
};
}
// ─── Generic Executor ───────────────────────────────────────────
/**
* The single generic request executor.
*
* All standard operations delegate to this method with a
* pre-defined descriptor. This is the engine that replaces
* 22 near-identical legacy methods.
*
* @typeParam T - Expected response data type
* @param descriptor - Defines which URL, method, and action to use
* @param options - Dynamic URL params and additional Axios config
* @returns Typed API response with data and status
*/
protected async execute<T = unknown>(
descriptor: RequestDescriptor,
options?: ExecuteOptions,
): Promise<ApiResponse<T>> {
const { urlKey, methodKey, action } = descriptor;
const response = await this.httpClient.request<T>({
url: interpolateUrl(this.urls[urlKey], options?.variableURL),
method: this.methods[methodKey],
...(options?.config ?? {}),
headers: {
...(this.moduleKey ? { 'ex-module-key': this.moduleKey } : {}),
'ex-module-action': action,
...(options?.config?.headers ?? {}),
},
telemetryContext: options?.telemetryContext ?? options?.config?.telemetryContext // FIXME,
});
return { data: response.data, status: response.status };
}
// ─── Escape Hatch ───────────────────────────────────────────────
/**
* Execute a fully custom request that doesn't fit standard CRUD.
*
* Use this for non-standard endpoints like `/calculate-tax`,
* custom aggregations, or third-party integrations.
*
* The request still flows through the injected httpClient, so
* all interceptors (auth, observability, error handling) are
* preserved automatically.
*
* @typeParam T - Expected response data type
* @param config - Complete Axios request configuration
* @returns Typed API response with data and status
*
* @example
* ```ts
* const tax = await services.customRequest<TaxResult>({
* url: '/bookings/42/calculate-tax',
* method: 'POST',
* data: { items: [...] },
* });
* ```
*/
async customRequest<T = unknown>(
config: AxiosRequestConfig,
): Promise<ApiResponse<T>> {
const response = await this.httpClient.request<T>({
...config,
headers: {
...(this.moduleKey ? { 'ex-module-key': this.moduleKey } : {}),
...(config.headers ?? {}),
},
});
return { data: response.data, status: response.status };
}
// ─── CRUD Operations ───────────────────────────────────────────
/** Fetch a paginated list of entities. */
getMany<T = E[]>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.getMany, { config });
}
/** Fetch a single entity by ID. */
getOne<T = E>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.getOne, {
variableURL: { id },
config,
});
}
/** Create a new entity. */
create<T = E>(data: Partial<E>, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.create, {
config: { ...config, data },
});
}
/** Update an existing entity by ID. */
edit<T = E>(id: string, data: Partial<E>, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.edit, {
variableURL: { id },
config: { ...config, data },
});
}
/** Delete a single entity by ID. */
delete(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.delete, {
variableURL: { id },
config,
});
}
/** Delete multiple entities by IDs. */
batchDelete(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDelete, {
config: { ...config, data: { ids } },
});
}
// ─── Activation Lifecycle ─────────────────────────────────────
/** Activate a single entity. */
activate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.activate, {
variableURL: { id },
config,
});
}
/** Activate multiple entities. */
batchActivate(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchActivate, {
config: { ...config, data: { ids } },
});
}
/** Deactivate a single entity. */
deactivate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.deactivate, {
variableURL: { id },
config,
});
}
/** Deactivate multiple entities. */
batchDeactivate(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDeactivate, {
config: { ...config, data: { ids } },
});
}
// ─── Data Processing Lifecycle ────────────────────────────────
/** Confirm processing of a single data record. */
confirmProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.confirmProcessData, {
variableURL: { id },
config,
});
}
/** Confirm processing of multiple data records. */
batchConfirmProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmProcessData, {
config: { ...config, data: { ids } },
});
}
/** Cancel processing of a single data record. */
cancelProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.cancelProcessData, {
variableURL: { id },
config,
});
}
/** Cancel processing of multiple data records. */
batchCancelProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelProcessData, {
config: { ...config, data: { ids } },
});
}
// ─── Transaction Lifecycle ────────────────────────────────────
/** Confirm a transaction. */
confirmProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.confirmProcessTransaction, {
variableURL: { id },
config,
});
}
/** Confirm multiple transactions. */
batchConfirmProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Cancel a transaction. */
cancelProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.cancelProcessTransaction, {
variableURL: { id },
config,
});
}
/** Cancel multiple transactions. */
batchCancelProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Rollback a transaction. */
rollbackProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.rollbackProcessTransaction, {
variableURL: { id },
config,
});
}
/** Rollback multiple transactions. */
batchRollbackProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchRollbackProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Hold a transaction. */
holdProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.holdProcessTransaction, {
variableURL: { id },
config,
});
}
/** Hold multiple transactions. */
batchHoldProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchHoldProcessTransaction, {
config: { ...config, data: { ids } },
});
}
}
@@ -0,0 +1,43 @@
import type { BaseEntity } from './types';
import { BaseRemoteDataServices } from './base-remote.data-services';
/**
* General-purpose remote data services.
*
* A concrete, non-abstract version of BaseRemoteDataServices that
* can be instantiated directly for standard CRUD modules that don't
* need additional custom methods.
*
* For modules requiring domain-specific operations beyond standard
* CRUD + lifecycle, extend BaseRemoteDataServices instead and add
* custom methods using `this.execute()` or `this.customRequest()`.
*
* @typeParam E - The domain entity type
*
* @example
* ```ts
* // Direct instantiation for standard modules
* const bookingServices = new CommonRemoteDataServices<BookingEntity>(
* apiClient,
* { apiUrl: '/bookings', moduleKey: 'BOOKING' },
* );
*
* const { data } = await bookingServices.getMany();
* ```
*
* @example
* ```ts
* // For modules needing custom operations, extend the base:
* class InvoiceDataServices extends BaseRemoteDataServices<InvoiceEntity> {
* async calculateTax(invoiceId: string) {
* return this.customRequest<TaxResult>({
* url: `/invoices/${invoiceId}/calculate-tax`,
* method: 'POST',
* });
* }
* }
* ```
*/
export class CommonRemoteDataServices<
E extends BaseEntity = BaseEntity,
> extends BaseRemoteDataServices<E> {}
@@ -0,0 +1,126 @@
import type { RequestMethodMap, RequestDescriptor, ApiURLMap } from './types';
// ─── Request Actions ────────────────────────────────────────────
/**
* Constants for the 'ex-module-action' header.
* Maps to backend permission/audit checks.
*/
export const REQUEST_ACTION = {
VIEW: 'VIEW',
CREATE: 'CREATE',
EDIT: 'EDIT',
DELETE: 'DELETE',
CONFIRM_DATA: 'CONFIRM_DATA',
CANCEL_DATA: 'CANCEL_DATA',
CONFIRM_PROCESS_TRANSACTION: 'CONFIRM_PROCESS_TRANSACTION',
CANCEL_PROCESS_TRANSACTION: 'CANCEL_PROCESS_TRANSACTION',
} as const;
// ─── Default HTTP Methods ───────────────────────────────────────
/**
* Sensible REST defaults for all operations.
* Can be overridden per data-services instance.
*/
export const DEFAULT_METHODS: RequestMethodMap = {
getManyMethod: 'GET',
getOneMethod: 'GET',
createMethod: 'POST',
editMethod: 'PUT',
deleteMethod: 'DELETE',
batchDeleteMethod: 'POST',
activateMethod: 'PATCH',
batchActivateMethod: 'POST',
deactivateMethod: 'PATCH',
batchDeactivateMethod: 'POST',
confirmProcessDataMethod: 'PATCH',
batchConfirmProcessDataMethod: 'POST',
cancelProcessDataMethod: 'PATCH',
batchCancelProcessDataMethod: 'POST',
confirmProcessTransactionMethod: 'PATCH',
batchConfirmProcessTransactionMethod: 'POST',
cancelProcessTransactionMethod: 'PATCH',
batchCancelProcessTransactionMethod: 'POST',
rollbackProcessTransactionMethod: 'PATCH',
batchRollbackProcessTransactionMethod: 'POST',
holdProcessTransactionMethod: 'PATCH',
batchHoldProcessTransactionMethod: 'POST',
};
// ─── Operation Descriptors ──────────────────────────────────────
/**
* Pre-defined descriptors for all standard operations.
* Each descriptor maps an operation to its URL key, method key,
* and action header — eliminating 22 boilerplate methods.
*/
export const DESCRIPTORS = {
getMany: { urlKey: 'getManyUrl', methodKey: 'getManyMethod', action: REQUEST_ACTION.VIEW },
getOne: { urlKey: 'getOneUrl', methodKey: 'getOneMethod', action: REQUEST_ACTION.VIEW },
create: { urlKey: 'createUrl', methodKey: 'createMethod', action: REQUEST_ACTION.CREATE },
edit: { urlKey: 'editUrl', methodKey: 'editMethod', action: REQUEST_ACTION.EDIT },
delete: { urlKey: 'deleteUrl', methodKey: 'deleteMethod', action: REQUEST_ACTION.DELETE },
batchDelete: { urlKey: 'batchDeleteUrl', methodKey: 'batchDeleteMethod', action: REQUEST_ACTION.DELETE },
activate: { urlKey: 'activateUrl', methodKey: 'activateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchActivate: { urlKey: 'batchActivateUrl', methodKey: 'batchActivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
deactivate: { urlKey: 'deactivateUrl', methodKey: 'deactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchDeactivate: { urlKey: 'batchDeactivateUrl', methodKey: 'batchDeactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
confirmProcessData: { urlKey: 'confirmProcessDataUrl', methodKey: 'confirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchConfirmProcessData: { urlKey: 'batchConfirmProcessDataUrl', methodKey: 'batchConfirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
cancelProcessData: { urlKey: 'cancelProcessDataUrl', methodKey: 'cancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
batchCancelProcessData: { urlKey: 'batchCancelProcessDataUrl', methodKey: 'batchCancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
confirmProcessTransaction: { urlKey: 'confirmProcessTransactionUrl', methodKey: 'confirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchConfirmProcessTransaction: { urlKey: 'batchConfirmProcessTransactionUrl', methodKey: 'batchConfirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
cancelProcessTransaction: { urlKey: 'cancelProcessTransactionUrl', methodKey: 'cancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
batchCancelProcessTransaction: { urlKey: 'batchCancelProcessTransactionUrl', methodKey: 'batchCancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
rollbackProcessTransaction: { urlKey: 'rollbackProcessTransactionUrl', methodKey: 'rollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchRollbackProcessTransaction: { urlKey: 'batchRollbackProcessTransactionUrl', methodKey: 'batchRollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
holdProcessTransaction: { urlKey: 'holdProcessTransactionUrl', methodKey: 'holdProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchHoldProcessTransaction: { urlKey: 'batchHoldProcessTransactionUrl', methodKey: 'batchHoldProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
} as const satisfies Record<string, RequestDescriptor>;
// ─── Default URL Factory ────────────────────────────────────────
/**
* Generates the complete API URL map from a base path.
*
* @param apiUrl - Base API path (e.g., '/bookings')
* @returns Full ApiURLMap with all CRUD + lifecycle URLs
*/
export function makeDefaultURLs(apiUrl: string): ApiURLMap {
return {
getManyUrl: `${apiUrl}`,
getOneUrl: `${apiUrl}/:id`,
createUrl: `${apiUrl}`,
editUrl: `${apiUrl}/:id`,
deleteUrl: `${apiUrl}/:id`,
batchDeleteUrl: `${apiUrl}/batch-delete`,
activateUrl: `${apiUrl}/:id/active`,
batchActivateUrl: `${apiUrl}/batch-active`,
deactivateUrl: `${apiUrl}/:id/inactive`,
batchDeactivateUrl: `${apiUrl}/batch-inactive`,
confirmProcessDataUrl: `${apiUrl}/:id/confirm`,
batchConfirmProcessDataUrl: `${apiUrl}/batch-confirm`,
cancelProcessDataUrl: `${apiUrl}/:id/cancel`,
batchCancelProcessDataUrl: `${apiUrl}/batch-cancel`,
confirmProcessTransactionUrl: `${apiUrl}/:id/confirm-data`,
batchConfirmProcessTransactionUrl: `${apiUrl}/batch-confirm-data`,
cancelProcessTransactionUrl: `${apiUrl}/:id/cancel`,
batchCancelProcessTransactionUrl: `${apiUrl}/batch-cancel`,
rollbackProcessTransactionUrl: `${apiUrl}/:id/confirm-rollback`,
batchRollbackProcessTransactionUrl: `${apiUrl}/batch-confirm-rollback`,
holdProcessTransactionUrl: `${apiUrl}/:id/confirm-hold`,
batchHoldProcessTransactionUrl: `${apiUrl}/batch-confirm-hold`,
};
}
@@ -0,0 +1,19 @@
// ─── Classes ────────────────────────────────────────────────────
export { BaseRemoteDataServices } from './base-remote.data-services';
export { CommonRemoteDataServices } from './common-remote.data-services';
// ─── Utilities ──────────────────────────────────────────────────
export { interpolateUrl } from './url-builder';
// ─── Constants ──────────────────────────────────────────────────
export { REQUEST_ACTION, DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
// ─── Types ──────────────────────────────────────────────────────
export type {
BaseEntity,
ApiURLMap,
RequestMethodMap,
RequestDescriptor,
ExecuteOptions,
DataServicesConfig,
} from './types';
@@ -0,0 +1,125 @@
import type { AxiosRequestConfig } from 'axios';
import type { TelemetryContext } from '../http-client/types';
// ─── Base Entity ────────────────────────────────────────────────
/**
* Minimal entity contract. All domain entities must have
* an optional `id` field for CRUD operations.
*/
export interface BaseEntity {
id?: string;
}
// ─── API URL Map ────────────────────────────────────────────────
/**
* Complete URL map for all standard CRUD and lifecycle operations.
* Each key maps to a URL template string (e.g., '/bookings/:id').
*/
export interface ApiURLMap {
getManyUrl: string;
getOneUrl: string;
createUrl: string;
editUrl: string;
deleteUrl: string;
batchDeleteUrl: string;
activateUrl: string;
batchActivateUrl: string;
deactivateUrl: string;
batchDeactivateUrl: string;
confirmProcessDataUrl: string;
batchConfirmProcessDataUrl: string;
cancelProcessDataUrl: string;
batchCancelProcessDataUrl: string;
confirmProcessTransactionUrl: string;
batchConfirmProcessTransactionUrl: string;
cancelProcessTransactionUrl: string;
batchCancelProcessTransactionUrl: string;
rollbackProcessTransactionUrl: string;
batchRollbackProcessTransactionUrl: string;
holdProcessTransactionUrl: string;
batchHoldProcessTransactionUrl: string;
}
// ─── HTTP Method Map ────────────────────────────────────────────
/**
* HTTP method overrides for each operation.
* Defaults to sensible REST conventions (GET, POST, PUT, DELETE, PATCH).
*/
export interface RequestMethodMap {
getManyMethod: string;
getOneMethod: string;
createMethod: string;
editMethod: string;
deleteMethod: string;
batchDeleteMethod: string;
activateMethod: string;
batchActivateMethod: string;
deactivateMethod: string;
batchDeactivateMethod: string;
confirmProcessDataMethod: string;
batchConfirmProcessDataMethod: string;
cancelProcessDataMethod: string;
batchCancelProcessDataMethod: string;
confirmProcessTransactionMethod: string;
batchConfirmProcessTransactionMethod: string;
cancelProcessTransactionMethod: string;
batchCancelProcessTransactionMethod: string;
rollbackProcessTransactionMethod: string;
batchRollbackProcessTransactionMethod: string;
holdProcessTransactionMethod: string;
batchHoldProcessTransactionMethod: string;
}
// ─── Request Descriptor ─────────────────────────────────────────
/**
* Describes a single operation in terms of its URL, method, and
* action header. Used by the generic `execute()` method.
*/
export interface RequestDescriptor {
/** Key into the ApiURLMap. */
urlKey: keyof ApiURLMap;
/** Key into the RequestMethodMap. */
methodKey: keyof RequestMethodMap;
/** Value for the 'ex-module-action' header. */
action: string;
}
// ─── Execute Options ────────────────────────────────────────────
/**
* Options passed to the generic `execute()` method.
*/
export interface ExecuteOptions {
/** Dynamic URL parameters (e.g., `{ id: '42' }`). */
variableURL?: Record<string, string>;
/** Additional Axios request config (params, data, headers, etc). */
config?: AxiosRequestConfig;
/** Per-request telemetry context for custom spans, tags, events. */
telemetryContext?: TelemetryContext;
}
// ─── Data Services Constructor ──────────────────────────────────
/**
* Configuration for constructing a BaseRemoteDataServices instance.
*/
export interface DataServicesConfig {
/** Base API path (e.g., '/bookings'). Used to generate all URL templates. */
apiUrl?: string;
/** Module key for the 'ex-module-key' header (e.g., 'BOOKING'). */
moduleKey?: string;
/** Override specific URL templates. */
urls?: Partial<ApiURLMap>;
/** Override specific HTTP methods. */
methods?: Partial<RequestMethodMap>;
}
@@ -0,0 +1,44 @@
/**
* Interpolates dynamic URL parameters.
*
* Replaces `:paramName` segments in a URL template with values
* from the provided variables object.
*
* @param template - URL template (e.g., '/bookings/:id/confirm')
* @param variables - Key-value map of parameter names to values
* @returns The interpolated URL string
*
* @example
* ```ts
* interpolateUrl('/bookings/:id/confirm', { id: '42' });
* // => '/bookings/42/confirm'
*
* interpolateUrl('/orgs/:orgId/users/:userId', { orgId: 'a', userId: 'b' });
* // => '/orgs/a/users/b'
* ```
*/
export function interpolateUrl(
template: string,
variables?: Record<string, string>,
): string {
if (!variables || Object.keys(variables).length === 0) {
return template;
}
return template
.split('/')
.map((segment) => {
if (segment.startsWith(':')) {
const key = segment.slice(1);
const value = variables[key];
if (value === undefined) {
throw new Error(
`[interpolateUrl] Missing value for URL parameter ":${key}" in template "${template}"`,
);
}
return encodeURIComponent(value);
}
return segment;
})
.join('/');
}
+128
View File
@@ -0,0 +1,128 @@
import { AxiosError, type AxiosResponse } from 'axios';
import { ApiErrorCode, httpStatusToErrorCode } from './error-codes';
/**
* Structured API error that normalizes Axios errors into a
* predictable, serializable format.
*
* Replaces the legacy `ErrorRequest` class with richer metadata.
*
* @example
* ```ts
* try {
* await apiClient.get('/users');
* } catch (err) {
* if (err instanceof ApiError) {
* console.log(err.code); // ApiErrorCode.UNAUTHORIZED
* console.log(err.status); // 401
* console.log(err.data); // { message: "Token expired" }
* }
* }
* ```
*/
export class ApiError extends Error {
/** Structured error code for programmatic handling. */
readonly code: ApiErrorCode;
/** HTTP status code (0 if no response, e.g., network error). */
readonly status: number;
/** Raw response body from the server, if available. */
readonly data: unknown;
/** The original Axios error, preserved for debugging. */
readonly cause: AxiosError | undefined;
constructor(
message: string,
code: ApiErrorCode,
status: number,
data?: unknown,
cause?: AxiosError,
) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
this.data = data;
this.cause = cause;
// Maintain proper prototype chain for instanceof checks
Object.setPrototypeOf(this, ApiError.prototype);
}
/**
* Factory: creates an ApiError from an AxiosError.
* Automatically resolves the error code from the HTTP status.
*/
static fromAxiosError(error: AxiosError<unknown>): ApiError {
// Network error (no response received)
if (!error.response) {
if (error.code === 'ECONNABORTED') {
return new ApiError(
'Request timed out',
ApiErrorCode.TIMEOUT,
0,
undefined,
error,
);
}
if (error.code === 'ERR_CANCELED') {
return new ApiError(
'Request was cancelled',
ApiErrorCode.CANCELLED,
0,
undefined,
error,
);
}
return new ApiError(
error.message || 'Network error',
ApiErrorCode.NETWORK_ERROR,
0,
undefined,
error,
);
}
// Server responded with an error status
const response: AxiosResponse = error.response;
const status = response.status;
const data = response.data;
const code = httpStatusToErrorCode(status);
// Extract message from common server response formats
const serverMessage =
(data && typeof data === 'object' && 'message' in data)
? String((data as Record<string, unknown>).message)
: `Request failed with status ${status}`;
return new ApiError(serverMessage, code, status, data, error);
}
/** Convenience check for authentication failures. */
get isUnauthorized(): boolean {
return this.code === ApiErrorCode.UNAUTHORIZED;
}
/** Convenience check for permission failures. */
get isForbidden(): boolean {
return this.code === ApiErrorCode.FORBIDDEN;
}
/** Convenience check for network/connectivity issues. */
get isNetworkError(): boolean {
return this.code === ApiErrorCode.NETWORK_ERROR;
}
/** JSON-serializable representation for logging/telemetry. */
toJSON(): Record<string, unknown> {
return {
name: this.name,
message: this.message,
code: this.code,
status: this.status,
data: this.data,
};
}
}
@@ -0,0 +1,42 @@
/**
* Enumeration of well-known API error codes.
*
* Use these to programmatically handle specific server responses
* without relying on magic strings scattered across the codebase.
*/
export enum ApiErrorCode {
// ─── HTTP Standard ────────────────────────────────────────────
BAD_REQUEST = 'BAD_REQUEST',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
NOT_FOUND = 'NOT_FOUND',
CONFLICT = 'CONFLICT',
UNPROCESSABLE_ENTITY = 'UNPROCESSABLE_ENTITY',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
// ─── Client-side ──────────────────────────────────────────────
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
CANCELLED = 'CANCELLED',
UNKNOWN = 'UNKNOWN',
}
/**
* Maps HTTP status codes to ApiErrorCode enum values.
*/
export function httpStatusToErrorCode(status: number): ApiErrorCode {
switch (status) {
case 400: return ApiErrorCode.BAD_REQUEST;
case 401: return ApiErrorCode.UNAUTHORIZED;
case 403: return ApiErrorCode.FORBIDDEN;
case 404: return ApiErrorCode.NOT_FOUND;
case 409: return ApiErrorCode.CONFLICT;
case 422: return ApiErrorCode.UNPROCESSABLE_ENTITY;
case 429: return ApiErrorCode.TOO_MANY_REQUESTS;
case 500: return ApiErrorCode.INTERNAL_SERVER_ERROR;
case 503: return ApiErrorCode.SERVICE_UNAVAILABLE;
default: return ApiErrorCode.UNKNOWN;
}
}
+2
View File
@@ -0,0 +1,2 @@
export { ApiError } from './api-error';
export { ApiErrorCode, httpStatusToErrorCode } from './error-codes';
@@ -0,0 +1,116 @@
import axios, { type AxiosInstance, type AxiosError } from 'axios';
import type { HttpClientConfig, InterceptorHooks } from './types';
import { noopObservabilityAdapter } from '../observability/noop.adapter';
import { ApiError } from '../errors/api-error';
/**
* Creates an isolated Axios instance with per-app configuration.
*
* **CRITICAL**: This function creates a NEW AxiosInstance on every call.
* It NEVER touches `axios.defaults` or `axios.interceptors`. Each consumer
* receives a fully autonomous client with its own interceptor chain.
*
* @param config - Base configuration (URL, timeout, headers, observability).
* @param hooks - Optional per-app interceptor hooks for auth, error handling, etc.
* @returns A configured, isolated AxiosInstance.
*
* @example
* ```ts
* // apps/web — full auth + telemetry
* const apiClient = createHttpClient(
* { baseURL: 'https://api.eigen.co/v1', observability: otelAdapter },
* {
* onRequest: async (config) => {
* config.headers.Authorization = `Bearer ${getToken()}`;
* return config;
* },
* onResponseError: async (error) => {
* if (error.response?.status === 401) redirect('/login');
* throw error;
* },
* },
* );
*
* // apps/landing — minimal public client
* const publicClient = createHttpClient({
* baseURL: 'https://api.eigen.co/public/v1',
* });
* ```
*/
export function createHttpClient(
config: HttpClientConfig,
hooks?: InterceptorHooks,
): AxiosInstance {
const observability = config.observability ?? noopObservabilityAdapter;
// ── Create isolated instance ──────────────────────────────────
const instance = axios.create({
baseURL: config.baseURL,
timeout: config.timeout ?? 15000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(config.defaultHeaders ?? {}),
},
});
// ── Request Interceptor Chain ─────────────────────────────────
instance.interceptors.request.use(
async (reqConfig) => {
// 1. Observability hook (tracing span start)
// Wrapped in try-catch: adapter failures must never block the request
try {
observability.onRequestStart(reqConfig);
} catch (adapterError) {
console.warn('[core-api] Observability adapter error in onRequestStart:', adapterError);
}
// 2. App-specific hook (e.g., inject auth token)
if (hooks?.onRequest) {
return hooks.onRequest(reqConfig);
}
return reqConfig;
},
(error) => Promise.reject(error),
);
// ── Response Interceptor Chain ────────────────────────────────
instance.interceptors.response.use(
(response) => {
// 1. Observability hook (tracing span end)
try {
observability.onRequestEnd(response);
} catch (adapterError) {
console.warn('[core-api] Observability adapter error in onRequestEnd:', adapterError);
}
// 2. App-specific response transform
if (hooks?.onResponse) {
return hooks.onResponse(response);
}
return response;
},
async (error: AxiosError) => {
// 1. Observability hook (tracing error record)
// CRITICAL: Wrapped in try-catch so adapter crashes never
// swallow the original API error from the UI.
try {
observability.onRequestError(error);
} catch (adapterError) {
console.warn('[core-api] Observability adapter error in onRequestError:', adapterError);
}
// 2. App-specific error handler (e.g., 401 redirect)
if (hooks?.onResponseError) {
return hooks.onResponseError(error);
}
// 3. Default: wrap in structured ApiError
throw ApiError.fromAxiosError(error);
},
);
return instance;
}
@@ -0,0 +1,12 @@
export { createHttpClient } from './create-http-client';
export type {
HttpClientConfig,
InterceptorHooks,
ApiResponse,
TelemetryContext,
AxiosInstance,
AxiosError,
AxiosResponse,
AxiosRequestConfig,
InternalAxiosRequestConfig,
} from './types';
+123
View File
@@ -0,0 +1,123 @@
import type {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import type { IObservabilityAdapter } from '../observability/types';
// ─── Factory Configuration ──────────────────────────────────────
/**
* Configuration for creating an isolated HTTP client instance.
* Each app provides its own config — no globals are shared.
*/
export interface HttpClientConfig {
/** Base URL for all requests (e.g., 'https://api.eigen.co/v1'). */
baseURL: string;
/** Default request timeout in milliseconds. @default 15000 */
timeout?: number;
/** Default headers applied to every outgoing request. */
defaultHeaders?: Record<string, string>;
/**
* Observability adapter for tracing, logging, and metrics.
* If not provided, a zero-overhead No-Op adapter is used.
*/
observability?: IObservabilityAdapter;
}
// ─── Interceptor Hooks ──────────────────────────────────────────
/**
* Per-app hooks for customizing request/response behavior.
*
* These hooks are the app's "autonomy layer" — each app decides
* how to inject tokens, handle 401s, transform responses, etc.
*/
export interface InterceptorHooks {
/**
* Called before every request is dispatched.
* Use this to inject authentication tokens, tenant headers, etc.
*/
onRequest?: (
config: InternalAxiosRequestConfig,
) => Promise<InternalAxiosRequestConfig> | InternalAxiosRequestConfig;
/**
* Called on every successful response (2xx status).
* Use this to normalize response shapes if needed.
*/
onResponse?: (response: AxiosResponse) => AxiosResponse;
/**
* Called on every failed response (non-2xx or network error).
* Use this for app-specific error handling (e.g., redirect on 401).
* MUST throw or return a rejected promise.
*/
onResponseError?: (error: AxiosError) => Promise<never>;
}
// ─── Standardized API Response ──────────────────────────────────
/**
* Type-safe API response wrapper.
*
* Replaces the legacy `ResponseEntity` and the lost-in-callback
* `Promise<void>` return type with a fully typed contract.
*/
export interface ApiResponse<T = unknown> {
/** The parsed response body. */
data: T;
/** The HTTP status code. */
status: number;
}
// ─── Per-Request Telemetry Context ──────────────────────────────
/**
* Advanced escape hatch for per-request telemetry enrichment.
*
* Attach this to any request to push custom spans, tags, or
* business events into the observability pipeline.
*
* @example
* ```ts
* await bookingServices.getMany({
* telemetryContext: {
* customSpanName: 'booking.list.fetch',
* tags: { region: 'asia', priority: 'high' },
* pushEventOnSuccess: 'booking_list_loaded',
* },
* });
* ```
*/
export interface TelemetryContext {
/** Custom keys/tags to enrich the Faro log/error or OTel Span. */
tags?: Record<string, string | number | boolean>;
/** If provided, manually starts a custom OTel span wrapping this request. */
customSpanName?: string;
/** Force an explicit business event to be pushed to Faro on success. */
pushEventOnSuccess?: string;
}
// ─── Re-export Axios types consumers frequently need ────────────
export type {
AxiosInstance,
AxiosError,
AxiosResponse,
AxiosRequestConfig,
InternalAxiosRequestConfig,
} from 'axios';
// ─── Augment Axios to carry TelemetryContext ────────────────────
declare module 'axios' {
interface AxiosRequestConfig {
/** Per-request telemetry context for custom spans, tags, events. */
telemetryContext?: TelemetryContext;
}
}
@@ -0,0 +1,5 @@
export type { IObservabilityAdapter } from './types';
export { noopObservabilityAdapter } from './noop.adapter';
export { faroAdapter } from './otel.adapter';
export { initTelemetry, getFaro } from './setup';
export type { TelemetryConfig } from './setup';
@@ -0,0 +1,17 @@
import type { IObservabilityAdapter } from './types';
/**
* No-Op Observability Adapter.
*
* Default adapter when no telemetry is configured.
* All methods are empty — V8's TurboFan JIT compiler will inline
* and dead-code-eliminate these calls during optimization,
* resulting in effectively ZERO runtime overhead.
*
* Used by apps that don't need APM (e.g., `apps/landing`).
*/
export const noopObservabilityAdapter: IObservabilityAdapter = {
onRequestStart() {},
onRequestEnd() {},
onRequestError() {},
};
@@ -0,0 +1,212 @@
import { trace, SpanStatusCode, type Span } from '@opentelemetry/api';
import { LogLevel } from '@grafana/faro-web-sdk';
import type { IObservabilityAdapter } from './types';
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import type { TelemetryContext } from '../http-client/types';
import { getFaro } from './setup';
// ─── Symbol Keys ────────────────────────────────────────────────
/** Symbol-keyed storage for custom spans on the Axios config. */
const CUSTOM_SPAN_KEY = Symbol('__customOtelSpan');
function attachSpan(config: InternalAxiosRequestConfig, span: Span): void {
(config as unknown as Record<symbol, Span>)[CUSTOM_SPAN_KEY] = span;
}
function getSpan(config: unknown): Span | undefined {
if (!config) return undefined;
return (config as Record<symbol, Span>)?.[CUSTOM_SPAN_KEY];
}
/**
* Safely close a span, guarding against double-close.
* After ending, removes the reference from the config to prevent
* duplicate Span ID errors on potential Axios retries.
*/
function safeEndSpan(config: unknown, span: Span): void {
span.end();
// Detach from config to prevent double-close on retry
if (config) {
delete (config as Record<symbol, unknown>)[CUSTOM_SPAN_KEY];
}
}
/** Extract TelemetryContext from an Axios config. */
function getTelemetryContext(config: unknown): TelemetryContext | undefined {
if (!config) return undefined;
return (config as Record<string, unknown>)?.telemetryContext as TelemetryContext | undefined;
}
// ─── Helpers ────────────────────────────────────────────────────
/** Convert TelemetryContext tags to a string record for Faro context. */
function tagsToFaroContext(tags?: Record<string, string | number | boolean>): Record<string, string> {
if (!tags) return {};
return Object.fromEntries(
Object.entries(tags).map(([k, v]) => [k, String(v)]),
);
}
/**
* Build the standardized base context used by ALL Faro pushLog/pushError calls.
* Ensures `module.key`, `module.action`, and tags are always at the top-level
* `context` object — making them directly queryable in LogQL (Loki).
*/
function buildBaseContext(
method: string,
url: string,
moduleKey?: string,
action?: string,
tags?: Record<string, string | number | boolean>,
): Record<string, string> {
return {
'http.method': method,
'http.url': url,
...(moduleKey ? { 'module.key': moduleKey } : {}),
...(action ? { 'module.action': action } : {}),
...tagsToFaroContext(tags),
};
}
// ─── Adapter ────────────────────────────────────────────────────
/**
* Production-grade Observability Adapter.
*
* Strategy: **Opt-In Custom Spans + Faro/Loki Baseline Logging**
*
* `trace.getActiveSpan()` returns `undefined` inside Axios interceptors
* due to browser XHR/Fetch lifecycle race conditions with Faro's
* `TracingInstrumentation`. Therefore this adapter does NOT attempt
* to enrich auto-instrumented spans.
*
* Instead it focuses on two responsibilities:
*
* 1. **Custom Span Mode** (opt-in via `telemetryContext.customSpanName`):
* Creates an explicit OTel span, attaches business tags, and ensures
* the span is ALWAYS closed — even on abort, timeout, or unexpected
* errors — to prevent span leaks.
*
* 2. **Faro/Loki Baseline** (always):
* Pushes rich contextual logs (`pushLog`), errors (`pushError`), and
* success events (`pushEvent`) with standardized `baseContext` for
* direct LogQL queryability.
*
* Safety guarantees:
* - Spans are always closed via `safeEndSpan()` which detaches the
* reference after closing, preventing double-close on Axios retries.
* - Adapter errors are caught internally and never swallowed — the
* original API error always propagates to the UI.
* - `null`/`undefined` config guards prevent crashes on network timeouts
* where `error.config` may be undefined.
*/
export const faroAdapter: IObservabilityAdapter = {
onRequestStart(config: InternalAxiosRequestConfig) {
const ctx = getTelemetryContext(config);
const method = (config.method ?? 'UNKNOWN').toUpperCase();
const url = config.url ?? '/';
const moduleKey = config.headers?.['ex-module-key'] as string | undefined;
const action = config.headers?.['ex-module-action'] as string | undefined;
// ── 1. Create optional custom span ──────────────────────────
if (ctx?.customSpanName) {
const tracer = trace.getTracer('@repo/core-api', '1.0.0');
const span = tracer.startSpan(ctx.customSpanName, {
attributes: {
'http.method': method,
'http.url': url,
...(moduleKey ? { 'custom.module_key': moduleKey } : {}),
...(action ? { 'custom.module_action': action } : {}),
},
});
// Attach custom tags with `custom.` prefix
if (ctx.tags) {
for (const [key, value] of Object.entries(ctx.tags)) {
span.setAttribute(`custom.${key}`, value);
}
}
attachSpan(config, span);
}
// ── 2. Push structured log (Loki) ───────────────────────────
const faro = getFaro();
if (faro) {
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx?.tags);
faro.api.pushLog(
[`[core-api] ${method} ${url}`],
{ level: LogLevel.DEBUG, context: baseContext },
);
}
},
onRequestEnd(response: AxiosResponse) {
const ctx = getTelemetryContext(response.config);
const moduleKey = response.config?.headers?.['ex-module-key'] as string | undefined;
const action = response.config?.headers?.['ex-module-action'] as string | undefined;
// ── 1. Close custom span (OK) ───────────────────────────────
const customSpan = getSpan(response.config);
if (customSpan) {
customSpan.setAttribute('http.status_code', response.status);
customSpan.setStatus({ code: SpanStatusCode.OK });
safeEndSpan(response.config, customSpan);
}
// ── 2. Push success event (Faro) ────────────────────────────
if (ctx?.pushEventOnSuccess) {
const faro = getFaro();
if (faro) {
const method = (response.config?.method ?? 'UNKNOWN').toUpperCase();
const url = response.config?.url ?? '/';
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx.tags);
faro.api.pushEvent(ctx.pushEventOnSuccess, {
...baseContext,
'http.status_code': String(response.status),
});
}
}
},
onRequestError(error: AxiosError) {
const ctx = getTelemetryContext(error.config);
const status = error.response?.status ?? 0;
const method = (error.config?.method ?? 'UNKNOWN').toUpperCase();
const url = error.config?.url ?? '/';
const moduleKey = error.config?.headers?.['ex-module-key'] as string | undefined;
const action = error.config?.headers?.['ex-module-action'] as string | undefined;
// Standardized context for ALL Faro calls in this handler
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx?.tags);
const errorContext: Record<string, string> = {
...baseContext,
'http.status_code': String(status),
'error.message': error.message,
};
// ── 1. Close custom span with error (if present) ────────────
const customSpan = getSpan(error.config);
if (customSpan) {
customSpan.setAttribute('http.status_code', status);
customSpan.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
customSpan.recordException(error);
safeEndSpan(error.config, customSpan);
}
// ── 2. Push structured error + log (Faro → Loki) ────────────
const faro = getFaro();
if (faro) {
faro.api.pushError(error, {
type: 'api_error',
context: errorContext,
});
faro.api.pushLog(
[`[core-api] ERROR ${method} ${url}${status}`],
{ level: LogLevel.ERROR, context: errorContext },
);
}
},
};
@@ -0,0 +1,151 @@
/**
* Centralized Telemetry Setup — Grafana Faro + OpenTelemetry.
*
* Provides a plug-and-play `initTelemetry()` function that hides
* all Faro/OTel complexity behind a simple config interface.
*
* @example
* ```ts
* // apps/web/src/main.tsx (top of file)
* import { initTelemetry } from '@repo/core-api/observability/setup';
* initTelemetry({
* appName: 'web',
* appVersion: '1.0.0',
* telemetryUrl: 'https://telemetry.eigen.co.id/collect',
* environment: 'production',
* });
* ```
*/
import {
getWebInstrumentations,
initializeFaro,
type Faro,
} from '@grafana/faro-react';
import { TracingInstrumentation } from '@grafana/faro-web-tracing';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
// ─── Configuration Interface ────────────────────────────────────
/**
* Configuration for initializing the full telemetry stack.
* Apps pass this once at startup — everything else is automatic.
*/
export interface TelemetryConfig {
/** Application name for Faro + OTel resource attributes. */
appName: string;
/** Application version (SemVer). */
appVersion: string;
/** Grafana Faro collector URL (e.g., 'https://telemetry.eigen.co.id/collect'). */
telemetryUrl: string;
/** Deployment environment ('production', 'staging', 'development'). */
environment: string;
/**
* Optional: Separate OTLP trace endpoint for direct Tempo ingestion.
* If not provided, traces are only sent through Faro's built-in exporter.
*/
otlpTraceUrl?: string;
/**
* Optional: CORS URL patterns for W3C trace context propagation.
* @default [/.* /]
*/
propagateTraceHeaderCorsUrls?: Array<string | RegExp>;
}
// ─── Session Persistence ────────────────────────────────────────
const FARO_SESSION_KEY = 'faroSession';
function getStoredSession(): Record<string, unknown> | null {
try {
const data = localStorage.getItem(FARO_SESSION_KEY);
return data ? JSON.parse(data) : null;
} catch {
return null;
}
}
// ─── Singleton ──────────────────────────────────────────────────
let faroInstance: Faro | null = null;
/**
* Initializes the Grafana Faro + OpenTelemetry observability stack.
*
* Call this ONCE at the top of your app's entry point, before any
* React code, HTTP requests, or other imports execute.
*
* `TracingInstrumentation` internally handles:
* - WebTracerProvider setup with resource attributes
* - FaroMetaAttributesSpanProcessor (session/user enrichment)
* - FaroTraceExporter (sends spans to the Faro collector)
* - Auto-instrumentation for fetch/XHR
* - `faro.api.initOTEL(trace, context)` bridge
*
* @returns The initialized Faro instance for advanced usage.
*/
export function initTelemetry(config: TelemetryConfig): Faro {
if (faroInstance) return faroInstance;
const storedSession = getStoredSession();
// Build optional extra span processors
const tracingOptions: Record<string, unknown> = {};
if (config.otlpTraceUrl) {
tracingOptions.spanProcessor = new BatchSpanProcessor(
new OTLPTraceExporter({
url: config.otlpTraceUrl,
headers: {},
}),
);
}
faroInstance = initializeFaro({
url: config.telemetryUrl,
app: {
name: config.appName,
version: config.appVersion,
environment: config.environment,
},
sessionTracking: {
enabled: true,
persistent: true,
session: storedSession ?? undefined,
onSessionChange: (_oldSession, newSession) => {
if (newSession) {
localStorage.setItem(FARO_SESSION_KEY, JSON.stringify(newSession));
}
},
},
instrumentations: [
...getWebInstrumentations(),
new TracingInstrumentation({
...tracingOptions,
instrumentationOptions: {
propagateTraceHeaderCorsUrls:
config.propagateTraceHeaderCorsUrls ?? [/.*/],
fetchInstrumentationOptions: {
applyCustomAttributesOnSpan(span) {
span.setAttribute('app.synthetic_request', 'false');
},
},
xhrInstrumentationOptions: {
applyCustomAttributesOnSpan(span) {
span.setAttribute('app.synthetic_request', 'false');
},
},
},
}),
],
});
return faroInstance;
}
/** Returns the Faro instance (null if not yet initialized). */
export function getFaro(): Faro | null {
return faroInstance;
}
@@ -0,0 +1,22 @@
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
/**
* Interface-driven observability contract.
*
* The HTTP client calls these hooks at request lifecycle points.
* Implementations decide whether to trace, log, metric, or do nothing.
*
* This interface is the ONLY dependency between the HTTP client and
* any telemetry library. The core-api package NEVER imports
* OpenTelemetry, Datadog, Sentry, or any vendor SDK directly.
*/
export interface IObservabilityAdapter {
/** Called immediately before a request is dispatched. */
onRequestStart(config: InternalAxiosRequestConfig): void;
/** Called when a response is successfully received. */
onRequestEnd(response: AxiosResponse): void;
/** Called when a request fails (network error or error status). */
onRequestError(error: AxiosError): void;
}