diff --git a/source/backend/api/Areas/Reports/Controllers/LeaseController.cs b/source/backend/api/Areas/Reports/Controllers/LeaseController.cs index 9771743de8..4670df047b 100644 --- a/source/backend/api/Areas/Reports/Controllers/LeaseController.cs +++ b/source/backend/api/Areas/Reports/Controllers/LeaseController.cs @@ -39,7 +39,6 @@ public class LeaseController : ControllerBase private readonly ILookupRepository _lookupRepository; private readonly ILeaseService _leaseService; private readonly ILeaseReportsService _leaseReportService; - private readonly ILeasePaymentService _leasePaymentService; private readonly IMapper _mapper; private readonly IWebHostEnvironment _webHostEnvironment; #endregion @@ -52,15 +51,13 @@ public class LeaseController : ControllerBase /// /// /// - /// /// /// - public LeaseController(ILookupRepository lookupRepository, ILeaseService leaseService, ILeaseReportsService leaseReportService, ILeasePaymentService leasePaymentService, IWebHostEnvironment webHostEnvironment, IMapper mapper) + public LeaseController(ILookupRepository lookupRepository, ILeaseService leaseService, ILeaseReportsService leaseReportService, IWebHostEnvironment webHostEnvironment, IMapper mapper) { _lookupRepository = lookupRepository; _leaseService = leaseService; _leaseReportService = leaseReportService; - _leasePaymentService = leasePaymentService; _mapper = mapper; _webHostEnvironment = webHostEnvironment; } diff --git a/source/frontend/index.html b/source/frontend/index.html index f20433e996..4f8cc25bd6 100644 --- a/source/frontend/index.html +++ b/source/frontend/index.html @@ -14,7 +14,7 @@ Property Inventory Management System - + diff --git a/source/frontend/public/config.js b/source/frontend/public/runtime-vars.js similarity index 81% rename from source/frontend/public/config.js rename to source/frontend/public/runtime-vars.js index 78cd96e6ce..b967eda8c3 100644 --- a/source/frontend/public/config.js +++ b/source/frontend/public/runtime-vars.js @@ -1,2 +1,2 @@ // This file is meant to be replaced with configuration values via config maps in OpenShift -window.config = {}; +window.runtime = {}; diff --git a/source/frontend/src/components/common/ErrorModal.test.tsx b/source/frontend/src/components/common/ErrorModal.test.tsx index 48e6f9e02b..d6affecbd6 100644 --- a/source/frontend/src/components/common/ErrorModal.test.tsx +++ b/source/frontend/src/components/common/ErrorModal.test.tsx @@ -4,7 +4,9 @@ import ErrorModal from './ErrorModal'; describe('Error modal tests...', () => { it('renders correctly', () => { - const { container } = render(); + const { container } = render( + , + ); expect(container).toMatchSnapshot(); }); }); diff --git a/source/frontend/src/components/common/ErrorModal.tsx b/source/frontend/src/components/common/ErrorModal.tsx index 72afbb3f51..541576365c 100644 --- a/source/frontend/src/components/common/ErrorModal.tsx +++ b/source/frontend/src/components/common/ErrorModal.tsx @@ -1,3 +1,4 @@ +import { FallbackProps } from 'react-error-boundary'; import { useHistory } from 'react-router-dom'; import GenericModal from './GenericModal'; @@ -10,7 +11,7 @@ import GenericModal from './GenericModal'; * see https://reactjs.org/docs/error-boundaries.html for more details. * @param props */ -const ErrorModal = (props: any) => { +const ErrorModal = (props: FallbackProps) => { const history = useHistory(); return ( ({ data: { records: x } }); @@ -67,7 +66,7 @@ export const CustomAxios = ({ // clear query parameters - we don't want to include them in the span name url.search = ''; const spanName = `HTTP ${method} ${url.href}`; - span = startTrace(spanName, spanAttributes); + span = Telemetry.startSpan(spanName, spanAttributes); if (config.headers === undefined) { config.headers = {} as AxiosRequestHeaders; @@ -90,8 +89,7 @@ export const CustomAxios = ({ instance.interceptors.response.use( response => { SpanEnrichment.enrichWithXhrResponse(span, response); - span.setStatus({ code: SpanStatusCode.OK }); - span.end(); + Telemetry.endSpan(span); if (lifecycleToasts?.successToast && response.status < 300) { loadingToastId && toast.dismiss(loadingToastId); @@ -102,9 +100,7 @@ export const CustomAxios = ({ return response; }, error => { - span.recordException(error); - span.setStatus({ code: SpanStatusCode.ERROR }); - span.end(); + Telemetry.endSpan(span, error); if (axios.isCancel(error)) { return Promise.resolve(error.message); diff --git a/source/frontend/src/features/leases/hooks/useLeaseExport.ts b/source/frontend/src/features/leases/hooks/useLeaseExport.ts index 670e196a6a..18c6dc6eda 100644 --- a/source/frontend/src/features/leases/hooks/useLeaseExport.ts +++ b/source/frontend/src/features/leases/hooks/useLeaseExport.ts @@ -5,7 +5,6 @@ import { useCallback } from 'react'; import { useDispatch } from 'react-redux'; import { hideLoading, showLoading } from 'react-redux-loading-bar'; -import * as actionTypes from '@/constants/actionTypes'; import { catchAxiosError } from '@/customAxios'; import { IPaginateLeases, useApiLeases } from '@/hooks/pims-api/useApiLeases'; import { logRequest, logSuccess } from '@/store/slices/network/networkSlice'; @@ -26,7 +25,7 @@ export const useLeaseExport = () => { filter: IPaginateLeases, outputFormat: 'csv' | 'excel' = 'excel', fileName = `pims-leases.${outputFormat === 'csv' ? 'csv' : 'xlsx'}`, - requestId = 'properties-report', + requestId = 'leases-report', ) => { dispatch(logRequest(requestId)); dispatch(showLoading()); @@ -38,7 +37,7 @@ export const useLeaseExport = () => { fileDownload(data, fileName); } catch (axiosError) { if (axios.isAxiosError(axiosError)) { - catchAxiosError(axiosError, dispatch, actionTypes.DELETE_PARCEL); + catchAxiosError(axiosError, dispatch, requestId); } } }, @@ -57,7 +56,7 @@ export const useLeaseExport = () => { fileDownload(data, `pims-aggregated-leases-${fiscalYearStart}-${fiscalYearStart + 1}.xlsx`); } catch (axiosError) { if (axios.isAxiosError(axiosError)) { - catchAxiosError(axiosError, dispatch, actionTypes.DELETE_PARCEL); + catchAxiosError(axiosError, dispatch, requestId); } } }, @@ -81,7 +80,7 @@ export const useLeaseExport = () => { ); } catch (axiosError) { if (axios.isAxiosError(axiosError)) { - catchAxiosError(axiosError, dispatch, actionTypes.DELETE_PARCEL); + catchAxiosError(axiosError, dispatch, requestId); } } }, diff --git a/source/frontend/src/hooks/repositories/useProperties.ts b/source/frontend/src/hooks/repositories/useProperties.ts index 9e4df1e4b7..14ca9ca8ae 100644 --- a/source/frontend/src/hooks/repositories/useProperties.ts +++ b/source/frontend/src/hooks/repositories/useProperties.ts @@ -116,7 +116,7 @@ export const useProperties = () => { fileDownload(data, fileName); } catch (axiosError) { if (axios.isAxiosError(axiosError)) { - catchAxiosError(axiosError, dispatch, actionTypes.DELETE_PARCEL); + catchAxiosError(axiosError, dispatch, requestId); } } }, diff --git a/source/frontend/src/index.tsx b/source/frontend/src/index.tsx index 8acdffade4..a7e01f8b1c 100644 --- a/source/frontend/src/index.tsx +++ b/source/frontend/src/index.tsx @@ -23,20 +23,18 @@ import LoginLoading from '@/features/account/LoginLoading'; import EmptyLayout from '@/layouts/EmptyLayout'; import { store } from '@/store/store'; import { TenantConsumer, TenantProvider } from '@/tenants'; +import { TelemetryConfig } from '@/utils/config'; import getKeycloakEventHandler from '@/utils/getKeycloakEventHandler'; import App from './App'; -import { config } from './config'; import { NavigationIntentProvider } from './contexts/NavigationIntentContext'; import { DocumentViewerContextProvider } from './features/documents/context/DocumentViewerContext'; import { WorklistContextProvider } from './features/properties/worklist/context/WorklistContext'; import { ITenantConfig2 } from './hooks/pims-api/interfaces/ITenantConfig'; import { useRefreshSiteminder } from './hooks/useRefreshSiteminder'; -import { initializeTelemetry } from './telemetry'; -import { defaultHistogramBuckets, TelemetryConfig } from './telemetry/config'; +import { Telemetry } from './telemetry'; import { ReactRouterSpanProcessor } from './telemetry/traces/ReactRouterSpanProcessor'; -import { exists } from './utils'; -import { stringToNull, stringToNullableBoolean, stringToNumberOrNull } from './utils/formUtils'; +import { stringToNullableBoolean, stringToNumberOrNull } from './utils/formUtils'; async function prepare() { if (process.env.NODE_ENV === 'development') { @@ -93,39 +91,20 @@ const InnerComponent = ({ tenant }: { tenant: ITenantConfig2 }) => { ); }; -// get telemetry options from global configuration. -// window.config is set in index.html, populated by env variables. -const setupTelemetry = () => { - const isTelemetryEnabled = stringToNullableBoolean(config.VITE_TELEMERY_ENABLED) ?? false; - const isDebugEnabled = stringToNullableBoolean(config.VITE_TELEMERY_DEBUG) ?? false; - - if (isTelemetryEnabled) { - const jsonValues = stringToNull(config.VITE_TELEMERY_HISTOGRAM_BUCKETS); - const buckets: number[] = exists(jsonValues) ? JSON.parse(jsonValues) : defaultHistogramBuckets; - const options: TelemetryConfig = { - name: config.VITE_TELEMERY_SERVICE_NAME ?? 'frontend', - appVersion: import.meta.env.VITE_PACKAGE_VERSION ?? '', - environment: config.VITE_TELEMERY_ENVIRONMENT || 'local', - otlpEndpoint: config.VITE_TELEMERY_URL || '', - debug: isDebugEnabled, - exportInterval: stringToNumberOrNull(config.VITE_TELEMERY_EXPORT_INTERVAL) ?? 30000, - histogramBuckets: buckets, - }; - - // configure browser telemetry (if enabled via dynamic config-map) - initializeTelemetry(options); - - console.log('[INFO] Telemetry enabled'); - if (isDebugEnabled) { - console.log(options); - } - } else { - console.log('[INFO] Telemetry disabled'); - } -}; - prepare().then(() => { - setupTelemetry(); + // Bootstrap once at app entry point to ensure telemetry is setup before any other code runs, so that we can capture telemetry for the entire app lifecycle. + Telemetry.init({ + appName: TelemetryConfig.appName || 'pims-frontend', + appVersion: TelemetryConfig.appVersion || 'unknown', + environment: TelemetryConfig.environment || 'local', + collectorUrl: TelemetryConfig.telemetryUrl || '', + debug: stringToNullableBoolean(TelemetryConfig.debug) ?? false, + metricExportIntervalMs: stringToNumberOrNull(TelemetryConfig.metricExportIntervalMs) ?? 30000, + traceExportIntervalMs: stringToNumberOrNull(TelemetryConfig.traceExportIntervalMs) ?? 5000, + histogramBuckets: TelemetryConfig.histogramBuckets, + }); + + // Now that telemetry is initialized, we can render the app. const root = createRoot(document.getElementById('root') as Element); root.render(); }); diff --git a/source/frontend/src/store/store.ts b/source/frontend/src/store/store.ts index ec577cd054..3700311a3c 100644 --- a/source/frontend/src/store/store.ts +++ b/source/frontend/src/store/store.ts @@ -3,11 +3,15 @@ import { loadingBarMiddleware } from 'react-redux-loading-bar'; import logger from 'redux-logger'; import { reducer } from './rootReducer'; +import { telemetryMiddleware } from './telemetryMiddleware'; export const store = configureStore({ reducer: reducer, middleware: (getDefaultMiddleware: () => any[]) => - getDefaultMiddleware().concat(logger).concat(loadingBarMiddleware()), + getDefaultMiddleware() + .concat(telemetryMiddleware) + .concat(logger) + .concat(loadingBarMiddleware()), devTools: import.meta.env.PROD === false, }); export type AppDispatch = typeof store.dispatch; diff --git a/source/frontend/src/store/telemetryMiddleware.ts b/source/frontend/src/store/telemetryMiddleware.ts new file mode 100644 index 0000000000..8758a555b0 --- /dev/null +++ b/source/frontend/src/store/telemetryMiddleware.ts @@ -0,0 +1,29 @@ +import { Middleware } from '@reduxjs/toolkit'; + +import { Telemetry } from '@/telemetry'; + +import { logError } from './slices/network/networkSlice'; + +// Log "bomb" errors to telemetry with relevant attributes for easier debugging and monitoring of network errors +export const telemetryMiddleware: Middleware = _storeAPI => next => async action => { + const result = await next(action); + + if (logError.match(action)) { + const { name, status, error } = action.payload; + + Telemetry.recordException( + error, + { + 'network.request.name': name, + 'network.response.status': status, + 'network.error.message': error?.message ?? 'Unknown error', + 'network.error.code': error?.code, + 'network.error.config.url': error?.config?.url, + 'network.error.config.method': error?.config?.method, + }, + 'bomb.network.error', + ); + } + + return result; +}; diff --git a/source/frontend/src/telemetry/config.ts b/source/frontend/src/telemetry/config.ts index 4afd43eb33..3aca76e3eb 100644 --- a/source/frontend/src/telemetry/config.ts +++ b/source/frontend/src/telemetry/config.ts @@ -1,20 +1,25 @@ +import { Attributes } from '@opentelemetry/api'; + // The configuration for browser telemetry (metrics and logs) -export interface TelemetryConfig { - // by default the service name is set to 'frontend' - helps finding traces in the trace UI dashboard - name?: string; +export interface TelemetrySettings { + appName: string; appVersion?: string; - // set this to match the deployed environment (dev, test, uat, prod) or set to local for local development + // Set this to match the deployed environment (dev, test, uat, prod) or set to local for local development environment?: string; - // the URL to the open-telemetry collector - otlpEndpoint?: string; + // The URL to the open-telemetry collector service that will receive the telemetry data + collectorUrl?: string; // a list of URLs to ignore for traces and metrics denyUrls?: string[]; - // if true, it will output extra information to the console + // If true, it will output extra information to the console debug?: boolean; - // how often to send traces and metrics back to the collector - defaults to 30 seconds - exportInterval?: number; - // the default buckets to apply to histogram metrics + // Metric export interval in ms (default: 30,000) + metricExportIntervalMs?: number; + // Trace export interval in ms (default: 5,000) + traceExportIntervalMs?: number; + // Default buckets to apply to histogram metrics histogramBuckets?: number[]; + // Additional resource atttributes to add to all telemetry data (traces and metrics) + resourceAttributes?: Attributes; } export const defaultHistogramBuckets = [ diff --git a/source/frontend/src/telemetry/index.ts b/source/frontend/src/telemetry/index.ts index 9a24e6c2f1..396ab141b5 100644 --- a/source/frontend/src/telemetry/index.ts +++ b/source/frontend/src/telemetry/index.ts @@ -1,28 +1,406 @@ -import { exists } from '@/utils'; +import { + Attributes, + Context, + context, + metrics, + Span, + SpanKind, + SpanStatusCode, + trace, + Tracer, +} from '@opentelemetry/api'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { browserDetector } from '@opentelemetry/opentelemetry-browser-detector'; +import { detectResourcesSync, Resource } from '@opentelemetry/resources'; +import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { + BatchSpanProcessor, + ConsoleSpanExporter, + SimpleSpanProcessor, + SpanProcessor, + WebTracerProvider, +} from '@opentelemetry/sdk-trace-web'; +import { + ATTR_EXCEPTION_MESSAGE, + ATTR_EXCEPTION_STACKTRACE, + ATTR_EXCEPTION_TYPE, + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, + ATTR_USER_AGENT_ORIGINAL, +} from '@opentelemetry/semantic-conventions'; +import { ATTR_SERVICE_INSTANCE_ID } from '@opentelemetry/semantic-conventions/incubating'; +import { v4 as uuidv4 } from 'uuid'; -import { TelemetryConfig } from './config'; +import { DisableTelemetry } from '@/utils/config'; +import { exists } from '@/utils/utils'; + +import { TelemetrySettings } from './config'; +import { BrowserAttributesSpanProcessor } from './traces/BrowserAttributesSpanProcessor'; +import { UserInfoSpanProcessor } from './traces/UserInfoSpanProcessor'; import { UserAPI } from './users/UserAPI'; -import { registerMeterProvider, registerTracerProvider } from './utils'; -// Main entry-point to configure the collection of application telemetry -export const initializeTelemetry = (config: TelemetryConfig) => { - try { - if (!exists(config)) { - throw Error('[ERR] No metrics configuration provided, it will not be initialized.'); +// Global provider to capture logged-in user in traces +export const UserTelemetry = UserAPI.getInstance(); + +export interface SpanOptions { + attributes?: Attributes; + kind?: SpanKind; + /** Parent context to link the span to */ + parentContext?: Context; +} + +/** + * Static facade around OpenTelemetry for browser environments. + * + * Usage: + * Telemetry.init({ serviceName: "my-app", serviceVersion: "1.0.0" }); + * + * // Traces + * const span = Telemetry.startSpan("user.login", { attributes: { "user.id": "42" } }); + * try { ... } finally { Telemetry.endSpan(span); } + * + * // Or wrap a function + * const result = await Telemetry.withSpan("fetchUser", async (span) => { + * span.setAttribute("user.id", userId); + * return fetchUser(userId); + * }); + * + * // Metrics + * Telemetry.counter("button.click").add(1, { "button.name": "signup" }); + * Telemetry.histogram("api.latency").record(responseTimeMs, { route: "/api/users" }); + * Telemetry.gauge("cart.size").record(cartItems.length); + * + * // Cleanup on app teardown + * await Telemetry.shutdown(); + */ +export class Telemetry { + private static _initialized = false; + private static _tracerName = 'browser-tracer'; + private static _meterName = 'browser-meter'; + private static _tracerProvider: WebTracerProvider | null = null; + private static _meterProvider: MeterProvider | null = null; + + static init(config: TelemetrySettings): void { + // Wrap the entire initialization in a try/catch to prevent telemetry errors from impacting the app. + try { + if (DisableTelemetry) { + console.info( + '[Telemetry] Initialization skipped - telemetry is disabled via configuration.', + ); + return; + } + + if (Telemetry._initialized) { + console.warn('[Telemetry] Already initialized. Ignoring duplicate init call.'); + return; + } + + const { + appName, + appVersion = '0.0.0', + collectorUrl = 'http://localhost:4318', + metricExportIntervalMs = 30_000, + traceExportIntervalMs = 5_000, + resourceAttributes = {}, + debug = false, + } = config; + + // This is common metadata sent with every metric measurement and trace span, describing the application and environment. + let resource = new Resource({ + [ATTR_SERVICE_NAME]: appName, + [ATTR_SERVICE_VERSION]: appVersion, + [ATTR_SERVICE_INSTANCE_ID]: uuidv4(), + [ATTR_USER_AGENT_ORIGINAL]: typeof navigator !== 'undefined' ? navigator.userAgent : '', + 'screen.width': window.screen.width, + 'screen.height': window.screen.height, + ...resourceAttributes, + }); + + const detectedResources = detectResourcesSync({ detectors: [browserDetector] }); + resource = resource.merge(detectedResources); + + // Setup telemetry tracing + const traceExporter = new OTLPTraceExporter({ + url: new URL('v1/traces', collectorUrl).href, + }); + + const processors: SpanProcessor[] = []; + + // Print telemetry data to the console in debug mode, in addition to sending to the collector. + if (debug) { + processors.push(new SimpleSpanProcessor(new ConsoleSpanExporter())); + } + + // Traces are exported every 5s by default, while batching requests together to reduce network overhead. + processors.push( + new BatchSpanProcessor(traceExporter, { scheduledDelayMillis: traceExportIntervalMs }), + new BrowserAttributesSpanProcessor(), + new UserInfoSpanProcessor(), + ); + + const tracerProvider = new WebTracerProvider({ + resource, + spanProcessors: [...processors], + }); + + tracerProvider.register({ + propagator: new W3CTraceContextPropagator(), + }); + + trace.setGlobalTracerProvider(tracerProvider); + Telemetry._tracerProvider = tracerProvider; + + // Setup telemetry metrics + const metricExporter = new OTLPMetricExporter({ + url: new URL('/v1/metrics', collectorUrl).href, + }); + + // Metrics are exported every 30s by default. + const meterProvider = new MeterProvider({ + resource, + readers: [ + new PeriodicExportingMetricReader({ + exporter: metricExporter, + exportIntervalMillis: metricExportIntervalMs, + }), + ], + }); + + metrics.setGlobalMeterProvider(meterProvider); + Telemetry._meterProvider = meterProvider; + + Telemetry._initialized = true; + console.info( + `[Telemetry] Initialized — service: ${appName} v${appVersion}, collector: ${collectorUrl}`, + ); + } catch (error) { + console.error(`[Telemetry] Initialization error: ${error}`); + } + } + + /** Flush and shut down both providers. Call on app teardown. */ + static async shutdown(): Promise { + await Promise.all([ + Telemetry._tracerProvider?.shutdown(), + Telemetry._meterProvider?.shutdown(), + ]); + Telemetry._initialized = false; + } + + /** Get the underlying OpenTelemetry tracer. */ + static get tracer(): Tracer { + return trace.getTracer(Telemetry._tracerName); + } + + /** + * Start a new span. You are responsible for ending it via `endSpan()`. + * Prefer `withSpan()` for automatic lifecycle management. + */ + static startSpan(name: string, options: SpanOptions = {}): Span { + const { attributes = {}, kind = SpanKind.CLIENT, parentContext } = options; + const ctx = parentContext ?? context.active(); + return Telemetry.tracer.startSpan(name, { kind, attributes }, ctx); + } + + /** End a span, optionally recording an error. */ + static endSpan(span: Span, error?: unknown): void { + if (exists(error)) { + span.recordException(error instanceof Error ? error : new Error(String(error))); + span.setStatus({ code: SpanStatusCode.ERROR }); + } else { + span.setStatus({ code: SpanStatusCode.OK }); } + span.end(); + } - if (!exists(config.otlpEndpoint)) { - throw Error('[ERR] Invalid metrics endpoint provided, it will not be initialized.'); + /** + * Wrap an async (or sync) function in a span. + * The span is automatically ended — with error recording — when the function settles. + * + * @example + * const data = await Telemetry.withSpan("loadDashboard", async (span) => { + * span.setAttribute("user.id", userId); + * return fetchDashboard(userId); + * }); + */ + static async withSpan( + name: string, + fn: (span: Span) => T | Promise, + options: SpanOptions = {}, + ): Promise { + const span = Telemetry.startSpan(name, options); + try { + const result = await fn(span); + Telemetry.endSpan(span); + return result; + } catch (err) { + Telemetry.endSpan(span, err); + throw err; } + } + + /** + * Record a one-off event as a zero-duration span. + * Useful for marking significant moments (e.g. "user.logout", "feature.enabled"). + */ + static recordEvent(name: string, attributes: Attributes = {}): void { + const span = Telemetry.startSpan(name, { attributes }); + Telemetry.endSpan(span); + } + + /** + * Record an error as a dedicated ERROR span and increment the `error.count` counter. + * + * The span is named `"error"` by default but can be overridden via `spanName`. + * + * Any extra `attributes` you supply are merged in and also forwarded to the + * `error.count` counter, making it easy to slice error metrics by dimension + * (e.g. `{ "http.route": "/api/checkout", "error.handled": "false" }`). + * + * @example — basic + * try { + * await placeOrder(cart); + * } catch (err) { + * Telemetry.recordException(err); + * throw err; + * } + * + * @example — with extra context + * Telemetry.recordException(err, { + * "http.route": "/api/checkout", + * "user.id": userId, + * "error.handled": "false", + * }); + * + * @example — custom span name + * Telemetry.recordException(err, { "cart.id": cartId }, "checkout.error"); + */ + static recordException(error: unknown, attributes: Attributes = {}, spanName = 'error'): void { + const err = error instanceof Error ? error : new Error(String(error)); + + // OpenTelemetry semantic convention exception attributes + const errorAttributes: Attributes = { + [ATTR_EXCEPTION_TYPE]: err.name, + [ATTR_EXCEPTION_MESSAGE]: err.message, + [ATTR_EXCEPTION_STACKTRACE]: err.stack ?? '', + // Convenience duplicates for quick filtering + 'error.name': err.name, + ...attributes, + }; + + // Emit a dedicated ERROR span so the error is visible in any trace backend + const span = Telemetry.startSpan(spanName, { attributes: errorAttributes }); + span.recordException(err); + span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); + span.end(); + + // Increment the error counter so dashboards can track error rates + Telemetry.counter('error.count', 'Total number of recorded errors').add(1, { + [ATTR_EXCEPTION_TYPE]: err.name, + ...attributes, + }); + } + + // ── Metrics ─────────────────────────────────────────────────────────────── - registerTracerProvider(config); - registerMeterProvider(config); - } catch (error) { - if (config.debug) { - console.error(error); + /** Get the underlying OpenTelemetry meter. */ + static get meter() { + return metrics.getMeter(Telemetry._meterName); + } + + /** + * Monotonically increasing counter (e.g. page views, button clicks, errors). + * + * @example + * Telemetry.counter("page.view").add(1, { page: "/home" }); + */ + static counter(name: string, description?: string) { + return Telemetry.meter.createCounter(name, { description }); + } + + /** + * Up-down counter for values that can increase or decrease (e.g. active connections, queue depth). + * + * @example + * Telemetry.upDownCounter("active.users").add(1); + * Telemetry.upDownCounter("active.users").add(-1); + */ + static upDownCounter(name: string, description?: string) { + return Telemetry.meter.createUpDownCounter(name, { description }); + } + + /** + * Histogram for recording distributions of values over time (e.g. latency, response size). + * + * @example + * Telemetry.histogram("api.duration_ms").record(elapsed, { route: "/search" }); + */ + static histogram(name: string, description?: string) { + return Telemetry.meter.createHistogram(name, { description }); + } + + /** + * Synchronous gauge — record an instantaneous value on demand. + * Use this when you want to report a known value at a specific moment in code, + * rather than polling on a schedule. Ideal for request-scoped snapshots + * (e.g. queue depth at the time of a request, current score, form progress). + * + * @example + * Telemetry.gauge("cart.size").record(cart.length, { "user.id": userId }); + * Telemetry.gauge("upload.progress_pct").record(pct, { "file.name": fileName }); + */ + static gauge(name: string, description?: string) { + return Telemetry.meter.createGauge(name, { description }); + } + + // ── Convenience helpers ─────────────────────────────────────────────────── + + /** + * Measure how long an async function takes and record it as a histogram. + * + * @example + * const user = await Telemetry.measure("db.query", () => db.getUser(id), { "db.table": "users" }); + */ + static async measure( + metricName: string, + fn: () => T | Promise, + attributes: Attributes = {}, + ): Promise { + const hist = Telemetry.histogram(`${metricName}.duration_ms`); + const start = performance.now(); + try { + return await fn(); + } finally { + hist.record(performance.now() - start, attributes); } } -}; -// Global provider to capture logged-in user in traces -export const user = UserAPI.getInstance(); + /** + * Combine tracing + timing in one call. + * Creates a span AND records a histogram for the duration. + * + * @example + * const result = await Telemetry.trace("fetchProducts", fetchProducts, { "category": "shoes" }); + */ + static async trace( + name: string, + fn: (span: Span) => T | Promise, + attributes: Attributes = {}, + ): Promise { + return Telemetry.withSpan( + name, + async span => { + const hist = Telemetry.histogram(`${name}.duration_ms`); + const start = performance.now(); + try { + return await fn(span); + } finally { + hist.record(performance.now() - start, attributes); + } + }, + { attributes }, + ); + } +} diff --git a/source/frontend/src/telemetry/metrics.ts b/source/frontend/src/telemetry/metrics.ts index 3822c96726..5e13a6c91c 100644 --- a/source/frontend/src/telemetry/metrics.ts +++ b/source/frontend/src/telemetry/metrics.ts @@ -7,7 +7,7 @@ import { import { exists, isNumber } from '@/utils'; -import { TelemetryConfig } from './config'; +import { TelemetrySettings } from './config'; import { isBlocked, isBrowserEnvironment, NETWORK_METER } from './utils'; // These values are lifted from suggested defaults in open-telemetry documentation: @@ -16,7 +16,7 @@ const defaultHistogramBucketsInSeconds = [ 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, ]; -export const registerNetworkMetrics = (config: TelemetryConfig) => { +export const registerNetworkMetrics = (config: TelemetrySettings) => { if (!isBrowserEnvironment() || !window.performance) { return; } diff --git a/source/frontend/src/telemetry/traces.ts b/source/frontend/src/telemetry/traces.ts deleted file mode 100644 index e2eece1400..0000000000 --- a/source/frontend/src/telemetry/traces.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Attributes, Span, SpanKind, SpanStatusCode, trace, Tracer } from '@opentelemetry/api'; - -export const BROWSER_TRACER = 'react-client'; - -// Export the tracer for custom instrumentation -export const getTracer = (): Tracer => { - return trace.getTracer(BROWSER_TRACER); -}; - -export const startTrace = (spanName: string, additionalAttributes?: Attributes) => { - const span = getTracer().startActiveSpan(spanName, { kind: SpanKind.CLIENT }, span => span); - - if (additionalAttributes) { - span.setAttributes(additionalAttributes); - } - return span; -}; - -export const runWithSpan = async unknown>( - spanName: string, - additionalAttributes: Attributes, - fn: F, -) => { - const asyncCallback = wrapExternalCallInSpan(fn, additionalAttributes); - return getTracer().startActiveSpan(spanName, { kind: SpanKind.CLIENT }, asyncCallback); -}; - -const wrapExternalCallInSpan = unknown>( - fn: F, - additionalAttributes: Attributes, -): ((span: Span) => unknown) => { - return async span => { - try { - span.setAttributes(additionalAttributes); - const result = await fn(span); - span.setStatus({ code: SpanStatusCode.OK }); - return result; - } catch (err) { - // Record the exception and update the span status. - span.recordException(err); - span.setStatus({ - code: SpanStatusCode.ERROR, - message: err.message, - }); - throw err; - } finally { - // Be sure to end the span! - span.end(); - } - }; -}; diff --git a/source/frontend/src/telemetry/traces/UserInfoSpanProcessor.ts b/source/frontend/src/telemetry/traces/UserInfoSpanProcessor.ts index cf3e96354b..84ce77ab10 100644 --- a/source/frontend/src/telemetry/traces/UserInfoSpanProcessor.ts +++ b/source/frontend/src/telemetry/traces/UserInfoSpanProcessor.ts @@ -5,14 +5,14 @@ import { ATTR_USER_NAME, } from '@opentelemetry/semantic-conventions/incubating'; -import { user } from '..'; +import { UserTelemetry } from '..'; /** - * A {@link SpanProcessor} that adds user information to each span. + * A {@link SpanProcessor} that adds information about the currently logged-in user to each span. */ export class UserInfoSpanProcessor implements SpanProcessor { onStart(span: Span) { - const userDetails = user.getUserManager().getUser(); + const userDetails = UserTelemetry.getUserManager().getUser(); span.setAttributes({ [ATTR_USER_FULL_NAME]: userDetails?.displayName ?? '', diff --git a/source/frontend/src/telemetry/utils.ts b/source/frontend/src/telemetry/utils.ts index 23686d9022..3c823be606 100644 --- a/source/frontend/src/telemetry/utils.ts +++ b/source/frontend/src/telemetry/utils.ts @@ -1,32 +1,4 @@ -import { metrics, trace } from '@opentelemetry/api'; -import { W3CTraceContextPropagator } from '@opentelemetry/core'; -import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; -import { browserDetector } from '@opentelemetry/opentelemetry-browser-detector'; -import { detectResourcesSync, Resource, ResourceAttributes } from '@opentelemetry/resources'; -import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; -import { - BatchSpanProcessor, - ConsoleSpanExporter, - SimpleSpanProcessor, - SpanProcessor, - WebTracerProvider, -} from '@opentelemetry/sdk-trace-web'; -import { - ATTR_SERVICE_NAME, - ATTR_SERVICE_VERSION, - ATTR_USER_AGENT_ORIGINAL, -} from '@opentelemetry/semantic-conventions'; -import { - ATTR_DEPLOYMENT_ENVIRONMENT_NAME, - ATTR_SERVICE_INSTANCE_ID, -} from '@opentelemetry/semantic-conventions/incubating'; -import isAbsoluteUrl from 'is-absolute-url'; -import { v4 as uuidv4 } from 'uuid'; - -import { TelemetryConfig } from './config'; -import { BrowserAttributesSpanProcessor } from './traces/BrowserAttributesSpanProcessor'; -import { UserInfoSpanProcessor } from './traces/UserInfoSpanProcessor'; +import { TelemetrySettings } from './config'; export const isBrowserEnvironment = () => { return typeof window !== 'undefined'; @@ -36,110 +8,10 @@ export const isNodeEnvironment = () => { return typeof process !== 'undefined' && process.release && process.release.name === 'node'; }; -// creates URL and appends query parameters -export const buildUrl = (inputUrl: string, queryParams: Record = {}): URL => { - const baseUrl = window.location.origin; - const urlInstance = isAbsoluteUrl(inputUrl) ? new URL(inputUrl) : new URL(inputUrl, baseUrl); - Object.keys(queryParams).forEach(k => { - if (queryParams[k] !== undefined) { - urlInstance.searchParams.set(k, queryParams[k]); - } - }); - return urlInstance; -}; - -export const isBlocked = (uri: string, config: TelemetryConfig) => { - const blockList = [...(config.denyUrls ?? []), config.otlpEndpoint]; +export const isBlocked = (uri: string, config: TelemetrySettings) => { + const blockList = [...(config.denyUrls ?? []), config.collectorUrl]; return blockList.findIndex(blocked => uri.includes(blocked)) >= 0; }; // List of meters in the application: e.g. "network", "webvitals", "app", etc export const NETWORK_METER = 'network-meter'; - -const makeResource = (config: TelemetryConfig, extraAttributes?: ResourceAttributes) => { - const uuid = uuidv4(); - let resource = new Resource({ - [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: config?.environment, - [ATTR_SERVICE_NAME]: config?.name, - [ATTR_SERVICE_VERSION]: config?.appVersion, - [ATTR_SERVICE_INSTANCE_ID]: uuid, - [ATTR_USER_AGENT_ORIGINAL]: typeof navigator !== 'undefined' ? navigator.userAgent : '', - 'session.instance.id': uuid, - 'screen.width': window.screen.width, - 'screen.height': window.screen.height, - ...extraAttributes, - }); - - const detectedResources = detectResourcesSync({ detectors: [browserDetector] }); - resource = resource.merge(detectedResources); - return resource; -}; - -export const registerMeterProvider = ( - config: TelemetryConfig, - extraAttributes?: ResourceAttributes, -) => { - if (config.debug) { - console.info('[INFO] Registering metrics provider'); - } - - // This is common metadata sent with every metric measurement - const resource = makeResource(config, extraAttributes); - const metricExporter = new OTLPMetricExporter({ - url: new URL('/v1/metrics', config.otlpEndpoint).href, - }); - - const meterProvider = new MeterProvider({ - resource: resource, - readers: [ - new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: config?.exportInterval || 30_000, // export metrics every 30s by default - }), - ], - }); - - // set this MeterProvider to be global to the app being instrumented. - metrics.setGlobalMeterProvider(meterProvider); -}; - -export const registerTracerProvider = ( - config: TelemetryConfig, - extraAttributes?: ResourceAttributes, -) => { - if (config.debug) { - console.info('[INFO] Registering trace provider'); - } - - // This is common metadata sent with every trace - const resource = makeResource(config, extraAttributes); - const exporter = new OTLPTraceExporter({ - url: new URL('v1/traces', config.otlpEndpoint).href, - }); - - const processors: SpanProcessor[] = []; - - if (config.debug) { - processors.push(new SimpleSpanProcessor(new ConsoleSpanExporter())); - } - - // use the batch processor for better performance - processors.push( - new BatchSpanProcessor(exporter, { scheduledDelayMillis: config?.exportInterval || 5000 }), // export traces every 5s by default - new BrowserAttributesSpanProcessor(), - new UserInfoSpanProcessor(), - ); - - const provider = new WebTracerProvider({ - resource, - spanProcessors: [...processors], - }); - - // set up context propagation - provider.register({ - propagator: new W3CTraceContextPropagator(), - }); - - // set this TracerProvider to be global to the app - trace.setGlobalTracerProvider(provider); -}; diff --git a/source/frontend/src/utils/config.ts b/source/frontend/src/utils/config.ts new file mode 100644 index 0000000000..f5286943b9 --- /dev/null +++ b/source/frontend/src/utils/config.ts @@ -0,0 +1,73 @@ +import { defaultHistogramBuckets } from '@/telemetry/config'; + +import { exists } from '.'; +import { stringToNull, stringToNullableBoolean } from './formUtils'; + +declare global { + interface Window { + runtime: { + VITE_DISABLE_TELEMERY: string; + VITE_TELEMERY_DEBUG: string; + VITE_TELEMERY_ENVIRONMENT: string; + VITE_TELEMERY_SERVICE_NAME: string; + VITE_TELEMERY_URL: string; + VITE_TELEMERY_METRIC_EXPORT_INTERVAL: string; + VITE_TELEMERY_TRACE_EXPORT_INTERVAL: string; + VITE_TELEMERY_HISTOGRAM_BUCKETS: string; + }; + } +} + +// Global toggle to disable telemetry, can be set via environment variable VITE_DISABLE_TELEMERY or window.runtime.VITE_DISABLE_TELEMERY in production. +const DISABLE_TELEMETRY: string = + window.runtime?.VITE_DISABLE_TELEMERY || import.meta.env.VITE_DISABLE_TELEMERY || ''; + +const APP_TELEMETRY_DEBUG: string = + window.runtime?.VITE_TELEMERY_DEBUG || import.meta.env.VITE_TELEMERY_DEBUG || ''; + +const APP_TELEMETRY_ENVIRONMENT: string = + window.runtime?.VITE_TELEMERY_ENVIRONMENT || import.meta.env.VITE_TELEMERY_ENVIRONMENT || ''; + +const APP_NAME: string = + window.runtime?.VITE_TELEMERY_SERVICE_NAME || import.meta.env.VITE_TELEMERY_SERVICE_NAME || ''; + +const APP_VERSION: string = import.meta.env.VITE_PACKAGE_VERSION || ''; + +const APP_TELEMETRY_URL: string = + window.runtime?.VITE_TELEMERY_URL || import.meta.env.VITE_TELEMERY_URL || ''; + +const APP_TELEMETRY_METRIC_EXPORT_INTERVAL: string = + window.runtime?.VITE_TELEMERY_METRIC_EXPORT_INTERVAL || + import.meta.env.VITE_TELEMERY_METRIC_EXPORT_INTERVAL || + ''; + +const APP_TELEMETRY_TRACE_EXPORT_INTERVAL: string = + window.runtime?.VITE_TELEMERY_TRACE_EXPORT_INTERVAL || + import.meta.env.VITE_TELEMERY_TRACE_EXPORT_INTERVAL || + ''; + +const APP_TELEMETRY_HISTOGRAM_BUCKETS: string = + window.runtime?.VITE_TELEMERY_HISTOGRAM_BUCKETS || + import.meta.env.VITE_TELEMERY_HISTOGRAM_BUCKETS || + ''; + +const jsonValues = stringToNull(APP_TELEMETRY_HISTOGRAM_BUCKETS); +const buckets: number[] = exists(jsonValues) ? JSON.parse(jsonValues) : defaultHistogramBuckets; + +// See: https://www.justinpolidori.it/posts/20210913_containerize_fe_react/ +// These global config values will come from: +// - .env file in development (npm start) +// - window.config is set in index.html, populated by env variables in production (npm run build) +export const TelemetryConfig = { + enabled: DISABLE_TELEMETRY, + debug: APP_TELEMETRY_DEBUG, + environment: APP_TELEMETRY_ENVIRONMENT, + appName: APP_NAME, + appVersion: APP_VERSION, + telemetryUrl: APP_TELEMETRY_URL, + metricExportIntervalMs: APP_TELEMETRY_METRIC_EXPORT_INTERVAL, + traceExportIntervalMs: APP_TELEMETRY_TRACE_EXPORT_INTERVAL, + histogramBuckets: buckets, +}; + +export const DisableTelemetry: boolean = stringToNullableBoolean(DISABLE_TELEMETRY) === true; diff --git a/source/frontend/src/utils/getKeycloakEventHandler.ts b/source/frontend/src/utils/getKeycloakEventHandler.ts index 370d2fbd72..ab9574c7f0 100644 --- a/source/frontend/src/utils/getKeycloakEventHandler.ts +++ b/source/frontend/src/utils/getKeycloakEventHandler.ts @@ -12,8 +12,7 @@ import { toast } from 'react-toastify'; import { clearJwt, saveJwt } from '@/store/slices/jwt/JwtSlice'; import { setKeycloakReady } from '@/store/slices/keycloakReady/keycloakReadySlice'; import { store } from '@/store/store'; -import { user } from '@/telemetry'; -import { runWithSpan } from '@/telemetry/traces'; +import { Telemetry, UserTelemetry } from '@/telemetry'; import { SpanEnrichment } from '@/telemetry/traces/SpanEnrichment'; import { getUserDetailsFromKeycloakToken } from '@/telemetry/users/UserAPI'; @@ -25,39 +24,38 @@ const getKeycloakEventHandler = (keycloak: Keycloak, onRefresh: () => void) => { error?: AuthClientError | undefined, ) => { // Track keycloak authentication events with browser telemetry - runWithSpan('process keycloak.event', { component: 'keycloak' }, span => { - const attributes: Attributes = { - [ATTR_EVENT_NAME]: eventType, - [ATTR_CODE_FUNCTION]: 'getKeycloakEventHandler', - 'user.is_authenticated': keycloak?.authenticated ?? false, - }; + const spanAttributes: Attributes = { + component: 'keycloak', + [ATTR_EVENT_NAME]: eventType, + [ATTR_CODE_FUNCTION]: 'getKeycloakEventHandler', + 'user.is_authenticated': keycloak?.authenticated ?? false, + }; - if (eventType === 'onAuthSuccess') { - onRefresh(); - store.dispatch(saveJwt(keycloak.token ?? '')); - // store the currently logged user so that telemetry spans can be traced back to user actions - const userDetails = getUserDetailsFromKeycloakToken(keycloak.tokenParsed); - user.getUserManager().setUser(userDetails); - SpanEnrichment.enrichWithKeycloakToken(attributes, keycloak); - } else if (eventType === 'onAuthRefreshSuccess') { - onRefresh(); - store.dispatch(saveJwt(keycloak.token ?? '')); - SpanEnrichment.enrichWithKeycloakToken(attributes, keycloak); - } else if (eventType === 'onAuthLogout' || eventType === 'onTokenExpired') { - store.dispatch(clearJwt()); - user.getUserManager().clearUser(); - attributes[ATTR_EXCEPTION_TYPE] = error?.error ?? ''; - attributes[ATTR_EXCEPTION_MESSAGE] = error?.error_description ?? ''; - } else if (eventType === 'onReady') { - store.dispatch(setKeycloakReady(true)); - } else { - toast.error(errorMessage); - console.debug(`keycloak event: ${eventType} error ${JSON.stringify(error)}`); - attributes[ATTR_EXCEPTION_TYPE] = error?.error ?? ''; - attributes[ATTR_EXCEPTION_MESSAGE] = error?.error_description ?? ''; - } - span.addEvent('keycloak_event_handled', attributes); - }); + if (eventType === 'onAuthSuccess') { + onRefresh(); + store.dispatch(saveJwt(keycloak.token ?? '')); + // store the currently logged user so that telemetry spans can be traced back to user actions + const userDetails = getUserDetailsFromKeycloakToken(keycloak.tokenParsed); + UserTelemetry.getUserManager().setUser(userDetails); + SpanEnrichment.enrichWithKeycloakToken(spanAttributes, keycloak); + } else if (eventType === 'onAuthRefreshSuccess') { + onRefresh(); + store.dispatch(saveJwt(keycloak.token ?? '')); + SpanEnrichment.enrichWithKeycloakToken(spanAttributes, keycloak); + } else if (eventType === 'onAuthLogout' || eventType === 'onTokenExpired') { + store.dispatch(clearJwt()); + UserTelemetry.getUserManager().clearUser(); + spanAttributes[ATTR_EXCEPTION_TYPE] = error?.error ?? ''; + spanAttributes[ATTR_EXCEPTION_MESSAGE] = error?.error_description ?? ''; + } else if (eventType === 'onReady') { + store.dispatch(setKeycloakReady(true)); + } else { + toast.error(errorMessage); + console.debug(`keycloak event: ${eventType} error ${JSON.stringify(error)}`); + spanAttributes[ATTR_EXCEPTION_TYPE] = error?.error ?? ''; + spanAttributes[ATTR_EXCEPTION_MESSAGE] = error?.error_description ?? ''; + } + Telemetry.recordEvent(`keycloak_${eventType}`, spanAttributes); }; return keycloakEventHandler; diff --git a/source/frontend/src/utils/utils.ts b/source/frontend/src/utils/utils.ts index aea27f4167..1dbc356089 100644 --- a/source/frontend/src/utils/utils.ts +++ b/source/frontend/src/utils/utils.ts @@ -1,5 +1,6 @@ import { AxiosResponse } from 'axios'; import { FormikProps, getIn } from 'formik'; +import isAbsoluteUrl from 'is-absolute-url'; import { isEmpty, isNull, isUndefined, lowerFirst, startCase } from 'lodash'; import { first } from 'lodash'; import { hideLoading, showLoading } from 'react-redux-loading-bar'; @@ -317,3 +318,15 @@ export const getFilePropertyIndex = ( export const formatGuid = (sub: string): string => { return first(sub?.split('@'))?.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5'); }; + +// creates URL and appends query parameters +export const buildUrl = (inputUrl: string, queryParams: Record = {}): URL => { + const baseUrl = window.location.origin; + const urlInstance = isAbsoluteUrl(inputUrl) ? new URL(inputUrl) : new URL(inputUrl, baseUrl); + Object.keys(queryParams).forEach(k => { + if (queryParams[k] !== undefined) { + urlInstance.searchParams.set(k, queryParams[k]); + } + }); + return urlInstance; +};