diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml index 4177688871..8ef924825c 100644 --- a/.github/workflows/ci-dist-telemetry-scan.yaml +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -110,6 +110,24 @@ jobs: fi echo '✅ No PostHog references found' + - name: Scan dist for Datadog RUM references + run: | + set -euo pipefail + echo '🔍 Scanning for Datadog RUM references...' + if rg --no-ignore -n \ + -g '*.html' \ + -g '*.js' \ + -e '@datadog/browser-rum' \ + -e '041a9897-5516-4b1f-a245-1a9aa6895488' \ + -e 'pub7704486e5b64eb4ff6f62891cda45559' \ + -e 'comfy-cloud-frontend' \ + dist; then + echo '❌ ERROR: Datadog RUM references found in dist assets!' + echo 'Datadog RUM must be properly tree-shaken from OSS builds.' + exit 1 + fi + echo '✅ No Datadog RUM references found' + - name: Scan dist for Customer.io telemetry references run: | set -euo pipefail diff --git a/index.html b/index.html index 672b5bf420..b18dfa6bc8 100644 --- a/index.html +++ b/index.html @@ -162,6 +162,6 @@ })()
- + diff --git a/package.json b/package.json index 848084f4b3..2b3c71416d 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@comfyorg/shared-frontend-utils": "workspace:*", "@comfyorg/tailwind-utils": "workspace:*", "@customerio/cdp-analytics-browser": "catalog:", + "@datadog/browser-rum": "catalog:", "@formkit/auto-animate": "catalog:", "@iconify/json": "catalog:", "@primeuix/forms": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49def6180d..3731de58a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ catalogs: '@customerio/cdp-analytics-browser': specifier: ^0.5.3 version: 0.5.3 + '@datadog/browser-rum': + specifier: ^6.33.0 + version: 6.33.0 '@eslint/js': specifier: ^10.0.1 version: 10.0.1 @@ -468,6 +471,9 @@ importers: '@customerio/cdp-analytics-browser': specifier: 'catalog:' version: 0.5.3 + '@datadog/browser-rum': + specifier: 'catalog:' + version: 6.33.0 '@formkit/auto-animate': specifier: 'catalog:' version: 0.9.0 @@ -1507,6 +1513,20 @@ packages: '@cyberalien/svg-utils@1.2.15': resolution: {integrity: sha512-ZbKU6npzW5PNocdoLVJYfKzaP+c/RpT6JUkoaKrW1DOcw6lyXub8XtcNpI3xok6FnyNjS6ZbsrrtjTnS9yeZAQ==} + '@datadog/browser-core@6.33.0': + resolution: {integrity: sha512-h/xuBuY4Xi32CbBHkChMMWlOQ4al/mFF08x3o0hqrderfJvX7VO5tD3OVNwF+pfMXOGlDtBCIhwazr9sn24h3g==} + + '@datadog/browser-rum-core@6.33.0': + resolution: {integrity: sha512-Ais4x7zQBrBI6JyILYwFAmjUsqy0iQp2C7JcEEfzU4YYYcVO3DGpyVuYYrqHT27jY1QToHY8EnQUr3Yaj6saDA==} + + '@datadog/browser-rum@6.33.0': + resolution: {integrity: sha512-zdxs1PyKt1RQ/fdxsmaCJUeBhB+leCIaPJBcum7uDi/DL8nuu9A9NUc5qGVPlY9zc12v7t2NI8bLpOrMjv+jTg==} + peerDependencies: + '@datadog/browser-logs': 6.33.0 + peerDependenciesMeta: + '@datadog/browser-logs': + optional: true + '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} @@ -9791,6 +9811,17 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@datadog/browser-core@6.33.0': {} + + '@datadog/browser-rum-core@6.33.0': + dependencies: + '@datadog/browser-core': 6.33.0 + + '@datadog/browser-rum@6.33.0': + dependencies: + '@datadog/browser-core': 6.33.0 + '@datadog/browser-rum-core': 6.33.0 + '@dimforge/rapier3d-compat@0.12.0': {} '@dual-bundle/import-meta-resolve@4.2.1': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 58b8b22841..83ad7c0dc3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ catalog: '@astrojs/vue': ^6.0.1 '@comfyorg/comfyui-electron-types': 0.6.2 '@customerio/cdp-analytics-browser': ^0.5.3 + '@datadog/browser-rum': ^6.33.0 '@eslint/js': ^10.0.1 '@formkit/auto-animate': ^0.9.0 '@iconify-json/lucide': ^1.1.178 diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000000..4ca83e78ef --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,8 @@ +if (__DISTRIBUTION__ === 'cloud') { + const { initDatadogRum } = await import('@/platform/telemetry/initDatadogRum') + initDatadogRum() +} + +await import('./main') + +export {} diff --git a/src/platform/telemetry/datadogRumBeforeSend.test.ts b/src/platform/telemetry/datadogRumBeforeSend.test.ts new file mode 100644 index 0000000000..7a82241ac0 --- /dev/null +++ b/src/platform/telemetry/datadogRumBeforeSend.test.ts @@ -0,0 +1,62 @@ +import { fromPartial } from '@total-typescript/shoehorn' +import type { RumErrorEvent } from '@datadog/browser-rum' +import { describe, expect, it } from 'vitest' + +import { classifyRumErrorOrigin, rumBeforeSend } from './datadogRumBeforeSend' + +function createErrorEvent(message: string, stack?: string): RumErrorEvent { + return fromPartial({ + type: 'error', + error: { message, source: 'source', stack } + }) +} + +describe('rumBeforeSend', () => { + it('drops known third-party network noise', () => { + const event = createErrorEvent( + 'Failed to fetch https://px.ads.linkedin.com/pixel' + ) + + expect(rumBeforeSend(event, fromPartial({}))).toBe(false) + }) + + it('keeps application errors and tags their origin', () => { + const event = createErrorEvent( + 'Application failed', + 'at render (https://cloud.comfy.org/assets/app.js:1:2)' + ) + + expect(rumBeforeSend(event, fromPartial({}))).toBe(true) + expect(event.context).toEqual({ error: { origin: 'first_party' } }) + }) + + it('tags custom extension errors with their folder', () => { + const event = createErrorEvent( + 'Extension failed', + 'at run (https://cloud.comfy.org/extensions/comfyui-foo/main.js:1:2)' + ) + + expect(rumBeforeSend(event, fromPartial({}))).toBe(true) + expect(event.context).toEqual({ + error: { origin: 'extension', extension: 'comfyui-foo' } + }) + }) +}) + +describe('classifyRumErrorOrigin', () => { + it('uses the first recognizable in-app stack frame', () => { + const stack = [ + 'at external (https://cdn.example.com/library.js:1:2)', + 'at cloud (https://cloud.comfy.org/extensions/cloud/main.js:1:2)', + 'at custom (https://cloud.comfy.org/extensions/comfyui-foo/main.js:1:2)' + ].join('\n') + + expect(classifyRumErrorOrigin(stack)).toEqual({ origin: 'first_party' }) + }) + + it('classifies stacks without in-app frames as third party', () => { + expect( + classifyRumErrorOrigin('at external (https://cdn.example.com/app.js:1:2)') + ).toEqual({ origin: 'third_party' }) + }) +}) diff --git a/src/platform/telemetry/datadogRumBeforeSend.ts b/src/platform/telemetry/datadogRumBeforeSend.ts new file mode 100644 index 0000000000..7e5d73ed0d --- /dev/null +++ b/src/platform/telemetry/datadogRumBeforeSend.ts @@ -0,0 +1,72 @@ +import type { RumBeforeSend, RumErrorEvent } from '@datadog/browser-rum' + +const RUM_NOISE_HOSTS = [ + 'facebook.com', + 'px.ads.linkedin.com', + 'browser-intake-us5-datadoghq.com', + 'e2.sy-d.io', + 'google-analytics.com', + 'googletagmanager.com' +] + +const FIRST_PARTY_EXTENSION_FOLDERS = new Set(['cloud', 'core']) + +type RumErrorOrigin = + | { origin: 'first_party' } + | { origin: 'extension'; extension: string } + | { origin: 'third_party' } + +export function classifyRumErrorOrigin(stack?: string): RumErrorOrigin { + if (!stack) return { origin: 'third_party' } + + for (const line of stack.split('\n')) { + const extensionFolder = /\/extensions\/([^/?#]+)\//.exec(line)?.[1] + if (extensionFolder) { + return FIRST_PARTY_EXTENSION_FOLDERS.has(extensionFolder) + ? { origin: 'first_party' } + : { origin: 'extension', extension: extensionFolder } + } + + if (line.includes('/assets/')) return { origin: 'first_party' } + } + + return { origin: 'third_party' } +} + +function shouldKeepRumEvent(event: Parameters[0]): boolean { + if (event.type !== 'error') return true + + const message = event.error.message + if (message.startsWith('intervention:')) return false + if (message.includes('ResizeObserver loop')) return false + + const isNetworkNoise = + message.includes('csp_violation') || message.includes('Failed to fetch') + return ( + !isNetworkNoise || !RUM_NOISE_HOSTS.some((host) => message.includes(host)) + ) +} + +function tagRumErrorOrigin(event: RumErrorEvent): void { + try { + const errorOrigin = classifyRumErrorOrigin(event.error.stack) + const existingErrorContext = event.context?.error + const errorContext = + typeof existingErrorContext === 'object' && existingErrorContext !== null + ? existingErrorContext + : {} + + event.context = { + ...event.context, + error: { ...errorContext, ...errorOrigin } + } + } catch { + return + } +} + +export const rumBeforeSend: RumBeforeSend = (event) => { + if (!shouldKeepRumEvent(event)) return false + if (event.type === 'error') tagRumErrorOrigin(event) + return true +} diff --git a/src/platform/telemetry/initDatadogRum.test.ts b/src/platform/telemetry/initDatadogRum.test.ts new file mode 100644 index 0000000000..e488d6b10b --- /dev/null +++ b/src/platform/telemetry/initDatadogRum.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const hoisted = vi.hoisted(() => ({ + getInitConfiguration: vi.fn(), + init: vi.fn() +})) + +vi.mock('@datadog/browser-rum', () => ({ + datadogRum: hoisted +})) + +import { rumBeforeSend } from './datadogRumBeforeSend' +import { initDatadogRum } from './initDatadogRum' + +describe('initDatadogRum', () => { + beforeEach(() => { + vi.clearAllMocks() + hoisted.getInitConfiguration.mockReturnValue(undefined) + }) + + it.for([ + { hostname: 'cloud.comfy.org', env: 'prod-v2' }, + { hostname: 'stagingcloud.comfy.org', env: 'stg-v2' }, + { hostname: 'testcloud.comfy.org', env: 'test-v2' } + ])('initializes $hostname with the $env environment', ({ hostname, env }) => { + initDatadogRum(hostname) + + expect(hoisted.init).toHaveBeenCalledWith({ + clientToken: 'pub7704486e5b64eb4ff6f62891cda45559', + applicationId: '041a9897-5516-4b1f-a245-1a9aa6895488', + site: 'us5.datadoghq.com', + service: 'comfy-cloud-frontend', + env, + version: __COMFYUI_FRONTEND_VERSION__, + beforeSend: rumBeforeSend, + sessionSampleRate: 100, + sessionReplaySampleRate: 0, + allowedTracingUrls: [expect.any(RegExp)] + }) + }) + + it('does not initialize on an unknown hostname', () => { + initDatadogRum('localhost') + + expect(hoisted.init).not.toHaveBeenCalled() + }) + + it('does not initialize twice', () => { + hoisted.getInitConfiguration.mockReturnValue({}) + + initDatadogRum('cloud.comfy.org') + + expect(hoisted.init).not.toHaveBeenCalled() + }) +}) diff --git a/src/platform/telemetry/initDatadogRum.ts b/src/platform/telemetry/initDatadogRum.ts new file mode 100644 index 0000000000..abc3bc9feb --- /dev/null +++ b/src/platform/telemetry/initDatadogRum.ts @@ -0,0 +1,27 @@ +import { datadogRum } from '@datadog/browser-rum' + +import { rumBeforeSend } from './datadogRumBeforeSend' + +const DATADOG_ENV_BY_HOSTNAME = new Map([ + ['cloud.comfy.org', 'prod-v2'], + ['stagingcloud.comfy.org', 'stg-v2'], + ['testcloud.comfy.org', 'test-v2'] +]) + +export function initDatadogRum(hostname = window.location.hostname): void { + const env = DATADOG_ENV_BY_HOSTNAME.get(hostname) + if (!env || datadogRum.getInitConfiguration()) return + + datadogRum.init({ + clientToken: 'pub7704486e5b64eb4ff6f62891cda45559', + applicationId: '041a9897-5516-4b1f-a245-1a9aa6895488', + site: 'us5.datadoghq.com', + service: 'comfy-cloud-frontend', + env, + version: __COMFYUI_FRONTEND_VERSION__, + beforeSend: rumBeforeSend, + sessionSampleRate: 100, + sessionReplaySampleRate: 0, + allowedTracingUrls: [/^https:\/\/[^/]+\.comfy\.org/] + }) +} diff --git a/src/services/extensionService.test.ts b/src/services/extensionService.test.ts new file mode 100644 index 0000000000..47adbcb8a9 --- /dev/null +++ b/src/services/extensionService.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { shouldLoadExtension } from './extensionService' + +describe('shouldLoadExtension', () => { + it('skips the legacy Cloud RUM extension in cloud builds', () => { + expect(shouldLoadExtension('/extensions/cloud/rum.js', true)).toBe(false) + }) + + it('keeps the legacy path available outside cloud builds', () => { + expect(shouldLoadExtension('/extensions/cloud/rum.js', false)).toBe(true) + }) + + it('skips core extensions that load through the core entry point', () => { + expect(shouldLoadExtension('/extensions/core/foo.js', false)).toBe(false) + }) + + it('loads other extensions', () => { + expect(shouldLoadExtension('/extensions/comfyui-foo/main.js', true)).toBe( + true + ) + }) +}) diff --git a/src/services/extensionService.ts b/src/services/extensionService.ts index 65df85caba..bc3564f596 100644 --- a/src/services/extensionService.ts +++ b/src/services/extensionService.ts @@ -15,6 +15,16 @@ import type { AuthUserInfo } from '@/types/authTypes' import { app } from '@/scripts/app' import type { ComfyApp } from '@/scripts/app' +const LEGACY_CLOUD_RUM_EXTENSION = '/extensions/cloud/rum.js' + +export function shouldLoadExtension( + extension: string, + isCloudBuild: boolean +): boolean { + if (extension.includes('extensions/core')) return false + return !isCloudBuild || extension !== LEGACY_CLOUD_RUM_EXTENSION +} + export const useExtensionService = () => { const extensionStore = useExtensionStore() const settingStore = useSettingStore() @@ -41,11 +51,8 @@ export const useExtensionService = () => { extensionStore.captureCoreExtensions() await Promise.all( extensions - .filter((extension) => !extension.includes('extensions/core')) - .filter( - (extension) => - __DISTRIBUTION__ !== 'cloud' || - extension !== '/extensions/cloud/rum.js' + .filter((extension) => + shouldLoadExtension(extension, __DISTRIBUTION__ === 'cloud') ) .map(async (ext) => { try { diff --git a/vite.config.mts b/vite.config.mts index 6b39f143e7..bedc0c4b1f 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -334,27 +334,6 @@ export default defineConfig({ tailwindcss(), typegpuPlugin({}), comfyAPIPlugin(IS_DEV), - { - name: 'inject-cloud-rum', - apply: 'build', - transformIndexHtml(html) { - if (DISTRIBUTION !== 'cloud') return html - - return { - html, - tags: [ - { - tag: 'script', - attrs: { - type: 'module', - src: '/extensions/cloud/rum.js' - }, - injectTo: 'head-prepend' - } - ] - } - } - }, // Exclude proprietary ABCROM fonts from non-cloud builds { name: 'exclude-proprietary-fonts', @@ -628,6 +607,11 @@ export default defineConfig({ test: /[\\/]node_modules[\\/]@sentry[\\/]/, priority: 15 }, + { + name: 'vendor-datadog', + test: /[\\/]node_modules[\\/]@datadog[\\/]/, + priority: 15 + }, // UI component libraries {