From 8285190d3b084b506d30eae33482d7716b0daf08 Mon Sep 17 00:00:00 2001 From: Gabriel Lee Date: Fri, 7 Aug 2026 15:37:31 +0100 Subject: [PATCH 1/6] feat: add device permission diagnostics metrics --- .../internal-plugin-metrics/src/index.ts | 2 + .../src/metrics.types.ts | 6 + .../src/new-metrics.ts | 222 ++++++++- .../test/unit/spec/new-metrics.ts | 424 ++++++++++++++++-- 4 files changed, 605 insertions(+), 49 deletions(-) diff --git a/packages/@webex/internal-plugin-metrics/src/index.ts b/packages/@webex/internal-plugin-metrics/src/index.ts index 83608270df0..ed8eb71dfc9 100644 --- a/packages/@webex/internal-plugin-metrics/src/index.ts +++ b/packages/@webex/internal-plugin-metrics/src/index.ts @@ -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'; @@ -70,4 +71,5 @@ export type { PreComputedLatencies, SubmitFeatureEvent, LocusSyncLatencyEventName, + PrivacyAndSecurityPermissionProvider, }; diff --git a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts index 15e21513770..8cb38e5dde5 100644 --- a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts +++ b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts @@ -23,6 +23,12 @@ export type ClientUserNameInput = NonNullable; export type ClientEmailInput = NonNullable; +export type PrivacyAndSecurityPermission = NonNullable< + RawClientEvent['privacyAndSecurityPermission'] +>; + +export type PrivacyAndSecurityPermissionProvider = () => PrivacyAndSecurityPermission | undefined; + export type BrowserLaunchMethodType = NonNullable< RawEvent['origin']['clientInfo'] >['browserLaunchMethod']; diff --git a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts index c36ce380065..9ef101810eb 100644 --- a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts @@ -26,12 +26,100 @@ import { Table, DelayedClientEvent, DelayedClientFeatureEvent, + PrivacyAndSecurityPermission, + 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'; +const CAMERA_AND_MICROPHONE_PERMISSION_EVENTS = new Set([ + '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([ + 'client.media.tx.start', + 'client.media.tx.stop', +]); + +const CONTENT_SHARE_PERMISSION_EVENTS = new Set([ + 'client.share.initiated', + 'client.share.floor-grant.request', + 'client.share.floor-granted.local', +]); + +const FINAL_PERMISSION_EVENTS = new Set([ + 'client.call.leave', + 'client.call.remote-ended', + 'client.call.aborted', +]); + +type PermissionResource = keyof PrivacyAndSecurityPermission; +type PermissionState = PrivacyAndSecurityPermission[PermissionResource]; + +const DEFAULT_PERMISSION_SCOPE = 'default'; + +const isSamePermissionState = (current?: PermissionState, previous?: PermissionState): boolean => + current?.status === previous?.status && current?.reason === previous?.reason; + +const getPermissionResourcesForEvent = ( + name: ClientEvent['name'], + payload?: RecursivePartial +): PermissionResource[] => { + if (CAMERA_AND_MICROPHONE_PERMISSION_EVENTS.has(name)) { + return ['camera', 'microphone']; + } + + if (MEDIA_TX_PERMISSION_EVENTS.has(name)) { + if (payload?.mediaType === 'audio') { + return ['microphone']; + } + + if (payload?.mediaType === 'video') { + return ['camera']; + } + + if (payload?.mediaType === 'share') { + return ['contentShare']; + } + + return []; + } + + if (CONTENT_SHARE_PERMISSION_EVENTS.has(name)) { + return payload?.mediaType === 'share' ? ['contentShare'] : []; + } + + if (FINAL_PERMISSION_EVENTS.has(name)) { + return ['camera', 'microphone', 'contentShare']; + } + + return []; +}; + +const projectPrivacyAndSecurityPermission = ( + permission: PrivacyAndSecurityPermission, + resources: PermissionResource[] +): 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; +}; + /** * Metrics plugin to centralize all types of metrics. * https://confluence-eng-gpk2.cisco.com/conf/pages/viewpage.action?pageId=231011379 @@ -68,6 +156,13 @@ class Metrics extends WebexPlugin { delayedClientFeatureEventsOverrides: Partial = {}; + private privacyAndSecurityPermissionProvider?: PrivacyAndSecurityPermissionProvider; + + private lastReportedPrivacyAndSecurityPermission = new Map< + string, + PrivacyAndSecurityPermission + >(); + /** * Constructor * @param args @@ -381,14 +476,139 @@ class Metrics extends WebexPlugin { options: {meetingId: options?.meetingId}, }); + const enrichedPayload = this.addPrivacyAndSecurityPermission({name, payload, options}); + return this.callDiagnosticMetrics.submitClientEvent({ name, - payload, + 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.privacyAndSecurityPermissionProvider = provider; + this.lastReportedPrivacyAndSecurityPermission.clear(); + } + + /** + * 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_PERMISSION_SCOPE; + } + + /** + * Adds the relevant permission resources to an eligible client event. + * @param args client event name and payload + * @returns the original or enriched payload + */ + private addPrivacyAndSecurityPermission({ + name, + payload, + options, + }: { + name: ClientEvent['name']; + payload?: RecursivePartial; + options?: SubmitClientEventOptions; + }): RecursivePartial | undefined { + const scope = this.getPermissionScope(options); + const isFinalEvent = FINAL_PERMISSION_EVENTS.has(name); + + if (payload?.privacyAndSecurityPermission !== undefined) { + if (isFinalEvent) { + this.lastReportedPrivacyAndSecurityPermission.delete(scope); + } else { + this.lastReportedPrivacyAndSecurityPermission.set(scope, { + ...this.lastReportedPrivacyAndSecurityPermission.get(scope), + ...(payload.privacyAndSecurityPermission as PrivacyAndSecurityPermission), + }); + } + + return payload; + } + + const resources = getPermissionResourcesForEvent(name, payload); + + if (!this.privacyAndSecurityPermissionProvider || resources.length === 0) { + if (isFinalEvent) { + this.lastReportedPrivacyAndSecurityPermission.delete(scope); + } + + return payload; + } + + try { + const permission = this.privacyAndSecurityPermissionProvider(); + const projectedPermission = permission + ? projectPrivacyAndSecurityPermission(permission, resources) + : undefined; + + if (!projectedPermission) { + return payload; + } + + if (isFinalEvent) { + this.lastReportedPrivacyAndSecurityPermission.delete(scope); + + return {...payload, privacyAndSecurityPermission: projectedPermission}; + } + + const lastReported = this.lastReportedPrivacyAndSecurityPermission.get(scope) ?? {}; + const changedPermission: PrivacyAndSecurityPermission = { + ...(resources.includes('camera') && + projectedPermission.camera && + !isSamePermissionState(projectedPermission.camera, lastReported.camera) + ? {camera: {...projectedPermission.camera}} + : {}), + ...(resources.includes('microphone') && + projectedPermission.microphone && + !isSamePermissionState(projectedPermission.microphone, lastReported.microphone) + ? {microphone: {...projectedPermission.microphone}} + : {}), + ...(resources.includes('contentShare') && + projectedPermission.contentShare && + !isSamePermissionState(projectedPermission.contentShare, lastReported.contentShare) + ? {contentShare: {...projectedPermission.contentShare}} + : {}), + }; + + if (Object.keys(changedPermission).length === 0) { + return payload; + } + + this.lastReportedPrivacyAndSecurityPermission.set(scope, { + ...lastReported, + ...changedPermission, + }); + + return {...payload, privacyAndSecurityPermission: changedPermission}; + } catch (error) { + if (isFinalEvent) { + this.lastReportedPrivacyAndSecurityPermission.delete(scope); + } + // @ts-ignore + this.webex.logger.error( + 'NewMetrics: @submitClientEvent. Privacy and security permission provider failed.', + error + ); + + return payload; + } + } + /** * Issue request to alias a user's pre-login ID with their CI UUID * @param {string} preLoginId diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts index 69725054c4b..0fe49578457 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts @@ -9,19 +9,23 @@ import MockWebex from '@webex/test-helper-mock-webex'; import sinon from 'sinon'; describe('internal-plugin-metrics', () => { - - const mockWebex = () => new MockWebex({ - children: { - newMetrics: NewMetrics, - }, - meetings: { - }, - request: sinon.stub().resolves({}), - logger: { - log: sinon.stub(), - error: sinon.stub(), - } - }); + const mockWebex = () => + new MockWebex({ + children: { + newMetrics: NewMetrics, + }, + meetings: { + getBasicMeetingInformation: sinon.stub().callsFake((meetingId) => ({ + id: meetingId, + correlationId: `correlation-${meetingId}`, + })), + }, + request: sinon.stub().resolves({}), + logger: { + log: sinon.stub(), + error: sinon.stub(), + }, + }); describe('check submitClientEvent, submitFeatureEvent when webex is not ready', () => { let webex; @@ -50,12 +54,14 @@ describe('internal-plugin-metrics', () => { payload: { meetingSummaryInfo: { featureName: 'syncSystemMuteStatus', - featureActions: [{ - actionName: 'syncMeetingMicUnmuteStatusToSystem', - actionId: '14200', - isInitialValue: false, - clickCount: '1' - }] + featureActions: [ + { + actionName: 'syncMeetingMicUnmuteStatusToSystem', + actionId: '14200', + isInitialValue: false, + clickCount: '1', + }, + ], }, }, }); @@ -68,7 +74,6 @@ describe('internal-plugin-metrics', () => { describe('new-metrics contstructor', () => { it('checks callDiagnosticLatencies is defined before ready emit', () => { - const webex = mockWebex(); assert.instanceOf(webex.internal.newMetrics.callDiagnosticLatencies, CallDiagnosticLatencies); @@ -98,7 +103,7 @@ describe('internal-plugin-metrics', () => { afterEach(() => { sinon.restore(); - }) + }); it('lazy metrics backend initialization when checking if backend ready', () => { assert.isUndefined(webex.internal.newMetrics.behavioralMetrics); @@ -109,10 +114,10 @@ describe('internal-plugin-metrics', () => { webex.internal.newMetrics.isReadyToSubmitOperationalEvents(); assert.isDefined(webex.internal.newMetrics.operationalMetrics); - assert.isUndefined(webex.internal.newMetrics.businessMetrics) + assert.isUndefined(webex.internal.newMetrics.businessMetrics); webex.internal.newMetrics.isReadyToSubmitBusinessEvents(); assert.isDefined(webex.internal.newMetrics.businessMetrics); - }) + }); it('returns the automated user classification', () => { assert.strictEqual( @@ -122,7 +127,7 @@ describe('internal-plugin-metrics', () => { }); it('passes the table through to the business metrics', () => { - assert.isUndefined(webex.internal.newMetrics.businessMetrics) + assert.isUndefined(webex.internal.newMetrics.businessMetrics); webex.internal.newMetrics.isReadyToSubmitBusinessEvents(); assert.isDefined(webex.internal.newMetrics.businessMetrics); webex.internal.newMetrics.businessMetrics.submitBusinessEvent = sinon.stub(); @@ -130,14 +135,14 @@ describe('internal-plugin-metrics', () => { name: 'foobar', payload: {}, table: 'test', - metadata: { foo: 'bar' }, + metadata: {foo: 'bar'}, }); assert.calledWith(webex.internal.newMetrics.businessMetrics.submitBusinessEvent, { name: 'foobar', payload: {}, table: 'test', - metadata: { foo: 'bar' }, + metadata: {foo: 'bar'}, }); }); @@ -161,6 +166,319 @@ describe('internal-plugin-metrics', () => { }); }); + describe('privacy and security permission enrichment', () => { + const permission = { + camera: {status: 'GRANTED' as const}, + microphone: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, + contentShare: {status: 'REQUESTING' as const}, + }; + + it('projects camera and microphone onto join events without mutating the input', () => { + const provider = sinon.stub().returns(permission); + const payload = {mediaType: 'audio' as const}; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(provider); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.call.initiated', + payload, + }); + + const submittedPayload = + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] + .payload; + + assert.deepEqual(submittedPayload, { + mediaType: 'audio', + privacyAndSecurityPermission: { + camera: {status: 'GRANTED'}, + microphone: {status: 'DENIED', reason: 'DENIED_BY_USER'}, + }, + }); + assert.notStrictEqual(submittedPayload, payload); + assert.notStrictEqual( + submittedPayload.privacyAndSecurityPermission.camera, + permission.camera + ); + assert.notProperty(payload, 'privacyAndSecurityPermission'); + }); + + [ + {mediaType: 'audio' as const, expected: {microphone: permission.microphone}}, + {mediaType: 'video' as const, expected: {camera: permission.camera}}, + {mediaType: 'share' as const, expected: {contentShare: permission.contentShare}}, + ].forEach(({mediaType, expected}) => { + it(`projects the matching resource for media tx ${mediaType}`, () => { + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.media.tx.start', + payload: {mediaType}, + }); + + const submittedPayload = + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] + .payload; + + assert.deepEqual(submittedPayload.privacyAndSecurityPermission, expected); + }); + }); + + it('only enriches screen-share events when mediaType is share', () => { + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.share.initiated', + payload: {mediaType: 'share'}, + }); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.share.initiated', + payload: {mediaType: 'whiteboard'}, + }); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.media.tx.start', + payload: {mediaType: 'share_audio'}, + }); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.deepEqual(submissions[0][0].payload.privacyAndSecurityPermission, { + contentShare: permission.contentShare, + }); + assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission'); + assert.notProperty(submissions[2][0].payload, 'privacyAndSecurityPermission'); + }); + + it('projects every available resource onto final events', () => { + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended'}); + + const submittedPayload = + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] + .payload; + + assert.deepEqual(submittedPayload.privacyAndSecurityPermission, permission); + }); + + it('reports initial permission once and suppresses an unchanged later join event', () => { + const options = {meetingId: 'meeting-1'}; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.call.initiated', + payload: {}, + options, + }); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.ice.end', + payload: {}, + options, + }); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.deepEqual(submissions[0][0].payload.privacyAndSecurityPermission, { + camera: permission.camera, + microphone: permission.microphone, + }); + assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission'); + }); + + it('uses the meeting correlation id to preserve history across identifier transitions', () => { + webex.meetings.getBasicMeetingInformation + .withArgs('meeting-1') + .returns({id: 'meeting-1', correlationId: 'correlation-1'}); + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + + webex.internal.newMetrics.submitClientEvent({ + name: 'client.call.initiated', + payload: {}, + options: {correlationId: 'correlation-1'}, + }); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.ice.end', + payload: {}, + options: {meetingId: 'meeting-1'}, + }); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission'); + assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission'); + }); + + it('uses the default scope when no correlation id can be resolved', () => { + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + + webex.internal.newMetrics.submitClientEvent({ + name: 'client.call.initiated', + payload: {}, + options: {sessionCorrelationId: 'session-1'}, + }); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.ice.end', + payload: {}, + options: {sessionCorrelationId: 'session-2'}, + }); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission'); + assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission'); + }); + + it('reports only a permission resource that changed since its last report', () => { + let currentPermission = permission; + const options = {meetingId: 'meeting-1'}; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => currentPermission); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); + currentPermission = { + ...permission, + camera: {status: 'DENIED' as const, reason: 'DENIED_BY_SYSTEM' as const}, + }; + webex.internal.newMetrics.submitClientEvent({name: 'client.ice.end', options}); + + const submittedPayload = + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.secondCall.args[0] + .payload; + + assert.deepEqual(submittedPayload.privacyAndSecurityPermission, { + camera: currentPermission.camera, + }); + }); + + it('tracks the last report independently for concurrent meeting scopes', () => { + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.call.initiated', + options: {meetingId: 'meeting-1'}, + }); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.call.initiated', + options: {meetingId: 'meeting-2'}, + }); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission'); + assert.property(submissions[1][0].payload, 'privacyAndSecurityPermission'); + }); + + it('always reports current permission on each emitted terminal event', () => { + const options = {meetingId: 'meeting-1'}; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended', options}); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.deepEqual(submissions[1][0].payload.privacyAndSecurityPermission, permission); + assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, permission); + }); + + it('preserves an explicit permission payload without invoking the provider', () => { + const provider = sinon.stub().returns(permission); + const explicitPermission = { + camera: {status: 'FAILED' as const, reason: 'UNKNOWN' as const}, + }; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(provider); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.call.leave', + payload: {privacyAndSecurityPermission: explicitPermission}, + }); + + const submittedPayload = + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] + .payload; + + assert.strictEqual(submittedPayload.privacyAndSecurityPermission, explicitPermission); + assert.notCalled(provider); + }); + + it('uses an explicit permission payload as the baseline for later events', () => { + const options = {meetingId: 'meeting-1'}; + const payload = { + privacyAndSecurityPermission: { + camera: permission.camera, + microphone: permission.microphone, + }, + }; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.call.initiated', + payload, + options, + }); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.ice.end', + payload: {}, + options, + }); + + const submittedPayload = + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.secondCall.args[0] + .payload; + + assert.notProperty(submittedPayload, 'privacyAndSecurityPermission'); + }); + + it('submits unchanged when the provider is cleared or throws', () => { + const payload = {mediaType: 'audio' as const}; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', payload}); + + assert.strictEqual( + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] + .payload, + payload + ); + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => { + throw new Error('provider failed'); + }); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', payload}); + + assert.strictEqual( + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.secondCall.args[0] + .payload, + payload + ); + assert.calledOnce(webex.logger.error); + }); + + it('captures the permission snapshot before a delayed event is queued', () => { + let currentPermission = permission; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => currentPermission); + webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: true}); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'}); + currentPermission = {camera: {status: 'DENIED' as const}}; + + const submission = + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0]; + + assert.isTrue(submission.delaySubmitEvent); + assert.deepEqual(submission.payload.privacyAndSecurityPermission, { + camera: permission.camera, + microphone: permission.microphone, + }); + }); + + it('does not invoke the provider for unrelated or permission prompt events', () => { + const provider = sinon.stub().returns(permission); + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(provider); + webex.internal.newMetrics.submitClientEvent({name: 'client.alert.displayed'}); + webex.internal.newMetrics.submitClientEvent({name: 'client.permission.prompted'}); + + assert.notCalled(provider); + }); + }); + it('submits feature Event successfully', () => { webex.internal.newMetrics.submitFeatureEvent({ name: 'client.feature.meeting.summary', @@ -170,12 +488,14 @@ describe('internal-plugin-metrics', () => { payload: { meetingSummaryInfo: { featureName: 'syncSystemMuteStatus', - featureActions: [{ - actionName: 'syncMeetingMicUnmuteStatusToSystem', - actionId: '14200', - isInitialValue: false, - clickCount: '1' - }] + featureActions: [ + { + actionName: 'syncMeetingMicUnmuteStatusToSystem', + actionId: '14200', + isInitialValue: false, + clickCount: '1', + }, + ], }, }, }); @@ -189,12 +509,14 @@ describe('internal-plugin-metrics', () => { payload: { meetingSummaryInfo: { featureName: 'syncSystemMuteStatus', - featureActions: [{ - actionName: 'syncMeetingMicUnmuteStatusToSystem', - actionId: '14200', - isInitialValue: false, - clickCount: '1' - }] + featureActions: [ + { + actionName: 'syncMeetingMicUnmuteStatusToSystem', + actionId: '14200', + isInitialValue: false, + clickCount: '1', + }, + ], }, }, options: {meetingId: '123'}, @@ -255,9 +577,9 @@ describe('internal-plugin-metrics', () => { method: 'POST', api: 'metrics', resource: 'clientmetrics', - headers: { 'x-prelogin-userid': 'my-id' }, + headers: {'x-prelogin-userid': 'my-id'}, body: {}, - qs: { alias: true }, + qs: {alias: true}, }); assert.calledWith( webex.logger.log, @@ -266,8 +588,8 @@ describe('internal-plugin-metrics', () => { }); it('handles failed request correctly', async () => { - webex.request.rejects(new Error("test error")); - sinon.stub(Utils, 'generateCommonErrorMetadata').returns('formattedError') + webex.request.rejects(new Error('test error')); + sinon.stub(Utils, 'generateCommonErrorMetadata').returns('formattedError'); try { await webex.internal.newMetrics.clientMetricsAliasUser({event: 'test'}, 'my-id'); } catch (err) { @@ -349,19 +671,26 @@ describe('internal-plugin-metrics', () => { sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, true); sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {}); - webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false, overrides: {foo: 'bar'}}); + webex.internal.newMetrics.setDelaySubmitClientEvents({ + shouldDelay: false, + overrides: {foo: 'bar'}, + }); - assert.calledOnce(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents); - assert.calledWith(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents, {foo: 'bar'}); + assert.calledOnce( + webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents + ); + assert.calledWith( + webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents, + {foo: 'bar'} + ); sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, false); sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {foo: 'bar'}); }); it('should not fail when called before webex is ready', () => { - // Create mock - webex = mockWebex() + webex = mockWebex(); webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub(); webex.internal.newMetrics.callDiagnosticLatencies.clearTimestamps = sinon.stub(); @@ -376,7 +705,6 @@ describe('internal-plugin-metrics', () => { webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false}); // Webex is ready webex.emit('ready'); - }); }); }); From 97a54bda89280852f61bcb2c8561e993440821f7 Mon Sep 17 00:00:00 2001 From: Gabriel Lee Date: Wed, 12 Aug 2026 14:39:07 +0100 Subject: [PATCH 2/6] feat: address comments --- .../src/metrics.types.ts | 5 + .../src/new-metrics.ts | 215 ++------------- ...rivacy-and-security-permission-enricher.ts | 252 ++++++++++++++++++ .../test/unit/spec/new-metrics.ts | 36 +++ 4 files changed, 311 insertions(+), 197 deletions(-) create mode 100644 packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts diff --git a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts index 8cb38e5dde5..d0eea10213d 100644 --- a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts +++ b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts @@ -27,6 +27,11 @@ 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< diff --git a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts index 9ef101810eb..f71cae78731 100644 --- a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts @@ -19,106 +19,19 @@ import { ClientEvent, FeatureEvent, EventPayload, - OperationalEvent, MediaQualityEvent, InternalEvent, SubmitClientEventOptions, Table, DelayedClientEvent, DelayedClientFeatureEvent, - PrivacyAndSecurityPermission, 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'; - -const CAMERA_AND_MICROPHONE_PERMISSION_EVENTS = new Set([ - '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([ - 'client.media.tx.start', - 'client.media.tx.stop', -]); - -const CONTENT_SHARE_PERMISSION_EVENTS = new Set([ - 'client.share.initiated', - 'client.share.floor-grant.request', - 'client.share.floor-granted.local', -]); - -const FINAL_PERMISSION_EVENTS = new Set([ - 'client.call.leave', - 'client.call.remote-ended', - 'client.call.aborted', -]); - -type PermissionResource = keyof PrivacyAndSecurityPermission; -type PermissionState = PrivacyAndSecurityPermission[PermissionResource]; - -const DEFAULT_PERMISSION_SCOPE = 'default'; - -const isSamePermissionState = (current?: PermissionState, previous?: PermissionState): boolean => - current?.status === previous?.status && current?.reason === previous?.reason; - -const getPermissionResourcesForEvent = ( - name: ClientEvent['name'], - payload?: RecursivePartial -): PermissionResource[] => { - if (CAMERA_AND_MICROPHONE_PERMISSION_EVENTS.has(name)) { - return ['camera', 'microphone']; - } - - if (MEDIA_TX_PERMISSION_EVENTS.has(name)) { - if (payload?.mediaType === 'audio') { - return ['microphone']; - } - - if (payload?.mediaType === 'video') { - return ['camera']; - } - - if (payload?.mediaType === 'share') { - return ['contentShare']; - } - - return []; - } - - if (CONTENT_SHARE_PERMISSION_EVENTS.has(name)) { - return payload?.mediaType === 'share' ? ['contentShare'] : []; - } - - if (FINAL_PERMISSION_EVENTS.has(name)) { - return ['camera', 'microphone', 'contentShare']; - } - - return []; -}; - -const projectPrivacyAndSecurityPermission = ( - permission: PrivacyAndSecurityPermission, - resources: PermissionResource[] -): 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; -}; +import PrivacyAndSecurityPermissionEnricher from './privacy-and-security-permission-enricher'; /** * Metrics plugin to centralize all types of metrics. @@ -156,12 +69,7 @@ class Metrics extends WebexPlugin { delayedClientFeatureEventsOverrides: Partial = {}; - private privacyAndSecurityPermissionProvider?: PrivacyAndSecurityPermissionProvider; - - private lastReportedPrivacyAndSecurityPermission = new Map< - string, - PrivacyAndSecurityPermission - >(); + private privacyAndSecurityPermissionEnricher: PrivacyAndSecurityPermissionEnricher; /** * Constructor @@ -173,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(); @@ -476,7 +393,11 @@ class Metrics extends WebexPlugin { options: {meetingId: options?.meetingId}, }); - const enrichedPayload = this.addPrivacyAndSecurityPermission({name, payload, options}); + const enrichedPayload = this.privacyAndSecurityPermissionEnricher.enrich({ + name, + payload, + scope: this.getPermissionScope(options), + }); return this.callDiagnosticMetrics.submitClientEvent({ name, @@ -493,8 +414,7 @@ class Metrics extends WebexPlugin { public setPrivacyAndSecurityPermissionProvider( provider?: PrivacyAndSecurityPermissionProvider ): void { - this.privacyAndSecurityPermissionProvider = provider; - this.lastReportedPrivacyAndSecurityPermission.clear(); + this.privacyAndSecurityPermissionEnricher.setProvider(provider); } /** @@ -507,106 +427,7 @@ class Metrics extends WebexPlugin { ? (this as any).webex.meetings?.getBasicMeetingInformation?.(options.meetingId) : undefined; - return options?.correlationId ?? meeting?.correlationId ?? DEFAULT_PERMISSION_SCOPE; - } - - /** - * Adds the relevant permission resources to an eligible client event. - * @param args client event name and payload - * @returns the original or enriched payload - */ - private addPrivacyAndSecurityPermission({ - name, - payload, - options, - }: { - name: ClientEvent['name']; - payload?: RecursivePartial; - options?: SubmitClientEventOptions; - }): RecursivePartial | undefined { - const scope = this.getPermissionScope(options); - const isFinalEvent = FINAL_PERMISSION_EVENTS.has(name); - - if (payload?.privacyAndSecurityPermission !== undefined) { - if (isFinalEvent) { - this.lastReportedPrivacyAndSecurityPermission.delete(scope); - } else { - this.lastReportedPrivacyAndSecurityPermission.set(scope, { - ...this.lastReportedPrivacyAndSecurityPermission.get(scope), - ...(payload.privacyAndSecurityPermission as PrivacyAndSecurityPermission), - }); - } - - return payload; - } - - const resources = getPermissionResourcesForEvent(name, payload); - - if (!this.privacyAndSecurityPermissionProvider || resources.length === 0) { - if (isFinalEvent) { - this.lastReportedPrivacyAndSecurityPermission.delete(scope); - } - - return payload; - } - - try { - const permission = this.privacyAndSecurityPermissionProvider(); - const projectedPermission = permission - ? projectPrivacyAndSecurityPermission(permission, resources) - : undefined; - - if (!projectedPermission) { - return payload; - } - - if (isFinalEvent) { - this.lastReportedPrivacyAndSecurityPermission.delete(scope); - - return {...payload, privacyAndSecurityPermission: projectedPermission}; - } - - const lastReported = this.lastReportedPrivacyAndSecurityPermission.get(scope) ?? {}; - const changedPermission: PrivacyAndSecurityPermission = { - ...(resources.includes('camera') && - projectedPermission.camera && - !isSamePermissionState(projectedPermission.camera, lastReported.camera) - ? {camera: {...projectedPermission.camera}} - : {}), - ...(resources.includes('microphone') && - projectedPermission.microphone && - !isSamePermissionState(projectedPermission.microphone, lastReported.microphone) - ? {microphone: {...projectedPermission.microphone}} - : {}), - ...(resources.includes('contentShare') && - projectedPermission.contentShare && - !isSamePermissionState(projectedPermission.contentShare, lastReported.contentShare) - ? {contentShare: {...projectedPermission.contentShare}} - : {}), - }; - - if (Object.keys(changedPermission).length === 0) { - return payload; - } - - this.lastReportedPrivacyAndSecurityPermission.set(scope, { - ...lastReported, - ...changedPermission, - }); - - return {...payload, privacyAndSecurityPermission: changedPermission}; - } catch (error) { - if (isFinalEvent) { - this.lastReportedPrivacyAndSecurityPermission.delete(scope); - } - // @ts-ignore - this.webex.logger.error( - 'NewMetrics: @submitClientEvent. Privacy and security permission provider failed.', - error - ); - - return payload; - } + return options?.correlationId ?? meeting?.correlationId ?? 'default'; } /** diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts new file mode 100644 index 00000000000..048a09313f6 --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts @@ -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; + resolve: (payload?: ClientEventPayload) => PermissionEnrichmentPolicy; +}; + +type PermissionEnrichmentContext = { + name: ClientEvent['name']; + payload?: ClientEventPayload; + scope: string; +}; + +const CAMERA_AND_MICROPHONE_PERMISSION_EVENTS = new Set([ + '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([ + 'client.media.tx.start', + 'client.media.tx.stop', +]); + +const CONTENT_SHARE_PERMISSION_EVENTS = new Set([ + 'client.share.initiated', + 'client.share.floor-grant.request', + 'client.share.floor-granted.local', +]); + +const FINAL_PERMISSION_EVENTS = new Set([ + '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(); + + 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(); + + 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}); + + return {...payload, privacyAndSecurityPermission: changedPermission}; + } catch (error) { + this.onProviderError(error); + + return payload; + } finally { + if (policy.terminal) { + this.lastReported.delete(scope); + } + } + } +} diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts index 0fe49578457..a823c37e5a5 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts @@ -376,6 +376,42 @@ describe('internal-plugin-metrics', () => { assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, permission); }); + it('clears permission history when a terminal event has no snapshot', () => { + const options = {meetingId: 'meeting-1'}; + let currentPermission: typeof permission | undefined = permission; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => currentPermission); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); + currentPermission = undefined; + webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); + currentPermission = permission; + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.isUndefined(submissions[1][0].payload); + assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, { + camera: permission.camera, + microphone: permission.microphone, + }); + }); + + it('reports initial permission again when a meeting scope is reused after a terminal event', () => { + const options = {meetingId: 'meeting-1'}; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, { + camera: permission.camera, + microphone: permission.microphone, + }); + }); + it('preserves an explicit permission payload without invoking the provider', () => { const provider = sinon.stub().returns(permission); const explicitPermission = { From 85600c25f266c531632a837cd8246c0c20588837 Mon Sep 17 00:00:00 2001 From: Gabriel Lee Date: Wed, 12 Aug 2026 23:35:52 +0100 Subject: [PATCH 3/6] fix: terminal metrics --- .../internal-plugin-metrics/src/index.ts | 2 + .../src/new-metrics.ts | 16 +++++ ...rivacy-and-security-permission-enricher.ts | 27 ++++++++- .../test/unit/spec/new-metrics.ts | 60 +++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/packages/@webex/internal-plugin-metrics/src/index.ts b/packages/@webex/internal-plugin-metrics/src/index.ts index ed8eb71dfc9..01efa79f0d0 100644 --- a/packages/@webex/internal-plugin-metrics/src/index.ts +++ b/packages/@webex/internal-plugin-metrics/src/index.ts @@ -21,6 +21,7 @@ import { PreComputedLatencies, SubmitFeatureEvent, LocusSyncLatencyEventName, + PrivacyAndSecurityPermission, PrivacyAndSecurityPermissionProvider, } from './metrics.types'; import * as CALL_DIAGNOSTIC_CONFIG from './call-diagnostic/config'; @@ -71,5 +72,6 @@ export type { PreComputedLatencies, SubmitFeatureEvent, LocusSyncLatencyEventName, + PrivacyAndSecurityPermission, PrivacyAndSecurityPermissionProvider, }; diff --git a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts index f71cae78731..776ad6c547b 100644 --- a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts @@ -25,6 +25,7 @@ import { Table, DelayedClientEvent, DelayedClientFeatureEvent, + PrivacyAndSecurityPermission, PrivacyAndSecurityPermissionProvider, } from './metrics.types'; import CallDiagnosticLatencies from './call-diagnostic/call-diagnostic-metrics-latencies'; @@ -417,6 +418,21 @@ class Metrics extends WebexPlugin { this.privacyAndSecurityPermissionEnricher.setProvider(provider); } + /** + * Preserves a permission snapshot for terminal events before call teardown mutates client state. + * @param permission point-in-time permission snapshot + * @param options call identity used to scope the snapshot + */ + public setPrivacyAndSecurityPermissionForTerminalEvents( + permission: PrivacyAndSecurityPermission, + options?: SubmitClientEventOptions + ): void { + this.privacyAndSecurityPermissionEnricher.setTerminalSnapshot( + this.getPermissionScope(options), + permission + ); + } + /** * Resolves event options to the call identity used by Call Diagnostic where possible. * @param options client event options diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts index 048a09313f6..c0aac4f7bbc 100644 --- a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts @@ -167,6 +167,8 @@ export default class PrivacyAndSecurityPermissionEnricher { private lastReported = new Map(); + private terminalSnapshots = new Map(); + private readonly onProviderError: (error: unknown) => void; /** @@ -185,6 +187,21 @@ export default class PrivacyAndSecurityPermissionEnricher { public setProvider(provider?: PrivacyAndSecurityPermissionProvider): void { this.provider = provider; this.lastReported.clear(); + this.terminalSnapshots.clear(); + } + + /** + * Preserves a point-in-time permission snapshot for terminal events in a call scope. + * @param {string} scope permission history scope + * @param {PrivacyAndSecurityPermission} permission permission snapshot + * @returns {void} + */ + public setTerminalSnapshot(scope: string, permission: PrivacyAndSecurityPermission): void { + this.terminalSnapshots.set(scope, { + ...(permission.camera ? {camera: {...permission.camera}} : {}), + ...(permission.microphone ? {microphone: {...permission.microphone}} : {}), + ...(permission.contentShare ? {contentShare: {...permission.contentShare}} : {}), + }); } /** @@ -199,6 +216,10 @@ export default class PrivacyAndSecurityPermissionEnricher { }: PermissionEnrichmentContext): ClientEventPayload | undefined { const policy = resolvePermissionEnrichmentPolicy(name, payload); + if (CAMERA_AND_MICROPHONE_PERMISSION_EVENTS.has(name)) { + this.terminalSnapshots.delete(scope); + } + try { if (payload?.privacyAndSecurityPermission !== undefined) { // An explicitly supplied permission payload is authoritative for this event. @@ -212,11 +233,13 @@ export default class PrivacyAndSecurityPermissionEnricher { return payload; } - if (!this.provider || policy.resources.length === 0) { + if (policy.resources.length === 0) { return payload; } - const permission = this.provider(); + const permission = policy.terminal + ? this.terminalSnapshots.get(scope) ?? this.provider?.() + : this.provider?.(); const projectedPermission = permission ? projectPrivacyAndSecurityPermission(permission, policy.resources) : undefined; diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts index a823c37e5a5..a76e4f7c7b9 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts @@ -376,6 +376,66 @@ describe('internal-plugin-metrics', () => { assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, permission); }); + it('uses a preserved pre-teardown snapshot for every terminal event in the call scope', () => { + const options = {meetingId: 'meeting-1'}; + const terminalPermission = { + camera: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, + microphone: {status: 'GRANTED' as const}, + contentShare: {status: 'GRANTED' as const}, + }; + const postTeardownPermission = { + camera: {status: 'UNKNOWN' as const, reason: 'UNKNOWN' as const}, + microphone: {status: 'UNKNOWN' as const, reason: 'UNKNOWN' as const}, + contentShare: {status: 'UNKNOWN' as const, reason: 'UNKNOWN' as const}, + }; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider( + () => postTeardownPermission + ); + webex.internal.newMetrics.setPrivacyAndSecurityPermissionForTerminalEvents( + terminalPermission, + options + ); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended', options}); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.aborted', options}); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.deepEqual( + submissions[0][0].payload.privacyAndSecurityPermission, + terminalPermission + ); + assert.deepEqual( + submissions[1][0].payload.privacyAndSecurityPermission, + terminalPermission + ); + assert.deepEqual( + submissions[2][0].payload.privacyAndSecurityPermission, + terminalPermission + ); + }); + + it('clears a preserved terminal snapshot when the call scope is reused', () => { + const options = {meetingId: 'meeting-1'}; + const terminalPermission = { + camera: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, + microphone: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, + }; + + webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermissionForTerminalEvents( + terminalPermission, + options + ); + webex.internal.newMetrics.submitClientEvent({name: 'client.locus.join.request', options}); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended', options}); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.deepEqual(submissions[1][0].payload.privacyAndSecurityPermission, permission); + }); + it('clears permission history when a terminal event has no snapshot', () => { const options = {meetingId: 'meeting-1'}; let currentPermission: typeof permission | undefined = permission; From 6c6a7db1e469f3a757b002d6288c152848a332f3 Mon Sep 17 00:00:00 2001 From: Gabriel Lee Date: Thu, 13 Aug 2026 10:30:51 +0100 Subject: [PATCH 4/6] fix: alt solution, cantina push all updates --- .../internal-plugin-metrics/src/index.ts | 2 - .../src/metrics.types.ts | 2 - .../src/new-metrics.ts | 28 +---- ...rivacy-and-security-permission-enricher.ts | 46 ++----- .../test/unit/spec/new-metrics.ts | 115 ++++++------------ 5 files changed, 53 insertions(+), 140 deletions(-) diff --git a/packages/@webex/internal-plugin-metrics/src/index.ts b/packages/@webex/internal-plugin-metrics/src/index.ts index 01efa79f0d0..8ec214a2e73 100644 --- a/packages/@webex/internal-plugin-metrics/src/index.ts +++ b/packages/@webex/internal-plugin-metrics/src/index.ts @@ -22,7 +22,6 @@ import { SubmitFeatureEvent, LocusSyncLatencyEventName, PrivacyAndSecurityPermission, - PrivacyAndSecurityPermissionProvider, } from './metrics.types'; import * as CALL_DIAGNOSTIC_CONFIG from './call-diagnostic/config'; import * as CallDiagnosticUtils from './call-diagnostic/call-diagnostic-metrics.util'; @@ -73,5 +72,4 @@ export type { SubmitFeatureEvent, LocusSyncLatencyEventName, PrivacyAndSecurityPermission, - PrivacyAndSecurityPermissionProvider, }; diff --git a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts index d0eea10213d..b4f34355d61 100644 --- a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts +++ b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts @@ -32,8 +32,6 @@ export type PrivacyAndSecurityPermissionResource = keyof PrivacyAndSecurityPermi export type PrivacyAndSecurityPermissionState = PrivacyAndSecurityPermission[PrivacyAndSecurityPermissionResource]; -export type PrivacyAndSecurityPermissionProvider = () => PrivacyAndSecurityPermission | undefined; - export type BrowserLaunchMethodType = NonNullable< RawEvent['origin']['clientInfo'] >['browserLaunchMethod']; diff --git a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts index 776ad6c547b..d85cd857122 100644 --- a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts @@ -26,7 +26,6 @@ import { DelayedClientEvent, DelayedClientFeatureEvent, PrivacyAndSecurityPermission, - PrivacyAndSecurityPermissionProvider, } from './metrics.types'; import CallDiagnosticLatencies from './call-diagnostic/call-diagnostic-metrics-latencies'; import {setMetricTimings} from './call-diagnostic/call-diagnostic-metrics.util'; @@ -86,7 +85,7 @@ class Metrics extends WebexPlugin { (error) => { // @ts-ignore this.webex.logger.error( - 'NewMetrics: @submitClientEvent. Privacy and security permission provider failed.', + 'NewMetrics: @submitClientEvent. Privacy and security permission enrichment failed.', error ); } @@ -409,28 +408,11 @@ class Metrics extends WebexPlugin { } /** - * Registers a provider for the latest browser permission state. - * @param provider permission snapshot provider, or undefined to clear it + * Stores the latest browser permission state supplied by the client. + * @param permission latest normalized permission snapshot */ - public setPrivacyAndSecurityPermissionProvider( - provider?: PrivacyAndSecurityPermissionProvider - ): void { - this.privacyAndSecurityPermissionEnricher.setProvider(provider); - } - - /** - * Preserves a permission snapshot for terminal events before call teardown mutates client state. - * @param permission point-in-time permission snapshot - * @param options call identity used to scope the snapshot - */ - public setPrivacyAndSecurityPermissionForTerminalEvents( - permission: PrivacyAndSecurityPermission, - options?: SubmitClientEventOptions - ): void { - this.privacyAndSecurityPermissionEnricher.setTerminalSnapshot( - this.getPermissionScope(options), - permission - ); + public setPrivacyAndSecurityPermission(permission: PrivacyAndSecurityPermission): void { + this.privacyAndSecurityPermissionEnricher.setPermission(permission); } /** diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts index c0aac4f7bbc..85a719d67d0 100644 --- a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts @@ -4,7 +4,6 @@ import { ClientEvent, ClientEventPayload, PrivacyAndSecurityPermission, - PrivacyAndSecurityPermissionProvider, PrivacyAndSecurityPermissionResource, PrivacyAndSecurityPermissionState, } from './metrics.types'; @@ -163,45 +162,31 @@ const getChangedPermission = ( * Enriches eligible client events with relevant browser permission changes. */ export default class PrivacyAndSecurityPermissionEnricher { - private provider?: PrivacyAndSecurityPermissionProvider; + private permission?: PrivacyAndSecurityPermission; private lastReported = new Map(); - private terminalSnapshots = new Map(); - - private readonly onProviderError: (error: unknown) => void; + private readonly onEnrichmentError: (error: unknown) => void; /** * Creates a permission enricher. - * @param {Function} onProviderError permission provider error handler + * @param {Function} onEnrichmentError permission enrichment error handler */ - constructor(onProviderError: (error: unknown) => void) { - this.onProviderError = onProviderError; + constructor(onEnrichmentError: (error: unknown) => void) { + this.onEnrichmentError = onEnrichmentError; } /** - * 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(); - this.terminalSnapshots.clear(); - } - - /** - * Preserves a point-in-time permission snapshot for terminal events in a call scope. - * @param {string} scope permission history scope + * Stores the latest normalized browser permission state. * @param {PrivacyAndSecurityPermission} permission permission snapshot * @returns {void} */ - public setTerminalSnapshot(scope: string, permission: PrivacyAndSecurityPermission): void { - this.terminalSnapshots.set(scope, { + public setPermission(permission: PrivacyAndSecurityPermission): void { + this.permission = { ...(permission.camera ? {camera: {...permission.camera}} : {}), ...(permission.microphone ? {microphone: {...permission.microphone}} : {}), ...(permission.contentShare ? {contentShare: {...permission.contentShare}} : {}), - }); + }; } /** @@ -216,10 +201,6 @@ export default class PrivacyAndSecurityPermissionEnricher { }: PermissionEnrichmentContext): ClientEventPayload | undefined { const policy = resolvePermissionEnrichmentPolicy(name, payload); - if (CAMERA_AND_MICROPHONE_PERMISSION_EVENTS.has(name)) { - this.terminalSnapshots.delete(scope); - } - try { if (payload?.privacyAndSecurityPermission !== undefined) { // An explicitly supplied permission payload is authoritative for this event. @@ -237,11 +218,8 @@ export default class PrivacyAndSecurityPermissionEnricher { return payload; } - const permission = policy.terminal - ? this.terminalSnapshots.get(scope) ?? this.provider?.() - : this.provider?.(); - const projectedPermission = permission - ? projectPrivacyAndSecurityPermission(permission, policy.resources) + const projectedPermission = this.permission + ? projectPrivacyAndSecurityPermission(this.permission, policy.resources) : undefined; if (!projectedPermission) { @@ -263,7 +241,7 @@ export default class PrivacyAndSecurityPermissionEnricher { return {...payload, privacyAndSecurityPermission: changedPermission}; } catch (error) { - this.onProviderError(error); + this.onEnrichmentError(error); return payload; } finally { diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts index a76e4f7c7b9..b99e04d2699 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts @@ -174,10 +174,9 @@ describe('internal-plugin-metrics', () => { }; it('projects camera and microphone onto join events without mutating the input', () => { - const provider = sinon.stub().returns(permission); const payload = {mediaType: 'audio' as const}; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(provider); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({ name: 'client.call.initiated', payload, @@ -208,7 +207,7 @@ describe('internal-plugin-metrics', () => { {mediaType: 'share' as const, expected: {contentShare: permission.contentShare}}, ].forEach(({mediaType, expected}) => { it(`projects the matching resource for media tx ${mediaType}`, () => { - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({ name: 'client.media.tx.start', payload: {mediaType}, @@ -223,7 +222,7 @@ describe('internal-plugin-metrics', () => { }); it('only enriches screen-share events when mediaType is share', () => { - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({ name: 'client.share.initiated', payload: {mediaType: 'share'}, @@ -247,7 +246,7 @@ describe('internal-plugin-metrics', () => { }); it('projects every available resource onto final events', () => { - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended'}); const submittedPayload = @@ -260,7 +259,7 @@ describe('internal-plugin-metrics', () => { it('reports initial permission once and suppresses an unchanged later join event', () => { const options = {meetingId: 'meeting-1'}; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({ name: 'client.call.initiated', payload: {}, @@ -285,7 +284,7 @@ describe('internal-plugin-metrics', () => { webex.meetings.getBasicMeetingInformation .withArgs('meeting-1') .returns({id: 'meeting-1', correlationId: 'correlation-1'}); - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({ name: 'client.call.initiated', @@ -305,7 +304,7 @@ describe('internal-plugin-metrics', () => { }); it('uses the default scope when no correlation id can be resolved', () => { - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({ name: 'client.call.initiated', @@ -328,12 +327,13 @@ describe('internal-plugin-metrics', () => { let currentPermission = permission; const options = {meetingId: 'meeting-1'}; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => currentPermission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(currentPermission); webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); currentPermission = { ...permission, camera: {status: 'DENIED' as const, reason: 'DENIED_BY_SYSTEM' as const}, }; + webex.internal.newMetrics.setPrivacyAndSecurityPermission(currentPermission); webex.internal.newMetrics.submitClientEvent({name: 'client.ice.end', options}); const submittedPayload = @@ -346,7 +346,7 @@ describe('internal-plugin-metrics', () => { }); it('tracks the last report independently for concurrent meeting scopes', () => { - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({ name: 'client.call.initiated', options: {meetingId: 'meeting-1'}, @@ -365,7 +365,7 @@ describe('internal-plugin-metrics', () => { it('always reports current permission on each emitted terminal event', () => { const options = {meetingId: 'meeting-1'}; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended', options}); @@ -376,80 +376,52 @@ describe('internal-plugin-metrics', () => { assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, permission); }); - it('uses a preserved pre-teardown snapshot for every terminal event in the call scope', () => { + it('uses the last permission pushed by the client for every terminal event', () => { const options = {meetingId: 'meeting-1'}; - const terminalPermission = { + const latestPermission = { camera: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, microphone: {status: 'GRANTED' as const}, contentShare: {status: 'GRANTED' as const}, }; - const postTeardownPermission = { - camera: {status: 'UNKNOWN' as const, reason: 'UNKNOWN' as const}, - microphone: {status: 'UNKNOWN' as const, reason: 'UNKNOWN' as const}, - contentShare: {status: 'UNKNOWN' as const, reason: 'UNKNOWN' as const}, - }; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider( - () => postTeardownPermission - ); - webex.internal.newMetrics.setPrivacyAndSecurityPermissionForTerminalEvents( - terminalPermission, - options - ); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(latestPermission); webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended', options}); webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); webex.internal.newMetrics.submitClientEvent({name: 'client.call.aborted', options}); const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - assert.deepEqual( - submissions[0][0].payload.privacyAndSecurityPermission, - terminalPermission - ); - assert.deepEqual( - submissions[1][0].payload.privacyAndSecurityPermission, - terminalPermission - ); - assert.deepEqual( - submissions[2][0].payload.privacyAndSecurityPermission, - terminalPermission - ); + assert.deepEqual(submissions[0][0].payload.privacyAndSecurityPermission, latestPermission); + assert.deepEqual(submissions[1][0].payload.privacyAndSecurityPermission, latestPermission); + assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, latestPermission); }); - it('clears a preserved terminal snapshot when the call scope is reused', () => { + it('uses a newer pushed permission value for later terminal events', () => { const options = {meetingId: 'meeting-1'}; - const terminalPermission = { + const previousPermission = { camera: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, microphone: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, }; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); - webex.internal.newMetrics.setPrivacyAndSecurityPermissionForTerminalEvents( - terminalPermission, - options - ); - webex.internal.newMetrics.submitClientEvent({name: 'client.locus.join.request', options}); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(previousPermission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended', options}); const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - assert.deepEqual(submissions[1][0].payload.privacyAndSecurityPermission, permission); + assert.deepEqual(submissions[0][0].payload.privacyAndSecurityPermission, permission); }); - it('clears permission history when a terminal event has no snapshot', () => { + it('clears permission history after a terminal event', () => { const options = {meetingId: 'meeting-1'}; - let currentPermission: typeof permission | undefined = permission; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => currentPermission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); - currentPermission = undefined; webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); - currentPermission = permission; webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - assert.isUndefined(submissions[1][0].payload); assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, { camera: permission.camera, microphone: permission.microphone, @@ -459,7 +431,7 @@ describe('internal-plugin-metrics', () => { it('reports initial permission again when a meeting scope is reused after a terminal event', () => { const options = {meetingId: 'meeting-1'}; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); @@ -472,13 +444,12 @@ describe('internal-plugin-metrics', () => { }); }); - it('preserves an explicit permission payload without invoking the provider', () => { - const provider = sinon.stub().returns(permission); + it('preserves an explicit permission payload', () => { const explicitPermission = { camera: {status: 'FAILED' as const, reason: 'UNKNOWN' as const}, }; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(provider); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({ name: 'client.call.leave', payload: {privacyAndSecurityPermission: explicitPermission}, @@ -489,7 +460,6 @@ describe('internal-plugin-metrics', () => { .payload; assert.strictEqual(submittedPayload.privacyAndSecurityPermission, explicitPermission); - assert.notCalled(provider); }); it('uses an explicit permission payload as the baseline for later events', () => { @@ -501,7 +471,7 @@ describe('internal-plugin-metrics', () => { }, }; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({ name: 'client.call.initiated', payload, @@ -520,11 +490,9 @@ describe('internal-plugin-metrics', () => { assert.notProperty(submittedPayload, 'privacyAndSecurityPermission'); }); - it('submits unchanged when the provider is cleared or throws', () => { + it('submits unchanged before the client supplies permission state', () => { const payload = {mediaType: 'audio' as const}; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => permission); - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(); webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', payload}); assert.strictEqual( @@ -532,24 +500,12 @@ describe('internal-plugin-metrics', () => { .payload, payload ); - - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => { - throw new Error('provider failed'); - }); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', payload}); - - assert.strictEqual( - webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.secondCall.args[0] - .payload, - payload - ); - assert.calledOnce(webex.logger.error); }); it('captures the permission snapshot before a delayed event is queued', () => { let currentPermission = permission; - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(() => currentPermission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(currentPermission); webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: true}); webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'}); currentPermission = {camera: {status: 'DENIED' as const}}; @@ -564,14 +520,15 @@ describe('internal-plugin-metrics', () => { }); }); - it('does not invoke the provider for unrelated or permission prompt events', () => { - const provider = sinon.stub().returns(permission); - - webex.internal.newMetrics.setPrivacyAndSecurityPermissionProvider(provider); + it('does not enrich unrelated or permission prompt events', () => { + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.submitClientEvent({name: 'client.alert.displayed'}); webex.internal.newMetrics.submitClientEvent({name: 'client.permission.prompted'}); - assert.notCalled(provider); + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.isUndefined(submissions[0][0].payload); + assert.isUndefined(submissions[1][0].payload); }); }); From d0008b369490bfe53fcad8942c46355fff75b2fe Mon Sep 17 00:00:00 2001 From: Gabriel Lee Date: Thu, 13 Aug 2026 12:00:48 +0100 Subject: [PATCH 5/6] fix: do check in test instead of runtime --- ...rivacy-and-security-permission-enricher.ts | 22 ++----------------- ...rivacy-and-security-permission-enricher.ts | 13 +++++++++++ 2 files changed, 15 insertions(+), 20 deletions(-) create mode 100644 packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts index 85a719d67d0..e6625f25599 100644 --- a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts @@ -70,25 +70,7 @@ const resolveMediaResources = ( } }; -const createNonOverlappingPermissionEnrichmentRules = ( - rules: readonly PermissionEnrichmentRule[] -): readonly PermissionEnrichmentRule[] => { - const registeredEvents = new Set(); - - 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([ +export const PERMISSION_ENRICHMENT_RULES = [ { events: CAMERA_AND_MICROPHONE_PERMISSION_EVENTS, resolve: () => ({resources: ['camera', 'microphone'], terminal: false}), @@ -108,7 +90,7 @@ const PERMISSION_ENRICHMENT_RULES = createNonOverlappingPermissionEnrichmentRule events: FINAL_PERMISSION_EVENTS, resolve: () => ({resources: ['camera', 'microphone', 'contentShare'], terminal: true}), }, -] satisfies readonly PermissionEnrichmentRule[]); +] satisfies readonly PermissionEnrichmentRule[]; const NO_PERMISSION_ENRICHMENT: PermissionEnrichmentPolicy = { resources: [], diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts new file mode 100644 index 00000000000..424a40d49ba --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts @@ -0,0 +1,13 @@ +import {assert} from '@webex/test-helper-chai'; + +import {PERMISSION_ENRICHMENT_RULES} from '../../../src/privacy-and-security-permission-enricher'; + +describe('PrivacyAndSecurityPermissionEnricher', () => { + describe('permission enrichment rules', () => { + it('registers each production event in only one rule', () => { + const registeredEvents = PERMISSION_ENRICHMENT_RULES.flatMap(({events}) => [...events]); + + assert.lengthOf(new Set(registeredEvents), registeredEvents.length); + }); + }); +}); From 90bff54b2ba2ee211faad16e073819c224818a39 Mon Sep 17 00:00:00 2001 From: Gabriel Lee Date: Mon, 17 Aug 2026 21:08:55 +0100 Subject: [PATCH 6/6] fix: address --- ...rivacy-and-security-permission-enricher.ts | 235 -------------- .../constants.ts | 33 ++ .../index.ts | 142 ++++++++ .../types.ts | 21 ++ .../utils.ts | 82 +++++ .../test/unit/spec/new-metrics.ts | 299 +---------------- ...rivacy-and-security-permission-enricher.ts | 307 +++++++++++++++++- 7 files changed, 591 insertions(+), 528 deletions(-) delete mode 100644 packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts create mode 100644 packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/constants.ts create mode 100644 packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/index.ts create mode 100644 packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/types.ts create mode 100644 packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/utils.ts diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts deleted file mode 100644 index e6625f25599..00000000000 --- a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher.ts +++ /dev/null @@ -1,235 +0,0 @@ -import {isEqual} from 'lodash'; - -import { - ClientEvent, - ClientEventPayload, - PrivacyAndSecurityPermission, - PrivacyAndSecurityPermissionResource, - PrivacyAndSecurityPermissionState, -} from './metrics.types'; - -type PermissionEnrichmentPolicy = { - resources: readonly PrivacyAndSecurityPermissionResource[]; - terminal: boolean; -}; - -type PermissionEnrichmentRule = { - events: ReadonlySet; - resolve: (payload?: ClientEventPayload) => PermissionEnrichmentPolicy; -}; - -type PermissionEnrichmentContext = { - name: ClientEvent['name']; - payload?: ClientEventPayload; - scope: string; -}; - -const CAMERA_AND_MICROPHONE_PERMISSION_EVENTS = new Set([ - '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([ - 'client.media.tx.start', - 'client.media.tx.stop', -]); - -const CONTENT_SHARE_PERMISSION_EVENTS = new Set([ - 'client.share.initiated', - 'client.share.floor-grant.request', - 'client.share.floor-granted.local', -]); - -const FINAL_PERMISSION_EVENTS = new Set([ - '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 []; - } -}; - -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}), - }, - { - 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 permission?: PrivacyAndSecurityPermission; - - private lastReported = new Map(); - - 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); - } - } - } -} diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/constants.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/constants.ts new file mode 100644 index 00000000000..ea02f6a345d --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/constants.ts @@ -0,0 +1,33 @@ +import type {ClientEvent} from '../metrics.types'; +import type {PermissionEnrichmentPolicy} from './types'; + +export const CAMERA_AND_MICROPHONE_PERMISSION_EVENTS = new Set([ + 'client.call.initiated', + 'client.media.capabilities', + 'client.ice.end', + 'client.locus.join.request', + 'client.locus.join.response', + 'client.media-engine.ready', +]); + +export const MEDIA_TX_PERMISSION_EVENTS = new Set([ + 'client.media.tx.start', + 'client.media.tx.stop', +]); + +export const CONTENT_SHARE_PERMISSION_EVENTS = new Set([ + 'client.share.initiated', + 'client.share.floor-grant.request', + 'client.share.floor-granted.local', +]); + +export const FINAL_PERMISSION_EVENTS = new Set([ + 'client.call.leave', + 'client.call.remote-ended', + 'client.call.aborted', +]); + +export const NO_PERMISSION_ENRICHMENT: PermissionEnrichmentPolicy = { + resources: [], + terminal: false, +}; diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/index.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/index.ts new file mode 100644 index 00000000000..8c566b7d0aa --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/index.ts @@ -0,0 +1,142 @@ +import type {ClientEventPayload, PrivacyAndSecurityPermission} from '../metrics.types'; +import { + CAMERA_AND_MICROPHONE_PERMISSION_EVENTS, + CONTENT_SHARE_PERMISSION_EVENTS, + FINAL_PERMISSION_EVENTS, + MEDIA_TX_PERMISSION_EVENTS, + NO_PERMISSION_ENRICHMENT, +} from './constants'; +import type { + PermissionEnrichmentContext, + PermissionEnrichmentPolicy, + PermissionEnrichmentRule, +} from './types'; +import { + getChangedPermission, + projectPrivacyAndSecurityPermission, + resolveContentShareResources, + resolveMediaResources, +} from './utils'; + +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}), + }, + { + events: CONTENT_SHARE_PERMISSION_EVENTS, + resolve: (payload) => ({ + resources: resolveContentShareResources(payload), + terminal: false, + }), + }, + { + events: FINAL_PERMISSION_EVENTS, + resolve: () => ({resources: ['camera', 'microphone', 'contentShare'], terminal: true}), + }, +] satisfies readonly PermissionEnrichmentRule[]; + +const resolvePermissionEnrichmentPolicy = ( + name: PermissionEnrichmentContext['name'], + payload?: ClientEventPayload +): PermissionEnrichmentPolicy => + PERMISSION_ENRICHMENT_RULES.find(({events}) => events.has(name))?.resolve(payload) ?? + NO_PERMISSION_ENRICHMENT; + +/** + * Enriches eligible client events with relevant browser permission changes. + */ +export default class PrivacyAndSecurityPermissionEnricher { + private permission?: PrivacyAndSecurityPermission; + + private lastReported = new Map(); + + 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 = projectPrivacyAndSecurityPermission(permission, [ + 'camera', + 'microphone', + '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); + } + } + } +} diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/types.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/types.ts new file mode 100644 index 00000000000..de8f660ddf5 --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/types.ts @@ -0,0 +1,21 @@ +import type { + ClientEvent, + ClientEventPayload, + PrivacyAndSecurityPermissionResource, +} from '../metrics.types'; + +export type PermissionEnrichmentPolicy = { + resources: readonly PrivacyAndSecurityPermissionResource[]; + terminal: boolean; +}; + +export type PermissionEnrichmentRule = { + events: ReadonlySet; + resolve: (payload?: ClientEventPayload) => PermissionEnrichmentPolicy; +}; + +export type PermissionEnrichmentContext = { + name: ClientEvent['name']; + payload?: ClientEventPayload; + scope: string; +}; diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/utils.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/utils.ts new file mode 100644 index 00000000000..aa645ef84cd --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/utils.ts @@ -0,0 +1,82 @@ +import {isEqual} from 'lodash'; + +import type { + ClientEventPayload, + PrivacyAndSecurityPermission, + PrivacyAndSecurityPermissionResource, +} from '../metrics.types'; + +export const resolveMediaResources = ( + payload?: ClientEventPayload +): PrivacyAndSecurityPermissionResource[] => { + switch (payload?.mediaType) { + case 'audio': + return ['microphone']; + case 'video': + return ['camera']; + case 'share': + return ['contentShare']; + default: + return []; + } +}; + +export const resolveContentShareResources = ( + payload?: ClientEventPayload +): PrivacyAndSecurityPermissionResource[] => { + if (payload?.mediaType !== 'share') { + return []; + } + + return ['contentShare']; +}; + +const copyPermissionState = ( + source: PrivacyAndSecurityPermission, + target: PrivacyAndSecurityPermission, + resource: PrivacyAndSecurityPermissionResource +): void => { + switch (resource) { + case 'camera': + if (source.camera) { + target.camera = {...source.camera}; + } + break; + case 'microphone': + if (source.microphone) { + target.microphone = {...source.microphone}; + } + break; + case 'contentShare': + if (source.contentShare) { + target.contentShare = {...source.contentShare}; + } + break; + default: + break; + } +}; + +export const projectPrivacyAndSecurityPermission = ( + permission: PrivacyAndSecurityPermission, + resources: readonly PrivacyAndSecurityPermissionResource[] +): PrivacyAndSecurityPermission | undefined => { + const projectedPermission: PrivacyAndSecurityPermission = {}; + + resources.forEach((resource) => { + copyPermissionState(permission, projectedPermission, resource); + }); + + return Object.keys(projectedPermission).length > 0 ? projectedPermission : undefined; +}; + +export const getChangedPermission = ( + current: PrivacyAndSecurityPermission, + previous: PrivacyAndSecurityPermission +): PrivacyAndSecurityPermission | undefined => { + const changedResources = (Object.keys(current) as PrivacyAndSecurityPermissionResource[]).filter( + (resource) => !isEqual(current[resource], previous[resource]) + ); + + return projectPrivacyAndSecurityPermission(current, changedResources); +}; diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts index b99e04d2699..fef66d822e8 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts @@ -173,111 +173,18 @@ describe('internal-plugin-metrics', () => { contentShare: {status: 'REQUESTING' as const}, }; - it('projects camera and microphone onto join events without mutating the input', () => { - const payload = {mediaType: 'audio' as const}; - + it('enriches an eligible client event through the public metrics API', () => { webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.call.initiated', - payload, - }); - - const submittedPayload = - webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] - .payload; - - assert.deepEqual(submittedPayload, { - mediaType: 'audio', - privacyAndSecurityPermission: { - camera: {status: 'GRANTED'}, - microphone: {status: 'DENIED', reason: 'DENIED_BY_USER'}, - }, - }); - assert.notStrictEqual(submittedPayload, payload); - assert.notStrictEqual( - submittedPayload.privacyAndSecurityPermission.camera, - permission.camera - ); - assert.notProperty(payload, 'privacyAndSecurityPermission'); - }); - - [ - {mediaType: 'audio' as const, expected: {microphone: permission.microphone}}, - {mediaType: 'video' as const, expected: {camera: permission.camera}}, - {mediaType: 'share' as const, expected: {contentShare: permission.contentShare}}, - ].forEach(({mediaType, expected}) => { - it(`projects the matching resource for media tx ${mediaType}`, () => { - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.media.tx.start', - payload: {mediaType}, - }); - - const submittedPayload = - webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] - .payload; - - assert.deepEqual(submittedPayload.privacyAndSecurityPermission, expected); - }); - }); - - it('only enriches screen-share events when mediaType is share', () => { - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.share.initiated', - payload: {mediaType: 'share'}, - }); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.share.initiated', - payload: {mediaType: 'whiteboard'}, - }); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.media.tx.start', - payload: {mediaType: 'share_audio'}, - }); - - const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - - assert.deepEqual(submissions[0][0].payload.privacyAndSecurityPermission, { - contentShare: permission.contentShare, - }); - assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission'); - assert.notProperty(submissions[2][0].payload, 'privacyAndSecurityPermission'); - }); - - it('projects every available resource onto final events', () => { - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended'}); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'}); const submittedPayload = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] .payload; - assert.deepEqual(submittedPayload.privacyAndSecurityPermission, permission); - }); - - it('reports initial permission once and suppresses an unchanged later join event', () => { - const options = {meetingId: 'meeting-1'}; - - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.call.initiated', - payload: {}, - options, - }); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.ice.end', - payload: {}, - options, - }); - - const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - - assert.deepEqual(submissions[0][0].payload.privacyAndSecurityPermission, { + assert.deepEqual(submittedPayload.privacyAndSecurityPermission, { camera: permission.camera, microphone: permission.microphone, }); - assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission'); }); it('uses the meeting correlation id to preserve history across identifier transitions', () => { @@ -288,7 +195,6 @@ describe('internal-plugin-metrics', () => { webex.internal.newMetrics.submitClientEvent({ name: 'client.call.initiated', - payload: {}, options: {correlationId: 'correlation-1'}, }); webex.internal.newMetrics.submitClientEvent({ @@ -308,7 +214,6 @@ describe('internal-plugin-metrics', () => { webex.internal.newMetrics.submitClientEvent({ name: 'client.call.initiated', - payload: {}, options: {sessionCorrelationId: 'session-1'}, }); webex.internal.newMetrics.submitClientEvent({ @@ -323,192 +228,13 @@ describe('internal-plugin-metrics', () => { assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission'); }); - it('reports only a permission resource that changed since its last report', () => { - let currentPermission = permission; - const options = {meetingId: 'meeting-1'}; - - webex.internal.newMetrics.setPrivacyAndSecurityPermission(currentPermission); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); - currentPermission = { - ...permission, - camera: {status: 'DENIED' as const, reason: 'DENIED_BY_SYSTEM' as const}, - }; - webex.internal.newMetrics.setPrivacyAndSecurityPermission(currentPermission); - webex.internal.newMetrics.submitClientEvent({name: 'client.ice.end', options}); - - const submittedPayload = - webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.secondCall.args[0] - .payload; - - assert.deepEqual(submittedPayload.privacyAndSecurityPermission, { - camera: currentPermission.camera, - }); - }); - - it('tracks the last report independently for concurrent meeting scopes', () => { - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.call.initiated', - options: {meetingId: 'meeting-1'}, - }); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.call.initiated', - options: {meetingId: 'meeting-2'}, - }); - - const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - - assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission'); - assert.property(submissions[1][0].payload, 'privacyAndSecurityPermission'); - }); - - it('always reports current permission on each emitted terminal event', () => { - const options = {meetingId: 'meeting-1'}; - - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended', options}); - - const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - - assert.deepEqual(submissions[1][0].payload.privacyAndSecurityPermission, permission); - assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, permission); - }); - - it('uses the last permission pushed by the client for every terminal event', () => { - const options = {meetingId: 'meeting-1'}; - const latestPermission = { - camera: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, - microphone: {status: 'GRANTED' as const}, - contentShare: {status: 'GRANTED' as const}, - }; - - webex.internal.newMetrics.setPrivacyAndSecurityPermission(latestPermission); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended', options}); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.aborted', options}); - - const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - - assert.deepEqual(submissions[0][0].payload.privacyAndSecurityPermission, latestPermission); - assert.deepEqual(submissions[1][0].payload.privacyAndSecurityPermission, latestPermission); - assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, latestPermission); - }); - - it('uses a newer pushed permission value for later terminal events', () => { - const options = {meetingId: 'meeting-1'}; - const previousPermission = { - camera: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, - microphone: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, - }; - - webex.internal.newMetrics.setPrivacyAndSecurityPermission(previousPermission); - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.remote-ended', options}); - - const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - - assert.deepEqual(submissions[0][0].payload.privacyAndSecurityPermission, permission); - }); - - it('clears permission history after a terminal event', () => { - const options = {meetingId: 'meeting-1'}; - - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); - - const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - - assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, { - camera: permission.camera, - microphone: permission.microphone, - }); - }); - - it('reports initial permission again when a meeting scope is reused after a terminal event', () => { - const options = {meetingId: 'meeting-1'}; - - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.leave', options}); - webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', options}); - - const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - - assert.deepEqual(submissions[2][0].payload.privacyAndSecurityPermission, { - camera: permission.camera, - microphone: permission.microphone, - }); - }); - - it('preserves an explicit permission payload', () => { - const explicitPermission = { - camera: {status: 'FAILED' as const, reason: 'UNKNOWN' as const}, - }; - - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.call.leave', - payload: {privacyAndSecurityPermission: explicitPermission}, - }); - - const submittedPayload = - webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] - .payload; - - assert.strictEqual(submittedPayload.privacyAndSecurityPermission, explicitPermission); - }); - - it('uses an explicit permission payload as the baseline for later events', () => { - const options = {meetingId: 'meeting-1'}; - const payload = { - privacyAndSecurityPermission: { - camera: permission.camera, - microphone: permission.microphone, - }, - }; - - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.call.initiated', - payload, - options, - }); - webex.internal.newMetrics.submitClientEvent({ - name: 'client.ice.end', - payload: {}, - options, - }); - - const submittedPayload = - webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.secondCall.args[0] - .payload; - - assert.notProperty(submittedPayload, 'privacyAndSecurityPermission'); - }); - - it('submits unchanged before the client supplies permission state', () => { - const payload = {mediaType: 'audio' as const}; - - webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated', payload}); - - assert.strictEqual( - webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] - .payload, - payload - ); - }); - it('captures the permission snapshot before a delayed event is queued', () => { - let currentPermission = permission; - - webex.internal.newMetrics.setPrivacyAndSecurityPermission(currentPermission); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: true}); webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'}); - currentPermission = {camera: {status: 'DENIED' as const}}; + webex.internal.newMetrics.setPrivacyAndSecurityPermission({ + camera: {status: 'DENIED' as const}, + }); const submission = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0]; @@ -519,17 +245,6 @@ describe('internal-plugin-metrics', () => { microphone: permission.microphone, }); }); - - it('does not enrich unrelated or permission prompt events', () => { - webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); - webex.internal.newMetrics.submitClientEvent({name: 'client.alert.displayed'}); - webex.internal.newMetrics.submitClientEvent({name: 'client.permission.prompted'}); - - const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; - - assert.isUndefined(submissions[0][0].payload); - assert.isUndefined(submissions[1][0].payload); - }); }); it('submits feature Event successfully', () => { diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts index 424a40d49ba..b163d044ecb 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts @@ -1,13 +1,318 @@ import {assert} from '@webex/test-helper-chai'; +import sinon from 'sinon'; -import {PERMISSION_ENRICHMENT_RULES} from '../../../src/privacy-and-security-permission-enricher'; +import type {ClientEvent, PrivacyAndSecurityPermission} from '../../../src/metrics.types'; +import PrivacyAndSecurityPermissionEnricher, { + PERMISSION_ENRICHMENT_RULES, +} from '../../../src/privacy-and-security-permission-enricher'; describe('PrivacyAndSecurityPermissionEnricher', () => { + const permission: PrivacyAndSecurityPermission = { + camera: {status: 'GRANTED'}, + microphone: {status: 'DENIED', reason: 'DENIED_BY_USER'}, + contentShare: {status: 'REQUESTING'}, + }; + let onEnrichmentError: sinon.SinonStub; + let enricher: PrivacyAndSecurityPermissionEnricher; + + beforeEach(() => { + onEnrichmentError = sinon.stub(); + enricher = new PrivacyAndSecurityPermissionEnricher(onEnrichmentError); + }); + + afterEach(() => { + sinon.restore(); + }); + describe('permission enrichment rules', () => { it('registers each production event in only one rule', () => { const registeredEvents = PERMISSION_ENRICHMENT_RULES.flatMap(({events}) => [...events]); assert.lengthOf(new Set(registeredEvents), registeredEvents.length); }); + + [ + 'client.call.initiated', + 'client.media.capabilities', + 'client.ice.end', + 'client.locus.join.request', + 'client.locus.join.response', + 'client.media-engine.ready', + ].forEach((name) => { + it(`projects camera and microphone for ${name}`, () => { + enricher.setPermission(permission); + + const payload = enricher.enrich({name: name as ClientEvent['name'], scope: 'meeting'}); + + assert.deepEqual(payload?.privacyAndSecurityPermission, { + camera: permission.camera, + microphone: permission.microphone, + }); + }); + }); + + ['client.media.tx.start', 'client.media.tx.stop'].forEach((name) => { + [ + {mediaType: 'audio' as const, expected: {microphone: permission.microphone}}, + {mediaType: 'video' as const, expected: {camera: permission.camera}}, + {mediaType: 'share' as const, expected: {contentShare: permission.contentShare}}, + ].forEach(({mediaType, expected}) => { + it(`projects ${mediaType} permission for ${name}`, () => { + enricher.setPermission(permission); + + const payload = enricher.enrich({ + name: name as ClientEvent['name'], + payload: {mediaType}, + scope: 'meeting', + }); + + assert.deepEqual(payload?.privacyAndSecurityPermission, expected); + }); + }); + }); + + [ + 'client.share.initiated', + 'client.share.floor-grant.request', + 'client.share.floor-granted.local', + ].forEach((name) => { + it(`projects content-share permission for ${name}`, () => { + enricher.setPermission(permission); + + const payload = enricher.enrich({ + name: name as ClientEvent['name'], + payload: {mediaType: 'share'}, + scope: 'meeting', + }); + + assert.deepEqual(payload?.privacyAndSecurityPermission, { + contentShare: permission.contentShare, + }); + }); + }); + + ['client.call.leave', 'client.call.remote-ended', 'client.call.aborted'].forEach((name) => { + it(`projects every available permission for ${name}`, () => { + enricher.setPermission(permission); + + const payload = enricher.enrich({name: name as ClientEvent['name'], scope: 'meeting'}); + + assert.deepEqual(payload?.privacyAndSecurityPermission, permission); + }); + }); + }); + + describe('setPermission', () => { + it('stores a defensive copy of the permission snapshot', () => { + const mutablePermission: PrivacyAndSecurityPermission = { + camera: {status: 'GRANTED'}, + }; + + enricher.setPermission(mutablePermission); + mutablePermission.camera = {status: 'DENIED', reason: 'DENIED_BY_USER'}; + + const payload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + + assert.deepEqual(payload?.privacyAndSecurityPermission, { + camera: {status: 'GRANTED'}, + }); + }); + }); + + describe('enrich', () => { + it('does not mutate the input payload or stored permission state', () => { + const inputPayload = {mediaType: 'audio' as const}; + + enricher.setPermission(permission); + const payload = enricher.enrich({ + name: 'client.call.initiated', + payload: inputPayload, + scope: 'meeting', + }); + + assert.notStrictEqual(payload, inputPayload); + assert.notProperty(inputPayload, 'privacyAndSecurityPermission'); + assert.notStrictEqual(payload?.privacyAndSecurityPermission?.camera, permission.camera); + }); + + it('returns the original payload when the requested permission is unavailable', () => { + const inputPayload = {mediaType: 'audio' as const}; + + enricher.setPermission({camera: permission.camera}); + const payload = enricher.enrich({ + name: 'client.media.tx.start', + payload: inputPayload, + scope: 'meeting', + }); + + assert.strictEqual(payload, inputPayload); + }); + + it('returns the original payload for unsupported share media types', () => { + const inputPayload = {mediaType: 'whiteboard' as const}; + + enricher.setPermission(permission); + const payload = enricher.enrich({ + name: 'client.share.initiated', + payload: inputPayload, + scope: 'meeting', + }); + + assert.strictEqual(payload, inputPayload); + }); + + it('reports initial permission once and suppresses an unchanged later event', () => { + enricher.setPermission(permission); + + const initialPayload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + const unchangedPayload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'}); + + assert.property(initialPayload, 'privacyAndSecurityPermission'); + assert.isUndefined(unchangedPayload); + }); + + it('reports only permission resources that changed', () => { + enricher.setPermission(permission); + enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + enricher.setPermission({ + ...permission, + camera: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'}, + }); + + const payload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'}); + + assert.deepEqual(payload?.privacyAndSecurityPermission, { + camera: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'}, + }); + }); + + it('reports a changed reason when status is unchanged', () => { + enricher.setPermission({microphone: {status: 'DENIED', reason: 'DENIED_BY_USER'}}); + enricher.enrich({ + name: 'client.media.tx.start', + payload: {mediaType: 'audio'}, + scope: 'meeting', + }); + enricher.setPermission({microphone: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'}}); + + const payload = enricher.enrich({ + name: 'client.media.tx.stop', + payload: {mediaType: 'audio'}, + scope: 'meeting', + }); + + assert.deepEqual(payload?.privacyAndSecurityPermission, { + microphone: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'}, + }); + }); + + it('shares resource history across enrichment rules', () => { + enricher.setPermission(permission); + enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + + const payload = enricher.enrich({ + name: 'client.media.tx.start', + payload: {mediaType: 'audio'}, + scope: 'meeting', + }); + + assert.notProperty(payload, 'privacyAndSecurityPermission'); + }); + + it('tracks permission history independently by scope', () => { + enricher.setPermission(permission); + + const firstMeeting = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'}); + const secondMeeting = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-2'}); + + assert.property(firstMeeting, 'privacyAndSecurityPermission'); + assert.property(secondMeeting, 'privacyAndSecurityPermission'); + }); + + it('clears only the terminal event scope', () => { + enricher.setPermission(permission); + enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'}); + enricher.enrich({name: 'client.call.initiated', scope: 'meeting-2'}); + enricher.enrich({name: 'client.call.leave', scope: 'meeting-1'}); + + const reusedScope = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'}); + const activeScope = enricher.enrich({name: 'client.ice.end', scope: 'meeting-2'}); + + assert.property(reusedScope, 'privacyAndSecurityPermission'); + assert.isUndefined(activeScope); + }); + + it('preserves an explicit permission payload and uses it as the baseline', () => { + const explicitPayload = { + privacyAndSecurityPermission: { + camera: permission.camera, + microphone: permission.microphone, + }, + }; + + enricher.setPermission(permission); + const returnedPayload = enricher.enrich({ + name: 'client.call.initiated', + payload: explicitPayload, + scope: 'meeting', + }); + const laterPayload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'}); + + assert.strictEqual(returnedPayload, explicitPayload); + assert.isUndefined(laterPayload); + }); + + it('clears history after an explicit terminal payload', () => { + enricher.setPermission(permission); + enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + enricher.enrich({ + name: 'client.call.leave', + payload: {privacyAndSecurityPermission: permission}, + scope: 'meeting', + }); + + const payload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + + assert.property(payload, 'privacyAndSecurityPermission'); + }); + + it('returns unchanged before permission state is supplied', () => { + const inputPayload = {mediaType: 'audio' as const}; + + const payload = enricher.enrich({ + name: 'client.call.initiated', + payload: inputPayload, + scope: 'meeting', + }); + + assert.strictEqual(payload, inputPayload); + }); + + it('does not enrich unrelated events', () => { + enricher.setPermission(permission); + + const payload = enricher.enrich({name: 'client.alert.displayed', scope: 'meeting'}); + + assert.isUndefined(payload); + }); + + it('reports enrichment errors and returns the original payload', () => { + const inputPayload = {}; + + Object.defineProperty(inputPayload, 'privacyAndSecurityPermission', { + get: () => { + throw new Error('permission read failed'); + }, + }); + + const payload = enricher.enrich({ + name: 'client.call.initiated', + payload: inputPayload, + scope: 'meeting', + }); + + assert.strictEqual(payload, inputPayload); + assert.calledOnce(onEnrichmentError); + }); }); });