Skip to content

Commit

Permalink
chore: Introduce telemetry to wizard
Browse files Browse the repository at this point in the history
  • Loading branch information
michaeldowseza committed Dec 16, 2022
1 parent 551368c commit 33dfd90
Show file tree
Hide file tree
Showing 4 changed files with 283 additions and 3 deletions.
54 changes: 54 additions & 0 deletions src/internal/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ export interface MetricsLogItem {
version: string;
}

export interface MetricsV2EventItem {
eventType?: string;
eventContext?: string;
eventDetail?: string | Record<string, string | number | boolean>;
eventValue?: string | Record<string, string | number | boolean>;
}

interface AWSC {
Clog: any;
}

interface MetricsWindow extends Window {
AWSC?: AWSC;
panorama?: any;
}

declare const AWSUI_METRIC_ORIGIN: string | undefined;
Expand Down Expand Up @@ -54,6 +62,24 @@ const buildMetricName = ({ source, version }: MetricsLogItem): string => {
return ['awsui', source, `${formatVersionForMetricName(THEME, version)}`].join('_');
};

const findPanorama = (currentWindow?: MetricsWindow): any | undefined => {
try {
if (typeof currentWindow?.panorama === 'function') {
return currentWindow?.panorama;
}

if (!currentWindow || currentWindow.parent === currentWindow) {
// When the window has no more parents, it references itself
return undefined;
}

return findPanorama(currentWindow.parent);
} catch (ex) {
// Most likely a cross-origin access error
return undefined;
}
};

