Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions @types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,9 @@ interface Window {
MONITORING_ENVIRONMENT?: string;
MONITORING_CLUSTER?: string;
MONITORING_SAMPLE_RATE_ERRORS?: string;
// Analytics configuration
ANALYTICS_ENABLED?: string;
ANALYTICS_WRITE_KEY?: string;
ANALYTICS_API_URL?: string;
};
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@patternfly/react-tokens": "^5.3.1",
"@patternfly/react-topology": "^5.4.0",
"@patternfly/react-virtualized-extension": "^5.1.0",
"@segment/analytics-next": "^1.81.1",
"@sentry/react": "^10.38.0",
"@tanstack/react-query": "5.59.15",
"@tanstack/react-query-devtools": "5.52.0",
Expand Down
11 changes: 11 additions & 0 deletions public/runtime-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,14 @@ if (window.KONFLUX_RUNTIME.MONITORING_CLUSTER === undefined) {
if (window.KONFLUX_RUNTIME.MONITORING_SAMPLE_RATE_ERRORS === undefined) {
window.KONFLUX_RUNTIME.MONITORING_SAMPLE_RATE_ERRORS = '0.2';
}

// Analytics defaults (disabled for local development)
if (window.KONFLUX_RUNTIME.ANALYTICS_ENABLED === undefined) {
window.KONFLUX_RUNTIME.ANALYTICS_ENABLED = 'false';
}
if (window.KONFLUX_RUNTIME.ANALYTICS_WRITE_KEY === undefined) {
window.KONFLUX_RUNTIME.ANALYTICS_WRITE_KEY = '';
}
if (window.KONFLUX_RUNTIME.ANALYTICS_API_URL === undefined) {
window.KONFLUX_RUNTIME.ANALYTICS_API_URL = '';
}
209 changes: 209 additions & 0 deletions src/analytics/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { mockConsole, MockConsole } from '~/unit-test-utils';
import type { AnalyticsConfig } from '../types';

// Mock the Segment SDK
const mockAnalyticsInstance = {
track: jest.fn(),
identify: jest.fn(),
page: jest.fn(),
group: jest.fn(),
alias: jest.fn(),
};

const mockAnalyticsBrowser = {
load: jest.fn().mockResolvedValue([mockAnalyticsInstance, {}]),
};

jest.mock('@segment/analytics-next', () => ({
AnalyticsBrowser: mockAnalyticsBrowser,
}));

jest.mock('../load-config', () => ({
loadAnalyticsConfig: jest.fn(),
}));

describe('initAnalytics and getAnalytics', () => {
let consoleMock: MockConsole;
let loadAnalyticsConfigMock: jest.Mock;

beforeEach(() => {
consoleMock = mockConsole();
jest.resetModules();
loadAnalyticsConfigMock = jest.requireMock('../load-config').loadAnalyticsConfig;
mockAnalyticsBrowser.load.mockResolvedValue([mockAnalyticsInstance, {}]);
});

afterEach(() => {
consoleMock.restore();
jest.clearAllMocks();
});

describe('initAnalytics', () => {
it('should successfully initialize with valid config', async () => {
const mockConfig: AnalyticsConfig = {
enabled: true,
writeKey: 'test-write-key-123',
apiUrl: 'https://api.segment.io/v1',
};
loadAnalyticsConfigMock.mockReturnValue(mockConfig);

const indexModule = await import('../index');
await indexModule.initAnalytics();

expect(loadAnalyticsConfigMock).toHaveBeenCalled();
expect(mockAnalyticsBrowser.load).toHaveBeenCalledWith(
{ writeKey: 'test-write-key-123' },
{
integrations: {
'Segment.io': {
apiHost: 'https://api.segment.io/v1',
protocol: 'https',
},
},
},
);
expect(indexModule.getAnalytics()).toBe(mockAnalyticsInstance);
expect(consoleMock.info).toHaveBeenCalledWith('Analytics loaded');
});

it('should not load SDK when analytics is disabled', async () => {
const mockConfig: AnalyticsConfig = {
enabled: false,
writeKey: 'test-write-key-123',
apiUrl: '',
};
loadAnalyticsConfigMock.mockReturnValue(mockConfig);

const indexModule = await import('../index');
await indexModule.initAnalytics();

expect(loadAnalyticsConfigMock).toHaveBeenCalled();
expect(mockAnalyticsBrowser.load).not.toHaveBeenCalled();
expect(indexModule.getAnalytics()).toBeUndefined();
});

it('should not load SDK when apiUrl is missing', async () => {
const mockConfig: AnalyticsConfig = {
enabled: true,
writeKey: 'test-write-key-123',
apiUrl: '',
};
loadAnalyticsConfigMock.mockReturnValue(mockConfig);

const indexModule = await import('../index');
await indexModule.initAnalytics();

expect(mockAnalyticsBrowser.load).not.toHaveBeenCalled();
expect(indexModule.getAnalytics()).toBeUndefined();
});

it('should not load SDK when apiUrl is only whitespace', async () => {
const mockConfig: AnalyticsConfig = {
enabled: true,
writeKey: 'test-write-key-123',
apiUrl: ' ',
};
loadAnalyticsConfigMock.mockReturnValue(mockConfig);

const indexModule = await import('../index');
await indexModule.initAnalytics();

expect(mockAnalyticsBrowser.load).not.toHaveBeenCalled();
expect(indexModule.getAnalytics()).toBeUndefined();
});

it('should not load SDK when write key is missing', async () => {
const mockConfig: AnalyticsConfig = {
enabled: true,
writeKey: '',
apiUrl: '',
};
loadAnalyticsConfigMock.mockReturnValue(mockConfig);

const indexModule = await import('../index');
await indexModule.initAnalytics();

expect(mockAnalyticsBrowser.load).not.toHaveBeenCalled();
expect(indexModule.getAnalytics()).toBeUndefined();
});

it('should not load SDK when write key is only whitespace', async () => {
const mockConfig: AnalyticsConfig = {
enabled: true,
writeKey: ' ',
apiUrl: '',
};
loadAnalyticsConfigMock.mockReturnValue(mockConfig);

const indexModule = await import('../index');
await indexModule.initAnalytics();

expect(mockAnalyticsBrowser.load).not.toHaveBeenCalled();
expect(indexModule.getAnalytics()).toBeUndefined();
});

it('should trim write key and apiUrl before using', async () => {
const mockConfig: AnalyticsConfig = {
enabled: true,
writeKey: ' test-key ',
apiUrl: ' https://api.example.com ',
};
loadAnalyticsConfigMock.mockReturnValue(mockConfig);

const indexModule = await import('../index');
await indexModule.initAnalytics();

expect(mockAnalyticsBrowser.load).toHaveBeenCalledWith(
{ writeKey: 'test-key' },
{
integrations: {
'Segment.io': {
apiHost: 'https://api.example.com',
protocol: 'https',
},
},
},
);
});

it('should handle initialization errors gracefully', async () => {
const mockConfig: AnalyticsConfig = {
enabled: true,
writeKey: 'test-write-key-123',
apiUrl: 'https://api.segment.io/v1',
};
loadAnalyticsConfigMock.mockReturnValue(mockConfig);

const initError = new Error('Failed to load Segment SDK');
mockAnalyticsBrowser.load.mockRejectedValue(initError);

const indexModule = await import('../index');
await indexModule.initAnalytics();

expect(consoleMock.error).toHaveBeenCalledWith('Error loading Analytics', initError);
expect(indexModule.getAnalytics()).toBeUndefined();
});
});

describe('getAnalytics', () => {
it('should return undefined when analytics is not initialized', async () => {
const indexModule = await import('../index');
expect(indexModule.getAnalytics()).toBeUndefined();
});

it('should return analytics instance after successful initialization', async () => {
const mockConfig: AnalyticsConfig = {
enabled: true,
writeKey: 'test-write-key-123',
apiUrl: 'https://api.segment.io/v1',
};
loadAnalyticsConfigMock.mockReturnValue(mockConfig);

const indexModule = await import('../index');
expect(indexModule.getAnalytics()).toBeUndefined();

await indexModule.initAnalytics();
expect(indexModule.getAnalytics()).toBe(mockAnalyticsInstance);
});
});
});
81 changes: 81 additions & 0 deletions src/analytics/__tests__/load-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { loadAnalyticsConfig } from '../load-config';

describe('loadAnalyticsConfig', () => {
const originalKonfluxRuntime = window.KONFLUX_RUNTIME;

afterEach(() => {
window.KONFLUX_RUNTIME = originalKonfluxRuntime;
});

it('should return default config when window.KONFLUX_RUNTIME is undefined', () => {
window.KONFLUX_RUNTIME = undefined;

const config = loadAnalyticsConfig();

expect(config).toEqual({
enabled: false,
writeKey: undefined,
apiUrl: undefined,
});
});

it('should return default config when ANALYTICS_ENABLED is false', () => {
window.KONFLUX_RUNTIME = {
ANALYTICS_ENABLED: 'false',
ANALYTICS_WRITE_KEY: 'some-key',
ANALYTICS_API_URL: 'https://api.example.com',
};

const config = loadAnalyticsConfig();

expect(config).toEqual({
enabled: false,
writeKey: undefined,
apiUrl: undefined,
});
});

it('should return default config when only ANALYTICS_ENABLED is set to false', () => {
window.KONFLUX_RUNTIME = {
ANALYTICS_ENABLED: 'false',
};

const config = loadAnalyticsConfig();

expect(config).toEqual({
enabled: false,
writeKey: undefined,
apiUrl: undefined,
});
});

it('should return full config when analytics is enabled with all fields set', () => {
window.KONFLUX_RUNTIME = {
ANALYTICS_ENABLED: 'true',
ANALYTICS_WRITE_KEY: 'segment-write-key-123',
ANALYTICS_API_URL: 'https://api.segment.io/v1',
};

const config = loadAnalyticsConfig();

expect(config).toEqual({
enabled: true,
writeKey: 'segment-write-key-123',
apiUrl: 'https://api.segment.io/v1',
});
});

it('should use empty string fallbacks when write key and api url are missing', () => {
window.KONFLUX_RUNTIME = {
ANALYTICS_ENABLED: 'true',
};

const config = loadAnalyticsConfig();

expect(config).toEqual({
enabled: true,
writeKey: '',
apiUrl: '',
});
});
});
54 changes: 54 additions & 0 deletions src/analytics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Analytics } from '@segment/analytics-next';
import { loadAnalyticsConfig } from './load-config';

let analyticsInstance: Analytics | undefined;

/**
* Returns the initialized Segment analytics instance, or undefined if analytics
* is disabled or not yet initialized. Callers must handle the undefined case.
*/
export function getAnalytics(): Analytics | undefined {
return analyticsInstance;
}

/**
* Initializes the Segment SDK when ANALYTICS_ENABLED is true and a valid write
* key is present. Uses dynamic import so the SDK is not in the main bundle when
* disabled. Errors are logged and reported to Sentry if available.
*/
export async function initAnalytics(): Promise<void> {
const config = loadAnalyticsConfig();

const writeKey = config.writeKey?.trim();
const apiHost = config.apiUrl?.trim();
if (!config.enabled || !writeKey || !apiHost) {
return;
}

try {
const { AnalyticsBrowser } = await import(
'@segment/analytics-next' /* webpackChunkName: "segment-analytics" */
);

const [analytics] = await AnalyticsBrowser.load(
{
writeKey,
},
{
integrations: {
'Segment.io': {
apiHost,
protocol: 'https',
},
},
},
);

analyticsInstance = analytics;
// eslint-disable-next-line no-console
console.info('Analytics loaded');
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error loading Analytics', error);
}
}
Loading
Loading