-
Notifications
You must be signed in to change notification settings - Fork 404
feat: add device permission diagnostics metrics 2 #5169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from 5 commits
8285190
97a54bd
85600c2
6c6a7db
d0008b3
90bff54
4e934b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,18 +19,19 @@ import { | |
| ClientEvent, | ||
| FeatureEvent, | ||
| EventPayload, | ||
| OperationalEvent, | ||
| MediaQualityEvent, | ||
| InternalEvent, | ||
| SubmitClientEventOptions, | ||
| Table, | ||
| DelayedClientEvent, | ||
| DelayedClientFeatureEvent, | ||
| PrivacyAndSecurityPermission, | ||
| } from './metrics.types'; | ||
| import CallDiagnosticLatencies from './call-diagnostic/call-diagnostic-metrics-latencies'; | ||
| import {setMetricTimings} from './call-diagnostic/call-diagnostic-metrics.util'; | ||
| import {generateCommonErrorMetadata} from './utils'; | ||
| import {isAutomatedUser as detectAutomatedUser} from './automated-user'; | ||
| import PrivacyAndSecurityPermissionEnricher from './privacy-and-security-permission-enricher'; | ||
|
|
||
| /** | ||
| * Metrics plugin to centralize all types of metrics. | ||
|
|
@@ -68,6 +69,8 @@ class Metrics extends WebexPlugin { | |
|
|
||
| delayedClientFeatureEventsOverrides: Partial<DelayedClientFeatureEvent['options']> = {}; | ||
|
|
||
| private privacyAndSecurityPermissionEnricher: PrivacyAndSecurityPermissionEnricher; | ||
|
|
||
| /** | ||
| * Constructor | ||
| * @param args | ||
|
|
@@ -78,6 +81,15 @@ class Metrics extends WebexPlugin { | |
| constructor(...args) { | ||
| super(...args); | ||
|
|
||
| this.privacyAndSecurityPermissionEnricher = new PrivacyAndSecurityPermissionEnricher( | ||
| (error) => { | ||
| // @ts-ignore | ||
| this.webex.logger.error( | ||
| 'NewMetrics: @submitClientEvent. Privacy and security permission enrichment failed.', | ||
| error | ||
| ); | ||
| } | ||
| ); | ||
| // @ts-ignore | ||
| this.callDiagnosticLatencies = new CallDiagnosticLatencies({}, {parent: this.webex}); | ||
| this.onReady(); | ||
|
|
@@ -381,14 +393,41 @@ class Metrics extends WebexPlugin { | |
| options: {meetingId: options?.meetingId}, | ||
| }); | ||
|
|
||
| return this.callDiagnosticMetrics.submitClientEvent({ | ||
| const enrichedPayload = this.privacyAndSecurityPermissionEnricher.enrich({ | ||
| name, | ||
| payload, | ||
| scope: this.getPermissionScope(options), | ||
| }); | ||
|
Comment on lines
+396
to
+400
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When permission state has been set and callers use the documented Useful? React with 👍 / 👎. |
||
|
|
||
| return this.callDiagnosticMetrics.submitClientEvent({ | ||
| name, | ||
| payload: enrichedPayload, | ||
| options, | ||
| delaySubmitEvent: this.delaySubmitClientEvents, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Stores the latest browser permission state supplied by the client. | ||
| * @param permission latest normalized permission snapshot | ||
| */ | ||
| public setPrivacyAndSecurityPermission(permission: PrivacyAndSecurityPermission): void { | ||
| this.privacyAndSecurityPermissionEnricher.setPermission(permission); | ||
| } | ||
|
|
||
| /** | ||
| * Resolves event options to the call identity used by Call Diagnostic where possible. | ||
| * @param options client event options | ||
| * @returns the permission history scope | ||
| */ | ||
| private getPermissionScope(options?: SubmitClientEventOptions): string { | ||
| const meeting = options?.meetingId | ||
| ? (this as any).webex.meetings?.getBasicMeetingInformation?.(options.meetingId) | ||
| : undefined; | ||
|
|
||
| return options?.correlationId ?? meeting?.correlationId ?? 'default'; | ||
| } | ||
|
|
||
| /** | ||
| * Issue request to alias a user's pre-login ID with their CI UUID | ||
| * @param {string} preLoginId | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| import {isEqual} from 'lodash'; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: Feels like this file deserves it's own folder with index |
||
|
|
||
| import { | ||
| ClientEvent, | ||
| ClientEventPayload, | ||
| PrivacyAndSecurityPermission, | ||
| PrivacyAndSecurityPermissionResource, | ||
| PrivacyAndSecurityPermissionState, | ||
| } from './metrics.types'; | ||
|
|
||
| type PermissionEnrichmentPolicy = { | ||
| resources: readonly PrivacyAndSecurityPermissionResource[]; | ||
| terminal: boolean; | ||
| }; | ||
|
|
||
| type PermissionEnrichmentRule = { | ||
| events: ReadonlySet<ClientEvent['name']>; | ||
| resolve: (payload?: ClientEventPayload) => PermissionEnrichmentPolicy; | ||
| }; | ||
|
|
||
| type PermissionEnrichmentContext = { | ||
| name: ClientEvent['name']; | ||
| payload?: ClientEventPayload; | ||
| scope: string; | ||
| }; | ||
|
|
||
| const CAMERA_AND_MICROPHONE_PERMISSION_EVENTS = new Set<ClientEvent['name']>([ | ||
| 'client.call.initiated', | ||
| 'client.media.capabilities', | ||
| 'client.ice.end', | ||
| 'client.locus.join.request', | ||
| 'client.locus.join.response', | ||
| 'client.media-engine.ready', | ||
| ]); | ||
|
|
||
| const MEDIA_TX_PERMISSION_EVENTS = new Set<ClientEvent['name']>([ | ||
| 'client.media.tx.start', | ||
| 'client.media.tx.stop', | ||
| ]); | ||
|
|
||
| const CONTENT_SHARE_PERMISSION_EVENTS = new Set<ClientEvent['name']>([ | ||
| 'client.share.initiated', | ||
| 'client.share.floor-grant.request', | ||
| 'client.share.floor-granted.local', | ||
| ]); | ||
|
|
||
| const FINAL_PERMISSION_EVENTS = new Set<ClientEvent['name']>([ | ||
| 'client.call.leave', | ||
| 'client.call.remote-ended', | ||
| 'client.call.aborted', | ||
| ]); | ||
|
|
||
| const isSamePermissionState = ( | ||
| current?: PrivacyAndSecurityPermissionState, | ||
| previous?: PrivacyAndSecurityPermissionState | ||
| ): boolean => isEqual(current, previous); | ||
|
|
||
| const resolveMediaResources = ( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| payload?: ClientEventPayload | ||
| ): PrivacyAndSecurityPermissionResource[] => { | ||
| switch (payload?.mediaType) { | ||
| case 'audio': | ||
| return ['microphone']; | ||
| case 'video': | ||
| return ['camera']; | ||
| case 'share': | ||
| return ['contentShare']; | ||
| default: | ||
| return []; | ||
| } | ||
| }; | ||
|
|
||
| export const PERMISSION_ENRICHMENT_RULES = [ | ||
| { | ||
| events: CAMERA_AND_MICROPHONE_PERMISSION_EVENTS, | ||
| resolve: () => ({resources: ['camera', 'microphone'], terminal: false}), | ||
| }, | ||
| { | ||
| events: MEDIA_TX_PERMISSION_EVENTS, | ||
| resolve: (payload) => ({resources: resolveMediaResources(payload), terminal: false}), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| }, | ||
| { | ||
| events: CONTENT_SHARE_PERMISSION_EVENTS, | ||
| resolve: (payload) => ({ | ||
| resources: payload?.mediaType === 'share' ? ['contentShare'] : [], | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. might be better as a func |
||
| terminal: false, | ||
| }), | ||
| }, | ||
| { | ||
| events: FINAL_PERMISSION_EVENTS, | ||
| resolve: () => ({resources: ['camera', 'microphone', 'contentShare'], terminal: true}), | ||
| }, | ||
| ] satisfies readonly PermissionEnrichmentRule[]; | ||
|
|
||
| const NO_PERMISSION_ENRICHMENT: PermissionEnrichmentPolicy = { | ||
| resources: [], | ||
| terminal: false, | ||
| }; | ||
|
|
||
| const resolvePermissionEnrichmentPolicy = ( | ||
| name: ClientEvent['name'], | ||
| payload?: ClientEventPayload | ||
| ): PermissionEnrichmentPolicy => | ||
| PERMISSION_ENRICHMENT_RULES.find(({events}) => events.has(name))?.resolve(payload) ?? | ||
| NO_PERMISSION_ENRICHMENT; | ||
|
|
||
| const projectPrivacyAndSecurityPermission = ( | ||
| permission: PrivacyAndSecurityPermission, | ||
| resources: readonly PrivacyAndSecurityPermissionResource[] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| ): PrivacyAndSecurityPermission | undefined => { | ||
| const projectedPermission: PrivacyAndSecurityPermission = { | ||
| ...(resources.includes('camera') && permission.camera ? {camera: {...permission.camera}} : {}), | ||
| ...(resources.includes('microphone') && permission.microphone | ||
| ? {microphone: {...permission.microphone}} | ||
| : {}), | ||
| ...(resources.includes('contentShare') && permission.contentShare | ||
| ? {contentShare: {...permission.contentShare}} | ||
| : {}), | ||
| }; | ||
|
|
||
| return Object.keys(projectedPermission).length > 0 ? projectedPermission : undefined; | ||
| }; | ||
|
|
||
| const getChangedPermission = ( | ||
| current: PrivacyAndSecurityPermission, | ||
| previous: PrivacyAndSecurityPermission | ||
| ): PrivacyAndSecurityPermission | undefined => { | ||
| const changedPermission: PrivacyAndSecurityPermission = { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: the layered ternaries are a bit tough with the object spread too |
||
| ...(current.camera && !isSamePermissionState(current.camera, previous.camera) | ||
| ? {camera: {...current.camera}} | ||
| : {}), | ||
| ...(current.microphone && !isSamePermissionState(current.microphone, previous.microphone) | ||
| ? {microphone: {...current.microphone}} | ||
| : {}), | ||
| ...(current.contentShare && !isSamePermissionState(current.contentShare, previous.contentShare) | ||
| ? {contentShare: {...current.contentShare}} | ||
| : {}), | ||
| }; | ||
|
|
||
| return Object.keys(changedPermission).length > 0 ? changedPermission : undefined; | ||
| }; | ||
|
|
||
| /** | ||
| * Enriches eligible client events with relevant browser permission changes. | ||
| */ | ||
| export default class PrivacyAndSecurityPermissionEnricher { | ||
| private permission?: PrivacyAndSecurityPermission; | ||
|
|
||
| private lastReported = new Map<string, PrivacyAndSecurityPermission>(); | ||
|
|
||
| private readonly onEnrichmentError: (error: unknown) => void; | ||
|
|
||
| /** | ||
| * Creates a permission enricher. | ||
| * @param {Function} onEnrichmentError permission enrichment error handler | ||
| */ | ||
| constructor(onEnrichmentError: (error: unknown) => void) { | ||
| this.onEnrichmentError = onEnrichmentError; | ||
| } | ||
|
|
||
| /** | ||
| * Stores the latest normalized browser permission state. | ||
| * @param {PrivacyAndSecurityPermission} permission permission snapshot | ||
| * @returns {void} | ||
| */ | ||
| public setPermission(permission: PrivacyAndSecurityPermission): void { | ||
| this.permission = { | ||
| ...(permission.camera ? {camera: {...permission.camera}} : {}), | ||
| ...(permission.microphone ? {microphone: {...permission.microphone}} : {}), | ||
| ...(permission.contentShare ? {contentShare: {...permission.contentShare}} : {}), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the original payload or a copy enriched with relevant permission changes. | ||
| * @param {PermissionEnrichmentContext} context event context and permission history scope | ||
| * @returns {ClientEventPayload | undefined} | ||
| */ | ||
| public enrich({ | ||
| name, | ||
| payload, | ||
| scope, | ||
| }: PermissionEnrichmentContext): ClientEventPayload | undefined { | ||
| const policy = resolvePermissionEnrichmentPolicy(name, payload); | ||
|
|
||
| try { | ||
| if (payload?.privacyAndSecurityPermission !== undefined) { | ||
| // An explicitly supplied permission payload is authoritative for this event. | ||
| if (!policy.terminal) { | ||
| this.lastReported.set(scope, { | ||
| ...this.lastReported.get(scope), | ||
| ...(payload.privacyAndSecurityPermission as PrivacyAndSecurityPermission), | ||
| }); | ||
| } | ||
|
|
||
| return payload; | ||
| } | ||
|
|
||
| if (policy.resources.length === 0) { | ||
| return payload; | ||
| } | ||
|
|
||
| const projectedPermission = this.permission | ||
| ? projectPrivacyAndSecurityPermission(this.permission, policy.resources) | ||
| : undefined; | ||
|
|
||
| if (!projectedPermission) { | ||
| return payload; | ||
| } | ||
|
|
||
| if (policy.terminal) { | ||
| return {...payload, privacyAndSecurityPermission: projectedPermission}; | ||
| } | ||
|
|
||
| const lastReported = this.lastReported.get(scope) ?? {}; | ||
| const changedPermission = getChangedPermission(projectedPermission, lastReported); | ||
|
|
||
| if (!changedPermission) { | ||
| return payload; | ||
| } | ||
|
|
||
| this.lastReported.set(scope, {...lastReported, ...changedPermission}); | ||
|
|
||
| return {...payload, privacyAndSecurityPermission: changedPermission}; | ||
| } catch (error) { | ||
| this.onEnrichmentError(error); | ||
|
|
||
| return payload; | ||
| } finally { | ||
| if (policy.terminal) { | ||
| this.lastReported.delete(scope); | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
$