Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions packages/@webex/internal-plugin-metrics/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
PreComputedLatencies,
SubmitFeatureEvent,
LocusSyncLatencyEventName,
PrivacyAndSecurityPermission,
} from './metrics.types';
import * as CALL_DIAGNOSTIC_CONFIG from './call-diagnostic/config';
import * as CallDiagnosticUtils from './call-diagnostic/call-diagnostic-metrics.util';
Expand Down Expand Up @@ -70,4 +71,5 @@ export type {
PreComputedLatencies,
SubmitFeatureEvent,
LocusSyncLatencyEventName,
PrivacyAndSecurityPermission,
};
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ export type ClientUserNameInput = NonNullable<RawClientEvent['userNameInput']>;

export type ClientEmailInput = NonNullable<RawClientEvent['emailInput']>;

export type PrivacyAndSecurityPermission = NonNullable<
RawClientEvent['privacyAndSecurityPermission']
>;

export type PrivacyAndSecurityPermissionResource = keyof PrivacyAndSecurityPermission;

export type PrivacyAndSecurityPermissionState =
PrivacyAndSecurityPermission[PrivacyAndSecurityPermissionResource];

export type BrowserLaunchMethodType = NonNullable<
RawEvent['origin']['clientInfo']
>['browserLaunchMethod'];
Expand Down
43 changes: 41 additions & 2 deletions packages/@webex/internal-plugin-metrics/src/new-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,19 @@ import {
ClientEvent,
FeatureEvent,
EventPayload,
OperationalEvent,
MediaQualityEvent,
InternalEvent,
SubmitClientEventOptions,
Table,
DelayedClientEvent,
DelayedClientFeatureEvent,
PrivacyAndSecurityPermission,
} from './metrics.types';
import CallDiagnosticLatencies from './call-diagnostic/call-diagnostic-metrics-latencies';
import {setMetricTimings} from './call-diagnostic/call-diagnostic-metrics.util';
import {generateCommonErrorMetadata} from './utils';
import {isAutomatedUser as detectAutomatedUser} from './automated-user';
import PrivacyAndSecurityPermissionEnricher from './privacy-and-security-permission-enricher';

/**
* Metrics plugin to centralize all types of metrics.
Expand Down Expand Up @@ -68,6 +69,8 @@ class Metrics extends WebexPlugin {

delayedClientFeatureEventsOverrides: Partial<DelayedClientFeatureEvent['options']> = {};

private privacyAndSecurityPermissionEnricher: PrivacyAndSecurityPermissionEnricher;

/**
* Constructor
* @param args
Expand All @@ -78,6 +81,15 @@ class Metrics extends WebexPlugin {
constructor(...args) {
super(...args);

this.privacyAndSecurityPermissionEnricher = new PrivacyAndSecurityPermissionEnricher(
(error) => {
// @ts-ignore
this.webex.logger.error(
'NewMetrics: @submitClientEvent. Privacy and security permission enrichment failed.',
error
);
}
);
// @ts-ignore
this.callDiagnosticLatencies = new CallDiagnosticLatencies({}, {parent: this.webex});
this.onReady();
Expand Down Expand Up @@ -381,14 +393,41 @@ class Metrics extends WebexPlugin {
options: {meetingId: options?.meetingId},
});

return this.callDiagnosticMetrics.submitClientEvent({
const enrichedPayload = this.privacyAndSecurityPermissionEnricher.enrich({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$

name,
payload,
scope: this.getPermissionScope(options),
});
Comment on lines +396 to +400

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enrich the fetch-based client-event path

When permission state has been set and callers use the documented buildClientEventFetchRequestOptions flow for tab-close/beforeUnload client events, this new enrichment never runs because it is only applied in submitClientEvent; the helper at lines 468-503 still forwards the original payload directly. That path exists specifically for cases where submitClientEvent cannot be used, so terminal events such as client.call.leave sent during page close will omit privacyAndSecurityPermission while normal submissions include it. Apply the same enrichment before building the fetch request options.

Useful? React with 👍 / 👎.


return this.callDiagnosticMetrics.submitClientEvent({
name,
payload: enrichedPayload,
options,
delaySubmitEvent: this.delaySubmitClientEvents,
});
}

/**
* Stores the latest browser permission state supplied by the client.
* @param permission latest normalized permission snapshot
*/
public setPrivacyAndSecurityPermission(permission: PrivacyAndSecurityPermission): void {
this.privacyAndSecurityPermissionEnricher.setPermission(permission);
}

