diff --git a/packages/@webex/internal-plugin-metrics/README.md b/packages/@webex/internal-plugin-metrics/README.md index 912eb97d7ed..1c2bba7e7d5 100644 --- a/packages/@webex/internal-plugin-metrics/README.md +++ b/packages/@webex/internal-plugin-metrics/README.md @@ -8,6 +8,7 @@ This is an internal Cisco Webex plugin. As such, it does not strictly adhere to - [Install](#install) - [Usage](#usage) +- [Unhandled exception telemetry](#unhandled-exception-telemetry) - [Contribute](#contribute) - [Maintainers](#maintainers) - [License](#license) @@ -29,6 +30,41 @@ const webex = new WebexCore(); webex.internal.metrics.WHATEVER; ``` +## Unhandled exception telemetry + +Unhandled exception telemetry is currently supported only in browser environments. It starts after +the Webex SDK emits `ready`. It does not install a standalone collector, capture errors before SDK +initialization, persist events, or retry failed telemetry submissions. + +The reporter captures uncaught errors, unhandled promise rejections, and resource load failures. +Matching failures captured in the same one-second in-memory window are submitted once with an +`occurrenceCount`. Non-HTTP(S) URLs are redacted; URL credentials, query parameters, and fragments +are stripped; and error names, messages, and stacks are truncated before submission. + +Telemetry is disabled by default. Enable it with +`metrics.unhandledExceptionTelemetry.enabled: true`. Applications may provide a synchronous +`getMetadata` callback in the same configuration object. It must return an object whose fields can +include application context such as `orgId` and `dataCenter`. Metadata must not contain personally +identifiable information or credentials. + +```js +import Webex from 'webex'; + +const webex = Webex.init({ + config: { + metrics: { + unhandledExceptionTelemetry: { + enabled: true, + getMetadata: () => ({ + orgId: 'your-organization-id', + dataCenter: 'your-data-center', + }), + }, + }, + }, +}); +``` + ## Maintainers This package is maintained by [Cisco Webex for Developers](https://developer.webex.com/). diff --git a/packages/@webex/internal-plugin-metrics/src/config.js b/packages/@webex/internal-plugin-metrics/src/config.js index 5d8eca110a0..d2e20884b06 100644 --- a/packages/@webex/internal-plugin-metrics/src/config.js +++ b/packages/@webex/internal-plugin-metrics/src/config.js @@ -15,6 +15,9 @@ export default { }, metrics: { appType: inBrowser ? 'browser' : 'nodejs', + unhandledExceptionTelemetry: { + enabled: false, + }, batcherWait: 500, batcherMaxCalls: 50, batcherMaxWait: 1500, diff --git a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts index 79848a79a59..b0a820599d6 100644 --- a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts @@ -31,6 +31,7 @@ import CallDiagnosticLatencies from './call-diagnostic/call-diagnostic-metrics-l import {setMetricTimings} from './call-diagnostic/call-diagnostic-metrics.util'; import {generateCommonErrorMetadata} from './utils'; import {isAutomatedUser as detectAutomatedUser} from './automated-user'; +import {startUnhandledExceptionTelemetry} from './unhandled-exception-telemetry'; /** * Metrics plugin to centralize all types of metrics. @@ -103,6 +104,8 @@ class Metrics extends WebexPlugin { shouldDelay: this.delaySubmitClientEvents, overrides: this.delayedClientEventsOverrides, }); + // @ts-ignore + startUnhandledExceptionTelemetry(this.webex); }); } diff --git a/packages/@webex/internal-plugin-metrics/src/unhandled-exception-telemetry/index.ts b/packages/@webex/internal-plugin-metrics/src/unhandled-exception-telemetry/index.ts new file mode 100644 index 00000000000..12c963ef0a7 --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/unhandled-exception-telemetry/index.ts @@ -0,0 +1,403 @@ +/* eslint-disable require-jsdoc, valid-jsdoc */ + +import {safeSetTimeout} from '@webex/common-timers'; +import uuid from 'uuid'; + +import { + createFingerprint, + removeUrlDetails, + removeUrlDetailsFromText, + sanitizeResourceUrl, + stringifyReason, + truncate, +} from './utils'; + +export const UNHANDLED_EXCEPTION_METRIC_NAME = 'JS_SDK_OBSERVED_CLIENT_UNHANDLED_EXCEPTION'; + +const DEDUPE_WINDOW_MS = 1_000; +const MAX_ERROR_MESSAGE_LENGTH = 4_096; +const MAX_ERROR_NAME_LENGTH = 256; +const MAX_METADATA_LENGTH = 32_000; +const MAX_STACK_LENGTH = 8_192; + +const TELEMETRY_LOG_IDENTIFIER = 'Unhandled Exception Telemetry -->'; + +type ErrorDetails = { + column?: number; + filename?: string; + kind: 'error' | 'unhandledrejection' | 'resource_error'; + line?: number; + message?: string; + name: string; + /** Uppercase DOM tag name, such as SCRIPT, LINK, or IMG; RESOURCE when unavailable. */ + resourceType?: string; + resourceUrl?: string; + stack?: string; +}; + +export type UnhandledExceptionEvent = { + schemaVersion: 1; + capturedAt: number; + eventId: string; + occurrenceCount: number; + common: { + appName: string; + appVersion?: string; + runtime: 'browser'; + sdkVersion?: string; + }; + error: ErrorDetails & {fingerprint: string}; + metadata?: Record; + metadataCaptureStatus?: 'invalid_type' | 'provider_error' | 'too_large'; +}; + +type WebexForUnhandledExceptionTelemetry = { + canAuthorize?: boolean; + version?: string; + config?: { + appName?: string; + appVersion?: string; + sdkType?: string; + metrics?: { + unhandledExceptionTelemetry?: { + enabled?: boolean; + getMetadata?: () => Record | undefined; + }; + }; + }; + internal?: { + metrics?: { + submitClientMetrics?: (name: string, properties: object, preLoginId?: string) => unknown; + }; + }; + logger?: { + error?: (...args: unknown[]) => unknown; + }; +}; + +function logTelemetryFailure(webex: WebexForUnhandledExceptionTelemetry, message: string): void { + try { + webex.logger?.error?.(TELEMETRY_LOG_IDENTIFIER, message); + } catch { + // Logging must not turn a telemetry failure into an application failure. + } +} + +/** + * SDK-owned browser exception reporter with a single in-memory deduplication window. + */ +class UnhandledExceptionTelemetry { + private readonly pendingEvents = new Map(); + private readonly eventTarget: Window; + private readonly errorListener = (event: ErrorEvent) => this.captureError(event); + private readonly preLoginId: string; + private readonly rejectionListener = (event: PromiseRejectionEvent) => + this.captureRejection(event); + + private readonly webex: WebexForUnhandledExceptionTelemetry; + private flushTimer?: number | NodeJS.Timeout; + + /** + * Registers browser listeners after the SDK is ready. + * @param webex SDK instance used for configuration and submission. + * @param eventTarget Browser window. + */ + constructor(webex: WebexForUnhandledExceptionTelemetry, eventTarget: Window) { + this.webex = webex; + this.eventTarget = eventTarget; + this.preLoginId = uuid.v4(); + eventTarget.addEventListener('error', this.errorListener, {capture: true}); + eventTarget.addEventListener('unhandledrejection', this.rejectionListener, {capture: true}); + } + + /** + * Removes global listeners and either flushes or discards pending events. + * @param flushPending Whether to submit pending telemetry before teardown. + * @returns {void} + */ + stop(flushPending: boolean): void { + this.eventTarget.removeEventListener('error', this.errorListener, {capture: true}); + this.eventTarget.removeEventListener('unhandledrejection', this.rejectionListener, { + capture: true, + }); + this.clearFlushTimer(); + + if (flushPending) { + this.pendingEvents.forEach((event) => this.submit(event)); + } + + this.pendingEvents.clear(); + } + + private captureError(event: ErrorEvent): void { + try { + const resourceTarget = event.target; + const currentSrc = resourceTarget ? Reflect.get(resourceTarget, 'currentSrc') : undefined; + const src = resourceTarget ? Reflect.get(resourceTarget, 'src') : undefined; + const href = resourceTarget ? Reflect.get(resourceTarget, 'href') : undefined; + const rawResourceUrl = + (typeof currentSrc === 'string' && currentSrc.length > 0 && currentSrc) || + (typeof src === 'string' && src.length > 0 && src) || + (typeof href === 'string' && href.length > 0 && href) || + undefined; + + if (rawResourceUrl) { + const resourceUrl = sanitizeResourceUrl(rawResourceUrl); + + if (!resourceUrl) { + return; + } + + const resourceType = String( + (resourceTarget && Reflect.get(resourceTarget, 'tagName')) ?? 'RESOURCE' + ).toUpperCase(); + + this.capture({ + kind: 'resource_error', + message: `Failed to load ${resourceType} resource`, + name: 'ResourceError', + resourceType, + resourceUrl, + }); + + return; + } + + const {error} = event; + let message = 'Unknown uncaught error'; + + if (typeof error?.message === 'string') { + message = error.message; + } else if (typeof event.message === 'string') { + message = event.message; + } + + this.capture({ + column: event.colno, + filename: removeUrlDetails(event.filename), + kind: 'error', + line: event.lineno, + message, + name: typeof error?.name === 'string' ? error.name : 'Error', + stack: typeof error?.stack === 'string' ? error.stack : undefined, + }); + } catch { + logTelemetryFailure(this.webex, 'Failed to extract an uncaught error.'); + } + } + + private captureRejection(event: PromiseRejectionEvent): void { + try { + const {reason} = event; + + this.capture({ + kind: 'unhandledrejection', + message: typeof reason?.message === 'string' ? reason.message : stringifyReason(reason), + name: typeof reason?.name === 'string' ? reason.name : 'UnhandledRejection', + stack: typeof reason?.stack === 'string' ? reason.stack : undefined, + }); + } catch { + logTelemetryFailure(this.webex, 'Failed to extract an unhandled rejection.'); + } + } + + private capture(details: ErrorDetails): void { + try { + const error = { + ...details, + name: truncate(removeUrlDetailsFromText(details.name), MAX_ERROR_NAME_LENGTH) ?? 'Error', + message: truncate(removeUrlDetailsFromText(details.message), MAX_ERROR_MESSAGE_LENGTH), + stack: truncate(removeUrlDetailsFromText(details.stack), MAX_STACK_LENGTH), + }; + const fingerprint = createFingerprint( + [ + error.kind, + error.name, + error.message, + error.stack, + error.filename, + error.line, + error.column, + error.resourceUrl, + ].join('|') + ); + const capturedAt = new Date().getTime(); + const existingEvent = this.pendingEvents.get(fingerprint); + + if (existingEvent) { + const isWithinDedupeWindow = capturedAt - existingEvent.capturedAt < DEDUPE_WINDOW_MS; + + if (isWithinDedupeWindow) { + existingEvent.occurrenceCount += 1; + + return; + } + + // A throttled timer can leave an expired event pending. Its completed window must be + // submitted before this fingerprint is replaced with a new event and a new window. + this.pendingEvents.delete(fingerprint); + this.submit(existingEvent); + } + + const event: UnhandledExceptionEvent = { + schemaVersion: 1, + capturedAt, + eventId: uuid.v4(), + occurrenceCount: 1, + common: { + appName: this.webex.config?.appName ?? this.webex.config?.sdkType ?? 'webex-js-sdk', + appVersion: this.webex.config?.appVersion, + runtime: 'browser', + sdkVersion: this.webex.version, + }, + error: {...error, fingerprint}, + }; + + this.addMetadata(event); + this.pendingEvents.set(fingerprint, event); + + if (this.flushTimer === undefined) { + this.flushTimer = safeSetTimeout(() => this.flush(), DEDUPE_WINDOW_MS); + } + } catch { + logTelemetryFailure(this.webex, 'Failed to capture an exception.'); + } + } + + private addMetadata(event: UnhandledExceptionEvent): void { + const getMetadata = this.webex.config?.metrics?.unhandledExceptionTelemetry?.getMetadata; + + if (!getMetadata) { + return; + } + + try { + const metadata = getMetadata(); + + if (metadata === undefined) { + return; + } + + const serializedMetadata = JSON.stringify(metadata); + + if (serializedMetadata === undefined || serializedMetadata.length > MAX_METADATA_LENGTH) { + event.metadataCaptureStatus = + serializedMetadata === undefined ? 'invalid_type' : 'too_large'; + + return; + } + + const parsedMetadata = JSON.parse(serializedMetadata); + + if ( + parsedMetadata === null || + typeof parsedMetadata !== 'object' || + Array.isArray(parsedMetadata) + ) { + event.metadataCaptureStatus = 'invalid_type'; + + return; + } + + event.metadata = parsedMetadata; + } catch { + event.metadataCaptureStatus = 'provider_error'; + } + } + + private flush(): void { + const now = new Date().getTime(); + let nextFlushInMs: number | undefined; + + this.clearFlushTimer(); + this.pendingEvents.forEach((event, fingerprint) => { + const remainingWindowMs = DEDUPE_WINDOW_MS - (now - event.capturedAt); + + if (remainingWindowMs <= 0) { + this.pendingEvents.delete(fingerprint); + this.submit(event); + + return; + } + + nextFlushInMs = Math.min(nextFlushInMs ?? remainingWindowMs, remainingWindowMs); + }); + + if (nextFlushInMs !== undefined) { + this.flushTimer = safeSetTimeout(() => this.flush(), nextFlushInMs); + } + } + + private submit(event: UnhandledExceptionEvent): void { + const submitClientMetrics = this.webex.internal?.metrics?.submitClientMetrics; + + if (!submitClientMetrics) { + logTelemetryFailure(this.webex, 'submitClientMetrics is unavailable.'); + + return; + } + + try { + Promise.resolve( + submitClientMetrics.call( + this.webex.internal?.metrics, + UNHANDLED_EXCEPTION_METRIC_NAME, + { + tags: { + app_name: event.common.appName, + exception_kind: event.error.kind, + runtime: event.common.runtime, + }, + fields: { + captured_at: event.capturedAt, + error_fingerprint: event.error.fingerprint, + error_name: event.error.name, + event_id: event.eventId, + occurrence_count: event.occurrenceCount, + }, + eventPayload: event, + }, + this.webex.canAuthorize === true ? undefined : this.preLoginId + ) + ).catch(() => { + logTelemetryFailure(this.webex, 'Failed to submit exception telemetry.'); + }); + } catch { + logTelemetryFailure(this.webex, 'Failed to submit exception telemetry.'); + } + } + + private clearFlushTimer(): void { + if (this.flushTimer !== undefined) { + clearTimeout(this.flushTimer); + this.flushTimer = undefined; + } + } +} + +let activeTelemetry: UnhandledExceptionTelemetry | undefined; + +/** + * Starts browser exception telemetry after SDK readiness. + * @param webex Initialized SDK instance. + * @returns {void} + */ +export function startUnhandledExceptionTelemetry(webex: WebexForUnhandledExceptionTelemetry): void { + const shouldStart = + webex.config?.metrics?.unhandledExceptionTelemetry?.enabled === true && + typeof window !== 'undefined' && + typeof window.addEventListener === 'function'; + + activeTelemetry?.stop(shouldStart); + activeTelemetry = undefined; + + if (!shouldStart) { + return; + } + + try { + activeTelemetry = new UnhandledExceptionTelemetry(webex, window); + } catch { + logTelemetryFailure(webex, 'Failed to start exception telemetry.'); + } +} diff --git a/packages/@webex/internal-plugin-metrics/src/unhandled-exception-telemetry/utils.ts b/packages/@webex/internal-plugin-metrics/src/unhandled-exception-telemetry/utils.ts new file mode 100644 index 00000000000..7d9e7886f4f --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/unhandled-exception-telemetry/utils.ts @@ -0,0 +1,101 @@ +/* eslint-disable require-jsdoc */ + +const MAX_RESOURCE_URL_LENGTH = 2_048; + +export function truncate(value: string | undefined, maxLength: number): string | undefined { + return value?.slice(0, maxLength); +} + +export function removeUrlDetails(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length === 0) { + return undefined; + } + + const withoutQueryOrFragment = value.split(/[?#]/, 1)[0]; + + // Strip URL userinfo from the authority. For example, + // https://user:token@host/path becomes https://host/path. + return withoutQueryOrFragment.replace(/^((?:[a-z][a-z0-9+.-]*:)?\/\/)[^/?#]*@/i, '$1'); +} + +// Keep HTTP(S) and relative resource URLs, remove credentials/query/fragment details, and cap them. +// For example, https://host/app.js?token=x becomes https://host/app.js, while +// data:image/svg+xml,... is rejected because its scheme is not HTTP(S). +export function sanitizeResourceUrl(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length === 0) { + return undefined; + } + + const url = value.trim(); + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url)?.[1]?.toLowerCase(); + + if (scheme && scheme !== 'http' && scheme !== 'https') { + return undefined; + } + + return truncate(removeUrlDetails(url), MAX_RESOURCE_URL_LENGTH); +} + +export function removeUrlDetailsFromText(value: string | undefined): string | undefined { + // Redact non-HTTP(S) schemes and strip credentials, queries, and fragments from other URLs. + // Examples: data:text/plain,secret -> [redacted-url], https://host/api?token=x -> + // https://host/api, api/messages?token=x -> api/messages, and GET app.js?token=x -> GET app.js. + return ( + value + // Handles root and dot-relative paths such as /api?token=x and ../app.js#fragment. + // These run before the scheme matcher so query values such as x:10 are not treated as URLs. + ?.replace( + /(^|[\s("'=[{])((?:\/|\.\.?\/)[^\s)"'\]}]+)/g, + (_, prefix, url) => `${prefix}${removeUrlDetails(url) ?? url}` + ) + .replace( + // Handles bare paths with a slash or file extension, such as api/messages?x=1 or app.js#x. + /(^|[\s("'=[{])((?:(?:[a-z0-9._~%-]+\/)+(?:[a-z0-9._~%-]+)?|[a-z0-9._~%-]+\.[a-z0-9._~%-]+)[?#][^\s)"'\]}]+)/gi, + (_, prefix, url) => `${prefix}${removeUrlDetails(url) ?? url}` + ) + .replace( + // Handles extensionless single-segment paths after an HTTP method, such as GET api?token=x. + /\b(DELETE|GET|HEAD|OPTIONS|PATCH|POST|PUT)(\s+)([a-z0-9._~%-]+[?#][^\s)"'\]}]+)/gi, + (_, method, spacing, url) => `${method}${spacing}${removeUrlDetails(url) ?? url}` + ) + .replace( + // Handles URLs with a scheme, including assigned values such as url=https://host/path?x=1. + // HTTP(S) details are stripped; data:, blob:, and other schemes are fully redacted. + /(^|[\s("'=[{,;])(([a-z][a-z0-9+.-]*):[^\s)"'\]}]+)/gi, + (_, prefix, url, scheme) => + `${prefix}${ + ['http', 'https'].includes(scheme.toLowerCase()) + ? removeUrlDetails(url) ?? url + : '[redacted-url]' + }` + ) + ); +} + +export function createFingerprint(value: string): string { + // DJB2-style hash: multiply by 33, add each UTF-16 code unit, and constrain the result to 32 bits. + // It provides a small stable deduplication key; it is not intended for cryptographic use. + let hash = 5381; + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 33 + value.charCodeAt(index)) % 4_294_967_296; + } + + return hash.toString(16).padStart(8, '0'); +} + +export function stringifyReason(reason: unknown): string { + if (typeof reason === 'string') { + return reason; + } + + try { + return JSON.stringify(reason) ?? String(reason); + } catch { + try { + return String(reason); + } catch { + return 'Unserializable rejection reason'; + } + } +} 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 fe1b9005d36..81a9edfa7ff 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 @@ -8,11 +8,14 @@ import { import MockWebex from '@webex/test-helper-mock-webex'; import sinon from 'sinon'; +import SourceNewMetrics from '@webex/internal-plugin-metrics/src/new-metrics'; +import * as Telemetry from '@webex/internal-plugin-metrics/src/unhandled-exception-telemetry'; + describe('internal-plugin-metrics', () => { - const mockWebex = () => new MockWebex({ + const mockWebex = (NewMetricsPlugin = NewMetrics) => new MockWebex({ children: { - newMetrics: NewMetrics, + newMetrics: NewMetricsPlugin, }, meetings: { }, @@ -67,6 +70,10 @@ describe('internal-plugin-metrics', () => { }); describe('new-metrics contstructor', () => { + afterEach(() => { + sinon.restore(); + }); + it('checks callDiagnosticLatencies is defined before ready emit', () => { const webex = mockWebex(); @@ -74,6 +81,15 @@ describe('internal-plugin-metrics', () => { assert.instanceOf(webex.internal.newMetrics.callDiagnosticLatencies, CallDiagnosticLatencies); }); + it('starts unhandled exception telemetry when webex is ready', () => { + const start = sinon.stub(Telemetry, 'startUnhandledExceptionTelemetry'); + const webex = mockWebex(SourceNewMetrics); + + assert.notCalled(start); + + webex.emit('ready'); + + assert.calledOnceWithExactly(start, webex); it('checks callDiagnosticMetrics is defined before ready emit', () => { const webex = mockWebex(); diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/unhandled-exception-telemetry.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/unhandled-exception-telemetry.ts new file mode 100644 index 00000000000..d473d102474 --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/unhandled-exception-telemetry.ts @@ -0,0 +1,687 @@ +import {assert} from '@webex/test-helper-chai'; +import sinon, {SinonFakeTimers} from 'sinon'; +import uuid from 'uuid'; + +import config from '@webex/internal-plugin-metrics/src/config'; +import * as Telemetry from '@webex/internal-plugin-metrics/src/unhandled-exception-telemetry'; + +class FakeWindow { + readonly registrations: Array<{capture: boolean; type: string}> = []; + readonly removals: Array<{capture: boolean; type: string}> = []; + private readonly listeners = new Map void>>(); + + addEventListener( + type: string, + listener: (event: any) => void, + options?: boolean | {capture?: boolean} + ) { + this.registrations.push({ + capture: typeof options === 'boolean' ? options : options?.capture ?? false, + type, + }); + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + removeEventListener( + type: string, + listener: (event: any) => void, + options?: boolean | {capture?: boolean} + ) { + this.removals.push({ + capture: typeof options === 'boolean' ? options : options?.capture ?? false, + type, + }); + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter((registeredListener) => registeredListener !== listener) + ); + } + + dispatch(type: string, event: any) { + this.listeners.get(type)?.forEach((listener) => listener(event)); + } + + listenerCount(type: string) { + return this.listeners.get(type)?.length ?? 0; + } +} + +describe('SDK unhandled exception telemetry', () => { + let browser: FakeWindow; + let clock: SinonFakeTimers; + let originalWindow: PropertyDescriptor | undefined; + + const makeWebex = (overrides: Record = {}) => ({ + canAuthorize: true, + version: '4.0.0', + config: { + appName: 'test-app', + appVersion: '1.2.3', + metrics: { + unhandledExceptionTelemetry: { + enabled: true, + getMetadata: () => ({dataCenter: 'eu-central', orgId: 'org-1', surface: 'meetings'}), + }, + }, + }, + internal: { + metrics: { + submitClientMetrics: sinon.stub().resolves(), + }, + }, + logger: { + error: sinon.stub(), + }, + ...overrides, + }); + + beforeEach(() => { + clock = sinon.useFakeTimers({now: 1_000, toFake: ['Date', 'setTimeout', 'clearTimeout']}); + sinon.stub(uuid, 'v4').returns('event-1'); + browser = new FakeWindow(); + originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: browser, + }); + }); + + afterEach(() => { + clock.restore(); + sinon.restore(); + + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + }); + + it('deduplicates seven matching errors and submits one sanitized event', async () => { + const webex = makeWebex(); + const error = new TypeError( + 'GET https://api.example.test/media?token=message-secret#fragment failed' + ); + + error.stack = + 'TypeError: GET https://api.example.test/media?token=stack-secret#fragment failed\n at start (https://example.test/app.js?token=filename-secret#fragment:10:20)'; + Telemetry.startUnhandledExceptionTelemetry(webex); + const errorEvent = { + error, + filename: 'https://example.test/app.js?token=secret', + lineno: 10, + colno: 20, + }; + + for (let occurrence = 0; occurrence < 7; occurrence += 1) { + browser.dispatch('error', errorEvent); + } + + clock.tick(999); + assert.notCalled(webex.internal.metrics.submitClientMetrics); + + await clock.tickAsync(1); + + const submittedEvent = + webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload; + + assert.calledOnceWithExactly( + webex.internal.metrics.submitClientMetrics, + Telemetry.UNHANDLED_EXCEPTION_METRIC_NAME, + { + tags: { + app_name: 'test-app', + exception_kind: 'error', + runtime: 'browser', + }, + fields: { + captured_at: 1_000, + error_fingerprint: submittedEvent.error.fingerprint, + error_name: 'TypeError', + event_id: 'event-1', + occurrence_count: 7, + }, + eventPayload: submittedEvent, + }, + undefined + ); + assert.equal(submittedEvent.error.filename, 'https://example.test/app.js'); + assert.equal(submittedEvent.error.message, 'GET https://api.example.test/media failed'); + assert.equal( + submittedEvent.error.stack, + 'TypeError: GET https://api.example.test/media failed\n at start (https://example.test/app.js)' + ); + assert.deepEqual(submittedEvent.metadata, { + dataCenter: 'eu-central', + orgId: 'org-1', + surface: 'meetings', + }); + assert.include(submittedEvent.common, { + appName: 'test-app', + appVersion: '1.2.3', + runtime: 'browser', + sdkVersion: '4.0.0', + }); + }); + + it('keeps stackless errors from different source coordinates separate', async () => { + const webex = makeWebex(); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', { + message: 'script failed', + filename: 'https://example.test/app.js', + lineno: 10, + colno: 20, + }); + browser.dispatch('error', { + message: 'script failed', + filename: 'https://example.test/app.js', + lineno: 11, + colno: 20, + }); + + await clock.tickAsync(1_000); + + assert.calledTwice(webex.internal.metrics.submitClientMetrics); + assert.deepEqual( + webex.internal.metrics.submitClientMetrics.args.map( + ([, properties]: [string, any]) => properties.eventPayload.error.line + ), + [10, 11] + ); + webex.internal.metrics.submitClientMetrics.args.forEach(([, properties]: [string, any]) => { + assert.equal(properties.eventPayload.occurrenceCount, 1); + }); + }); + + it('removes listeners and flushes pending events from a superseded SDK instance', async () => { + const staleWebex = makeWebex(); + const currentWebex = makeWebex({ + config: { + appName: 'current-app', + metrics: {unhandledExceptionTelemetry: {enabled: true}}, + }, + }); + + Telemetry.startUnhandledExceptionTelemetry(staleWebex); + browser.dispatch('error', {message: 'stale failure'}); + Telemetry.startUnhandledExceptionTelemetry(currentWebex); + + assert.equal(browser.listenerCount('error'), 1); + assert.equal(browser.listenerCount('unhandledrejection'), 1); + + browser.dispatch('error', {message: 'current failure'}); + await clock.tickAsync(1_000); + + assert.calledOnce(staleWebex.internal.metrics.submitClientMetrics); + assert.equal( + staleWebex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error.message, + 'stale failure' + ); + assert.calledOnce(currentWebex.internal.metrics.submitClientMetrics); + assert.equal( + currentWebex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error.message, + 'current failure' + ); + }); + + it('removes query parameters and fragments from relative URLs', async () => { + const webex = makeWebex(); + const error = new Error( + 'GET /api/messages?token=root-secret#fragment or api/messages?token=bare-secret#fragment or GET api?token=single-secret#fragment failed' + ); + + error.stack = + 'Error: GET /api/messages?token=stack-secret#fragment failed\n at load (../scripts/app.js?token=dot-secret#fragment:10:20)\n at retry (scripts/retry.js?token=bare-secret#fragment:30:40)\n at main (app.js?token=single-secret#fragment:50:60)'; + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', {error}); + + await clock.tickAsync(1_000); + + const submittedError = + webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error; + + assert.equal(submittedError.message, 'GET /api/messages or api/messages or GET api failed'); + assert.equal( + submittedError.stack, + 'Error: GET /api/messages failed\n at load (../scripts/app.js)\n at retry (scripts/retry.js)\n at main (app.js)' + ); + }); + + it('gives each distinct error a full deduplication window', async () => { + const webex = makeWebex(); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', {message: 'first failure'}); + clock.tick(500); + browser.dispatch('error', {message: 'second failure'}); + + await clock.tickAsync(500); + + assert.calledOnce(webex.internal.metrics.submitClientMetrics); + assert.equal( + webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error.message, + 'first failure' + ); + + clock.tick(499); + assert.calledOnce(webex.internal.metrics.submitClientMetrics); + + await clock.tickAsync(1); + + assert.calledTwice(webex.internal.metrics.submitClientMetrics); + assert.equal( + webex.internal.metrics.submitClientMetrics.secondCall.args[1].eventPayload.error.message, + 'second failure' + ); + }); + + it('starts a new window for a matching error when the previous timer is delayed', async () => { + const webex = makeWebex(); + const errorEvent = {message: 'repeated failure'}; + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', errorEvent); + browser.dispatch('error', errorEvent); + + // Simulate browser timer throttling by advancing wall-clock time without running timers. + clock.setSystemTime(2_500); + browser.dispatch('error', errorEvent); + + assert.calledOnce(webex.internal.metrics.submitClientMetrics); + assert.equal( + webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.occurrenceCount, + 2 + ); + + await clock.tickAsync(1_000); + + assert.calledTwice(webex.internal.metrics.submitClientMetrics); + assert.equal( + webex.internal.metrics.submitClientMetrics.secondCall.args[1].eventPayload.occurrenceCount, + 1 + ); + }); + + it('truncates error messages to 4096 characters', async () => { + const webex = makeWebex(); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', {message: 'x'.repeat(4_097)}); + + await clock.tickAsync(1_000); + + assert.lengthOf( + webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error.message, + 4_096 + ); + }); + + it('captures promise rejections and resource failures', async () => { + const webex = makeWebex(); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('unhandledrejection', {reason: 'promise failed'}); + browser.dispatch('error', { + target: { + tagName: 'SCRIPT', + src: 'https://cdn.example.test/webex.js?token=secret', + }, + }); + + await clock.tickAsync(1_000); + + const errors = webex.internal.metrics.submitClientMetrics.args.map( + ([, properties]: [string, any]) => properties.eventPayload.error + ); + + assert.include(errors[0], { + kind: 'unhandledrejection', + message: 'promise failed', + name: 'UnhandledRejection', + }); + assert.include(errors[1], { + kind: 'resource_error', + resourceType: 'SCRIPT', + resourceUrl: 'https://cdn.example.test/webex.js', + }); + }); + + it('contains rejection reasons whose property getters throw', async () => { + const webex = makeWebex(); + const reason = new Proxy( + {}, + { + get() { + throw new Error('unreadable rejection'); + }, + } + ); + + Telemetry.startUnhandledExceptionTelemetry(webex); + + assert.doesNotThrow(() => browser.dispatch('unhandledrejection', {reason})); + + await clock.tickAsync(1_000); + + assert.notCalled(webex.internal.metrics.submitClientMetrics); + assert.calledOnceWithExactly( + webex.logger.error, + 'Unhandled Exception Telemetry -->', + 'Failed to extract an unhandled rejection.' + ); + }); + + it('registers and removes error and rejection listeners in the capture phase', () => { + Telemetry.startUnhandledExceptionTelemetry(makeWebex()); + + assert.deepEqual(browser.registrations, [ + {capture: true, type: 'error'}, + {capture: true, type: 'unhandledrejection'}, + ]); + + Telemetry.startUnhandledExceptionTelemetry( + makeWebex({config: {metrics: {unhandledExceptionTelemetry: {enabled: false}}}}) + ); + + assert.deepEqual(browser.removals, [ + {capture: true, type: 'error'}, + {capture: true, type: 'unhandledrejection'}, + ]); + }); + + it('sanitizes and truncates rejection names before submission', async () => { + const webex = makeWebex(); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('unhandledrejection', { + reason: { + name: `GET https://name-user:name-secret@example.test/path?token=query-secret ${'x'.repeat( + 300 + )}`, + message: 'promise failed', + }, + }); + + await clock.tickAsync(1_000); + + const properties = webex.internal.metrics.submitClientMetrics.firstCall.args[1]; + const errorName = properties.eventPayload.error.name; + + assert.lengthOf(errorName, 256); + assert.match(errorName, /^GET https:\/\/example\.test\/path x+$/); + assert.equal(properties.fields.error_name, errorName); + assert.notInclude(JSON.stringify(properties), 'secret'); + }); + + it('redacts non-network URLs from error messages and stacks', async () => { + const webex = makeWebex(); + const error = new Error( + 'url=https://user:password@example.test/path?token=message-secret payload=data:text/plain,inline-secret' + ); + + error.stack = + 'Error: resource failed\n at load (payload=blob:https://example.test/stack-secret)'; + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', {error}); + + await clock.tickAsync(1_000); + + const submittedError = + webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error; + + assert.equal( + submittedError.message, + 'url=https://example.test/path payload=[redacted-url]' + ); + assert.equal( + submittedError.stack, + 'Error: resource failed\n at load (payload=[redacted-url])' + ); + assert.notInclude(JSON.stringify(submittedError), 'secret'); + }); + + it('captures the selected srcset candidate instead of the fallback image source', async () => { + const webex = makeWebex(); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', { + target: { + tagName: 'IMG', + currentSrc: 'https://cdn.example.test/responsive-image.png?token=secret', + src: 'https://cdn.example.test/fallback-image.png', + }, + }); + + await clock.tickAsync(1_000); + + const error = webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error; + + assert.include(error, { + kind: 'resource_error', + resourceType: 'IMG', + resourceUrl: 'https://cdn.example.test/responsive-image.png', + }); + }); + + it('ignores resource failures from non-network URL schemes', async () => { + const webex = makeWebex(); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', { + target: { + tagName: 'IMG', + src: 'data:image/svg+xml,inline-content', + }, + }); + + await clock.tickAsync(1_000); + + assert.notCalled(webex.internal.metrics.submitClientMetrics); + }); + + it('caps resource URLs before submission', async () => { + const webex = makeWebex(); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', { + target: { + tagName: 'SCRIPT', + src: `https://cdn.example.test/${'x'.repeat(3_000)}?token=secret`, + }, + }); + + await clock.tickAsync(1_000); + + const resourceUrl = + webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error.resourceUrl; + + assert.lengthOf(resourceUrl, 2_048); + assert.notInclude(resourceUrl, 'secret'); + }); + + it('removes URL userinfo from error and resource telemetry', async () => { + const webex = makeWebex(); + const error = new Error( + 'GET https://message-user:message-secret@api.example.test/messages?token=query-secret failed' + ); + + error.stack = + 'Error: request failed\n at load (https://stack-user:stack-secret@example.test/app.js?token=query-secret:10:20)'; + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', { + error, + filename: + 'https://filename-user:filename-secret@example.test/app.js?token=query-secret#fragment', + }); + + await clock.tickAsync(1_000); + + const errorTelemetry = + webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error; + + assert.equal( + errorTelemetry.message, + 'GET https://api.example.test/messages failed' + ); + assert.equal( + errorTelemetry.stack, + 'Error: request failed\n at load (https://example.test/app.js)' + ); + assert.equal(errorTelemetry.filename, 'https://example.test/app.js'); + + browser.dispatch('error', { + target: { + tagName: 'SCRIPT', + src: 'https://resource-user:resource-secret@cdn.example.test/app.js?token=query-secret', + }, + }); + + await clock.tickAsync(1_000); + + const resourceTelemetry = + webex.internal.metrics.submitClientMetrics.secondCall.args[1].eventPayload.error; + + assert.equal(resourceTelemetry.resourceUrl, 'https://cdn.example.test/app.js'); + assert.notInclude(JSON.stringify({errorTelemetry, resourceTelemetry}), 'secret'); + }); + + it('does not install browser listeners when disabled', () => { + const webex = makeWebex({ + config: { + metrics: { + unhandledExceptionTelemetry: {enabled: false}, + }, + }, + }); + + Telemetry.startUnhandledExceptionTelemetry(webex); + assert.isEmpty(browser.registrations); + }); + + it('stops an active reporter when a new SDK instance disables telemetry', async () => { + const enabledWebex = makeWebex(); + const disabledWebex = makeWebex({ + config: { + metrics: { + unhandledExceptionTelemetry: {enabled: false}, + }, + }, + }); + + Telemetry.startUnhandledExceptionTelemetry(enabledWebex); + browser.dispatch('error', {message: 'pending failure'}); + + Telemetry.startUnhandledExceptionTelemetry(disabledWebex); + assert.equal(browser.listenerCount('error'), 0); + assert.equal(browser.listenerCount('unhandledrejection'), 0); + + browser.dispatch('error', {message: 'ignored failure'}); + await clock.tickAsync(1_000); + + assert.notCalled(enabledWebex.internal.metrics.submitClientMetrics); + assert.notCalled(disabledWebex.internal.metrics.submitClientMetrics); + }); + + it('does not install browser listeners when not configured', () => { + const webex = makeWebex({config: {metrics: {}}}); + + Telemetry.startUnhandledExceptionTelemetry(webex); + assert.isEmpty(browser.registrations); + }); + + it('stops an active reporter and discards pending events when the environment is unsupported', async () => { + const enabledWebex = makeWebex(); + + Telemetry.startUnhandledExceptionTelemetry(enabledWebex); + browser.dispatch('error', {message: 'pending failure'}); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: {}, + }); + + Telemetry.startUnhandledExceptionTelemetry(makeWebex()); + assert.equal(browser.listenerCount('error'), 0); + assert.equal(browser.listenerCount('unhandledrejection'), 0); + + await clock.tickAsync(1_000); + + assert.notCalled(enabledWebex.internal.metrics.submitClientMetrics); + }); + + it('contains metadata and submission failures', async () => { + const webex = makeWebex({ + config: { + metrics: { + unhandledExceptionTelemetry: { + enabled: true, + getMetadata: () => { + throw new Error('metadata unavailable'); + }, + }, + }, + }, + internal: { + metrics: { + submitClientMetrics: sinon.stub().rejects(new Error('metrics unavailable')), + }, + }, + }); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', {message: 'application failed'}); + + await clock.tickAsync(1_000); + + assert.equal( + webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload + .metadataCaptureStatus, + 'provider_error' + ); + assert.calledOnceWithExactly( + webex.logger.error, + 'Unhandled Exception Telemetry -->', + 'Failed to submit exception telemetry.' + ); + }); + + it('routes telemetry through pre-login metrics before authentication', async () => { + const webex = makeWebex({canAuthorize: false}); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', {message: 'pre-login failure'}); + + await clock.tickAsync(1_000); + + assert.equal(webex.internal.metrics.submitClientMetrics.firstCall.args[2], 'event-1'); + }); + + it('omits metadata that does not serialize to an object', async () => { + const webex = makeWebex({ + config: { + metrics: { + unhandledExceptionTelemetry: { + enabled: true, + getMetadata: () => ['not', 'an', 'object'], + }, + }, + }, + }); + + Telemetry.startUnhandledExceptionTelemetry(webex); + browser.dispatch('error', {message: 'application failed'}); + + await clock.tickAsync(1_000); + + const submittedEvent = + webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload; + + assert.isUndefined(submittedEvent.metadata); + assert.equal(submittedEvent.metadataCaptureStatus, 'invalid_type'); + }); + + it('is disabled by default', () => { + assert.isFalse(config.metrics.unhandledExceptionTelemetry.enabled); + }); +}); diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/unhandled-exception-telemetry/utils.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/unhandled-exception-telemetry/utils.ts new file mode 100644 index 00000000000..89124b6b51f --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/unhandled-exception-telemetry/utils.ts @@ -0,0 +1,107 @@ +import {assert} from '@webex/test-helper-chai'; + +import { + createFingerprint, + removeUrlDetails, + removeUrlDetailsFromText, + sanitizeResourceUrl, + stringifyReason, + truncate, +} from '@webex/internal-plugin-metrics/src/unhandled-exception-telemetry/utils'; + +describe('Unhandled exception telemetry utilities', () => { + describe('#truncate()', () => { + it('truncates strings to the requested length', () => { + assert.equal(truncate('abcdef', 3), 'abc'); + assert.equal(truncate('abc', 3), 'abc'); + assert.isUndefined(truncate(undefined, 3)); + }); + }); + + describe('#removeUrlDetails()', () => { + [ + { + input: 'https://user:token@example.test/path?secret=x#fragment', + expected: 'https://example.test/path', + }, + {input: '//user:token@example.test/path#fragment', expected: '//example.test/path'}, + {input: '/api/messages?secret=x', expected: '/api/messages'}, + {input: '', expected: undefined}, + {input: 42, expected: undefined}, + ].forEach(({input, expected}) => { + it(`sanitizes ${JSON.stringify(input)}`, () => { + assert.strictEqual(removeUrlDetails(input), expected); + }); + }); + }); + + describe('#sanitizeResourceUrl()', () => { + [ + { + input: ' https://user:token@example.test/app.js?secret=x ', + expected: 'https://example.test/app.js', + }, + {input: '/assets/app.js?secret=x', expected: '/assets/app.js'}, + {input: 'data:text/plain,secret', expected: undefined}, + {input: 'blob:https://example.test/id', expected: undefined}, + {input: undefined, expected: undefined}, + ].forEach(({input, expected}) => { + it(`sanitizes ${JSON.stringify(input)}`, () => { + assert.strictEqual(sanitizeResourceUrl(input), expected); + }); + }); + + it('caps resource URLs at 2048 characters', () => { + assert.lengthOf(sanitizeResourceUrl(`/${'x'.repeat(3_000)}`) as string, 2_048); + }); + }); + + describe('#removeUrlDetailsFromText()', () => { + [ + { + input: 'url=https://user:token@example.test/path?secret=x', + expected: 'url=https://example.test/path', + }, + {input: 'payload=data:text/plain,secret', expected: 'payload=[redacted-url]'}, + {input: 'GET /api/messages?secret=x', expected: 'GET /api/messages'}, + {input: 'at load (../app.js#fragment:10:20)', expected: 'at load (../app.js)'}, + {input: 'retry api/messages?secret=x', expected: 'retry api/messages'}, + {input: 'at load (app.js?secret=x:10:20)', expected: 'at load (app.js)'}, + {input: 'GET api?secret=x', expected: 'GET api'}, + ].forEach(({input, expected}) => { + it(`sanitizes ${input}`, () => { + assert.equal(removeUrlDetailsFromText(input), expected); + }); + }); + }); + + describe('#createFingerprint()', () => { + it('returns a stable eight-character key that changes with its input', () => { + const fingerprint = createFingerprint('same error'); + + assert.lengthOf(fingerprint, 8); + assert.equal(createFingerprint('same error'), fingerprint); + assert.notEqual(createFingerprint('different error'), fingerprint); + }); + }); + + describe('#stringifyReason()', () => { + it('serializes strings and objects', () => { + assert.equal(stringifyReason('failed'), 'failed'); + assert.equal(stringifyReason({message: 'failed'}), '{"message":"failed"}'); + }); + + it('falls back safely when conversion hooks throw', () => { + const reason = new Proxy( + {}, + { + get() { + throw new Error('unreadable'); + }, + } + ); + + assert.equal(stringifyReason(reason), 'Unserializable rejection reason'); + }); + }); +});