From 80bdc9426f42d1ab42016d7fe61dd1aecbeb6548 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Sat, 21 Feb 2026 22:17:16 -0800 Subject: [PATCH 01/14] Add telemetry middleware to enhance error tracking in Redux store --- source/frontend/src/store/store.ts | 6 +++- .../frontend/src/store/telemetryMiddleware.ts | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 source/frontend/src/store/telemetryMiddleware.ts 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..eb5421ccdd --- /dev/null +++ b/source/frontend/src/store/telemetryMiddleware.ts @@ -0,0 +1,33 @@ +import { Middleware } from '@reduxjs/toolkit'; + +import { runWithSpan } from '@/telemetry/traces'; +import { exists } from '@/utils/utils'; + +import { logError } from './slices/network/networkSlice'; + +export const telemetryMiddleware: Middleware = _storeAPI => next => async action => { + const result = await next(action); + + if (logError.match(action)) { + const { name, status, error } = action.payload; + + await runWithSpan( + 'network.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, + }, + async span => { + if (exists(error)) { + span.recordException(error); + } + }, + ); + } + + return result; +}; From 6a9b2c8cc3e1711992076c05d25d588abbf5e164 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Fri, 20 Feb 2026 12:13:47 -0800 Subject: [PATCH 02/14] Improve typing --- source/frontend/src/components/common/ErrorModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ( Date: Mon, 23 Feb 2026 14:58:03 -0800 Subject: [PATCH 03/14] Refactor telemetry configuration: replace global config with TelemetryConfig and update related imports --- source/frontend/src/config.ts | 21 --------- source/frontend/src/index.tsx | 27 +++++------- source/frontend/src/telemetry/config.ts | 2 +- source/frontend/src/telemetry/index.ts | 4 +- source/frontend/src/telemetry/metrics.ts | 4 +- source/frontend/src/telemetry/utils.ts | 10 ++--- source/frontend/src/utils/config.ts | 56 ++++++++++++++++++++++++ 7 files changed, 78 insertions(+), 46 deletions(-) delete mode 100644 source/frontend/src/config.ts create mode 100644 source/frontend/src/utils/config.ts diff --git a/source/frontend/src/config.ts b/source/frontend/src/config.ts deleted file mode 100644 index 2b60bb935d..0000000000 --- a/source/frontend/src/config.ts +++ /dev/null @@ -1,21 +0,0 @@ -declare global { - interface Window { - config: any; - } -} - -export interface AppGlobalConfig { - VITE_TELEMERY_ENABLED: string; - VITE_TELEMERY_DEBUG: string; - VITE_TELEMERY_ENVIRONMENT: string; - VITE_TELEMERY_SERVICE_NAME: string; - VITE_TELEMERY_URL: string; - VITE_TELEMERY_EXPORT_INTERVAL: string; - VITE_TELEMERY_HISTOGRAM_BUCKETS: string; -} - -// 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 config: AppGlobalConfig = { ...import.meta.env, ...window.config } as AppGlobalConfig; diff --git a/source/frontend/src/index.tsx b/source/frontend/src/index.tsx index 8acdffade4..3cd996ffbe 100644 --- a/source/frontend/src/index.tsx +++ b/source/frontend/src/index.tsx @@ -23,20 +23,19 @@ 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 { TelemetrySettings } from './telemetry/config'; 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') { @@ -96,20 +95,18 @@ 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; + const isTelemetryEnabled = stringToNullableBoolean(TelemetryConfig.enabled) ?? false; + const isDebugEnabled = stringToNullableBoolean(TelemetryConfig.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 || '', + const options: TelemetrySettings = { + name: TelemetryConfig.appName || 'pims-frontend', + appVersion: TelemetryConfig.appVersion || 'unknown', + environment: TelemetryConfig.environment || 'local', + otlpEndpoint: TelemetryConfig.telemetryUrl || '', debug: isDebugEnabled, - exportInterval: stringToNumberOrNull(config.VITE_TELEMERY_EXPORT_INTERVAL) ?? 30000, - histogramBuckets: buckets, + exportInterval: stringToNumberOrNull(TelemetryConfig.exportInterval) ?? 30000, + histogramBuckets: TelemetryConfig.histogramBuckets, }; // configure browser telemetry (if enabled via dynamic config-map) diff --git a/source/frontend/src/telemetry/config.ts b/source/frontend/src/telemetry/config.ts index 4afd43eb33..46f82502ad 100644 --- a/source/frontend/src/telemetry/config.ts +++ b/source/frontend/src/telemetry/config.ts @@ -1,5 +1,5 @@ // The configuration for browser telemetry (metrics and logs) -export interface TelemetryConfig { +export interface TelemetrySettings { // by default the service name is set to 'frontend' - helps finding traces in the trace UI dashboard name?: string; appVersion?: string; diff --git a/source/frontend/src/telemetry/index.ts b/source/frontend/src/telemetry/index.ts index 9a24e6c2f1..00985019ab 100644 --- a/source/frontend/src/telemetry/index.ts +++ b/source/frontend/src/telemetry/index.ts @@ -1,11 +1,11 @@ import { exists } from '@/utils'; -import { TelemetryConfig } from './config'; +import { TelemetrySettings } from './config'; 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) => { +export const initializeTelemetry = (config: TelemetrySettings) => { try { if (!exists(config)) { throw Error('[ERR] No metrics configuration provided, it will not be initialized.'); 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/utils.ts b/source/frontend/src/telemetry/utils.ts index 23686d9022..c617ce3089 100644 --- a/source/frontend/src/telemetry/utils.ts +++ b/source/frontend/src/telemetry/utils.ts @@ -24,7 +24,7 @@ import { import isAbsoluteUrl from 'is-absolute-url'; import { v4 as uuidv4 } from 'uuid'; -import { TelemetryConfig } from './config'; +import { TelemetrySettings } from './config'; import { BrowserAttributesSpanProcessor } from './traces/BrowserAttributesSpanProcessor'; import { UserInfoSpanProcessor } from './traces/UserInfoSpanProcessor'; @@ -48,7 +48,7 @@ export const buildUrl = (inputUrl: string, queryParams: Record = {} return urlInstance; }; -export const isBlocked = (uri: string, config: TelemetryConfig) => { +export const isBlocked = (uri: string, config: TelemetrySettings) => { const blockList = [...(config.denyUrls ?? []), config.otlpEndpoint]; return blockList.findIndex(blocked => uri.includes(blocked)) >= 0; }; @@ -56,7 +56,7 @@ export const isBlocked = (uri: string, config: TelemetryConfig) => { // 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 makeResource = (config: TelemetrySettings, extraAttributes?: ResourceAttributes) => { const uuid = uuidv4(); let resource = new Resource({ [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: config?.environment, @@ -76,7 +76,7 @@ const makeResource = (config: TelemetryConfig, extraAttributes?: ResourceAttribu }; export const registerMeterProvider = ( - config: TelemetryConfig, + config: TelemetrySettings, extraAttributes?: ResourceAttributes, ) => { if (config.debug) { @@ -104,7 +104,7 @@ export const registerMeterProvider = ( }; export const registerTracerProvider = ( - config: TelemetryConfig, + config: TelemetrySettings, extraAttributes?: ResourceAttributes, ) => { if (config.debug) { diff --git a/source/frontend/src/utils/config.ts b/source/frontend/src/utils/config.ts new file mode 100644 index 0000000000..bfb5e8fe3d --- /dev/null +++ b/source/frontend/src/utils/config.ts @@ -0,0 +1,56 @@ +import { defaultHistogramBuckets } from '@/telemetry/config'; + +import { exists } from '.'; +import { stringToNull } from './formUtils'; + +declare global { + interface Window { + config: { + VITE_TELEMERY_ENABLED: string; + VITE_TELEMERY_DEBUG: string; + VITE_TELEMERY_ENVIRONMENT: string; + VITE_TELEMERY_SERVICE_NAME: string; + VITE_TELEMERY_URL: string; + VITE_TELEMERY_EXPORT_INTERVAL: string; + VITE_TELEMERY_HISTOGRAM_BUCKETS: string; + }; + } +} + +const APP_TELEMETRY_ENABLED: string = + window.config?.VITE_TELEMERY_ENABLED || import.meta.env.VITE_TELEMERY_ENABLED || ''; +const APP_TELEMETRY_DEBUG: string = + window.config?.VITE_TELEMERY_DEBUG || import.meta.env.VITE_TELEMERY_DEBUG || ''; +const APP_TELEMETRY_ENVIRONMENT: string = + window.config?.VITE_TELEMERY_ENVIRONMENT || import.meta.env.VITE_TELEMERY_ENVIRONMENT || ''; +const APP_NAME: string = + window.config?.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.config?.VITE_TELEMERY_URL || import.meta.env.VITE_TELEMERY_URL || ''; +const APP_TELEMETRY_EXPORT_INTERVAL: string = + window.config?.VITE_TELEMERY_EXPORT_INTERVAL || + import.meta.env.VITE_TELEMERY_EXPORT_INTERVAL || + ''; +const APP_TELEMETRY_HISTOGRAM_BUCKETS: string = + window.config?.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: APP_TELEMETRY_ENABLED, + debug: APP_TELEMETRY_DEBUG, + environment: APP_TELEMETRY_ENVIRONMENT, + appName: APP_NAME, + appVersion: APP_VERSION, + telemetryUrl: APP_TELEMETRY_URL, + exportInterval: APP_TELEMETRY_EXPORT_INTERVAL, + histogramBuckets: buckets, +}; From 53dea10a4a1c52e76985f5b64c3b5a34495f9c88 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Mon, 23 Feb 2026 17:39:39 -0800 Subject: [PATCH 04/14] Refactor telemetry user instance: rename 'user' to 'UserTelemetry' for consistency --- source/frontend/src/telemetry/index.ts | 2 +- .../frontend/src/telemetry/traces/UserInfoSpanProcessor.ts | 4 ++-- source/frontend/src/utils/getKeycloakEventHandler.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/source/frontend/src/telemetry/index.ts b/source/frontend/src/telemetry/index.ts index 00985019ab..3c40d1f6b0 100644 --- a/source/frontend/src/telemetry/index.ts +++ b/source/frontend/src/telemetry/index.ts @@ -25,4 +25,4 @@ export const initializeTelemetry = (config: TelemetrySettings) => { }; // Global provider to capture logged-in user in traces -export const user = UserAPI.getInstance(); +export const UserTelemetry = UserAPI.getInstance(); diff --git a/source/frontend/src/telemetry/traces/UserInfoSpanProcessor.ts b/source/frontend/src/telemetry/traces/UserInfoSpanProcessor.ts index cf3e96354b..052da7be99 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. */ 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/utils/getKeycloakEventHandler.ts b/source/frontend/src/utils/getKeycloakEventHandler.ts index 370d2fbd72..69a2d35f9b 100644 --- a/source/frontend/src/utils/getKeycloakEventHandler.ts +++ b/source/frontend/src/utils/getKeycloakEventHandler.ts @@ -12,7 +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 { UserTelemetry } from '@/telemetry'; import { runWithSpan } from '@/telemetry/traces'; import { SpanEnrichment } from '@/telemetry/traces/SpanEnrichment'; import { getUserDetailsFromKeycloakToken } from '@/telemetry/users/UserAPI'; @@ -37,7 +37,7 @@ const getKeycloakEventHandler = (keycloak: Keycloak, onRefresh: () => void) => { 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); + UserTelemetry.getUserManager().setUser(userDetails); SpanEnrichment.enrichWithKeycloakToken(attributes, keycloak); } else if (eventType === 'onAuthRefreshSuccess') { onRefresh(); @@ -45,7 +45,7 @@ const getKeycloakEventHandler = (keycloak: Keycloak, onRefresh: () => void) => { SpanEnrichment.enrichWithKeycloakToken(attributes, keycloak); } else if (eventType === 'onAuthLogout' || eventType === 'onTokenExpired') { store.dispatch(clearJwt()); - user.getUserManager().clearUser(); + UserTelemetry.getUserManager().clearUser(); attributes[ATTR_EXCEPTION_TYPE] = error?.error ?? ''; attributes[ATTR_EXCEPTION_MESSAGE] = error?.error_description ?? ''; } else if (eventType === 'onReady') { From b2664bc7a8ce7d727e3db8eaac1713998ead9d3d Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Mon, 23 Feb 2026 18:07:13 -0800 Subject: [PATCH 05/14] Refactor telemetry configuration: update TelemetrySettings interface and related logic for improved clarity and consistency --- source/frontend/src/index.tsx | 6 +++--- source/frontend/src/telemetry/config.ts | 21 ++++++++++++--------- source/frontend/src/telemetry/index.ts | 2 +- source/frontend/src/telemetry/utils.ts | 14 ++++++++------ 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/source/frontend/src/index.tsx b/source/frontend/src/index.tsx index 3cd996ffbe..170080bc00 100644 --- a/source/frontend/src/index.tsx +++ b/source/frontend/src/index.tsx @@ -100,12 +100,12 @@ const setupTelemetry = () => { if (isTelemetryEnabled) { const options: TelemetrySettings = { - name: TelemetryConfig.appName || 'pims-frontend', + appName: TelemetryConfig.appName || 'pims-frontend', appVersion: TelemetryConfig.appVersion || 'unknown', environment: TelemetryConfig.environment || 'local', - otlpEndpoint: TelemetryConfig.telemetryUrl || '', + collectorUrl: TelemetryConfig.telemetryUrl || '', debug: isDebugEnabled, - exportInterval: stringToNumberOrNull(TelemetryConfig.exportInterval) ?? 30000, + metricExportIntervalMs: stringToNumberOrNull(TelemetryConfig.exportInterval) ?? 30000, histogramBuckets: TelemetryConfig.histogramBuckets, }; diff --git a/source/frontend/src/telemetry/config.ts b/source/frontend/src/telemetry/config.ts index 46f82502ad..2e9297a815 100644 --- a/source/frontend/src/telemetry/config.ts +++ b/source/frontend/src/telemetry/config.ts @@ -1,20 +1,23 @@ +import { Attributes } from '@opentelemetry/api'; + // The configuration for browser telemetry (metrics and logs) export interface TelemetrySettings { - // by default the service name is set to 'frontend' - helps finding traces in the trace UI dashboard - name?: string; + 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; + // 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 3c40d1f6b0..e855d28a6e 100644 --- a/source/frontend/src/telemetry/index.ts +++ b/source/frontend/src/telemetry/index.ts @@ -11,7 +11,7 @@ export const initializeTelemetry = (config: TelemetrySettings) => { throw Error('[ERR] No metrics configuration provided, it will not be initialized.'); } - if (!exists(config.otlpEndpoint)) { + if (!exists(config.collectorUrl)) { throw Error('[ERR] Invalid metrics endpoint provided, it will not be initialized.'); } diff --git a/source/frontend/src/telemetry/utils.ts b/source/frontend/src/telemetry/utils.ts index c617ce3089..5638e99f53 100644 --- a/source/frontend/src/telemetry/utils.ts +++ b/source/frontend/src/telemetry/utils.ts @@ -49,7 +49,7 @@ export const buildUrl = (inputUrl: string, queryParams: Record = {} }; export const isBlocked = (uri: string, config: TelemetrySettings) => { - const blockList = [...(config.denyUrls ?? []), config.otlpEndpoint]; + const blockList = [...(config.denyUrls ?? []), config.collectorUrl]; return blockList.findIndex(blocked => uri.includes(blocked)) >= 0; }; @@ -60,7 +60,7 @@ const makeResource = (config: TelemetrySettings, extraAttributes?: ResourceAttri const uuid = uuidv4(); let resource = new Resource({ [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: config?.environment, - [ATTR_SERVICE_NAME]: config?.name, + [ATTR_SERVICE_NAME]: config?.appName, [ATTR_SERVICE_VERSION]: config?.appVersion, [ATTR_SERVICE_INSTANCE_ID]: uuid, [ATTR_USER_AGENT_ORIGINAL]: typeof navigator !== 'undefined' ? navigator.userAgent : '', @@ -86,7 +86,7 @@ export const registerMeterProvider = ( // 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, + url: new URL('/v1/metrics', config.collectorUrl).href, }); const meterProvider = new MeterProvider({ @@ -94,7 +94,7 @@ export const registerMeterProvider = ( readers: [ new PeriodicExportingMetricReader({ exporter: metricExporter, - exportIntervalMillis: config?.exportInterval || 30_000, // export metrics every 30s by default + exportIntervalMillis: config?.metricExportIntervalMs || 30_000, // export metrics every 30s by default }), ], }); @@ -114,7 +114,7 @@ export const registerTracerProvider = ( // 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, + url: new URL('v1/traces', config.collectorUrl).href, }); const processors: SpanProcessor[] = []; @@ -125,7 +125,9 @@ export const registerTracerProvider = ( // use the batch processor for better performance processors.push( - new BatchSpanProcessor(exporter, { scheduledDelayMillis: config?.exportInterval || 5000 }), // export traces every 5s by default + new BatchSpanProcessor(exporter, { + scheduledDelayMillis: config?.metricExportIntervalMs || 5000, + }), // export traces every 5s by default new BrowserAttributesSpanProcessor(), new UserInfoSpanProcessor(), ); From f0b67881f9ca5e0cee15ac8e58329f9f2ccaf631 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Tue, 24 Feb 2026 17:03:50 -0800 Subject: [PATCH 06/14] Move utility function to common folder --- source/frontend/src/telemetry/utils.ts | 13 ------------- source/frontend/src/utils/utils.ts | 13 +++++++++++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/source/frontend/src/telemetry/utils.ts b/source/frontend/src/telemetry/utils.ts index 5638e99f53..d5b970190b 100644 --- a/source/frontend/src/telemetry/utils.ts +++ b/source/frontend/src/telemetry/utils.ts @@ -21,7 +21,6 @@ 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 { TelemetrySettings } from './config'; @@ -36,18 +35,6 @@ 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: TelemetrySettings) => { const blockList = [...(config.denyUrls ?? []), config.collectorUrl]; return blockList.findIndex(blocked => uri.includes(blocked)) >= 0; 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; +}; From de1b404a0e77d4d379509705a75f30c1262acbfd Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Tue, 24 Feb 2026 17:23:13 -0800 Subject: [PATCH 07/14] Refactor telemetry integration: streamline telemetry initialization, enhance error handling, and improve span management across various components --- source/frontend/src/customAxios.ts | 18 +- source/frontend/src/index.tsx | 46 +- .../frontend/src/store/telemetryMiddleware.ts | 14 +- source/frontend/src/telemetry/config.ts | 2 + source/frontend/src/telemetry/index.ts | 412 +++++++++++++++++- source/frontend/src/telemetry/traces.ts | 51 --- .../telemetry/traces/UserInfoSpanProcessor.ts | 2 +- source/frontend/src/telemetry/utils.ts | 117 ----- source/frontend/src/utils/config.ts | 37 +- .../src/utils/getKeycloakEventHandler.ts | 66 ++- 10 files changed, 483 insertions(+), 282 deletions(-) delete mode 100644 source/frontend/src/telemetry/traces.ts diff --git a/source/frontend/src/customAxios.ts b/source/frontend/src/customAxios.ts index 018555a628..924d6baf2a 100644 --- a/source/frontend/src/customAxios.ts +++ b/source/frontend/src/customAxios.ts @@ -1,4 +1,4 @@ -import { Attributes, Span, SpanStatusCode } from '@opentelemetry/api'; +import { Attributes, Span } from '@opentelemetry/api'; import { ATTR_HTTP_REQUEST_METHOD, ATTR_URL_FULL } from '@opentelemetry/semantic-conventions'; import { Dispatch } from '@reduxjs/toolkit'; import axios, { AxiosError, AxiosRequestHeaders } from 'axios'; @@ -9,11 +9,10 @@ import { toast } from 'react-toastify'; import { IGenericNetworkAction } from '@/store/slices/network/interfaces'; import { logError } from '@/store/slices/network/networkSlice'; import { RootState, store } from '@/store/store'; +import { Telemetry } from '@/telemetry'; +import { SpanEnrichment } from '@/telemetry/traces/SpanEnrichment'; -import { startTrace } from './telemetry/traces'; -import { SpanEnrichment } from './telemetry/traces/SpanEnrichment'; -import { buildUrl } from './telemetry/utils'; -import { exists } from './utils'; +import { buildUrl, exists } from './utils'; export const defaultEnvelope = (x: any) => ({ 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/index.tsx b/source/frontend/src/index.tsx index 170080bc00..a7e01f8b1c 100644 --- a/source/frontend/src/index.tsx +++ b/source/frontend/src/index.tsx @@ -32,8 +32,7 @@ import { DocumentViewerContextProvider } from './features/documents/context/Docu 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 { TelemetrySettings } from './telemetry/config'; +import { Telemetry } from './telemetry'; import { ReactRouterSpanProcessor } from './telemetry/traces/ReactRouterSpanProcessor'; import { stringToNullableBoolean, stringToNumberOrNull } from './utils/formUtils'; @@ -92,37 +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(TelemetryConfig.enabled) ?? false; - const isDebugEnabled = stringToNullableBoolean(TelemetryConfig.debug) ?? false; - - if (isTelemetryEnabled) { - const options: TelemetrySettings = { - appName: TelemetryConfig.appName || 'pims-frontend', - appVersion: TelemetryConfig.appVersion || 'unknown', - environment: TelemetryConfig.environment || 'local', - collectorUrl: TelemetryConfig.telemetryUrl || '', - debug: isDebugEnabled, - metricExportIntervalMs: stringToNumberOrNull(TelemetryConfig.exportInterval) ?? 30000, - histogramBuckets: TelemetryConfig.histogramBuckets, - }; - - // 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/telemetryMiddleware.ts b/source/frontend/src/store/telemetryMiddleware.ts index eb5421ccdd..73d36284df 100644 --- a/source/frontend/src/store/telemetryMiddleware.ts +++ b/source/frontend/src/store/telemetryMiddleware.ts @@ -1,18 +1,18 @@ import { Middleware } from '@reduxjs/toolkit'; -import { runWithSpan } from '@/telemetry/traces'; -import { exists } from '@/utils/utils'; +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; - await runWithSpan( - 'network.error', + Telemetry.recordException( + error, { 'network.request.name': name, 'network.response.status': status, @@ -21,11 +21,7 @@ export const telemetryMiddleware: Middleware = _storeAPI => next => async action 'network.error.config.url': error?.config?.url, 'network.error.config.method': error?.config?.method, }, - async span => { - if (exists(error)) { - span.recordException(error); - } - }, + 'network.error', ); } diff --git a/source/frontend/src/telemetry/config.ts b/source/frontend/src/telemetry/config.ts index 2e9297a815..3aca76e3eb 100644 --- a/source/frontend/src/telemetry/config.ts +++ b/source/frontend/src/telemetry/config.ts @@ -14,6 +14,8 @@ export interface TelemetrySettings { debug?: boolean; // 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) diff --git a/source/frontend/src/telemetry/index.ts b/source/frontend/src/telemetry/index.ts index e855d28a6e..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 { 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: TelemetrySettings) => { - 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.collectorUrl)) { - 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); + } - registerTracerProvider(config); - registerMeterProvider(config); - } catch (error) { - if (config.debug) { - console.error(error); + /** + * 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 ─────────────────────────────────────────────────────────────── + + /** 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 UserTelemetry = 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/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 052da7be99..84ce77ab10 100644 --- a/source/frontend/src/telemetry/traces/UserInfoSpanProcessor.ts +++ b/source/frontend/src/telemetry/traces/UserInfoSpanProcessor.ts @@ -8,7 +8,7 @@ import { 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) { diff --git a/source/frontend/src/telemetry/utils.ts b/source/frontend/src/telemetry/utils.ts index d5b970190b..3c823be606 100644 --- a/source/frontend/src/telemetry/utils.ts +++ b/source/frontend/src/telemetry/utils.ts @@ -1,31 +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 { v4 as uuidv4 } from 'uuid'; - import { TelemetrySettings } from './config'; -import { BrowserAttributesSpanProcessor } from './traces/BrowserAttributesSpanProcessor'; -import { UserInfoSpanProcessor } from './traces/UserInfoSpanProcessor'; export const isBrowserEnvironment = () => { return typeof window !== 'undefined'; @@ -42,93 +15,3 @@ export const isBlocked = (uri: string, config: TelemetrySettings) => { // List of meters in the application: e.g. "network", "webvitals", "app", etc export const NETWORK_METER = 'network-meter'; - -const makeResource = (config: TelemetrySettings, extraAttributes?: ResourceAttributes) => { - const uuid = uuidv4(); - let resource = new Resource({ - [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: config?.environment, - [ATTR_SERVICE_NAME]: config?.appName, - [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: TelemetrySettings, - 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.collectorUrl).href, - }); - - const meterProvider = new MeterProvider({ - resource: resource, - readers: [ - new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: config?.metricExportIntervalMs || 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: TelemetrySettings, - 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.collectorUrl).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?.metricExportIntervalMs || 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 index bfb5e8fe3d..20880b19de 100644 --- a/source/frontend/src/utils/config.ts +++ b/source/frontend/src/utils/config.ts @@ -1,37 +1,51 @@ import { defaultHistogramBuckets } from '@/telemetry/config'; import { exists } from '.'; -import { stringToNull } from './formUtils'; +import { stringToNull, stringToNullableBoolean } from './formUtils'; declare global { interface Window { config: { - VITE_TELEMERY_ENABLED: string; + VITE_DISABLE_TELEMERY: string; VITE_TELEMERY_DEBUG: string; VITE_TELEMERY_ENVIRONMENT: string; VITE_TELEMERY_SERVICE_NAME: string; VITE_TELEMERY_URL: string; - VITE_TELEMERY_EXPORT_INTERVAL: string; + VITE_TELEMERY_METRIC_EXPORT_INTERVAL: string; + VITE_TELEMERY_TRACE_EXPORT_INTERVAL: string; VITE_TELEMERY_HISTOGRAM_BUCKETS: string; }; } } -const APP_TELEMETRY_ENABLED: string = - window.config?.VITE_TELEMERY_ENABLED || import.meta.env.VITE_TELEMERY_ENABLED || ''; +// Global toggle to disable telemetry, can be set via environment variable VITE_DISABLE_TELEMERY or window.config.VITE_DISABLE_TELEMERY in production. +const DISABLE_TELEMETRY: string = + window.config?.VITE_DISABLE_TELEMERY || import.meta.env.VITE_DISABLE_TELEMERY || ''; + const APP_TELEMETRY_DEBUG: string = window.config?.VITE_TELEMERY_DEBUG || import.meta.env.VITE_TELEMERY_DEBUG || ''; + const APP_TELEMETRY_ENVIRONMENT: string = window.config?.VITE_TELEMERY_ENVIRONMENT || import.meta.env.VITE_TELEMERY_ENVIRONMENT || ''; + const APP_NAME: string = window.config?.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.config?.VITE_TELEMERY_URL || import.meta.env.VITE_TELEMERY_URL || ''; -const APP_TELEMETRY_EXPORT_INTERVAL: string = - window.config?.VITE_TELEMERY_EXPORT_INTERVAL || - import.meta.env.VITE_TELEMERY_EXPORT_INTERVAL || + +const APP_TELEMETRY_METRIC_EXPORT_INTERVAL: string = + window.config?.VITE_TELEMERY_METRIC_EXPORT_INTERVAL || + import.meta.env.VITE_TELEMERY_METRIC_EXPORT_INTERVAL || + ''; + +const APP_TELEMETRY_TRACE_EXPORT_INTERVAL: string = + window.config?.VITE_TELEMERY_TRACE_EXPORT_INTERVAL || + import.meta.env.VITE_TELEMERY_TRACE_EXPORT_INTERVAL || ''; + const APP_TELEMETRY_HISTOGRAM_BUCKETS: string = window.config?.VITE_TELEMERY_HISTOGRAM_BUCKETS || import.meta.env.VITE_TELEMERY_HISTOGRAM_BUCKETS || @@ -45,12 +59,15 @@ const buckets: number[] = exists(jsonValues) ? JSON.parse(jsonValues) : defaultH // - .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: APP_TELEMETRY_ENABLED, + enabled: DISABLE_TELEMETRY, debug: APP_TELEMETRY_DEBUG, environment: APP_TELEMETRY_ENVIRONMENT, appName: APP_NAME, appVersion: APP_VERSION, telemetryUrl: APP_TELEMETRY_URL, - exportInterval: APP_TELEMETRY_EXPORT_INTERVAL, + 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 69a2d35f9b..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 { UserTelemetry } 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); - UserTelemetry.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()); - UserTelemetry.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; From 4665c9b7b893c3f58581e587d68515cc0717eaa9 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Tue, 24 Feb 2026 22:52:54 -0800 Subject: [PATCH 08/14] Refactor lease export hooks: update requestId for error handling consistency --- .../frontend/src/features/leases/hooks/useLeaseExport.ts | 9 ++++----- source/frontend/src/hooks/repositories/useProperties.ts | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) 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); } } }, From 7dfe5250a0c461ce225bfac410d681f1f8338be1 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 25 Feb 2026 12:51:10 -0800 Subject: [PATCH 09/14] Generate example error for demo --- .../api/Areas/Reports/Controllers/LeaseController.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/source/backend/api/Areas/Reports/Controllers/LeaseController.cs b/source/backend/api/Areas/Reports/Controllers/LeaseController.cs index 9771743de8..9e6b9b2e3c 100644 --- a/source/backend/api/Areas/Reports/Controllers/LeaseController.cs +++ b/source/backend/api/Areas/Reports/Controllers/LeaseController.cs @@ -173,6 +173,12 @@ public IActionResult ExportAggregatedLeases(int fiscalYearStart) [SwaggerOperation(Tags = new[] { "lease", "payments", "report" })] public IActionResult ExportLeasePayments(int fiscalYearStart) { + // TODO: REMOVE ME + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Local") + { + throw new InvalidOperationException("Sample error message for testing error handling in local environment."); + } + var acceptHeader = (string)Request.Headers["Accept"]; if (acceptHeader != ContentTypes.CONTENTTYPEEXCEL && acceptHeader != ContentTypes.CONTENTTYPEEXCELX) From 7ad413c776f49f948046fa15d6f79bf2ea0aa27e Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 25 Feb 2026 13:16:35 -0800 Subject: [PATCH 10/14] Fix unit test --- source/frontend/src/components/common/ErrorModal.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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(); }); }); From 56990bddac3f39f2ddc7cf4a3827b3e129283e9c Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 25 Feb 2026 14:31:45 -0800 Subject: [PATCH 11/14] Change name of env var for demo --- source/backend/api/Areas/Reports/Controllers/LeaseController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/backend/api/Areas/Reports/Controllers/LeaseController.cs b/source/backend/api/Areas/Reports/Controllers/LeaseController.cs index 9e6b9b2e3c..84524d424a 100644 --- a/source/backend/api/Areas/Reports/Controllers/LeaseController.cs +++ b/source/backend/api/Areas/Reports/Controllers/LeaseController.cs @@ -174,7 +174,7 @@ public IActionResult ExportAggregatedLeases(int fiscalYearStart) public IActionResult ExportLeasePayments(int fiscalYearStart) { // TODO: REMOVE ME - if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Local") + if (Environment.GetEnvironmentVariable("FEATURE_FLAG_TELEMETRY_DEMO") == "true") { throw new InvalidOperationException("Sample error message for testing error handling in local environment."); } From fd218bf6cf844d4e68b2e1e21061518522dac3df Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 25 Feb 2026 14:44:06 -0800 Subject: [PATCH 12/14] Update telemetry error type to 'bomb.network.error' for improved observability --- source/frontend/src/store/telemetryMiddleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/frontend/src/store/telemetryMiddleware.ts b/source/frontend/src/store/telemetryMiddleware.ts index 73d36284df..8758a555b0 100644 --- a/source/frontend/src/store/telemetryMiddleware.ts +++ b/source/frontend/src/store/telemetryMiddleware.ts @@ -21,7 +21,7 @@ export const telemetryMiddleware: Middleware = _storeAPI => next => async action 'network.error.config.url': error?.config?.url, 'network.error.config.method': error?.config?.method, }, - 'network.error', + 'bomb.network.error', ); } From 3f846dba2d6290c5a751b237abe71aaff45207a0 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 25 Feb 2026 21:08:05 -0800 Subject: [PATCH 13/14] Refactor configuration handling to use runtime variables instead of config.js --- source/frontend/index.html | 2 +- .../public/{config.js => runtime-vars.js} | 2 +- source/frontend/src/utils/config.ts | 20 +++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) rename source/frontend/public/{config.js => runtime-vars.js} (81%) 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/utils/config.ts b/source/frontend/src/utils/config.ts index 20880b19de..f5286943b9 100644 --- a/source/frontend/src/utils/config.ts +++ b/source/frontend/src/utils/config.ts @@ -5,7 +5,7 @@ import { stringToNull, stringToNullableBoolean } from './formUtils'; declare global { interface Window { - config: { + runtime: { VITE_DISABLE_TELEMERY: string; VITE_TELEMERY_DEBUG: string; VITE_TELEMERY_ENVIRONMENT: string; @@ -18,36 +18,36 @@ declare global { } } -// Global toggle to disable telemetry, can be set via environment variable VITE_DISABLE_TELEMERY or window.config.VITE_DISABLE_TELEMERY in production. +// 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.config?.VITE_DISABLE_TELEMERY || import.meta.env.VITE_DISABLE_TELEMERY || ''; + window.runtime?.VITE_DISABLE_TELEMERY || import.meta.env.VITE_DISABLE_TELEMERY || ''; const APP_TELEMETRY_DEBUG: string = - window.config?.VITE_TELEMERY_DEBUG || import.meta.env.VITE_TELEMERY_DEBUG || ''; + window.runtime?.VITE_TELEMERY_DEBUG || import.meta.env.VITE_TELEMERY_DEBUG || ''; const APP_TELEMETRY_ENVIRONMENT: string = - window.config?.VITE_TELEMERY_ENVIRONMENT || import.meta.env.VITE_TELEMERY_ENVIRONMENT || ''; + window.runtime?.VITE_TELEMERY_ENVIRONMENT || import.meta.env.VITE_TELEMERY_ENVIRONMENT || ''; const APP_NAME: string = - window.config?.VITE_TELEMERY_SERVICE_NAME || import.meta.env.VITE_TELEMERY_SERVICE_NAME || ''; + 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.config?.VITE_TELEMERY_URL || import.meta.env.VITE_TELEMERY_URL || ''; + window.runtime?.VITE_TELEMERY_URL || import.meta.env.VITE_TELEMERY_URL || ''; const APP_TELEMETRY_METRIC_EXPORT_INTERVAL: string = - window.config?.VITE_TELEMERY_METRIC_EXPORT_INTERVAL || + window.runtime?.VITE_TELEMERY_METRIC_EXPORT_INTERVAL || import.meta.env.VITE_TELEMERY_METRIC_EXPORT_INTERVAL || ''; const APP_TELEMETRY_TRACE_EXPORT_INTERVAL: string = - window.config?.VITE_TELEMERY_TRACE_EXPORT_INTERVAL || + window.runtime?.VITE_TELEMERY_TRACE_EXPORT_INTERVAL || import.meta.env.VITE_TELEMERY_TRACE_EXPORT_INTERVAL || ''; const APP_TELEMETRY_HISTOGRAM_BUCKETS: string = - window.config?.VITE_TELEMERY_HISTOGRAM_BUCKETS || + window.runtime?.VITE_TELEMERY_HISTOGRAM_BUCKETS || import.meta.env.VITE_TELEMERY_HISTOGRAM_BUCKETS || ''; From f562b69cd77a88d0b43d0fb28a5c7ab25e921e89 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Thu, 26 Feb 2026 18:10:55 -0800 Subject: [PATCH 14/14] Remove unused ILeasePaymentService from LeaseController constructor --- .../api/Areas/Reports/Controllers/LeaseController.cs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/source/backend/api/Areas/Reports/Controllers/LeaseController.cs b/source/backend/api/Areas/Reports/Controllers/LeaseController.cs index 84524d424a..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; } @@ -173,12 +170,6 @@ public IActionResult ExportAggregatedLeases(int fiscalYearStart) [SwaggerOperation(Tags = new[] { "lease", "payments", "report" })] public IActionResult ExportLeasePayments(int fiscalYearStart) { - // TODO: REMOVE ME - if (Environment.GetEnvironmentVariable("FEATURE_FLAG_TELEMETRY_DEMO") == "true") - { - throw new InvalidOperationException("Sample error message for testing error handling in local environment."); - } - var acceptHeader = (string)Request.Headers["Accept"]; if (acceptHeader != ContentTypes.CONTENTTYPEEXCEL && acceptHeader != ContentTypes.CONTENTTYPEEXCELX)