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;
+};