diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 80e8e20257d..61dee43370e 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -222,6 +222,7 @@ Experimental features might be changed or removed without prior notice. | `enableExtensionsAdminPage` | Enables the extension admin page regardless of development mode | | `zipkinBackendMigration` | Enables querying Zipkin data source without the proxy | | `enableSCIM` | Enables SCIM support for user and group management | +| `crashDetection` | Enables browser crash detection reporting to Faro. | ## Development feature toggles diff --git a/package.json b/package.json index 61d23688535..830d19cde25 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "codeowners": "^5.1.1", "copy-webpack-plugin": "12.0.2", "core-js": "3.38.1", + "crashme": "0.0.15", "css-loader": "7.1.2", "css-minimizer-webpack-plugin": "6.0.0", "cypress": "13.10.0", diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 0081e6fc721..d3dbd6c2c7f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -236,4 +236,5 @@ export interface FeatureToggles { enableExtensionsAdminPage?: boolean; zipkinBackendMigration?: boolean; enableSCIM?: boolean; + crashDetection?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 836bed5b50b..687326cefb8 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1628,6 +1628,13 @@ var ( Stage: FeatureStageExperimental, Owner: identityAccessTeam, }, + { + Name: "crashDetection", + Description: "Enables browser crash detection reporting to Faro.", + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityTracesAndProfilingSquad, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 68295315b36..cac9fde8832 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -217,3 +217,4 @@ exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,fals enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false zipkinBackendMigration,experimental,@grafana/oss-big-tent,false,false,false enableSCIM,experimental,@grafana/identity-access-team,false,false,false +crashDetection,experimental,@grafana/observability-traces-and-profiling,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 70c0813d282..1656aa935c3 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -878,4 +878,8 @@ const ( // FlagEnableSCIM // Enables SCIM support for user and group management FlagEnableSCIM = "enableSCIM" + + // FlagCrashDetection + // Enables browser crash detection reporting to Faro. + FlagCrashDetection = "crashDetection" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index bde889b2f00..34c1e5e5f78 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -855,6 +855,19 @@ "expression": "true" } }, + { + "metadata": { + "name": "crashDetection", + "resourceVersion": "1730381712885", + "creationTimestamp": "2024-10-31T13:35:12Z" + }, + "spec": { + "description": "Enables browser crash detection reporting to Faro.", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, { "metadata": { "name": "dashboardNewLayouts", @@ -3470,4 +3483,4 @@ } } ] -} \ No newline at end of file +} diff --git a/public/app/app.ts b/public/app/app.ts index 052a3382a58..dad577603d8 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -56,6 +56,7 @@ import { AppChromeService } from './core/components/AppChrome/AppChromeService'; import { getAllOptionEditors, getAllStandardFieldConfigs } from './core/components/OptionsUI/registry'; import { PluginPage } from './core/components/Page/PluginPage'; import { GrafanaContextType, useChromeHeaderHeight, useReturnToPreviousInternal } from './core/context/GrafanaContext'; +import { initializeCrashDetection } from './core/crash'; import { initIconCache } from './core/icons/iconBundle'; import { initializeI18n } from './core/internationalization'; import { setMonacoEnv } from './core/monacoEnv'; @@ -267,6 +268,10 @@ export class GrafanaApp { initializeScopes(); + if (config.featureToggles.crashDetection) { + initializeCrashDetection(); + } + const root = createRoot(document.getElementById('reactRoot')!); root.render( createElement(AppWrapper, { diff --git a/public/app/core/crash/client.worker.ts b/public/app/core/crash/client.worker.ts new file mode 100644 index 00000000000..e3afa5526a4 --- /dev/null +++ b/public/app/core/crash/client.worker.ts @@ -0,0 +1,7 @@ +import { initClientWorker } from 'crashme'; + +initClientWorker({ + dbName: 'grafana.crashes', + // How often the tab will report its state + pingInterval: 1000, +}); diff --git a/public/app/core/crash/crash.utils.ts b/public/app/core/crash/crash.utils.ts new file mode 100644 index 00000000000..ba9367a8923 --- /dev/null +++ b/public/app/core/crash/crash.utils.ts @@ -0,0 +1,58 @@ +import { LogContext } from '@grafana/faro-core/dist/types/api/logs/types'; + +export interface ChromePerformanceMemory { + totalJSHeapSize: number; + usedJSHeapSize: number; + jsHeapSizeLimit: number; +} + +export interface ChromePerformance { + memory: ChromePerformanceMemory; +} + +function isChromePerformanceMemory(memory: unknown): memory is ChromePerformanceMemory { + if (!memory || typeof memory !== 'object') { + return false; + } + + return 'totalJSHeapSize' in memory && 'usedJSHeapSize' in memory && 'jsHeapSizeLimit' in memory; +} + +export function isChromePerformance(performance: unknown): performance is ChromePerformance { + if (!performance || typeof performance !== 'object') { + return false; + } + + return 'memory' in performance && isChromePerformanceMemory(performance.memory); +} + +/** + * Ensures the context is a flat object with strings (required by Faro) + */ +export function prepareContext(context: Object): LogContext { + const preparedContext: LogContext = {}; + function prepare(value: object | string | number, propertyName: string) { + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + throw new Error('Array values are not supported.'); + } else { + for (const key in value) { + if (value.hasOwnProperty(key)) { + // @ts-ignore + prepare(value[key], propertyName ? `${propertyName}_${key}` : key); + } + } + } + } else if (typeof value === 'string') { + preparedContext[propertyName] = value; + } else if (typeof value === 'number') { + if (Number.isInteger(value)) { + preparedContext[propertyName] = value.toString(); + } else { + preparedContext[propertyName] = value.toFixed(4); + } + } + } + prepare(context, 'crash'); + return preparedContext; +} diff --git a/public/app/core/crash/detector.worker.ts b/public/app/core/crash/detector.worker.ts new file mode 100644 index 00000000000..58411a3d534 --- /dev/null +++ b/public/app/core/crash/detector.worker.ts @@ -0,0 +1,8 @@ +import { initDetectorWorker } from 'crashme'; + +initDetectorWorker({ + dbName: 'grafana.crashes', + interval: 5000, + crashThreshold: 5000, + staleThreshold: 5000, +}); diff --git a/public/app/core/crash/index.ts b/public/app/core/crash/index.ts new file mode 100644 index 00000000000..a2f54a9d866 --- /dev/null +++ b/public/app/core/crash/index.ts @@ -0,0 +1,81 @@ +import { initCrashDetection } from 'crashme'; +import { BaseStateReport } from 'crashme/dist/types'; +import { nanoid } from 'nanoid'; + +import { config, createMonitoringLogger } from '@grafana/runtime'; + +import { contextSrv } from '../services/context_srv'; + +import { isChromePerformance, prepareContext } from './crash.utils'; + +const logger = createMonitoringLogger('core.crash-detection'); + +interface GrafanaCrashReport extends BaseStateReport { + app: { + version: string; + url: string; + }; + user: { + email: string; + login: string; + name: string; + }; + memory?: { + heapUtilization: number; + limitUtilization: number; + usedJSHeapSize: number; + totalJSHeapSize: number; + jsHeapSizeLimit: number; + }; +} + +export function initializeCrashDetection() { + initCrashDetection({ + id: nanoid(5), + + dbName: 'grafana.crashes', + + createClientWorker(): Worker { + return new Worker(new URL('./client.worker', import.meta.url)); + }, + + createDetectorWorker(): SharedWorker { + return new SharedWorker(new URL('./detector.worker', import.meta.url)); + }, + + reportCrash: async (report) => { + const preparedContext = prepareContext(report); + logger.logWarning('browser crash detected', preparedContext); + return true; + }, + + reportStaleTab: async (report) => { + const preparedContext = prepareContext(report); + logger.logWarning('stale browser tab detected', preparedContext); + return true; + }, + + updateInfo: (info) => { + info.app = { + version: config.buildInfo.version, + url: window.location.href, + }; + + info.user = { + email: contextSrv.user.email, + login: contextSrv.user.login, + name: contextSrv.user.name, + }; + + if (isChromePerformance(performance)) { + info.memory = { + heapUtilization: performance.memory.usedJSHeapSize / performance.memory.totalJSHeapSize, + limitUtilization: performance.memory.totalJSHeapSize / performance.memory.jsHeapSizeLimit, + usedJSHeapSize: performance.memory.usedJSHeapSize, + totalJSHeapSize: performance.memory.totalJSHeapSize, + jsHeapSizeLimit: performance.memory.jsHeapSizeLimit, + }; + } + }, + }); +} diff --git a/yarn.lock b/yarn.lock index bc10b07814d..43186bbfe74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14324,6 +14324,13 @@ __metadata: languageName: node linkType: hard +"crashme@npm:0.0.15": + version: 0.0.15 + resolution: "crashme@npm:0.0.15" + checksum: 10/576110b3d61f996869b993c2f6b8dca33ac82d07ea708b39217b6349653e38a9894c1af838136bc60ba61f6c69bd68f1c60e3da772cb2fa1fe0b64c2fbc53b79 + languageName: node + linkType: hard + "create-jest@npm:^29.7.0": version: 29.7.0 resolution: "create-jest@npm:29.7.0" @@ -18878,6 +18885,7 @@ __metadata: common-tags: "npm:1.8.2" copy-webpack-plugin: "npm:12.0.2" core-js: "npm:3.38.1" + crashme: "npm:0.0.15" css-loader: "npm:7.1.2" css-minimizer-webpack-plugin: "npm:6.0.0" cypress: "npm:13.10.0"