139 lines
5.7 KiB
TypeScript
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;
|
|
}
|