Refactor code structure for improved readability and maintainability

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