const findAWSC = (currentWindow?: MetricsWindow): AWSC | undefined => {
try {
if (typeof currentWindow?.AWSC === 'object') {
Expand Down Expand Up @@ -102,6 +128,34 @@ export const Metrics = {
}
},

/**
* Calls Console Platform's client v2 logging JS API with provided metric name and detail.
* Does nothing if Console Platform client logging JS is not present in page.
*/
sendPanoramaMetric(metricName: string, metric: MetricsV2EventItem): void {
if (!metricName || !/^[a-zA-Z0-9_-]{1,32}$/.test(metricName)) {
console.error(`Invalid metric name: ${metricName}`);
return;
}
if (typeof metric.eventDetail === 'object') {
metric.eventDetail = JSON.stringify(metric.eventDetail);
}
if (metric.eventDetail && metric.eventDetail.length > 200) {
console.error(`Detail for metric ${metricName} is too long: ${metric.eventDetail}`);
return;
}
if (typeof metric.eventValue === 'object') {
metric.eventValue = JSON.stringify(metric.eventValue);
}
const panorama = findPanorama(window);
if (typeof panorama === 'function') {
panorama(metricName, {
...metric,
timestamp: Date.now(),
});
}
},

sendMetricObject(metric: MetricsLogItem, value: number): void {
this.sendMetric(buildMetricName(metric), value, buildMetricDetail(metric));
},
Expand Down
128 changes: 128 additions & 0 deletions src/internal/utils/__tests__/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jest.mock(
declare global {
interface Window {
AWSC?: any;
panorama?: any;
}
}

Expand All @@ -26,6 +27,11 @@ describe('Client Metrics support', () => {
jest.spyOn(window.AWSC.Clog, 'log');
};

const definePanorama = () => {
window.panorama = () => {};
jest.spyOn(window, 'panorama');
};

const checkMetric = (metricName: string, detail: string[]) => {
const detailObject = {
o: detail[0],
Expand Down Expand Up @@ -322,4 +328,126 @@ describe('Client Metrics support', () => {
checkMetric(`awsui_components_d30`, ['main', 'components', 'default', 'loaded', 'DummyFrameWork', '3.0(HEAD)']);
});
});

describe.only('sendPanoramaMetric', () => {
test('does nothing when panorama is undefined', () => {
Metrics.sendPanoramaMetric('name', {}); // only proves no exception thrown
});

describe('when panorama is defined', () => {
let consoleSpy: jest.SpyInstance;
const metric = {
eventContext: 'context',
eventDetail: 'detail',
eventType: 'type',
eventValue: 'value',
};

beforeEach(() => {
definePanorama();
consoleSpy = jest.spyOn(console, 'error');
});

afterEach(() => {
expect(consoleSpy).not.toHaveBeenCalled();
jest.clearAllMocks();
});

test('delegates to window.panorama when defined', () => {
const mockDateNow = new Date('2022-12-16T00:00:00.00Z').valueOf();
jest.spyOn(global.Date, 'now').mockImplementationOnce(() => mockDateNow);

Metrics.sendPanoramaMetric('name', metric);
expect(window.panorama).toHaveBeenCalledWith('name', { ...metric, timestamp: mockDateNow });
});

describe('Metric name validation', () => {
const tryValidMetric = (metricName: string) => {
it(`calls panorama when valid metric name used (${metricName})`, () => {
Metrics.sendPanoramaMetric(metricName, metric);
expect(window.panorama).toHaveBeenCalledWith(metricName, expect.objectContaining(metric));
});
};

const tryInvalidMetric = (metricName: string) => {
it(`logs an error when invalid metric name used (${metricName})`, () => {
Metrics.sendPanoramaMetric(metricName, metric);
expect(consoleSpy).toHaveBeenCalledWith(`Invalid metric name: ${metricName}`);
consoleSpy.mockReset();
});
};

tryValidMetric('1'); // min length 1 char
tryValidMetric('123456789'); // digits are ok
tryValidMetric('lowerUPPER'); // lower and uppercase chars ok
tryValidMetric('dash-dash-dash'); // dashes ok
tryValidMetric('underscore_underscore'); // 32 chars: max length
tryValidMetric('123456789_123456789_123456789_12'); // 32 chars: max length

tryInvalidMetric(''); // too short, empty string not allowed
tryInvalidMetric('123456789_123456789_123456789_123'); // 33 chars: too long
tryInvalidMetric('colons:not:allowed'); // invalid characters
tryInvalidMetric('spaces not allowed'); // invalid characters
});

describe('Metric detail validation', () => {
test('accepts event detail up to 200 characters', () => {
const inputMetric = {
...metric,
eventDetail: new Array(201).join('a'),
};

Metrics.sendPanoramaMetric('metricName', inputMetric);
expect(window.panorama).toHaveBeenCalledWith('metricName', expect.objectContaining(inputMetric));
});

test('throws an error when detail is too long', () => {
const invalidMetric = {
...metric,
eventDetail: new Array(202).join('a'),
};

Metrics.sendPanoramaMetric('metricName', invalidMetric);
expect(consoleSpy).toHaveBeenCalledWith(
`Detail for metric metricName is too long: ${invalidMetric.eventDetail}`
);
consoleSpy.mockReset();
});

test('accepts event detail as an object', () => {
const inputMetric = {
...metric,
eventDetail: {
name: 'Hello',
},
};

const expectedMetric = {
...metric,
eventDetail: JSON.stringify(inputMetric.eventDetail),
};

Metrics.sendPanoramaMetric('metricName', inputMetric);
expect(window.panorama).toHaveBeenCalledWith('metricName', expect.objectContaining(expectedMetric));
});

test('accepts event value as an object', () => {
const inputMetric = {
...metric,
eventValue: {
name: 'Hello',
},
};

const expectedMetric = {
...metric,
eventValue: JSON.stringify(inputMetric.eventValue),
};

Metrics.sendPanoramaMetric('metricName', inputMetric);
expect(window.panorama).toHaveBeenCalledWith('metricName', expect.objectContaining(expectedMetric));
});
});
});
});
});
20 changes: 17 additions & 3 deletions src/wizard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import { applyDisplayName } from '../internal/utils/apply-display-name';
import useBaseComponent from '../internal/hooks/use-base-component';
import { useMergeRefs } from '../internal/hooks/use-merge-refs';
import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
import { useEffectOnUpdate } from '../internal/hooks/use-effect-on-update';

import { useWizardAnalytics } from './internal/analytics';

export { WizardProps };

Expand All @@ -35,6 +38,7 @@ export default function Wizard({

const [breakpoint, breakpointsRef] = useContainerBreakpoints(['xs']);
const ref = useMergeRefs(breakpointsRef, __internalRootRef);
const { trackStartStep, trackNavigate, trackSubmit } = useWizardAnalytics();

const smallContainer = breakpoint === 'default';

Expand All @@ -52,16 +56,22 @@ export default function Wizard({
const isLastStep = actualActiveStepIndex >= steps.length - 1;

const navigationEvent = (requestedStepIndex: number, reason: WizardProps.NavigationReason) => {
trackNavigate(actualActiveStepIndex, requestedStepIndex, reason);
setActiveStepIndex(requestedStepIndex);
fireNonCancelableEvent(onNavigate, { requestedStepIndex, reason });
};
const onStepClick = (stepIndex: number) => navigationEvent(stepIndex, 'step');
const onSkipToClick = (stepIndex: number) => navigationEvent(stepIndex, 'skip');
const onCancelClick = () => fireNonCancelableEvent(onCancel);
const onPreviousClick = () => navigationEvent(actualActiveStepIndex - 1, 'previous');
const onPrimaryClick = isLastStep
? () => fireNonCancelableEvent(onSubmit)
: () => navigationEvent(actualActiveStepIndex + 1, 'next');
const onPrimaryClick = () => {
if (isLastStep) {
trackSubmit(actualActiveStepIndex);
fireNonCancelableEvent(onSubmit);
} else {
navigationEvent(actualActiveStepIndex + 1, 'next');
}
};

if (activeStepIndex && activeStepIndex >= steps.length) {
warnOnce(
Expand All @@ -79,6 +89,10 @@ export default function Wizard({
);
}

useEffectOnUpdate(() => {
trackStartStep(actualActiveStepIndex);
}, [actualActiveStepIndex, trackStartStep]);

return (
<div {...baseProps} className={clsx(styles.root, baseProps.className)} ref={ref}>
<div
Expand Down
84 changes: 84 additions & 0 deletions src/wizard/internal/analytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { Metrics } from '../../internal/metrics';
import { WizardProps } from '../interfaces';

const prefix = 'csa_wizard';

const createEventType = (eventType: string) => `${prefix}_${eventType}`;
const createEventContext = (stepIndex = 0) => `${prefix}_step${stepIndex + 1}`;
const createEventDetail = (stepIndex = 0) => `step${stepIndex + 1}`;

// A custom time cache is used to not clear the timer between navigation attempts
// This allows us the ability to track time to attempt each step as well as the time to complete
// each step
const timeCache: Record<string, number> = {};
const timeStart = (key = 'current') => {
timeCache[key] = Date.now();
};

const timeEnd = (key = 'current', clear = false) => {
const start = timeCache[key];
// No start time is available when starting the first step
if (!start) {
return undefined;
}

if (clear) {
delete timeCache[key];
}

return (Date.now() - start) / 1000; // Convert to seconds
};

export const useWizardAnalytics = () => {
const trackStartStep = (stepIndex?: number) => {
const eventContext = createEventContext(stepIndex);

// Track the starting time of the wizard
if (stepIndex === undefined) {
timeStart(prefix);
}

// End the timer of the previous step
const time = timeEnd();

// Start a new timer of the current step
timeStart();

Metrics.sendPanoramaMetric('trackStartStep', {
eventContext,
eventDetail: createEventDetail(stepIndex),
eventType: createEventType('step'),
...(time !== undefined && { eventValue: time.toString() }),
});
};

const trackNavigate = (activeStepIndex: number, requestedStepIndex: number, reason: WizardProps.NavigationReason) => {
const eventContext = createEventContext(activeStepIndex);
const time = timeEnd();

Metrics.sendPanoramaMetric('trackNavigate', {
eventContext,
eventDetail: createEventDetail(requestedStepIndex),
eventType: createEventType('navigate'),
eventValue: { reason, ...(time !== undefined && { time }) },
});
};

const trackSubmit = (stepIndex: number) => {
const eventContext = createEventContext(stepIndex);
// End the timer of the wizard
const time = timeEnd(prefix);

Metrics.sendPanoramaMetric('trackSubmit', {
eventContext,
eventDetail: createEventDetail(stepIndex),
eventType: createEventType('submit'),
...(time !== undefined && { eventValue: time.toString() }),
});
};

return { trackStartStep, trackNavigate, trackSubmit };
};

0 comments on commit 33dfd90

Please sign in to comment.