/**
* Resolves event options to the call identity used by Call Diagnostic where possible.
* @param options client event options
* @returns the permission history scope
*/
private getPermissionScope(options?: SubmitClientEventOptions): string {
const meeting = options?.meetingId
? (this as any).webex.meetings?.getBasicMeetingInformation?.(options.meetingId)
: undefined;

return options?.correlationId ?? meeting?.correlationId ?? 'default';
}

/**
* Issue request to alias a user's pre-login ID with their CI UUID
* @param {string} preLoginId
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import {isEqual} from 'lodash';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Feels like this file deserves it's own folder with

index
utils
constants
types


import {
ClientEvent,
ClientEventPayload,
PrivacyAndSecurityPermission,
PrivacyAndSecurityPermissionResource,
PrivacyAndSecurityPermissionState,
} from './metrics.types';

type PermissionEnrichmentPolicy = {
resources: readonly PrivacyAndSecurityPermissionResource[];
terminal: boolean;
};

type PermissionEnrichmentRule = {
events: ReadonlySet<ClientEvent['name']>;
resolve: (payload?: ClientEventPayload) => PermissionEnrichmentPolicy;
};

type PermissionEnrichmentContext = {
name: ClientEvent['name'];
payload?: ClientEventPayload;
scope: string;
};

const CAMERA_AND_MICROPHONE_PERMISSION_EVENTS = new Set<ClientEvent['name']>([
'client.call.initiated',
'client.media.capabilities',
'client.ice.end',
'client.locus.join.request',
'client.locus.join.response',
'client.media-engine.ready',
]);

const MEDIA_TX_PERMISSION_EVENTS = new Set<ClientEvent['name']>([
'client.media.tx.start',
'client.media.tx.stop',
]);

const CONTENT_SHARE_PERMISSION_EVENTS = new Set<ClientEvent['name']>([
'client.share.initiated',
'client.share.floor-grant.request',
'client.share.floor-granted.local',
]);

const FINAL_PERMISSION_EVENTS = new Set<ClientEvent['name']>([
'client.call.leave',
'client.call.remote-ended',
'client.call.aborted',
]);

const isSamePermissionState = (
current?: PrivacyAndSecurityPermissionState,
previous?: PrivacyAndSecurityPermissionState
): boolean => isEqual(current, previous);

const resolveMediaResources = (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

payload?: ClientEventPayload
): PrivacyAndSecurityPermissionResource[] => {
switch (payload?.mediaType) {
case 'audio':
return ['microphone'];
case 'video':
return ['camera'];
case 'share':
return ['contentShare'];
default:
return [];
}
};

export const PERMISSION_ENRICHMENT_RULES = [
{
events: CAMERA_AND_MICROPHONE_PERMISSION_EVENTS,
resolve: () => ({resources: ['camera', 'microphone'], terminal: false}),
},
{
events: MEDIA_TX_PERMISSION_EVENTS,
resolve: (payload) => ({resources: resolveMediaResources(payload), terminal: false}),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

},
{
events: CONTENT_SHARE_PERMISSION_EVENTS,
resolve: (payload) => ({
resources: payload?.mediaType === 'share' ? ['contentShare'] : [],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be better as a func

terminal: false,
}),
},
{
events: FINAL_PERMISSION_EVENTS,
resolve: () => ({resources: ['camera', 'microphone', 'contentShare'], terminal: true}),
},
] satisfies readonly PermissionEnrichmentRule[];

const NO_PERMISSION_ENRICHMENT: PermissionEnrichmentPolicy = {
resources: [],
terminal: false,
};

const resolvePermissionEnrichmentPolicy = (
name: ClientEvent['name'],
payload?: ClientEventPayload
): PermissionEnrichmentPolicy =>
PERMISSION_ENRICHMENT_RULES.find(({events}) => events.has(name))?.resolve(payload) ??
NO_PERMISSION_ENRICHMENT;

const projectPrivacyAndSecurityPermission = (
permission: PrivacyAndSecurityPermission,
resources: readonly PrivacyAndSecurityPermissionResource[]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

): PrivacyAndSecurityPermission | undefined => {
const projectedPermission: PrivacyAndSecurityPermission = {
...(resources.includes('camera') && permission.camera ? {camera: {...permission.camera}} : {}),
...(resources.includes('microphone') && permission.microphone
? {microphone: {...permission.microphone}}
: {}),
...(resources.includes('contentShare') && permission.contentShare
? {contentShare: {...permission.contentShare}}
: {}),
};

return Object.keys(projectedPermission).length > 0 ? projectedPermission : undefined;
};

const getChangedPermission = (
current: PrivacyAndSecurityPermission,
previous: PrivacyAndSecurityPermission
): PrivacyAndSecurityPermission | undefined => {
const changedPermission: PrivacyAndSecurityPermission = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: the layered ternaries are a bit tough with the object spread too

...(current.camera && !isSamePermissionState(current.camera, previous.camera)
? {camera: {...current.camera}}
: {}),
...(current.microphone && !isSamePermissionState(current.microphone, previous.microphone)
? {microphone: {...current.microphone}}
: {}),
...(current.contentShare && !isSamePermissionState(current.contentShare, previous.contentShare)
? {contentShare: {...current.contentShare}}
: {}),
};

return Object.keys(changedPermission).length > 0 ? changedPermission : undefined;
};

/**
* Enriches eligible client events with relevant browser permission changes.
*/
export default class PrivacyAndSecurityPermissionEnricher {
private permission?: PrivacyAndSecurityPermission;

private lastReported = new Map<string, PrivacyAndSecurityPermission>();

private readonly onEnrichmentError: (error: unknown) => void;

/**
* Creates a permission enricher.
* @param {Function} onEnrichmentError permission enrichment error handler
*/
constructor(onEnrichmentError: (error: unknown) => void) {
this.onEnrichmentError = onEnrichmentError;
}

/**
* Stores the latest normalized browser permission state.
* @param {PrivacyAndSecurityPermission} permission permission snapshot
* @returns {void}
*/
public setPermission(permission: PrivacyAndSecurityPermission): void {
this.permission = {
...(permission.camera ? {camera: {...permission.camera}} : {}),
...(permission.microphone ? {microphone: {...permission.microphone}} : {}),
...(permission.contentShare ? {contentShare: {...permission.contentShare}} : {}),
};
}

/**
* Returns the original payload or a copy enriched with relevant permission changes.
* @param {PermissionEnrichmentContext} context event context and permission history scope
* @returns {ClientEventPayload | undefined}
*/
public enrich({
name,
payload,
scope,
}: PermissionEnrichmentContext): ClientEventPayload | undefined {
const policy = resolvePermissionEnrichmentPolicy(name, payload);

try {
if (payload?.privacyAndSecurityPermission !== undefined) {
// An explicitly supplied permission payload is authoritative for this event.
if (!policy.terminal) {
this.lastReported.set(scope, {
...this.lastReported.get(scope),
...(payload.privacyAndSecurityPermission as PrivacyAndSecurityPermission),
});
}

return payload;
}

if (policy.resources.length === 0) {
return payload;
}

const projectedPermission = this.permission
? projectPrivacyAndSecurityPermission(this.permission, policy.resources)
: undefined;

if (!projectedPermission) {
return payload;
}

if (policy.terminal) {
return {...payload, privacyAndSecurityPermission: projectedPermission};
}

const lastReported = this.lastReported.get(scope) ?? {};
const changedPermission = getChangedPermission(projectedPermission, lastReported);

if (!changedPermission) {
return payload;
}

this.lastReported.set(scope, {...lastReported, ...changedPermission});

return {...payload, privacyAndSecurityPermission: changedPermission};
} catch (error) {
this.onEnrichmentError(error);

return payload;
} finally {
if (policy.terminal) {
this.lastReported.delete(scope);
}
}
}
}
Loading