Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user