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