From 4260e240a0d1cd646bbd96edbb714d290f5db2f6 Mon Sep 17 00:00:00 2001
From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com>
Date: Fri, 22 May 2026 17:53:35 +0700
Subject: [PATCH] Refactor code structure for improved readability and
maintainability
---
.gitignore | 5 +-
README.md | 40 +-
apps/landing/package.json | 1 +
apps/landing/src/app.tsx | 5 +
.../data/public.data-services.ts | 40 ++
.../presentation/LandingSample.tsx | 90 +++
apps/landing/src/lib/api-client.ts | 17 +
apps/landing/src/main.tsx | 14 +
apps/landing/src/vite-env.d.ts | 1 +
apps/web/package.json | 1 +
apps/web/src/apps/index.tsx | 3 +-
.../src/apps/modules/example/example.page.tsx | 9 +-
.../booking/data/booking.data-services.ts | 43 ++
.../booking/presentation/BookingSample.tsx | 92 +++
apps/web/src/lib/api-client.ts | 42 ++
apps/web/src/main.tsx | 21 +-
packages/core-api/README.md | 425 +++++++++++++
packages/core-api/package.json | 35 ++
.../base-remote.data-services.ts | 314 +++++++++
.../common-remote.data-services.ts | 43 ++
.../core-api/src/data-services/constants.ts | 126 ++++
packages/core-api/src/data-services/index.ts | 19 +
packages/core-api/src/data-services/types.ts | 125 ++++
.../core-api/src/data-services/url-builder.ts | 44 ++
packages/core-api/src/errors/api-error.ts | 128 ++++
packages/core-api/src/errors/error-codes.ts | 42 ++
packages/core-api/src/errors/index.ts | 2 +
.../src/http-client/create-http-client.ts | 116 ++++
packages/core-api/src/http-client/index.ts | 12 +
packages/core-api/src/http-client/types.ts | 123 ++++
packages/core-api/src/observability/index.ts | 5 +
.../src/observability/noop.adapter.ts | 17 +
.../src/observability/otel.adapter.ts | 212 +++++++
packages/core-api/src/observability/setup.ts | 151 +++++
packages/core-api/src/observability/types.ts | 22 +
packages/core-api/tsconfig.json | 9 +
pnpm-lock.yaml | 595 ++++++++++++++++--
37 files changed, 2915 insertions(+), 74 deletions(-)
create mode 100644 apps/landing/src/features/public-content/data/public.data-services.ts
create mode 100644 apps/landing/src/features/public-content/presentation/LandingSample.tsx
create mode 100644 apps/landing/src/lib/api-client.ts
create mode 100644 apps/landing/src/vite-env.d.ts
create mode 100644 apps/web/src/apps/modules/example/features/booking/data/booking.data-services.ts
create mode 100644 apps/web/src/apps/modules/example/features/booking/presentation/BookingSample.tsx
create mode 100644 apps/web/src/lib/api-client.ts
create mode 100644 packages/core-api/README.md
create mode 100644 packages/core-api/package.json
create mode 100644 packages/core-api/src/data-services/base-remote.data-services.ts
create mode 100644 packages/core-api/src/data-services/common-remote.data-services.ts
create mode 100644 packages/core-api/src/data-services/constants.ts
create mode 100644 packages/core-api/src/data-services/index.ts
create mode 100644 packages/core-api/src/data-services/types.ts
create mode 100644 packages/core-api/src/data-services/url-builder.ts
create mode 100644 packages/core-api/src/errors/api-error.ts
create mode 100644 packages/core-api/src/errors/error-codes.ts
create mode 100644 packages/core-api/src/errors/index.ts
create mode 100644 packages/core-api/src/http-client/create-http-client.ts
create mode 100644 packages/core-api/src/http-client/index.ts
create mode 100644 packages/core-api/src/http-client/types.ts
create mode 100644 packages/core-api/src/observability/index.ts
create mode 100644 packages/core-api/src/observability/noop.adapter.ts
create mode 100644 packages/core-api/src/observability/otel.adapter.ts
create mode 100644 packages/core-api/src/observability/setup.ts
create mode 100644 packages/core-api/src/observability/types.ts
create mode 100644 packages/core-api/tsconfig.json
diff --git a/.gitignore b/.gitignore
index 25e38e8..5698ad8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,4 +40,7 @@ storybook-static
# Electron Build Outputs
out/
release/
-web-dist/
\ No newline at end of file
+web-dist/
+
+# Legacy Code (if applicable)
+legacy/
\ No newline at end of file
diff --git a/README.md b/README.md
index dd2c1de..b0b2e49 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ The monorepo is organized into **Apps** (deployable applications) and **Packages
│ └── docs-dev/ # Component Documentation & Playground (Storybook)
│
├── packages/
+│ ├── core-api/ # Shared HTTP Client, Observability & Data Services Engine
│ ├── ui/ # Shared UI Component Library
│ ├── utils/ # Shared Utilities (Date, Encryption, Core Logic, etc)
│ └── configs/ # Shared Tooling Configurations
@@ -204,7 +205,40 @@ An isolated environment for developing and documenting UI components.
---
-### 5. `packages/utils`
+### 5. `packages/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.
+
+* Consumed by `apps/web`, `apps/landing`, and any future workspace
+* Centralizes all `@grafana/faro-*` and `@opentelemetry/*` dependencies
+* Provides plug-and-play telemetry via `initTelemetry()` + `faroAdapter`
+
+**Tech Stack**:
+
+* Axios (isolated instances, zero singleton pollution)
+* Grafana Faro (RUM, Logs, Error tracking)
+* OpenTelemetry (custom spans, distributed tracing)
+* TypeScript (strict types, module augmentation)
+
+**Key Capabilities**:
+
+| Feature | Description |
+|---|---|
+| 🏭 HTTP Client Factory | `createHttpClient()` — per-app isolated Axios instances with interceptor hooks |
+| 📡 Faro/Loki Baseline | Every request automatically pushes structured logs with `module.key` and `module.action` |
+| 🎯 Custom Spans (Opt-In) | `telemetryContext.customSpanName` creates explicit OTel spans visible in Grafana Tempo |
+| 🛡️ Error Normalization | `ApiError.fromAxiosError()` — structured, serializable error codes for all failure modes |
+| 📦 Data Services Engine | `CommonRemoteDataServices` — full CRUD + lifecycle operations with zero boilerplate |
+
+**Documentation**:
+
+| Document | Contents |
+|---|---|
+| [README.md](packages/core-api/README.md) | Architecture, HTTP client setup, observability strategy, data services, app integration guide |
+
+---
+
+### 6. `packages/utils`
Shared business logic and reusable utility modules that can be consumed across multiple applications. Fully tested using Vitest.
@@ -212,7 +246,7 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h
---
-### 6. `packages/ui`
+### 7. `packages/ui`
Shared UI component library (Buttons, Inputs, Cards, Layouts).
@@ -221,7 +255,7 @@ Shared UI component library (Buttons, Inputs, Cards, Layouts).
---
-### 7. `packages/configs`
+### 8. `packages/configs`
Single source of truth for tooling configuration.
diff --git a/apps/landing/package.json b/apps/landing/package.json
index e6ad75c..45cc029 100644
--- a/apps/landing/package.json
+++ b/apps/landing/package.json
@@ -11,6 +11,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@repo/core-api": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
diff --git a/apps/landing/src/app.tsx b/apps/landing/src/app.tsx
index c7f05fb..9e88335 100644
--- a/apps/landing/src/app.tsx
+++ b/apps/landing/src/app.tsx
@@ -1,5 +1,6 @@
import { ThemeProvider } from '@repo/ui/provider';
import { Button } from '@repo/ui/components';
+import LandingSample from './features/public-content/presentation/LandingSample';
export default function App() {
return (
@@ -31,7 +32,11 @@ export default function App() {
✅ @repo/ui workspace link verified — Button component rendered successfully.
+
+
Enterprise Web App
+
+
);
}
diff --git a/apps/landing/src/features/public-content/data/public.data-services.ts b/apps/landing/src/features/public-content/data/public.data-services.ts
new file mode 100644
index 0000000..6217c88
--- /dev/null
+++ b/apps/landing/src/features/public-content/data/public.data-services.ts
@@ -0,0 +1,40 @@
+import { CommonRemoteDataServices } from '@repo/core-api/data-services';
+import type { BaseEntity } from '@repo/core-api/data-services';
+import { publicClient } from '../../../lib/api-client';
+
+// ─── Domain Entity ──────────────────────────────────────────────
+
+/**
+ * Public content entity for the landing page.
+ *
+ * Represents promotional content, blog posts, or announcements
+ * served from a public API without authentication.
+ */
+export interface PublicContentEntity extends BaseEntity {
+ title: string;
+ slug: string;
+ excerpt: string;
+ imageUrl: string;
+ publishedAt: string;
+}
+
+// ─── Data Services Instance ─────────────────────────────────────
+
+/**
+ * Public content data services — wired to the lightweight `publicClient`.
+ *
+ * No auth, no OTel — pure zero-overhead HTTP calls.
+ *
+ * @example
+ * ```ts
+ * const { data } = await publicContentServices.getMany();
+ * const { data: post } = await publicContentServices.getOne('hello-world');
+ * ```
+ */
+export const publicContentServices = new CommonRemoteDataServices(
+ publicClient,
+ {
+ apiUrl: '/content',
+ moduleKey: 'PUBLIC_CONTENT',
+ },
+);
diff --git a/apps/landing/src/features/public-content/presentation/LandingSample.tsx b/apps/landing/src/features/public-content/presentation/LandingSample.tsx
new file mode 100644
index 0000000..42fd681
--- /dev/null
+++ b/apps/landing/src/features/public-content/presentation/LandingSample.tsx
@@ -0,0 +1,90 @@
+import { useState } from 'react';
+import { publicContentServices } from '../data/public.data-services';
+import type { PublicContentEntity } from '../data/public.data-services';
+import type { ApiResponse } from '@repo/core-api/http-client';
+import { ApiError } from '@repo/core-api/errors';
+
+/**
+ * Sample component demonstrating the `@repo/core-api` integration
+ * for the Landing App with TelemetryContext escape hatch.
+ *
+ * Pipeline: Faro auto-instrumentation → No Auth → GET /content
+ * + Custom span "public.content.fetch" with enriched tags
+ */
+export default function LandingSample() {
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState | null>(null);
+ const [error, setError] = useState(null);
+
+ const handleFetch = async () => {
+ setLoading(true);
+ setError(null);
+ setResult(null);
+
+ try {
+ const response = await publicContentServices.getMany({
+ // ── Telemetry Escape Hatch ──────────────────────────────
+ // Even the lightweight landing app can push custom spans
+ // and business events when needed.
+ telemetryContext: {
+ customSpanName: 'public.content.fetch',
+ tags: {
+ 'feature': 'landing',
+ 'ui.component': 'LandingSample',
+ 'ui.action': 'content_fetch',
+ },
+ pushEventOnSuccess: 'public_content_loaded',
+ },
+ });
+ setResult(response);
+ console.log('[LandingSample] Response:', response);
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
+ console.error('[LandingSample] ApiError:', err.toJSON());
+ } else {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
🧪 Public Content Services — Integration Test
+
+ Pipeline: Faro + Custom Span "public.content.fetch" → No Auth → GET /content
+
+
+
+
+ {error && (
+
+ ❌ {error}
+
+ )}
+
+ {result && (
+
+ {JSON.stringify(result, null, 2)}
+
+ )}
+
+ );
+}
diff --git a/apps/landing/src/lib/api-client.ts b/apps/landing/src/lib/api-client.ts
new file mode 100644
index 0000000..624c7fa
--- /dev/null
+++ b/apps/landing/src/lib/api-client.ts
@@ -0,0 +1,17 @@
+import { createHttpClient } from '@repo/core-api/http-client';
+import { faroAdapter } from '@repo/core-api/observability';
+
+/**
+ * Lightweight public HTTP client for `apps/landing`.
+ *
+ * - Uses the shared Faro adapter (initialized via `initTelemetry()` in main.tsx)
+ * - No auth token injection
+ * - Minimal config for maximum performance
+ *
+ * Designed for public-facing content that requires no authentication.
+ */
+export const publicClient = createHttpClient({
+ baseURL: import.meta.env.VITE_API_URL ?? 'https://api.eigen.co/public/v1',
+ timeout: 10000,
+ observability: faroAdapter,
+});
diff --git a/apps/landing/src/main.tsx b/apps/landing/src/main.tsx
index 4c1a5be..12ade2e 100644
--- a/apps/landing/src/main.tsx
+++ b/apps/landing/src/main.tsx
@@ -1,3 +1,17 @@
+// ─── Optional Telemetry Bootstrap ───────────────────────────────
+// Landing app uses minimal telemetry. Remove this block entirely
+// if you want zero observability overhead.
+import { initTelemetry } from '@repo/core-api/observability/setup';
+
+initTelemetry({
+ appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-landing',
+ appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0',
+ telemetryUrl: import.meta.env.VITE_FARO_URL || 'https://telemetry.eigen.co.id/collect',
+ environment: import.meta.env.VITE_ENV || 'development',
+ // No otlpTraceUrl — only Faro collector, minimal overhead
+});
+
+// ─── Application Bootstrap ──────────────────────────────────────
import './main.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
diff --git a/apps/landing/src/vite-env.d.ts b/apps/landing/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/landing/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/web/package.json b/apps/web/package.json
index c12910c..140d498 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@repo/core-api": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
diff --git a/apps/web/src/apps/index.tsx b/apps/web/src/apps/index.tsx
index 7ca77b6..0b35f3d 100644
--- a/apps/web/src/apps/index.tsx
+++ b/apps/web/src/apps/index.tsx
@@ -33,7 +33,8 @@ export default function App() {
} />
} />
} />
- } />
+ {/* } /> */}
+ } />
} />
diff --git a/apps/web/src/apps/modules/example/example.page.tsx b/apps/web/src/apps/modules/example/example.page.tsx
index e6c1363..af334a7 100644
--- a/apps/web/src/apps/modules/example/example.page.tsx
+++ b/apps/web/src/apps/modules/example/example.page.tsx
@@ -1,3 +1,10 @@
+import BookingSample from "./features/booking/presentation/BookingSample";
+
export default function ExamplePage() {
- return example
;
+ return example
+
+
Enterprise Web App
+
+
+
;
}
diff --git a/apps/web/src/apps/modules/example/features/booking/data/booking.data-services.ts b/apps/web/src/apps/modules/example/features/booking/data/booking.data-services.ts
new file mode 100644
index 0000000..2473f15
--- /dev/null
+++ b/apps/web/src/apps/modules/example/features/booking/data/booking.data-services.ts
@@ -0,0 +1,43 @@
+import { CommonRemoteDataServices } from '@repo/core-api/data-services';
+import type { BaseEntity } from '@repo/core-api/data-services';
+import { apiClient } from '../../../../../../lib/api-client';
+
+// ─── Domain Entity ──────────────────────────────────────────────
+
+/**
+ * Booking domain entity.
+ *
+ * In a real module, this would be defined in the domain layer
+ * (e.g., `features/booking/domain/entities.ts`) and imported here.
+ */
+export interface BookingEntity extends BaseEntity {
+ bookingCode: string;
+ customerName: string;
+ checkInDate: string;
+ checkOutDate: string;
+ status: 'pending' | 'confirmed' | 'cancelled';
+ totalAmount: number;
+}
+
+// ─── Data Services Instance ─────────────────────────────────────
+
+/**
+ * Booking data services — wired to the enterprise `apiClient`.
+ *
+ * All requests flow through the full interceptor chain:
+ * Faro tracing → Bearer token injection → ApiError normalization.
+ *
+ * @example
+ * ```ts
+ * const { data } = await bookingServices.getMany({ params: { page: 1 } });
+ * const { data: booking } = await bookingServices.getOne('42');
+ * await bookingServices.confirmProcessTransaction('42');
+ * ```
+ */
+export const bookingServices = new CommonRemoteDataServices(
+ apiClient,
+ {
+ apiUrl: '/bookings',
+ moduleKey: 'BOOKING',
+ },
+);
diff --git a/apps/web/src/apps/modules/example/features/booking/presentation/BookingSample.tsx b/apps/web/src/apps/modules/example/features/booking/presentation/BookingSample.tsx
new file mode 100644
index 0000000..5cdbccb
--- /dev/null
+++ b/apps/web/src/apps/modules/example/features/booking/presentation/BookingSample.tsx
@@ -0,0 +1,92 @@
+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';
+
+/**
+ * Sample component demonstrating `@repo/core-api` integration
+ * with the advanced TelemetryContext escape hatch.
+ *
+ * Pipeline: Faro auto-instrumentation → Bearer token → GET /bookings
+ * + Custom span "booking.list.fetch" with enriched tags
+ */
+export default function BookingSample() {
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState | null>(null);
+ const [error, setError] = useState(null);
+
+ const handleFetch = async () => {
+ setLoading(true);
+ setError(null);
+ setResult(null);
+
+ try {
+ const response = await bookingServices.getMany({
+ params: { page: 1, limit: 20 },
+ // ── Telemetry Escape Hatch ──────────────────────────────
+ // This creates a custom OTel span named "booking.list.fetch",
+ // attaches business tags, and pushes a Faro event on success.
+ telemetryContext: {
+ customSpanName: 'booking.list.fetch',
+ tags: {
+ 'feature': 'booking',
+ 'ui.component': 'BookingSample',
+ 'ui.action': 'list_fetch',
+ 'page': 1,
+ },
+ pushEventOnSuccess: 'booking_list_loaded',
+ },
+ });
+ setResult(response);
+ console.log('[BookingSample] Response:', response);
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
+ console.error('[BookingSample] ApiError:', err.toJSON());
+ } else {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
🧪 Booking Data Services — Integration Test
+
+ Pipeline: Faro + Custom Span "booking.list.fetch" → Bearer Token → GET /bookings
+
+
+
+
+ {error && (
+
+ ❌ {error}
+
+ )}
+
+ {result && (
+
+ {JSON.stringify(result, null, 2)}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts
new file mode 100644
index 0000000..8bc1c9a
--- /dev/null
+++ b/apps/web/src/lib/api-client.ts
@@ -0,0 +1,42 @@
+import { createHttpClient } from '@repo/core-api/http-client';
+import { faroAdapter } from '@repo/core-api/observability';
+
+/**
+ * Enterprise HTTP client for `apps/web`.
+ *
+ * - Full Faro observability via the shared `faroAdapter`
+ * - Automatic Bearer token injection from localStorage
+ * - 401 redirect to `/auth/login`
+ * - Supports per-request `telemetryContext` for custom spans/tags
+ *
+ * All interceptors (auth, observability, error normalization)
+ * are baked into this instance. Import this singleton throughout
+ * the web application — never create raw axios instances.
+ */
+export const apiClient = createHttpClient(
+ {
+ baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1',
+ timeout: 15000,
+ observability: faroAdapter,
+ },
+ {
+ // ── Auth Interceptor ──────────────────────────────────────────
+ onRequest: async (config) => {
+ const token = localStorage.getItem('access_token');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+ },
+
+ // ── Error Interceptor ─────────────────────────────────────────
+ onResponseError: async (error) => {
+ if (error.response?.status === 401) {
+ // Clear stale token and redirect to login
+ localStorage.removeItem('access_token');
+ window.location.href = '/auth/login';
+ }
+ throw error;
+ },
+ },
+);
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 5e62636..8414dc5 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,15 +1,22 @@
-// import { DateUtils } from '@repo/utils';
-// import { CurrencyUtils } from '@repo/utils';
+// ─── Observability Bootstrap (MUST be first) ────────────────────
+// Initializes Grafana Faro + OTel auto-instrumentation before any
+// React code or HTTP requests are executed.
+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',
+ otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || 'https://telemetry.eigen.co.id/v1/traces',
+ environment: import.meta.env.VITE_ENV || 'development',
+});
+
+// ─── Application Bootstrap ──────────────────────────────────────
import './main.css';
import { lazy, StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
const App = lazy(() => import('./apps'));
-// DateUtils.setGlobalConfig('Asia/Jakarta');
-// DateUtils.setGlobalConfig('Asia/Makassar');
-
-// CurrencyUtils.setGlobalPrefix('IDR ');
-// CurrencyUtils.setGlobalDecimalSeparator(',');
createRoot(document.getElementById('app')!).render(
diff --git a/packages/core-api/README.md b/packages/core-api/README.md
new file mode 100644
index 0000000..202201f
--- /dev/null
+++ b/packages/core-api/README.md
@@ -0,0 +1,425 @@
+# @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 — consumed by `apps/web`, `apps/landing`, and any future workspace.
+
+---
+
+## Table of Contents
+
+- [Architecture Overview](#architecture-overview)
+- [HTTP Client](#http-client)
+- [Observability](#observability)
+- [Data Services](#data-services)
+- [Application Setup Guide](#application-setup-guide)
+- [Per-Request Telemetry (Escape Hatch)](#per-request-telemetry-escape-hatch)
+- [Error Handling](#error-handling)
+- [Package Exports](#package-exports)
+
+---
+
+## Architecture Overview
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ @repo/core-api │
+│ │
+│ ┌──────────────┐ ┌───────────────────┐ ┌───────────────────┐ │
+│ │ http-client │ │ observability │ │ data-services │ │
+│ │ │ │ │ │ │ │
+│ │ createHttp │◄──│ faroAdapter │ │ BaseRemoteData │ │
+│ │ Client() │ │ initTelemetry() │ │ Services │ │
+│ │ │ │ getFaro() │ │ CommonRemoteData │ │
+│ │ ApiResponse │ │ noopAdapter │ │ Services │ │
+│ └──────┬───────┘ └───────────────────┘ └────────┬──────────┘ │
+│ │ │ │
+│ └────────────────────┬───────────────────────┘ │
+│ │ │
+│ ┌────────┴────────┐ │
+│ │ errors │ │
+│ │ ApiError │ │
+│ │ ErrorCodes │ │
+│ └─────────────────┘ │
+└─────────────────────────────────────────────────────────────────────┘
+ │ │ │
+ ▼ ▼ ▼
+ apps/web apps/landing apps/desktop
+```
+
+### Data Flow
+
+Every HTTP request flows through this pipeline:
+
+```
+Component → DataService.getMany() → execute()
+ → httpClient.request()
+ → Request Interceptor:
+ 1. faroAdapter.onRequestStart() ← Faro log + optional custom span
+ 2. hooks.onRequest() ← App-specific (e.g., Bearer token)
+ → Network (fetch/XHR)
+ → Response Interceptor:
+ SUCCESS: faroAdapter.onRequestEnd() → hooks.onResponse()
+ ERROR: faroAdapter.onRequestError() → hooks.onResponseError()
+ → ApiError.fromAxiosError()
+```
+
+> [!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',
+ environment: 'production',
+ // Optional: direct OTLP export to Grafana Tempo
+ otlpTraceUrl: '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',
+ otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || '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` |
diff --git a/packages/core-api/package.json b/packages/core-api/package.json
new file mode 100644
index 0000000..3ba255d
--- /dev/null
+++ b/packages/core-api/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@repo/core-api",
+ "version": "0.0.0",
+ "type": "module",
+ "exports": {
+ "./http-client": "./src/http-client/index.ts",
+ "./observability": "./src/observability/index.ts",
+ "./observability/setup": "./src/observability/setup.ts",
+ "./data-services": "./src/data-services/index.ts",
+ "./errors": "./src/errors/index.ts"
+ },
+ "license": "MIT",
+ "scripts": {
+ "lint": "eslint \"**/*.ts\"",
+ "test": "vitest run",
+ "test:watch": "vitest --watch",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@grafana/faro-react": "^2.1.0",
+ "@grafana/faro-web-sdk": "^2.1.0",
+ "@grafana/faro-web-tracing": "^2.1.0",
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
+ "@opentelemetry/sdk-trace-web": "^2.2.0",
+ "axios": "^1.9.0"
+ },
+ "devDependencies": {
+ "@repo/eslint-config": "workspace:*",
+ "@repo/typescript-config": "workspace:*",
+ "eslint": "^8.57.1",
+ "typescript": "5.5.4",
+ "vitest": "^4.0.17"
+ }
+}
\ No newline at end of file
diff --git a/packages/core-api/src/data-services/base-remote.data-services.ts b/packages/core-api/src/data-services/base-remote.data-services.ts
new file mode 100644
index 0000000..51e875f
--- /dev/null
+++ b/packages/core-api/src/data-services/base-remote.data-services.ts
@@ -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>` (no callback-based onSuccess/onFailed)
+ * - All operations are fully typed end-to-end
+ * - Includes `customRequest()` as an escape hatch for non-standard endpoints
+ *
+ * @typeParam E - The domain entity type (must extend BaseEntity)
+ *
+ * @example
+ * ```ts
+ * class BookingDataServices extends BaseRemoteDataServices {}
+ *
+ * const services = new BookingDataServices(apiClient, {
+ * apiUrl: '/bookings',
+ * moduleKey: 'BOOKING',
+ * });
+ *
+ * const { data, status } = await services.getOne('42');
+ * ```
+ */
+export abstract class BaseRemoteDataServices {
+ /** 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(
+ descriptor: RequestDescriptor,
+ options?: ExecuteOptions,
+ ): Promise> {
+ const { urlKey, methodKey, action } = descriptor;
+
+ const response = await this.httpClient.request({
+ 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({
+ * url: '/bookings/42/calculate-tax',
+ * method: 'POST',
+ * data: { items: [...] },
+ * });
+ * ```
+ */
+ async customRequest(
+ config: AxiosRequestConfig,
+ ): Promise> {
+ const response = await this.httpClient.request({
+ ...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(config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.getMany, { config });
+ }
+
+ /** Fetch a single entity by ID. */
+ getOne(id: string, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.getOne, {
+ variableURL: { id },
+ config,
+ });
+ }
+
+ /** Create a new entity. */
+ create(data: Partial, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.create, {
+ config: { ...config, data },
+ });
+ }
+
+ /** Update an existing entity by ID. */
+ edit(id: string, data: Partial, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.edit, {
+ variableURL: { id },
+ config: { ...config, data },
+ });
+ }
+
+ /** Delete a single entity by ID. */
+ delete(id: string, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.delete, {
+ variableURL: { id },
+ config,
+ });
+ }
+
+ /** Delete multiple entities by IDs. */
+ batchDelete(ids: string[], config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.batchDelete, {
+ config: { ...config, data: { ids } },
+ });
+ }
+
+ // ─── Activation Lifecycle ─────────────────────────────────────
+
+ /** Activate a single entity. */
+ activate(id: string, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.activate, {
+ variableURL: { id },
+ config,
+ });
+ }
+
+ /** Activate multiple entities. */
+ batchActivate(ids: string[], config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.batchActivate, {
+ config: { ...config, data: { ids } },
+ });
+ }
+
+ /** Deactivate a single entity. */
+ deactivate(id: string, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.deactivate, {
+ variableURL: { id },
+ config,
+ });
+ }
+
+ /** Deactivate multiple entities. */
+ batchDeactivate(ids: string[], config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.batchDeactivate, {
+ config: { ...config, data: { ids } },
+ });
+ }
+
+ // ─── Data Processing Lifecycle ────────────────────────────────
+
+ /** Confirm processing of a single data record. */
+ confirmProcessData(id: string, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.confirmProcessData, {
+ variableURL: { id },
+ config,
+ });
+ }
+
+ /** Confirm processing of multiple data records. */
+ batchConfirmProcessData(ids: string[], config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.batchConfirmProcessData, {
+ config: { ...config, data: { ids } },
+ });
+ }
+
+ /** Cancel processing of a single data record. */
+ cancelProcessData(id: string, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.cancelProcessData, {
+ variableURL: { id },
+ config,
+ });
+ }
+
+ /** Cancel processing of multiple data records. */
+ batchCancelProcessData(ids: string[], config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.batchCancelProcessData, {
+ config: { ...config, data: { ids } },
+ });
+ }
+
+ // ─── Transaction Lifecycle ────────────────────────────────────
+
+ /** Confirm a transaction. */
+ confirmProcessTransaction(id: string, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.confirmProcessTransaction, {
+ variableURL: { id },
+ config,
+ });
+ }
+
+ /** Confirm multiple transactions. */
+ batchConfirmProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.batchConfirmProcessTransaction, {
+ config: { ...config, data: { ids } },
+ });
+ }
+
+ /** Cancel a transaction. */
+ cancelProcessTransaction(id: string, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.cancelProcessTransaction, {
+ variableURL: { id },
+ config,
+ });
+ }
+
+ /** Cancel multiple transactions. */
+ batchCancelProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.batchCancelProcessTransaction, {
+ config: { ...config, data: { ids } },
+ });
+ }
+
+ /** Rollback a transaction. */
+ rollbackProcessTransaction(id: string, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.rollbackProcessTransaction, {
+ variableURL: { id },
+ config,
+ });
+ }
+
+ /** Rollback multiple transactions. */
+ batchRollbackProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.batchRollbackProcessTransaction, {
+ config: { ...config, data: { ids } },
+ });
+ }
+
+ /** Hold a transaction. */
+ holdProcessTransaction(id: string, config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.holdProcessTransaction, {
+ variableURL: { id },
+ config,
+ });
+ }
+
+ /** Hold multiple transactions. */
+ batchHoldProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise> {
+ return this.execute(DESCRIPTORS.batchHoldProcessTransaction, {
+ config: { ...config, data: { ids } },
+ });
+ }
+}
diff --git a/packages/core-api/src/data-services/common-remote.data-services.ts b/packages/core-api/src/data-services/common-remote.data-services.ts
new file mode 100644
index 0000000..bdcae39
--- /dev/null
+++ b/packages/core-api/src/data-services/common-remote.data-services.ts
@@ -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(
+ * apiClient,
+ * { apiUrl: '/bookings', moduleKey: 'BOOKING' },
+ * );
+ *
+ * const { data } = await bookingServices.getMany();
+ * ```
+ *
+ * @example
+ * ```ts
+ * // For modules needing custom operations, extend the base:
+ * class InvoiceDataServices extends BaseRemoteDataServices {
+ * async calculateTax(invoiceId: string) {
+ * return this.customRequest({
+ * url: `/invoices/${invoiceId}/calculate-tax`,
+ * method: 'POST',
+ * });
+ * }
+ * }
+ * ```
+ */
+export class CommonRemoteDataServices<
+ E extends BaseEntity = BaseEntity,
+> extends BaseRemoteDataServices {}
diff --git a/packages/core-api/src/data-services/constants.ts b/packages/core-api/src/data-services/constants.ts
new file mode 100644
index 0000000..95c8e26
--- /dev/null
+++ b/packages/core-api/src/data-services/constants.ts
@@ -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;
+
+// ─── 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`,
+ };
+}
diff --git a/packages/core-api/src/data-services/index.ts b/packages/core-api/src/data-services/index.ts
new file mode 100644
index 0000000..edaf7ef
--- /dev/null
+++ b/packages/core-api/src/data-services/index.ts
@@ -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';
diff --git a/packages/core-api/src/data-services/types.ts b/packages/core-api/src/data-services/types.ts
new file mode 100644
index 0000000..76ec63c
--- /dev/null
+++ b/packages/core-api/src/data-services/types.ts
@@ -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;
+ /** 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;
+ /** Override specific HTTP methods. */
+ methods?: Partial;
+}
diff --git a/packages/core-api/src/data-services/url-builder.ts b/packages/core-api/src/data-services/url-builder.ts
new file mode 100644
index 0000000..a69d9a4
--- /dev/null
+++ b/packages/core-api/src/data-services/url-builder.ts
@@ -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 {
+ 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('/');
+}
diff --git a/packages/core-api/src/errors/api-error.ts b/packages/core-api/src/errors/api-error.ts
new file mode 100644
index 0000000..ff7f955
--- /dev/null
+++ b/packages/core-api/src/errors/api-error.ts
@@ -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): 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).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 {
+ return {
+ name: this.name,
+ message: this.message,
+ code: this.code,
+ status: this.status,
+ data: this.data,
+ };
+ }
+}
diff --git a/packages/core-api/src/errors/error-codes.ts b/packages/core-api/src/errors/error-codes.ts
new file mode 100644
index 0000000..0fa5491
--- /dev/null
+++ b/packages/core-api/src/errors/error-codes.ts
@@ -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;
+ }
+}
diff --git a/packages/core-api/src/errors/index.ts b/packages/core-api/src/errors/index.ts
new file mode 100644
index 0000000..ff1b751
--- /dev/null
+++ b/packages/core-api/src/errors/index.ts
@@ -0,0 +1,2 @@
+export { ApiError } from './api-error';
+export { ApiErrorCode, httpStatusToErrorCode } from './error-codes';
diff --git a/packages/core-api/src/http-client/create-http-client.ts b/packages/core-api/src/http-client/create-http-client.ts
new file mode 100644
index 0000000..9262c7c
--- /dev/null
+++ b/packages/core-api/src/http-client/create-http-client.ts
@@ -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;
+}
diff --git a/packages/core-api/src/http-client/index.ts b/packages/core-api/src/http-client/index.ts
new file mode 100644
index 0000000..f10da2c
--- /dev/null
+++ b/packages/core-api/src/http-client/index.ts
@@ -0,0 +1,12 @@
+export { createHttpClient } from './create-http-client';
+export type {
+ HttpClientConfig,
+ InterceptorHooks,
+ ApiResponse,
+ TelemetryContext,
+ AxiosInstance,
+ AxiosError,
+ AxiosResponse,
+ AxiosRequestConfig,
+ InternalAxiosRequestConfig,
+} from './types';
diff --git a/packages/core-api/src/http-client/types.ts b/packages/core-api/src/http-client/types.ts
new file mode 100644
index 0000000..046244f
--- /dev/null
+++ b/packages/core-api/src/http-client/types.ts
@@ -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;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+}
+
+// ─── Standardized API Response ──────────────────────────────────
+
+/**
+ * Type-safe API response wrapper.
+ *
+ * Replaces the legacy `ResponseEntity` and the lost-in-callback
+ * `Promise` return type with a fully typed contract.
+ */
+export interface ApiResponse {
+ /** 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;
+ /** 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;
+ }
+}
diff --git a/packages/core-api/src/observability/index.ts b/packages/core-api/src/observability/index.ts
new file mode 100644
index 0000000..01f30a4
--- /dev/null
+++ b/packages/core-api/src/observability/index.ts
@@ -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';
diff --git a/packages/core-api/src/observability/noop.adapter.ts b/packages/core-api/src/observability/noop.adapter.ts
new file mode 100644
index 0000000..6097ec1
--- /dev/null
+++ b/packages/core-api/src/observability/noop.adapter.ts
@@ -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() {},
+};
diff --git a/packages/core-api/src/observability/otel.adapter.ts b/packages/core-api/src/observability/otel.adapter.ts
new file mode 100644
index 0000000..2d6ebd6
--- /dev/null
+++ b/packages/core-api/src/observability/otel.adapter.ts
@@ -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)[CUSTOM_SPAN_KEY] = span;
+}
+
+function getSpan(config: unknown): Span | undefined {
+ if (!config) return undefined;
+ return (config as Record)?.[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)[CUSTOM_SPAN_KEY];
+ }
+}
+
+/** Extract TelemetryContext from an Axios config. */
+function getTelemetryContext(config: unknown): TelemetryContext | undefined {
+ if (!config) return undefined;
+ return (config as Record)?.telemetryContext as TelemetryContext | undefined;
+}
+
+// ─── Helpers ────────────────────────────────────────────────────
+
+/** Convert TelemetryContext tags to a string record for Faro context. */
+function tagsToFaroContext(tags?: Record): Record {
+ 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,
+): Record {
+ 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 = {
+ ...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 },
+ );
+ }
+ },
+};
diff --git a/packages/core-api/src/observability/setup.ts b/packages/core-api/src/observability/setup.ts
new file mode 100644
index 0000000..654ddf7
--- /dev/null
+++ b/packages/core-api/src/observability/setup.ts
@@ -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;
+}
+
+// ─── Session Persistence ────────────────────────────────────────
+
+const FARO_SESSION_KEY = 'faroSession';
+
+function getStoredSession(): Record | 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 = {};
+
+ 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;
+}
diff --git a/packages/core-api/src/observability/types.ts b/packages/core-api/src/observability/types.ts
new file mode 100644
index 0000000..b4c7395
--- /dev/null
+++ b/packages/core-api/src/observability/types.ts
@@ -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;
+}
diff --git a/packages/core-api/tsconfig.json b/packages/core-api/tsconfig.json
new file mode 100644
index 0000000..65866cf
--- /dev/null
+++ b/packages/core-api/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@repo/typescript-config/library.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "strict": true,
+ "declaration": true,
+ "declarationMap": true
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 61667a2..16d6eb4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -96,10 +96,13 @@ importers:
version: 5.5.4
vite:
specifier: ^5.1.4
- version: 5.4.17
+ version: 5.4.17(@types/node@22.19.3)
apps/landing:
dependencies:
+ '@repo/core-api':
+ specifier: workspace:*
+ version: link:../../packages/core-api
'@repo/ui':
specifier: workspace:*
version: link:../../packages/ui
@@ -142,10 +145,13 @@ importers:
version: 5.5.4
vite:
specifier: ^5.1.4
- version: 5.4.17
+ version: 5.4.17(@types/node@22.19.3)
apps/web:
dependencies:
+ '@repo/core-api':
+ specifier: workspace:*
+ version: link:../../packages/core-api
'@repo/ui':
specifier: workspace:*
version: link:../../packages/ui
@@ -194,10 +200,10 @@ importers:
version: 5.5.4
vite:
specifier: ^5.1.4
- version: 5.4.17
+ version: 5.4.17(@types/node@22.19.3)
vitest:
specifier: ^4.0.17
- version: 4.0.17
+ version: 4.0.17(@opentelemetry/api@1.9.1)
packages/configs/eslint:
dependencies:
@@ -236,6 +242,46 @@ importers:
specifier: ^8.57.0
version: 8.57.1
+ packages/core-api:
+ dependencies:
+ '@grafana/faro-react':
+ specifier: ^2.1.0
+ version: 2.7.0(react@19.2.3)
+ '@grafana/faro-web-sdk':
+ specifier: ^2.1.0
+ version: 2.7.0
+ '@grafana/faro-web-tracing':
+ specifier: ^2.1.0
+ version: 2.7.0
+ '@opentelemetry/api':
+ specifier: ^1.9.0
+ version: 1.9.1
+ '@opentelemetry/exporter-trace-otlp-http':
+ specifier: ^0.213.0
+ version: 0.213.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-web':
+ specifier: ^2.2.0
+ version: 2.7.1(@opentelemetry/api@1.9.1)
+ axios:
+ specifier: ^1.9.0
+ version: 1.16.1
+ devDependencies:
+ '@repo/eslint-config':
+ specifier: workspace:*
+ version: link:../configs/eslint
+ '@repo/typescript-config':
+ specifier: workspace:*
+ version: link:../configs/typescript
+ eslint:
+ specifier: ^8.57.1
+ version: 8.57.1
+ typescript:
+ specifier: 5.5.4
+ version: 5.5.4
+ vitest:
+ specifier: ^4.0.17
+ version: 4.0.17(@opentelemetry/api@1.9.1)
+
packages/ui:
dependencies:
'@mantine/core':
@@ -292,10 +338,10 @@ importers:
version: 5.5.4
vite:
specifier: ^5.1.4
- version: 5.4.17
+ version: 5.4.17(@types/node@22.19.3)
vitest:
specifier: ^4.0.17
- version: 4.0.17
+ version: 4.0.17(@opentelemetry/api@1.9.1)
packages/utils:
dependencies:
@@ -323,7 +369,7 @@ importers:
version: 5.5.4
vitest:
specifier: ^4.0.17
- version: 4.0.17
+ version: 4.0.17(@opentelemetry/api@1.9.1)
packages:
@@ -1156,6 +1202,62 @@ packages:
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
dev: true
+ /@grafana/faro-core@2.7.0:
+ resolution: {integrity: sha512-fNOfWEouoHhZ4dw+oLO+UGoMBLgiLLQU56dp9+NgO3mGNwCYsFTQuKgKQfOe5DDmAbwpRISEyLbiMkEyY7mIJQ==}
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1)
+ dev: false
+
+ /@grafana/faro-react@2.7.0(react@19.2.3):
+ resolution: {integrity: sha512-95wmypcDjHdGe64TCivq3DKLut9q3RRANgKoSLSQhGHmXdKTwE2Ov0+UdUkVQ9uu3KsGGQJRuXkldP0YMIEMiw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-router: ^7.12.0
+ react-router-dom: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-router:
+ optional: true
+ react-router-dom:
+ optional: true
+ dependencies:
+ '@grafana/faro-web-sdk': 2.7.0
+ '@grafana/faro-web-tracing': 2.7.0
+ hoist-non-react-statics: 3.3.2
+ react: 19.2.3
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
+ /@grafana/faro-web-sdk@2.7.0:
+ resolution: {integrity: sha512-NIbFK2g634/KOcq6ej1LseZrbbRejBiIrO8beQpYX6CkyXVGrcdoWRfJdqkQ3Ys60h4aTH+xoAh2Oh0HebhOZg==}
+ dependencies:
+ '@grafana/faro-core': 2.7.0
+ ua-parser-js: 1.0.41
+ web-vitals: 5.2.0
+ dev: false
+
+ /@grafana/faro-web-tracing@2.7.0:
+ resolution: {integrity: sha512-k6eIJeLwm60FpCB+OevL+VLqnoibtCsnozs3ZCQpiUMt6G2PUIQ6/wkHfSEF46R2H+AuVB3nk9oHUKpS5o2wQQ==}
+ dependencies:
+ '@grafana/faro-web-sdk': 2.7.0
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/exporter-trace-otlp-http': 0.218.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/instrumentation': 0.218.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/instrumentation-fetch': 0.218.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/instrumentation-xml-http-request': 0.218.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-web': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.41.1
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/@humanwhocodes/config-array@0.13.0:
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'}
@@ -1197,7 +1299,7 @@ packages:
magic-string: 0.27.0
react-docgen-typescript: 2.4.0(typescript@5.5.4)
typescript: 5.5.4
- vite: 5.4.17
+ vite: 5.4.17(@types/node@22.19.3)
dev: true
/@jridgewell/gen-mapping@0.3.13:
@@ -1443,6 +1545,279 @@ packages:
which: 4.0.0
dev: false
+ /@opentelemetry/api-logs@0.213.0:
+ resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==}
+ engines: {node: '>=8.0.0'}
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ dev: false
+
+ /@opentelemetry/api-logs@0.218.0:
+ resolution: {integrity: sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==}
+ engines: {node: '>=8.0.0'}
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ dev: false
+
+ /@opentelemetry/api@1.9.1:
+ resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
+ engines: {node: '>=8.0.0'}
+
+ /@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/semantic-conventions': 1.41.1
+ dev: false
+
+ /@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/semantic-conventions': 1.41.1
+ dev: false
+
+ /@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1)
+ dev: false
+
+ /@opentelemetry/exporter-trace-otlp-http@0.218.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
+ dev: false
+
+ /@opentelemetry/instrumentation-fetch@0.218.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-eP/Y5hDupb+6MwZSaMw4ZdsDz8YgfJbJ4Ta86BMVeOmI2EArwXcd0v1nfNIvUzXTPi7nakidwqeuUa3FwRwECg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/instrumentation': 0.218.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-web': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.41.1
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
+ /@opentelemetry/instrumentation-xml-http-request@0.218.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-KzqSO62lTiqbT1ihKbbdJSt6A3TngIkacHJHi0SNUJmlpS0/Jg8Sn8kneKffQGjjpScODjZJ6LLs640zWlOGIg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/instrumentation': 0.218.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-web': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.41.1
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
+ /@opentelemetry/instrumentation@0.218.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/api-logs': 0.218.0
+ import-in-the-middle: 3.0.1
+ require-in-the-middle: 8.0.1
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
+ /@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1)
+ dev: false
+
+ /@opentelemetry/otlp-exporter-base@0.218.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1)
+ dev: false
+
+ /@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/api-logs': 0.213.0
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1)
+ protobufjs: 7.6.1
+ dev: false
+
+ /@opentelemetry/otlp-transformer@0.218.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/api-logs': 0.218.0
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-logs': 0.218.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
+ dev: false
+
+ /@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.41.1
+ dev: false
+
+ /@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.41.1
+ dev: false
+
+ /@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.4.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/api-logs': 0.213.0
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.41.1
+ dev: false
+
+ /@opentelemetry/sdk-logs@0.218.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.4.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/api-logs': 0.218.0
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.41.1
+ dev: false
+
+ /@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.9.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1)
+ dev: false
+
+ /@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.9.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
+ dev: false
+
+ /@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.41.1
+ dev: false
+
+ /@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.41.1
+ dev: false
+
+ /@opentelemetry/sdk-trace-web@2.7.1(@opentelemetry/api@1.9.1):
+ resolution: {integrity: sha512-K806OouCSOjMd8Nr7+ZCq3QT22tdAzzS/7h8vprfiKjkgFQ99/dvwU8d12WJANA6D5Qtme65hyBAqAu9CkQuxQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
+ dev: false
+
+ /@opentelemetry/semantic-conventions@1.41.1:
+ resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
+ engines: {node: '>=14'}
+ dev: false
+
/@pkgjs/parseargs@0.11.0:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1454,6 +1829,48 @@ packages:
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
dev: false
+ /@protobufjs/aspromise@1.1.2:
+ resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
+ dev: false
+
+ /@protobufjs/base64@1.1.2:
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+ dev: false
+
+ /@protobufjs/codegen@2.0.5:
+ resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
+ dev: false
+
+ /@protobufjs/eventemitter@1.1.1:
+ resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
+ dev: false
+
+ /@protobufjs/fetch@1.1.1:
+ resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ dev: false
+
+ /@protobufjs/float@1.0.2:
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+ dev: false
+
+ /@protobufjs/inquire@1.1.2:
+ resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==}
+ dev: false
+
+ /@protobufjs/path@1.1.2:
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+ dev: false
+
+ /@protobufjs/pool@1.1.0:
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+ dev: false
+
+ /@protobufjs/utf8@1.1.1:
+ resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
+ dev: false
+
/@rolldown/pluginutils@1.0.0-beta.53:
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
dev: true
@@ -2004,7 +2421,7 @@ packages:
browser-assert: 1.2.1
storybook: 8.6.15(prettier@3.5.3)
ts-dedent: 2.2.0
- vite: 5.4.17
+ vite: 5.4.17(@types/node@22.19.3)
dev: true
/@storybook/components@8.6.15(storybook@8.6.15):
@@ -2146,7 +2563,7 @@ packages:
resolve: 1.22.11
storybook: 8.6.15(prettier@3.5.3)
tsconfig-paths: 4.2.0
- vite: 5.4.17
+ vite: 5.4.17(@types/node@22.19.3)
transitivePeerDependencies:
- rollup
- supports-color
@@ -2333,7 +2750,7 @@ packages:
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
tailwindcss: 4.1.18
- vite: 5.4.17
+ vite: 5.4.17(@types/node@22.19.3)
/@tootallnate/once@2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
@@ -3094,7 +3511,7 @@ packages:
'@rolldown/pluginutils': 1.0.0-beta.53
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
- vite: 5.4.17
+ vite: 5.4.17(@types/node@22.19.3)
transitivePeerDependencies:
- supports-color
dev: true
@@ -3178,6 +3595,14 @@ packages:
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
dev: false
+ /acorn-import-attributes@1.9.5(acorn@8.15.0):
+ resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==}
+ peerDependencies:
+ acorn: ^8
+ dependencies:
+ acorn: 8.15.0
+ dev: false
+
/acorn-jsx@5.3.2(acorn@8.14.1):
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -3209,7 +3634,6 @@ packages:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
- dev: true
/agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
@@ -3546,7 +3970,6 @@ packages:
/asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
- dev: true
/at-least-node@1.0.0:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
@@ -3564,6 +3987,18 @@ packages:
engines: {node: '>=4'}
dev: false
+ /axios@1.16.1:
+ resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==}
+ dependencies:
+ follow-redirects: 1.16.0
+ form-data: 4.0.5
+ https-proxy-agent: 5.0.1
+ proxy-from-env: 2.1.0
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+ dev: false
+
/axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -3894,6 +4329,10 @@ packages:
engines: {node: '>=8'}
dev: false
+ /cjs-module-lexer@2.2.0:
+ resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
+ dev: false
+
/clean-regexp@1.0.0:
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
engines: {node: '>=4'}
@@ -3986,7 +4425,6 @@ packages:
engines: {node: '>= 0.8'}
dependencies:
delayed-stream: 1.0.0
- dev: true
/commander@5.1.0:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
@@ -4249,7 +4687,6 @@ packages:
/delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
- dev: true
/delegates@1.0.0:
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
@@ -5385,6 +5822,16 @@ packages:
/flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+ /follow-redirects@1.16.0:
+ resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+ dev: false
+
/for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@@ -5407,7 +5854,6 @@ packages:
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.18
- dev: true
/fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -5723,6 +6169,12 @@ packages:
dependencies:
function-bind: 1.1.2
+ /hoist-non-react-statics@3.3.2:
+ resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
+ dependencies:
+ react-is: 16.13.1
+ dev: false
+
/hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
dev: false
@@ -5782,7 +6234,6 @@ packages:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
- dev: true
/https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
@@ -5844,6 +6295,16 @@ packages:
parent-module: 1.0.1
resolve-from: 4.0.0
+ /import-in-the-middle@3.0.1:
+ resolution: {integrity: sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==}
+ engines: {node: '>=18'}
+ dependencies:
+ acorn: 8.15.0
+ acorn-import-attributes: 1.9.5(acorn@8.15.0)
+ cjs-module-lexer: 2.2.0
+ module-details-from-path: 1.0.4
+ dev: false
+
/import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
dev: false
@@ -6540,6 +7001,10 @@ packages:
is-unicode-supported: 0.1.0
dev: true
+ /long@5.3.2:
+ resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+ dev: false
+
/longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
dev: false
@@ -7021,7 +7486,6 @@ packages:
/mime-db@1.33.0:
resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==}
engines: {node: '>= 0.6'}
- dev: true
/mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
@@ -7033,7 +7497,6 @@ packages:
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.33.0
- dev: true
/mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
@@ -7165,6 +7628,10 @@ packages:
hasBin: true
dev: true
+ /module-details-from-path@1.0.4:
+ resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
+ dev: false
+
/mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -7712,6 +8179,30 @@ packages:
react-is: 16.13.1
dev: false
+ /protobufjs@7.6.1:
+ resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==}
+ engines: {node: '>=12.0.0'}
+ requiresBuild: true
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.5
+ '@protobufjs/eventemitter': 1.1.1
+ '@protobufjs/fetch': 1.1.1
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/inquire': 1.1.2
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.1
+ '@types/node': 22.19.3
+ long: 5.3.2
+ dev: false
+
+ /proxy-from-env@2.1.0:
+ resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
+ engines: {node: '>=10'}
+ dev: false
+
/pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
dependencies:
@@ -8059,6 +8550,16 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
+ /require-in-the-middle@8.0.1:
+ resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
+ engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
+ dependencies:
+ debug: 4.4.3
+ module-details-from-path: 1.0.4
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/requireindex@1.2.0:
resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==}
engines: {node: '>=0.10.5'}
@@ -9111,6 +9612,11 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ /ua-parser-js@1.0.41:
+ resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
+ hasBin: true
+ dev: false
+
/unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -9457,43 +9963,6 @@ packages:
vfile-message: 4.0.3
dev: false
- /vite@5.4.17:
- resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
- engines: {node: ^18.0.0 || >=20.0.0}
- hasBin: true
- peerDependencies:
- '@types/node': ^18.0.0 || >=20.0.0
- less: '*'
- lightningcss: ^1.21.0
- sass: '*'
- sass-embedded: '*'
- stylus: '*'
- sugarss: '*'
- terser: ^5.4.0
- peerDependenciesMeta:
- '@types/node':
- optional: true
- less:
- optional: true
- lightningcss:
- optional: true
- sass:
- optional: true
- sass-embedded:
- optional: true
- stylus:
- optional: true
- sugarss:
- optional: true
- terser:
- optional: true
- dependencies:
- esbuild: 0.21.5
- postcss: 8.5.3
- rollup: 4.39.0
- optionalDependencies:
- fsevents: 2.3.3
-
/vite@5.4.17(@types/node@22.19.3):
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -9527,11 +9996,10 @@ packages:
dependencies:
'@types/node': 22.19.3
esbuild: 0.21.5
- postcss: 8.5.6
- rollup: 4.55.1
+ postcss: 8.5.3
+ rollup: 4.39.0
optionalDependencies:
fsevents: 2.3.3
- dev: true
/vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
@@ -9583,7 +10051,7 @@ packages:
fsevents: 2.3.3
dev: true
- /vitest@4.0.17:
+ /vitest@4.0.17(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
@@ -9617,6 +10085,7 @@ packages:
jsdom:
optional: true
dependencies:
+ '@opentelemetry/api': 1.9.1
'@vitest/expect': 4.0.17
'@vitest/mocker': 4.0.17(vite@7.3.1)
'@vitest/pretty-format': 4.0.17
@@ -9661,6 +10130,10 @@ packages:
defaults: 1.0.4
dev: true
+ /web-vitals@5.2.0:
+ resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==}
+ dev: false
+
/webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
dev: true