Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/otel.client.ts
T

139 lines
5.7 KiB
TypeScript

/**
* OpenTelemetry bootstrap for the apps/web browser bundle.
*
* Initialises a `WebTracerProvider` with a `BatchSpanProcessor` wrapped in
* the shared `PIISpanProcessor` (so query-strings and disallowed headers
* are stripped before the spans leave the browser). The provider becomes
* the global tracer provider — `@sentry/react-router` v10 then plugs into
* the same provider so both stacks share the trace_id.
*
* IMPORTANT: This module MUST be initialised BEFORE `Sentry.init()` in
* `apps/web/app/entry.client.tsx`. The Sentry browser SDK depends on
* `@opentelemetry/api`'s global TracerProvider — `WebTracerProvider.register()`
* installs that provider, so calling Sentry.init afterwards is what allows
* them to coexist.
*
* No-op when `VITE_OTEL_EXPORTER_OTLP_ENDPOINT` is empty/unset at build
* time, so production builds without a configured collector keep working.
*
* @see docs/improvement-plan/02-observability/opentelemetry.md (Step 7 + 8)
*/
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
import { XMLHttpRequestInstrumentation } from "@opentelemetry/instrumentation-xml-http-request";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
import { PIISpanProcessor } from "@toolctrl/observability";
export interface ClientOtelConfig {
endpoint: string | undefined;
serviceName: string;
serviceVersion: string | undefined;
deploymentEnvironment: string | undefined;
serviceNamespace: string;
/**
* URL allowlist for which `traceparent`/`tracestate` headers may be propagated
* cross-origin. Per the OWASP guidance in the spec, this MUST be an allowlist
* (regex), never a wildcard, to avoid leaking the trace context to third-party
* fetches (analytics, CDNs, ...).
*/
propagateTraceHeaderCorsUrls: RegExp[];
}
const DEFAULT_SERVICE_NAME = "boilerplate-web";
const DEFAULT_SERVICE_NAMESPACE = "toolctrl"; // derived from the @toolctrl/ npm-scope
/**
* Reads the OTel browser configuration from Vite-injected env vars.
* The `import.meta.env.*` reads happen at build time — production
* builds without the env var bake a `false` endpoint into the bundle.
*/
export function readClientOtelConfig(
env: Record<string, string | undefined> = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {},
): ClientOtelConfig {
const apiHost = env.VITE_OTEL_PROPAGATE_API_HOST || env.VITE_API_HOST || "";
const allowlist: RegExp[] = [];
// Always allow same-origin (RegExp matching the empty origin trivially works).
allowlist.push(/^\//);
if (apiHost) {
// Escape regex meta-characters in the host so VITE_API_HOST=api.example.com
// is matched literally.
const escaped = apiHost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
allowlist.push(new RegExp(`^https?://${escaped}`));
}
return {
endpoint: env.VITE_OTEL_EXPORTER_OTLP_ENDPOINT || undefined,
serviceName: env.VITE_OTEL_SERVICE_NAME || DEFAULT_SERVICE_NAME,
serviceVersion: env.VITE_SENTRY_RELEASE || env.VITE_APP_REVISION || undefined,
deploymentEnvironment: env.VITE_SENTRY_ENVIRONMENT || env.MODE || undefined,
serviceNamespace: DEFAULT_SERVICE_NAMESPACE,
propagateTraceHeaderCorsUrls: allowlist,
};
}
let cachedProvider: WebTracerProvider | undefined;
let cachedConfig: ClientOtelConfig | undefined;
/**
* Initialises the OTel browser SDK. Returns `true` when init actually
* happened (endpoint configured), `false` when the build had no endpoint
* (no-op). Idempotent — repeat calls are short-circuited.
*/
export function initClientOtel(config: ClientOtelConfig = readClientOtelConfig()): boolean {
if (cachedProvider) return true;
cachedConfig = config;
if (!config.endpoint) return false;
const exporter = new OTLPTraceExporter({ url: config.endpoint });
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: config.serviceName,
...(config.serviceVersion ? { [ATTR_SERVICE_VERSION]: config.serviceVersion } : {}),
...(config.deploymentEnvironment ? { "deployment.environment": config.deploymentEnvironment } : {}),
"service.namespace": config.serviceNamespace,
});
const provider = new WebTracerProvider({
resource,
spanProcessors: [new PIISpanProcessor(new BatchSpanProcessor(exporter))],
});
provider.register({
contextManager: new ZoneContextManager(),
});
// Wiring instrumentations directly (instead of via `registerInstrumentations`)
// keeps the bundle smaller and avoids the @opentelemetry/instrumentation
// peer-dep dance.
const fetchInstrumentation = new FetchInstrumentation({
propagateTraceHeaderCorsUrls: config.propagateTraceHeaderCorsUrls,
});
fetchInstrumentation.setTracerProvider(provider);
fetchInstrumentation.enable();
const xhrInstrumentation = new XMLHttpRequestInstrumentation({
propagateTraceHeaderCorsUrls: config.propagateTraceHeaderCorsUrls,
});
xhrInstrumentation.setTracerProvider(provider);
xhrInstrumentation.enable();
cachedProvider = provider;
return true;
}
/** Test-only: reset the cached provider so multiple test cases can call init. */
export function __resetClientOtelForTests(): void {
cachedProvider = undefined;
cachedConfig = undefined;
}
/** Test-only: read back the resolved config (or `undefined` when not initialised). */
export function __getClientOtelConfigForTests(): ClientOtelConfig | undefined {
return cachedConfig;
}