Skip to content
Closed
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
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,
PrivacyAndSecurityPermissionProvider,
} 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,
PrivacyAndSecurityPermissionProvider,
};
11 changes: 11 additions & 0 deletions packages/@webex/internal-plugin-metrics/src/metrics.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ 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 PrivacyAndSecurityPermissionProvider = () => PrivacyAndSecurityPermission | undefined;

export type BrowserLaunchMethodType = NonNullable<
RawEvent['origin']['clientInfo']
>['browserLaunchMethod'];
Expand Down
45 changes: 43 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,
PrivacyAndSecurityPermissionProvider,
} 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 provider failed.',
error
);
}
);
// @ts-ignore
this.callDiagnosticLatencies = new CallDiagnosticLatencies({}, {parent: this.webex});
this.onReady();
Expand Down Expand Up @@ -381,14 +393,43 @@ class Metrics extends WebexPlugin {
options: {meetingId: options?.meetingId},
});

return this.callDiagnosticMetrics.submitClientEvent({
const enrichedPayload = this.privacyAndSecurityPermissionEnricher.enrich({
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 fetch-built client events

This enrichment only runs through submitClientEvent, but the public buildClientEventFetchRequestOptions path below still forwards the original payload directly to Call Diagnostics. In the documented before-unload flow for events such as client.call.leave, callers use the fetch-options builder instead of submitClientEvent, so the provider is never invoked, the final permission snapshot is missing, and the scope is not cleared if no normal terminal event is submitted. Apply the same enrichment/scope logic before building fetch options.

Useful? React with 👍 / 👎.


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

/**
* Registers a provider for the latest browser permission state.
* @param provider permission snapshot provider, or undefined to clear it
*/
public setPrivacyAndSecurityPermissionProvider(
provider?: PrivacyAndSecurityPermissionProvider
): void {
this.privacyAndSecurityPermissionEnricher.setProvider(provider);
}

/**
* 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,252 @@
import {isEqual} from 'lodash';

import {
ClientEvent,
ClientEventPayload,
PrivacyAndSecurityPermission,
PrivacyAndSecurityPermissionProvider,
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 = (
payload?: ClientEventPayload
): PrivacyAndSecurityPermissionResource[] => {
switch (payload?.mediaType) {
case 'audio':
return ['microphone'];
case 'video':
return ['camera'];
case 'share':
return ['contentShare'];
default:
return [];
}
};

const createNonOverlappingPermissionEnrichmentRules = (
rules: readonly PermissionEnrichmentRule[]
): readonly PermissionEnrichmentRule[] => {
const registeredEvents = new Set<ClientEvent['name']>();

rules.forEach(({events}) => {
events.forEach((event) => {
if (registeredEvents.has(event)) {
throw new Error(`Permission enrichment event is registered more than once: ${event}`);
}

registeredEvents.add(event);
});
});

return rules;
};

const PERMISSION_ENRICHMENT_RULES = createNonOverlappingPermissionEnrichmentRules([
{
events: CAMERA_AND_MICROPHONE_PERMISSION_EVENTS,
resolve: () => ({resources: ['camera', 'microphone'], terminal: false}),
},
{
events: MEDIA_TX_PERMISSION_EVENTS,
resolve: (payload) => ({resources: resolveMediaResources(payload), terminal: false}),
},
{
events: CONTENT_SHARE_PERMISSION_EVENTS,
resolve: (payload) => ({
resources: payload?.mediaType === 'share' ? ['contentShare'] : [],
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[]
): 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 = {
...(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 provider?: PrivacyAndSecurityPermissionProvider;

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

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

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

/**
* Registers the provider for the latest browser permission state.
* @param {PrivacyAndSecurityPermissionProvider} provider permission snapshot provider, or undefined to clear it
* @returns {void}
*/
public setProvider(provider?: PrivacyAndSecurityPermissionProvider): void {
this.provider = provider;
this.lastReported.clear();
}

/**
* 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 (!this.provider || policy.resources.length === 0) {
return payload;
}

const permission = this.provider();
const projectedPermission = permission
? projectPrivacyAndSecurityPermission(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});

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 Keep permission history tied to sent events

When a changed permission is observed on an event that Call Diagnostics later drops, this line still records it as reported. For example, CallDiagnosticMetrics.shouldSendEvent() limits duplicate client.media.tx.start events per media type/correlation; if the microphone permission changes on a duplicate tx-start, that metric is discarded after enrichment, but lastReported is already advanced, so a later sendable client.media.tx.stop in the same scope omits the changed permission snapshot. Update the history only after the metric is actually queued/sent, or avoid enriching events that will be suppressed.

Useful? React with 👍 / 👎.


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

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