diff --git a/eslint-local-rules/disallowSideEffects.ts b/eslint-local-rules/disallowSideEffects.ts index 08a07e76c1..77d2373714 100644 --- a/eslint-local-rules/disallowSideEffects.ts +++ b/eslint-local-rules/disallowSideEffects.ts @@ -46,6 +46,8 @@ const pathsWithSideEffect = new Set([ // Those packages are known to have no side effects when evaluated const packagesWithoutSideEffect = new Set([ '@datadog/js-core/time', + '@datadog/js-core/util', + '@datadog/js-core/monitor', '@datadog/browser-core', '@datadog/browser-rum-core', '@datadog/browser-rum-react/internal', diff --git a/package.json b/package.json index 803df68f2d..41210c9eeb 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,9 @@ "webpack-dev-middleware": "8.0.3", "ws": "8.21.0" }, + "resolutions": { + "rolldown": "1.1.1" + }, "volta": { "node": "26.3.0", "yarn": "4.16.0" diff --git a/packages/browser-core/AGENTS.md b/packages/browser-core/AGENTS.md new file mode 100644 index 0000000000..8edd7ff438 --- /dev/null +++ b/packages/browser-core/AGENTS.md @@ -0,0 +1,21 @@ +# `@datadog/browser-core` package + +`@datadog/browser-core` is primarily consumed by other `@datadog/browser-*` packages within this +monorepo. It is published to npm but **not supported for external use** and carries **no semver +stability guarantee**. + +## Breaking changes are allowed + +External consumers exist but are unsupported — we do not guarantee compatibility for them. You may +freely: + +- Remove or rename exported symbols +- Change function signatures (parameters, return types, arity) +- Reorganize modules and re-exports + +When migrating code from `@datadog/browser-core` to `@datadog/js-core`, do **not** keep a +backward-compatible re-export in `browser-core` just to avoid a "breaking change" — there is no +breaking change here. Move the code and update all internal call sites directly. + +Review comments that warn about "breaking changes" in this package (e.g. removed exports, +signature changes) are **not applicable** and should be disregarded. diff --git a/packages/browser-core/src/boot/init.ts b/packages/browser-core/src/boot/init.ts index 2abe3edc96..efc928e0fc 100644 --- a/packages/browser-core/src/boot/init.ts +++ b/packages/browser-core/src/boot/init.ts @@ -1,5 +1,5 @@ +import { setDebugMode } from '@datadog/js-core/util' import { catchUserErrors } from '../tools/catchUserErrors' -import { setDebugMode } from '../tools/monitor' import { display } from '../tools/display' // replaced at build time diff --git a/packages/browser-core/src/domain/bufferedData.spec.ts b/packages/browser-core/src/domain/bufferedData.spec.ts index 48d6e7a325..fc89a8851b 100644 --- a/packages/browser-core/src/domain/bufferedData.spec.ts +++ b/packages/browser-core/src/domain/bufferedData.spec.ts @@ -1,10 +1,10 @@ import { clocksNow } from '@datadog/js-core/time' +import { ConsoleApiName } from '@datadog/js-core/util' import type { MockFetch } from '../../test' import { collectAsyncCalls, mockFetch, mockXhr, registerCleanupTask, replaceMockable, withXhr } from '../../test' import { Observable } from '../tools/observable' import { resetFetchObservable } from '../browser/fetchObservable' import { resetXhrObservable } from '../browser/xhrObservable' -import { ConsoleApiName } from '../tools/display' import { noop } from '../tools/utils/functionUtils' import { resetConsoleObservable } from './console/consoleObservable' import type { BufferedData } from './bufferedData' diff --git a/packages/browser-core/src/domain/bufferedData.ts b/packages/browser-core/src/domain/bufferedData.ts index 844ae7ba0f..ba22f9d542 100644 --- a/packages/browser-core/src/domain/bufferedData.ts +++ b/packages/browser-core/src/domain/bufferedData.ts @@ -1,3 +1,4 @@ +import { ConsoleApiName } from '@datadog/js-core/util' import type { Observable, Subscription } from '../tools/observable' import { BufferedObservable } from '../tools/observable' import { mockable } from '../tools/mockable' @@ -5,7 +6,6 @@ import type { FetchContext } from '../browser/fetchObservable' import { initFetchObservable } from '../browser/fetchObservable' import type { XhrContext } from '../browser/xhrObservable' import { initXhrObservable } from '../browser/xhrObservable' -import { ConsoleApiName } from '../tools/display' import { addTelemetryDebug } from './telemetry' import type { RawError } from './error/error.types' import { trackRuntimeError } from './error/trackRuntimeError' diff --git a/packages/browser-core/src/domain/console/consoleObservable.spec.ts b/packages/browser-core/src/domain/console/consoleObservable.spec.ts index 0ee54da487..1b1ce6b5e1 100644 --- a/packages/browser-core/src/domain/console/consoleObservable.spec.ts +++ b/packages/browser-core/src/domain/console/consoleObservable.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ +import { ConsoleApiName } from '@datadog/js-core/util' import { ignoreConsoleLogs } from '../../../test' -import { ConsoleApiName } from '../../tools/display' import type { Subscription } from '../../tools/observable' import type { ErrorConsoleLog } from './consoleObservable' import { initConsoleObservable } from './consoleObservable' diff --git a/packages/browser-core/src/domain/console/consoleObservable.ts b/packages/browser-core/src/domain/console/consoleObservable.ts index 7a515dcf0a..c9802c46c1 100644 --- a/packages/browser-core/src/domain/console/consoleObservable.ts +++ b/packages/browser-core/src/domain/console/consoleObservable.ts @@ -1,7 +1,7 @@ import { clocksNow } from '@datadog/js-core/time' +import { ConsoleApiName, globalConsole } from '@datadog/js-core/util' import { isError, computeRawError } from '../error/error' import { Observable, mergeObservables } from '../../tools/observable' -import { ConsoleApiName, globalConsole } from '../../tools/display' import { callMonitored } from '../../tools/monitor' import { sanitize } from '../../tools/serialisation/sanitize' import { jsonStringify } from '../../tools/serialisation/jsonStringify' diff --git a/packages/browser-core/src/domain/telemetry/telemetry.ts b/packages/browser-core/src/domain/telemetry/telemetry.ts index 339e010da2..69e22f03f2 100644 --- a/packages/browser-core/src/domain/telemetry/telemetry.ts +++ b/packages/browser-core/src/domain/telemetry/telemetry.ts @@ -1,6 +1,6 @@ import { clocksNow } from '@datadog/js-core/time' +import { getDebugMode } from '@datadog/js-core/util' import type { Context } from '../../tools/serialisation/context' -import { ConsoleApiName } from '../../tools/display' import { NO_ERROR_STACK_PRESENT_MESSAGE, isError } from '../error/error' import { toStackTraceString } from '../../tools/stackTrace/handlingStack' import { getExperimentalFeatures } from '../../tools/experimentalFeatures' @@ -9,7 +9,8 @@ import type { Configuration } from '../configuration' import { buildTags } from '../tags' import { INTAKE_SITE_STAGING, INTAKE_SITE_US1_FED, INTAKE_SITE_US2_FED } from '../intakeSites' import { BufferedObservable, Observable } from '../../tools/observable' -import { displayIfDebugEnabled, startMonitorErrorCollection } from '../../tools/monitor' +import { startMonitorErrorCollection } from '../../tools/monitor' +import { display } from '../../tools/display' import { sendToExtension } from '../../tools/sendToExtension' import { performDraw } from '../../tools/utils/numberUtils' import { jsonStringify } from '../../tools/serialisation/jsonStringify' @@ -261,7 +262,9 @@ function isTelemetryReplicationAllowed(configuration: Configuration) { } export function addTelemetryDebug(message: string, context?: Context) { - displayIfDebugEnabled(ConsoleApiName.debug, message, context) + if (getDebugMode()) { + display.debug('[Telemetry]', message, context) + } getTelemetryObservable().notify({ rawEvent: { type: TelemetryType.LOG, diff --git a/packages/browser-core/src/index.ts b/packages/browser-core/src/index.ts index 7c2a230eee..cf61c840ac 100644 --- a/packages/browser-core/src/index.ts +++ b/packages/browser-core/src/index.ts @@ -48,7 +48,7 @@ export { addTelemetryUsage, addTelemetryMetrics, } from './domain/telemetry' -export { monitored, monitor, callMonitored, setDebugMode, monitorError } from './tools/monitor' +export { monitored, monitor, callMonitored, monitorError } from './tools/monitor' export type { Subscription } from './tools/observable' export { Observable, BufferedObservable } from './tools/observable' export type { SessionManager, SessionContext } from './domain/session/sessionManager' diff --git a/packages/browser-core/src/tools/display.ts b/packages/browser-core/src/tools/display.ts index 1d5002e75d..2bf50629f2 100644 --- a/packages/browser-core/src/tools/display.ts +++ b/packages/browser-core/src/tools/display.ts @@ -1,59 +1,7 @@ /* eslint-disable local-rules/disallow-side-effects */ -/** - * Keep references on console methods to avoid triggering patched behaviors - * - * NB: in some setup, console could already be patched by another SDK. - * In this case, some display messages can be sent by the other SDK - * but we should be safe from infinite loop nonetheless. - */ +import { createDisplay } from '@datadog/js-core/util' -export const ConsoleApiName = { - log: 'log', - debug: 'debug', - info: 'info', - warn: 'warn', - error: 'error', -} as const - -export type ConsoleApiName = (typeof ConsoleApiName)[keyof typeof ConsoleApiName] - -export interface Display { - debug: typeof console.debug - log: typeof console.log - info: typeof console.info - warn: typeof console.warn - error: typeof console.error -} - -/** - * When building JS bundles, some users might use a plugin[1] or configuration[2] to remove - * "console.*" references. This causes some issue as we expect `console.*` to be defined. - * As a workaround, let's use a variable alias, so those expressions won't be taken into account by - * simple static analysis. - * - * [1]: https://babeljs.io/docs/babel-plugin-transform-remove-console/ - * [2]: https://github.com/terser/terser#compress-options (look for drop_console) - */ -export const globalConsole = console - -export const originalConsoleMethods = {} as Display -Object.keys(ConsoleApiName).forEach((name) => { - originalConsoleMethods[name as ConsoleApiName] = globalConsole[name as ConsoleApiName] -}) - -const PREFIX = 'Datadog Browser SDK:' - -export function createDisplay(prefix: string): Display { - return { - debug: originalConsoleMethods.debug.bind(globalConsole, prefix), - log: originalConsoleMethods.log.bind(globalConsole, prefix), - info: originalConsoleMethods.info.bind(globalConsole, prefix), - warn: originalConsoleMethods.warn.bind(globalConsole, prefix), - error: originalConsoleMethods.error.bind(globalConsole, prefix), - } -} - -export const display: Display = createDisplay(PREFIX) +export const display = createDisplay('Datadog Browser SDK:') export const DOCS_ORIGIN = 'https://docs.datadoghq.com' export const DOCS_TROUBLESHOOTING = `${DOCS_ORIGIN}/real_user_monitoring/browser/troubleshooting` diff --git a/packages/browser-core/src/tools/monitor.spec.ts b/packages/browser-core/src/tools/monitor.spec.ts index 63cfb8616a..549ddeffd2 100644 --- a/packages/browser-core/src/tools/monitor.spec.ts +++ b/packages/browser-core/src/tools/monitor.spec.ts @@ -1,5 +1,4 @@ -import { display } from './display' -import { callMonitored, monitor, monitored, startMonitorErrorCollection, setDebugMode } from './monitor' +import { callMonitored, monitored, startMonitorErrorCollection } from './monitor' describe('monitor', () => { let onMonitorErrorCollectedSpy: jasmine.Spy<(error: unknown) => void> @@ -8,162 +7,23 @@ describe('monitor', () => { onMonitorErrorCollectedSpy = jasmine.createSpy() }) - describe('decorator', () => { + it('catches monitored errors but does not report them before startMonitorErrorCollection', () => { class Candidate { @monitored - monitoredThrowing() { + throwing() { throw new Error('monitored') } - - @monitored - monitoredStringErrorThrowing() { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw 'string error' - } - - @monitored - monitoredObjectErrorThrowing() { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw { foo: 'bar' } - } - - @monitored - monitoredNotThrowing() { - return 1 - } - - notMonitoredThrowing() { - throw new Error('not monitored') - } } - - let candidate: Candidate - beforeEach(() => { - candidate = new Candidate() - }) - - describe('before initialization', () => { - it('should not monitor', () => { - expect(() => candidate.notMonitoredThrowing()).toThrowError('not monitored') - expect(() => candidate.monitoredThrowing()).toThrowError('monitored') - expect(candidate.monitoredNotThrowing()).toEqual(1) - }) - }) - - describe('after initialization', () => { - beforeEach(() => { - startMonitorErrorCollection(onMonitorErrorCollectedSpy) - }) - - it('should preserve original behavior', () => { - expect(candidate.monitoredNotThrowing()).toEqual(1) - }) - - it('should catch error', () => { - expect(() => candidate.notMonitoredThrowing()).toThrowError() - expect(() => candidate.monitoredThrowing()).not.toThrowError() - }) - - it('should report error', () => { - candidate.monitoredThrowing() - - expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith(new Error('monitored')) - }) - - it('should report string error', () => { - candidate.monitoredStringErrorThrowing() - - expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith('string error') - }) - - it('should report object error', () => { - candidate.monitoredObjectErrorThrowing() - - expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith({ foo: 'bar' }) - }) - }) + const candidate = new Candidate() + expect(() => candidate.throwing()).not.toThrowError() + expect(onMonitorErrorCollectedSpy).not.toHaveBeenCalled() }) - describe('function', () => { - const notThrowing = () => 1 - const throwing = () => { + it('reports errors to the collection callback after startMonitorErrorCollection', () => { + startMonitorErrorCollection(onMonitorErrorCollectedSpy) + callMonitored(() => { throw new Error('error') - } - - beforeEach(() => { - startMonitorErrorCollection(onMonitorErrorCollectedSpy) - }) - - describe('direct call', () => { - it('should preserve original behavior', () => { - expect(callMonitored(notThrowing)).toEqual(1) - }) - - it('should catch error', () => { - expect(() => callMonitored(throwing)).not.toThrowError() - }) - - it('should report error', () => { - callMonitored(throwing) - - expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith(new Error('error')) - }) - }) - - describe('wrapper', () => { - it('should preserve original behavior', () => { - const decorated = monitor(notThrowing) - expect(decorated()).toEqual(1) - }) - - it('should catch error', () => { - const decorated = monitor(throwing) - expect(() => decorated()).not.toThrowError() - }) - - it('should report error', () => { - monitor(throwing)() - - expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith(new Error('error')) - }) - }) - }) - - describe('setDebugMode', () => { - let displaySpy: jasmine.Spy - - beforeEach(() => { - displaySpy = spyOn(display, 'error') - }) - - it('when not called, should not display error', () => { - callMonitored(() => { - throw new Error('message') - }) - - expect(displaySpy).not.toHaveBeenCalled() - }) - - it('when called, should display error', () => { - setDebugMode(true) - - callMonitored(() => { - throw new Error('message') - }) - - expect(displaySpy).toHaveBeenCalledOnceWith('[MONITOR]', new Error('message')) - }) - - it('displays errors thrown by the onMonitorErrorCollected callback', () => { - setDebugMode(true) - onMonitorErrorCollectedSpy.and.throwError(new Error('unexpected')) - startMonitorErrorCollection(onMonitorErrorCollectedSpy) - - callMonitored(() => { - throw new Error('message') - }) - expect(displaySpy).toHaveBeenCalledWith('[MONITOR]', new Error('message')) - expect(displaySpy).toHaveBeenCalledWith('[MONITOR]', new Error('unexpected')) }) + expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith(new Error('error')) }) }) diff --git a/packages/browser-core/src/tools/monitor.ts b/packages/browser-core/src/tools/monitor.ts index 3edee8c35f..ae590618aa 100644 --- a/packages/browser-core/src/tools/monitor.ts +++ b/packages/browser-core/src/tools/monitor.ts @@ -1,70 +1,24 @@ +/* eslint-disable local-rules/disallow-side-effects */ +import { createMonitor } from '@datadog/js-core/monitor' +import { setDebugMode } from '@datadog/js-core/util' import { display } from './display' -let onMonitorErrorCollected: undefined | ((error: unknown) => void) -let debugMode = false +// The error-collection callback is wired lazily (via `startMonitorErrorCollection`, during telemetry +// init), so we pass a stable forwarding function to `createMonitor` and keep the real callback in a +// mutable holder. +let onMonitorErrorCollected: ((error: unknown) => void) | undefined + +const { monitored, monitor, callMonitored, monitorError } = createMonitor(display, (error) => + onMonitorErrorCollected?.(error) +) + +export { monitored, monitor, callMonitored, monitorError } export function startMonitorErrorCollection(newOnMonitorErrorCollected: (error: unknown) => void) { onMonitorErrorCollected = newOnMonitorErrorCollected } -export function setDebugMode(newDebugMode: boolean) { - debugMode = newDebugMode -} - export function resetMonitor() { onMonitorErrorCollected = undefined - debugMode = false -} - -export function monitored unknown>( - _: any, - __: string, - descriptor: TypedPropertyDescriptor -) { - const originalMethod = descriptor.value! - descriptor.value = function (this: ThisParameterType, ...args: Parameters): ReturnType { - const decorated = onMonitorErrorCollected ? monitor(originalMethod) : originalMethod - return decorated.apply(this, args) as ReturnType - } as T -} - -export function monitor unknown>(fn: T): T { - return function (this: ThisParameterType, ...args: Parameters) { - return callMonitored(fn, this, args) - } as unknown as T // consider output type has input type -} - -export function callMonitored unknown>( - fn: T, - context: ThisParameterType, - args: Parameters -): ReturnType | undefined -export function callMonitored unknown>(fn: T): ReturnType | undefined -export function callMonitored unknown>( - fn: T, - context?: any, - args?: any -): ReturnType | undefined { - try { - return fn.apply(context, args) as ReturnType - } catch (e) { - monitorError(e) - } -} - -export function monitorError(e: unknown) { - displayIfDebugEnabled(e) - if (onMonitorErrorCollected) { - try { - onMonitorErrorCollected(e) - } catch (e) { - displayIfDebugEnabled(e) - } - } -} - -export function displayIfDebugEnabled(...args: unknown[]) { - if (debugMode) { - display.error('[MONITOR]', ...args) - } + setDebugMode(false) } diff --git a/packages/browser-debugger/src/domain/display.spec.ts b/packages/browser-debugger/src/domain/display.spec.ts index 086e3cd088..65feb27d60 100644 --- a/packages/browser-debugger/src/domain/display.spec.ts +++ b/packages/browser-debugger/src/domain/display.spec.ts @@ -1,4 +1,4 @@ -import { createDisplay, originalConsoleMethods } from '@datadog/browser-core' +import { createDisplay, originalConsoleMethods } from '@datadog/js-core/util' import { DEBUGGER_DISPLAY_PREFIX } from './display' describe('debugger display', () => { diff --git a/packages/browser-debugger/src/domain/display.ts b/packages/browser-debugger/src/domain/display.ts index 964fa1c362..613bc3b56b 100644 --- a/packages/browser-debugger/src/domain/display.ts +++ b/packages/browser-debugger/src/domain/display.ts @@ -1,5 +1,5 @@ -import type { Display } from '@datadog/browser-core' -import { createDisplay } from '@datadog/browser-core' +import { createDisplay } from '@datadog/js-core/util' +import type { Display } from '@datadog/js-core/util' export const DEBUGGER_DISPLAY_PREFIX = 'Datadog Debugger SDK:' // eslint-disable-next-line local-rules/disallow-side-effects diff --git a/packages/browser-logs/src/domain/configuration.ts b/packages/browser-logs/src/domain/configuration.ts index 156a2a3bba..3d3517706a 100644 --- a/packages/browser-logs/src/domain/configuration.ts +++ b/packages/browser-logs/src/domain/configuration.ts @@ -1,11 +1,11 @@ import type { Configuration, InitConfiguration, RawTelemetryConfiguration } from '@datadog/browser-core' +import { ConsoleApiName } from '@datadog/js-core/util' import { serializeConfiguration, ONE_KIBI_BYTE, validateAndBuildConfiguration, display, removeDuplicates, - ConsoleApiName, RawReportType, objectValues, } from '@datadog/browser-core' diff --git a/packages/browser-logs/src/domain/console/consoleCollection.spec.ts b/packages/browser-logs/src/domain/console/consoleCollection.spec.ts index f674ee6b72..999e577ef9 100644 --- a/packages/browser-logs/src/domain/console/consoleCollection.spec.ts +++ b/packages/browser-logs/src/domain/console/consoleCollection.spec.ts @@ -1,14 +1,7 @@ import type { BufferedData, ConsoleLog, RawError } from '@datadog/browser-core' import { clocksNow } from '@datadog/js-core/time' -import { - BufferedDataType, - ConsoleApiName, - ErrorHandling, - ErrorSource, - Observable, - noop, - objectEntries, -} from '@datadog/browser-core' +import { ConsoleApiName } from '@datadog/js-core/util' +import { BufferedDataType, ErrorHandling, ErrorSource, Observable, noop, objectEntries } from '@datadog/browser-core' import type { RawConsoleLogsEvent } from '../../rawLogsEvent.types' import { validateAndBuildLogsConfiguration } from '../configuration' import type { RawLogsEventCollectedData } from '../lifeCycle' diff --git a/packages/browser-logs/src/domain/console/consoleCollection.ts b/packages/browser-logs/src/domain/console/consoleCollection.ts index ff1e3df30b..7711c2f4b6 100644 --- a/packages/browser-logs/src/domain/console/consoleCollection.ts +++ b/packages/browser-logs/src/domain/console/consoleCollection.ts @@ -1,7 +1,8 @@ import type { ClocksState } from '@datadog/js-core/time' import type { Context, Observable, BufferedData } from '@datadog/browser-core' import { timeStampNow } from '@datadog/js-core/time' -import { BufferedDataType, ConsoleApiName, ErrorSource } from '@datadog/browser-core' +import { BufferedDataType, ErrorSource } from '@datadog/browser-core' +import { ConsoleApiName } from '@datadog/js-core/util' import type { LogsConfiguration } from '../configuration' import type { LifeCycle, RawLogsEventCollectedData } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' diff --git a/packages/browser-logs/src/domain/logger/loggerCollection.spec.ts b/packages/browser-logs/src/domain/logger/loggerCollection.spec.ts index c0e46dc336..b6957a36db 100644 --- a/packages/browser-logs/src/domain/logger/loggerCollection.spec.ts +++ b/packages/browser-logs/src/domain/logger/loggerCollection.spec.ts @@ -1,6 +1,7 @@ import type { TimeStamp } from '@datadog/js-core/time' import { timeStampNow } from '@datadog/js-core/time' -import { ConsoleApiName, ErrorSource, originalConsoleMethods } from '@datadog/browser-core' +import { ErrorSource } from '@datadog/browser-core' +import { ConsoleApiName, originalConsoleMethods } from '@datadog/js-core/util' import { mockClock } from '@datadog/browser-core/test' import type { CommonContext, RawLoggerLogsEvent } from '../../rawLogsEvent.types' import type { RawLogsEventCollectedData } from '../lifeCycle' diff --git a/packages/browser-logs/src/domain/logger/loggerCollection.ts b/packages/browser-logs/src/domain/logger/loggerCollection.ts index f07cae5ad9..e21b247c70 100644 --- a/packages/browser-logs/src/domain/logger/loggerCollection.ts +++ b/packages/browser-logs/src/domain/logger/loggerCollection.ts @@ -1,7 +1,8 @@ import { timeStampNow } from '@datadog/js-core/time' import type { TimeStamp } from '@datadog/js-core/time' import type { Context } from '@datadog/browser-core' -import { combine, ErrorSource, originalConsoleMethods, globalConsole, ConsoleApiName } from '@datadog/browser-core' +import { combine, ErrorSource } from '@datadog/browser-core' +import { originalConsoleMethods, globalConsole, ConsoleApiName } from '@datadog/js-core/util' import type { CommonContext, RawLogsEvent } from '../../rawLogsEvent.types' import type { LifeCycle, RawLogsEventCollectedData } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' diff --git a/packages/browser-logs/src/entries/main.ts b/packages/browser-logs/src/entries/main.ts index 9c384d489e..b1abf685cb 100644 --- a/packages/browser-logs/src/entries/main.ts +++ b/packages/browser-logs/src/entries/main.ts @@ -44,10 +44,10 @@ export type { MatchOption, ProxyFn, Site, - ConsoleApiName, RawReportType, ErrorSource, } from '@datadog/browser-core' +export type { ConsoleApiName } from '@datadog/js-core/util' /** * The global Logs instance. Use this to call Logs methods. diff --git a/packages/browser-rum-core/src/domain/error/errorCollection.ts b/packages/browser-rum-core/src/domain/error/errorCollection.ts index e4bd4c972a..affff7f3a9 100644 --- a/packages/browser-rum-core/src/domain/error/errorCollection.ts +++ b/packages/browser-rum-core/src/domain/error/errorCollection.ts @@ -1,8 +1,8 @@ import type { ClocksState } from '@datadog/js-core/time' +import { ConsoleApiName } from '@datadog/js-core/util' import type { Context, RawError, BufferedData } from '@datadog/browser-core' import { BufferedDataType, - ConsoleApiName, Observable, ErrorSource, generateUUID, diff --git a/packages/browser-rum/src/domain/segmentCollection/segment.spec.ts b/packages/browser-rum/src/domain/segmentCollection/segment.spec.ts index 3e760a83dd..786afdb09c 100644 --- a/packages/browser-rum/src/domain/segmentCollection/segment.spec.ts +++ b/packages/browser-rum/src/domain/segmentCollection/segment.spec.ts @@ -1,6 +1,7 @@ import type { DeflateEncoder, Uint8ArrayBuffer } from '@datadog/browser-core' import type { TimeStamp } from '@datadog/js-core/time' -import { noop, setDebugMode, DeflateEncoderStreamId } from '@datadog/browser-core' +import { noop, DeflateEncoderStreamId } from '@datadog/browser-core' +import { setDebugMode } from '@datadog/js-core/util' import { registerCleanupTask } from '@datadog/browser-core/test' import { MockWorker } from '../../../test' import type { CreationReason, BrowserRecord, SegmentContext, BrowserSegment, BrowserSegmentMetadata } from '../../types' diff --git a/packages/js-core/AGENTS.md b/packages/js-core/AGENTS.md index 2ae0b6ec0e..f4fe655c09 100644 --- a/packages/js-core/AGENTS.md +++ b/packages/js-core/AGENTS.md @@ -46,7 +46,10 @@ Do not add: ### Sub-path exports All APIs live under a named sub-path (e.g. `@datadog/js-core/time`). There is no root entry -point. Each sub-path corresponds to a single source file under `src/entries/`. +point. Each sub-path corresponds to a single entry file under `src/entries/` — either the +implementation itself, or a thin barrel that re-exports from sibling implementation files under +`src//` (e.g. `src/entries/util.ts` re-exports from `src/util/display.ts` and +`src/util/debug.ts`). Each sub-path is exposed **two ways** for maximum compatibility: @@ -61,16 +64,28 @@ Each sub-path is exposed **two ways** for maximum compatibility: When adding a new sub-path: -1. Create `src/entries/.ts` +1. Create `src/entries/.ts` (the implementation itself, or a barrel re-exporting from sibling + files under `src//`) 2. Add `"./"` to the `exports` field in `package.json` with `import`, `require`, and `types` conditions 3. Add a physical `/package.json` with relative `main`/`module`/`types` (see `time/package.json`), and add `""` to the `files` array so it ships in the package 4. Add `"@datadog/js-core/"` to the `paths` map in the root `tsconfig.base.json`, pointing at `./packages/js-core/src/entries/` +5. Add a section for the new sub-path in `README.md` (see below) + +### README maintenance + +Every sub-path must have a corresponding section in `README.md`. When adding or changing exports: + +- Add or update the sub-path section in `README.md` with an import example and API table(s) +- **Sort all entries within each API table alphabetically** (by export name) +- Follow the existing section structure: import example → Types table (if any) → Constants table (if any) → Functions table ## Current sub-paths -| Sub-path | Source file | Description | -| ----------------------- | --------------------- | -------------- | -| `@datadog/js-core/time` | `src/entries/time.ts` | Time utilities | +| Sub-path | Entry file | Description | +| -------------------------- | ----------------------------------------------- | ----------------- | +| `@datadog/js-core/monitor` | `src/entries/monitor.ts` | Monitor utilities | +| `@datadog/js-core/time` | `src/entries/time.ts` | Time utilities | +| `@datadog/js-core/util` | `src/entries/util.ts` (barrel over `src/util/`) | General utilities | diff --git a/packages/js-core/CLAUDE.md b/packages/js-core/CLAUDE.md new file mode 100644 index 0000000000..6bff40a5fe --- /dev/null +++ b/packages/js-core/CLAUDE.md @@ -0,0 +1,3 @@ +# `@datadog/js-core` package + +See @./AGENTS.md for documentation on working with the `@datadog/js-core` package. diff --git a/packages/js-core/README.md b/packages/js-core/README.md index b05df402ec..98637433e9 100644 --- a/packages/js-core/README.md +++ b/packages/js-core/README.md @@ -76,17 +76,90 @@ import type { Duration, ServerDuration, TimeStamp, RelativeTime, ClocksState } f | Export | Description | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `addDuration(a, b)` | Adds two time values, preserving the branded type (`TimeStamp`, `RelativeTime`, or `Duration`). | +| `clockDrift()` | Clock drift in ms between `Date.now()` and `performance.now()` relative to navigation start. | | `clocksNow()` | Current time as a `ClocksState` (both relative and absolute). | | `clocksOrigin()` | Origin clocks state: `relative = 0`, `timeStamp = getTimeOrigin()`. | -| `clockDrift()` | Clock drift in ms between `Date.now()` and `performance.now()` relative to navigation start. | | `dateNow()` | Current Unix timestamp in ms. Prefer over `Date.now()` to guard against broken polyfills. | | `elapsed(start, end)` | Duration between two timestamps or relative times. | | `getTimeOrigin()` | Time origin as a `TimeStamp` (cached). Falls back to `performance.timeOrigin` when `performance.timing` is unavailable (Service Workers, Node.js). | -| `toRelativeTime(ts)` | Returns the `RelativeTime` for a given absolute `TimeStamp`. | -| `toTimeStamp(relative)` | Returns the absolute `TimeStamp` for a given `RelativeTime`. | | `isRelativeTime(time)` | Heuristic type guard: returns `true` if the value is likely a `RelativeTime` (< one year). | | `relativeNow()` | Current relative time in ms since navigation/process start (`performance.now()`). | | `relativeToClocks(relative)` | Converts a `RelativeTime` to a `ClocksState` with a drift-corrected `TimeStamp`. | | `timeStampNow()` | Current Unix timestamp as a `TimeStamp`. | | `timeStampToClocks(ts)` | Converts a `TimeStamp` to a `ClocksState` with its corresponding `RelativeTime`. | +| `toRelativeTime(ts)` | Returns the `RelativeTime` for a given absolute `TimeStamp`. | | `toServerDuration(d)` | Converts a `Duration` (ms) to a `ServerDuration` (ns). Returns `undefined` if the input is `undefined`. | +| `toTimeStamp(relative)` | Returns the absolute `TimeStamp` for a given `RelativeTime`. | + +### `@datadog/js-core/monitor` + +Error-monitoring utilities that wrap callbacks to catch, report, and suppress SDK-internal exceptions. + +Each consumer creates its own isolated monitor via `createMonitor(display, onMonitorErrorCollected)`, +so error-collection state is not shared between SDKs. The `display` (from `createDisplay` in +`@datadog/js-core/util`) controls the log prefix. Both arguments are required and fixed for the +lifetime of the monitor. Caught errors are logged to the console via the given `display`, but only +when debug mode is on (toggled globally via `setDebugMode` from `@datadog/js-core/util`). + +```ts +import { createMonitor } from '@datadog/js-core/monitor' +import { createDisplay, setDebugMode } from '@datadog/js-core/util' + +const display = createDisplay('Datadog Browser SDK:') +const { monitor, callMonitored, monitored, monitorError } = createMonitor(display, (error) => reportToTelemetry(error)) + +setDebugMode(true) // global: also log caught errors to the console +``` + +#### Functions + +| Export | Description | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `createMonitor(display, onError)` | Creates an isolated monitor using the given `Display` for debug output and required error callback. Returns instance methods. | + +#### Monitor instance (returned by `createMonitor`) + +| Method | Description | +| ------------------------------------ | ----------------------------------------------------------------------------------------------- | +| `callMonitored(fn, context?, args?)` | Calls `fn` with error handling. Returns the function's result, or `undefined` if it throws. | +| `monitor(fn)` | Wraps a function so that any thrown exception is caught and reported instead of propagating. | +| `monitored` | Legacy class-method decorator equivalent to wrapping the method with `monitor`. | +| `monitorError(e)` | Reports an error directly: logs it if debug mode is on, then forwards it to the error callback. | + +### `@datadog/js-core/util` + +General-purpose utilities. + +```ts +import { + createDisplay, + setDebugMode, + getDebugMode, + ConsoleApiName, + globalConsole, + originalConsoleMethods, +} from '@datadog/js-core/util' +import type { Display } from '@datadog/js-core/util' +``` + +#### Types + +| Export | Description | +| --------- | ------------------------------------------------------------------------ | +| `Display` | Console methods pre-bound to the original (unpatched) console, prefixed. | + +#### Constants + +| Export | Description | +| ------------------------ | ------------------------------------------------------------------------------------ | +| `ConsoleApiName` | Enum-like map of the console method names (`log`, `debug`, `info`, `warn`, `error`). | +| `globalConsole` | Alias for the global `console`, resilient to bundler `console.*` stripping. | +| `originalConsoleMethods` | The original (unpatched) console methods, captured at module load. | + +#### Functions + +| Export | Description | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `createDisplay(prefix)` | Returns a `Display` bound to the original console methods, prefixing every message with `prefix`. Guards against patched console. | +| `getDebugMode()` | Returns whether debug mode is currently enabled. | +| `setDebugMode(enabled)` | Global debug-mode toggle. SDKs check `getDebugMode()` to decide whether to emit internal diagnostic logs to the console. | diff --git a/packages/js-core/monitor/package.json b/packages/js-core/monitor/package.json new file mode 100644 index 0000000000..0f2771b3ce --- /dev/null +++ b/packages/js-core/monitor/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "main": "../cjs/entries/monitor.js", + "module": "../esm/entries/monitor.mjs", + "types": "../cjs/entries/monitor.d.ts" +} diff --git a/packages/js-core/package.json b/packages/js-core/package.json index 80bdd8c26f..133040bea3 100644 --- a/packages/js-core/package.json +++ b/packages/js-core/package.json @@ -8,6 +8,16 @@ "import": "./esm/entries/time.mjs", "require": "./cjs/entries/time.js", "types": "./cjs/entries/time.d.ts" + }, + "./monitor": { + "import": "./esm/entries/monitor.mjs", + "require": "./cjs/entries/monitor.js", + "types": "./cjs/entries/monitor.d.ts" + }, + "./util": { + "import": "./esm/entries/util.mjs", + "require": "./cjs/entries/util.js", + "types": "./cjs/entries/util.d.ts" } }, "files": [ @@ -15,6 +25,8 @@ "esm", "src", "time", + "monitor", + "util", "!src/**/*.spec.ts", "!src/**/*.specHelper.ts" ], diff --git a/packages/js-core/src/entries/monitor.spec.ts b/packages/js-core/src/entries/monitor.spec.ts new file mode 100644 index 0000000000..658c62a4a3 --- /dev/null +++ b/packages/js-core/src/entries/monitor.spec.ts @@ -0,0 +1,176 @@ +import { setDebugMode } from './util' +import type { Display } from './util' +import type { Monitor } from './monitor' +import { createMonitor } from './monitor' + +describe('monitor', () => { + let onMonitorErrorCollectedSpy: jasmine.Spy<(error: unknown) => void> + let displayErrorSpy: jasmine.Spy + let currentMonitor: Monitor + + function createFakeDisplay(): Display { + displayErrorSpy = jasmine.createSpy('display.error') + return { + debug: jasmine.createSpy(), + log: jasmine.createSpy(), + info: jasmine.createSpy(), + warn: jasmine.createSpy(), + error: displayErrorSpy, + } + } + + beforeEach(() => { + onMonitorErrorCollectedSpy = jasmine.createSpy() + currentMonitor = createMonitor(createFakeDisplay(), onMonitorErrorCollectedSpy) + }) + + describe('decorator', () => { + interface CandidateApi { + monitoredThrowing: () => void + monitoredStringErrorThrowing: () => void + monitoredObjectErrorThrowing: () => void + monitoredNotThrowing: () => number + notMonitoredThrowing: () => void + } + + let candidate: CandidateApi + + beforeEach(() => { + const { monitored } = currentMonitor + + class Candidate implements CandidateApi { + @monitored + monitoredThrowing() { + throw new Error('monitored') + } + + @monitored + monitoredStringErrorThrowing() { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'string error' + } + + @monitored + monitoredObjectErrorThrowing() { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw { foo: 'bar' } + } + + @monitored + monitoredNotThrowing() { + return 1 + } + + notMonitoredThrowing() { + throw new Error('not monitored') + } + } + + candidate = new Candidate() + }) + + it('should preserve original behavior', () => { + expect(candidate.monitoredNotThrowing()).toEqual(1) + }) + + it('should catch error', () => { + expect(() => candidate.notMonitoredThrowing()).toThrowError() + expect(() => candidate.monitoredThrowing()).not.toThrowError() + }) + + it('should report error', () => { + candidate.monitoredThrowing() + + expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith(new Error('monitored')) + }) + + it('should report string error', () => { + candidate.monitoredStringErrorThrowing() + + expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith('string error') + }) + + it('should report object error', () => { + candidate.monitoredObjectErrorThrowing() + + expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith({ foo: 'bar' }) + }) + }) + + describe('function', () => { + const notThrowing = () => 1 + const throwing = () => { + throw new Error('error') + } + + describe('callMonitored', () => { + it('should preserve original behavior', () => { + expect(currentMonitor.callMonitored(notThrowing)).toEqual(1) + }) + + it('should catch error', () => { + expect(() => currentMonitor.callMonitored(throwing)).not.toThrowError() + }) + + it('should report error', () => { + currentMonitor.callMonitored(throwing) + + expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith(new Error('error')) + }) + }) + + describe('monitor (wrapper)', () => { + it('should preserve original behavior', () => { + const decorated = currentMonitor.monitor(notThrowing) + expect(decorated()).toEqual(1) + }) + + it('should catch error', () => { + const decorated = currentMonitor.monitor(throwing) + expect(() => decorated()).not.toThrowError() + }) + + it('should report error', () => { + currentMonitor.monitor(throwing)() + + expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith(new Error('error')) + }) + }) + }) + + describe('debug logging', () => { + afterEach(() => { + setDebugMode(false) + }) + + it('does not log caught errors when debug mode is disabled', () => { + currentMonitor.callMonitored(() => { + throw new Error('message') + }) + + expect(displayErrorSpy).not.toHaveBeenCalled() + }) + + it('logs caught errors to the display when debug mode is enabled', () => { + setDebugMode(true) + + currentMonitor.callMonitored(() => { + throw new Error('message') + }) + + expect(displayErrorSpy).toHaveBeenCalledWith('[MONITOR]', new Error('message')) + }) + + it('logs errors thrown by the onMonitorErrorCollected callback when debug mode is enabled', () => { + setDebugMode(true) + onMonitorErrorCollectedSpy.and.throwError(new Error('unexpected')) + + currentMonitor.callMonitored(() => { + throw new Error('message') + }) + + expect(displayErrorSpy).toHaveBeenCalledWith('[MONITOR]', new Error('message')) + expect(displayErrorSpy).toHaveBeenCalledWith('[MONITOR]', new Error('unexpected')) + }) + }) +}) diff --git a/packages/js-core/src/entries/monitor.ts b/packages/js-core/src/entries/monitor.ts new file mode 100644 index 0000000000..e2aea91fcf --- /dev/null +++ b/packages/js-core/src/entries/monitor.ts @@ -0,0 +1,160 @@ +import { getDebugMode } from './util' +import type { Display } from './util' + +/** An isolated monitor, as returned by {@link createMonitor}. */ +export interface Monitor { + /** + * TypeScript method decorator that routes a class method through {@link Monitor.monitor}, so any + * error it throws is caught and reported instead of propagating to the caller. Apply it as + * `@monitored` on the method (it replaces the method's descriptor value with the monitored + * wrapper). + * + * When to use: prefer this for **class methods** that are entry points from outside the SDK + * (public API methods, lifecycle callbacks) where an internal error must never reach the host + * application. For standalone functions or inline blocks, use {@link Monitor.monitor} or + * {@link Monitor.callMonitored} instead. + */ + monitored: unknown>( + _: any, + __: string, + descriptor: TypedPropertyDescriptor + ) => void + + /** + * Wraps a function so that, when called, any thrown error is caught and reported (via the error + * callback) instead of propagating. The wrapper keeps the same signature as the input function. + * + * When to use: prefer this when you need a **reusable monitored callback** to hand to something + * that invokes it later, possibly multiple times — an event listener, `setTimeout`, an observable + * subscription. For a one-shot inline block, use {@link Monitor.callMonitored} instead. + * + * @param fn - The function to wrap. + * @returns A function with the same signature that never throws (errors are collected instead). + * @example + * ```ts + * element.addEventListener( + * 'click', + * monitor((event) => { + * // handler errors are collected instead of surfacing to the page + * }) + * ) + * ``` + */ + monitor: unknown>(fn: T) => T + + /** + * Invokes a function with error handling: returns its result, or reports the error (via the error + * callback) and returns `undefined` if it throws. + * + * When to use: prefer this for a **one-off inline block** you want to run immediately under error + * protection. If you instead need a callback to pass elsewhere and reuse, wrap it once with + * {@link Monitor.monitor}. + * + * @param fn - The function to invoke. + * @param context - `this` value to invoke `fn` with (optional for context-free functions). + * @param args - Arguments to invoke `fn` with (optional for context-free functions). + * @returns The result of `fn`, or `undefined` if it threw. + * @example + * ```ts + * callMonitored(() => { + * const stackTrace = computeStackTrace(error) + * reportStackTrace(stackTrace) + * }) + * ``` + */ + callMonitored: { + unknown>( + fn: T, + context: ThisParameterType, + args: Parameters + ): ReturnType | undefined + unknown>(fn: T): ReturnType | undefined + } + + /** + * Reports an error directly: logs it to the console when debug mode is enabled, then forwards it + * to the error callback. Used internally by {@link Monitor.monitor}/{@link Monitor.callMonitored}, + * but can also be called to report an error caught elsewhere. + * + * When to use: prefer this when you **already hold an error value** and only need to route it to + * telemetry — e.g. a promise rejection, which `monitor`/`callMonitored` do not catch (they only + * handle synchronous throws). + * + * @param e - The error to report. + * @example + * ```ts + * // route a promise rejection to telemetry + * doAsyncThing().catch(monitorError) + * ``` + */ + monitorError: (e: unknown) => void +} + +/** + * Creates an isolated monitor with its own error-collection callback and display. + * + * Each consumer (SDK) should create its own monitor so that error-collection callbacks do not + * clobber each other when several SDKs share the same `@datadog/js-core/monitor` module instance. + * + * @param display - {@link Display} used for debug logging (see `createDisplay` in + * `@datadog/js-core/util`). Lets the consumer control the log prefix and console binding. Debug + * output is only emitted when debug mode is enabled (see `setDebugMode`/`getDebugMode`). + * @param onMonitorErrorCollected - Callback invoked with each error caught by the monitor (e.g. to + * forward it to telemetry). Fixed for the lifetime of the monitor. + * @returns A {@link Monitor}. + */ +export function createMonitor(display: Display, onMonitorErrorCollected: (error: unknown) => void): Monitor { + function monitored unknown>( + _: any, + __: string, + descriptor: TypedPropertyDescriptor + ) { + descriptor.value = monitor(descriptor.value!) + } + + function monitor unknown>(fn: T): T { + return function (this: ThisParameterType, ...args: Parameters) { + return callMonitored(fn, this, args) + } as unknown as T // consider output type has input type + } + + function callMonitored unknown>( + fn: T, + context: ThisParameterType, + args: Parameters + ): ReturnType | undefined + function callMonitored unknown>(fn: T): ReturnType | undefined + function callMonitored unknown>( + fn: T, + context?: any, + args?: any + ): ReturnType | undefined { + try { + return fn.apply(context, args) as ReturnType + } catch (e) { + monitorError(e) + } + } + + function monitorError(e: unknown) { + const displayIfDebugEnabled = (e: unknown) => { + if (getDebugMode()) { + display.error('[MONITOR]', e) + } + } + + displayIfDebugEnabled(e) + try { + onMonitorErrorCollected(e) + } catch (e) { + displayIfDebugEnabled(e) + } + } + + return { + monitored, + monitor, + callMonitored, + monitorError, + } +} diff --git a/packages/js-core/src/entries/util.ts b/packages/js-core/src/entries/util.ts new file mode 100644 index 0000000000..3bc61dfa5b --- /dev/null +++ b/packages/js-core/src/entries/util.ts @@ -0,0 +1,3 @@ +export { createDisplay, ConsoleApiName, globalConsole, originalConsoleMethods } from '../util/display' +export type { Display } from '../util/display' +export { setDebugMode, getDebugMode } from '../util/debug' diff --git a/packages/js-core/src/util/debug.spec.ts b/packages/js-core/src/util/debug.spec.ts new file mode 100644 index 0000000000..e1e9304cfa --- /dev/null +++ b/packages/js-core/src/util/debug.spec.ts @@ -0,0 +1,19 @@ +import { getDebugMode, setDebugMode } from './debug' + +describe('debug mode', () => { + afterEach(() => { + setDebugMode(false) + }) + + it('is disabled by default', () => { + expect(getDebugMode()).toBe(false) + }) + + it('reflects the value set via setDebugMode', () => { + setDebugMode(true) + expect(getDebugMode()).toBe(true) + + setDebugMode(false) + expect(getDebugMode()).toBe(false) + }) +}) diff --git a/packages/js-core/src/util/debug.ts b/packages/js-core/src/util/debug.ts new file mode 100644 index 0000000000..de58de1460 --- /dev/null +++ b/packages/js-core/src/util/debug.ts @@ -0,0 +1,22 @@ +let debugMode = false + +/** + * Enables or disables debug mode globally. + * + * Debug mode is a process-wide toggle that SDKs check (via {@link getDebugMode}) to decide whether + * to emit internal diagnostic logs to the console. It does not affect any data sent to Datadog. + * + * @param newDebugMode - `true` to enable debug mode, `false` to disable it. + */ +export function setDebugMode(newDebugMode: boolean) { + debugMode = newDebugMode +} + +/** + * Returns whether debug mode is currently enabled (see {@link setDebugMode}). + * + * @returns `true` when debug mode is on. + */ +export function getDebugMode() { + return debugMode +} diff --git a/packages/js-core/src/util/display.spec.ts b/packages/js-core/src/util/display.spec.ts new file mode 100644 index 0000000000..ed5d63d774 --- /dev/null +++ b/packages/js-core/src/util/display.spec.ts @@ -0,0 +1,18 @@ +import { createDisplay, originalConsoleMethods } from './display' + +const CONSOLE_METHODS = ['debug', 'log', 'info', 'warn', 'error'] as const + +describe('display', () => { + CONSOLE_METHODS.forEach((method) => { + it(`${method}() forwards to the console method, prefixed`, () => { + // Spy on the captured original *before* creating the display: createDisplay binds + // `originalConsoleMethods[method]` at call time, so the bound method forwards to the spy. + const consoleSpy = spyOn(originalConsoleMethods, method) + const display = createDisplay('[PREFIX]') + + display[method]('message') + + expect(consoleSpy).toHaveBeenCalledOnceWith('[PREFIX]', 'message') + }) + }) +}) diff --git a/packages/js-core/src/util/display.ts b/packages/js-core/src/util/display.ts new file mode 100644 index 0000000000..f845b3a1c9 --- /dev/null +++ b/packages/js-core/src/util/display.ts @@ -0,0 +1,66 @@ +/** Names of the console methods wrapped by {@link Display}. */ +export const ConsoleApiName = { + log: 'log', + debug: 'debug', + info: 'info', + warn: 'warn', + error: 'error', +} as const + +/** Union of the console method names (`'log' | 'debug' | 'info' | 'warn' | 'error'`). */ +export type ConsoleApiName = (typeof ConsoleApiName)[keyof typeof ConsoleApiName] + +/** Console methods pre-bound to the original (unpatched) console implementation. */ +export interface Display { + debug: typeof console.debug + log: typeof console.log + info: typeof console.info + warn: typeof console.warn + error: typeof console.error +} + +/** + * When building JS bundles, some users might use a plugin[1] or configuration[2] to remove + * "console.*" references. This causes some issue as we expect `console.*` to be defined. + * As a workaround, let's use a variable alias, so those expressions won't be taken into account by + * simple static analysis. + * + * [1]: https://babeljs.io/docs/babel-plugin-transform-remove-console/ + * [2]: https://github.com/terser/terser#compress-options (look for drop_console) + */ +export const globalConsole = console + +/** + * Keep references on console methods to avoid triggering patched behaviors + * + * NB: in some setup, console could already be patched by another SDK. + * In this case, some display messages can be sent by the other SDK + * but we should be safe from infinite loop nonetheless. + */ +export const originalConsoleMethods: Display = { + log: globalConsole.log, + debug: globalConsole.debug, + info: globalConsole.info, + warn: globalConsole.warn, + error: globalConsole.error, +} + +/** + * Creates a {@link Display} bound to the original (unpatched) console methods, prefixing every + * message with the given prefix. + * + * Capturing the original console methods up front avoids triggering behaviors added by another SDK + * (or the host application) that may have patched `console.*` afterwards. + * + * @param prefix - String prepended to every logged message (e.g. to attribute output to an SDK). + * @returns A {@link Display} whose methods forward to the original console methods. + */ +export function createDisplay(prefix: string): Display { + return { + debug: originalConsoleMethods.debug.bind(globalConsole, prefix), + log: originalConsoleMethods.log.bind(globalConsole, prefix), + info: originalConsoleMethods.info.bind(globalConsole, prefix), + warn: originalConsoleMethods.warn.bind(globalConsole, prefix), + error: originalConsoleMethods.error.bind(globalConsole, prefix), + } +} diff --git a/packages/js-core/util/package.json b/packages/js-core/util/package.json new file mode 100644 index 0000000000..1b9b8144ce --- /dev/null +++ b/packages/js-core/util/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "main": "../cjs/entries/util.js", + "module": "../esm/entries/util.mjs", + "types": "../cjs/entries/util.d.ts" +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 686d9a1b9f..f39d58460e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -46,7 +46,9 @@ "@datadog/browser-debugger": ["./packages/browser-debugger/src/entries/main"], - "@datadog/js-core/time": ["./packages/js-core/src/entries/time"] + "@datadog/js-core/time": ["./packages/js-core/src/entries/time"], + "@datadog/js-core/monitor": ["./packages/js-core/src/entries/monitor"], + "@datadog/js-core/util": ["./packages/js-core/src/entries/util"] } } } diff --git a/yarn.lock b/yarn.lock index ae7c28bcbf..7e53114d9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -652,6 +652,16 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:1.11.0": + version: 1.11.0 + resolution: "@emnapi/core@npm:1.11.0" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.2" + tslib: "npm:^2.4.0" + checksum: 10c0/268498d6ea42ff1e51d543725c7c9c3ce01d39be19a5790d1587ebe98dcfb9329666f0ce9864bb23e067caa064199a45ec2c63c4050b5e42166c1d7d40750135 + languageName: node + linkType: hard + "@emnapi/runtime@npm:1.10.0": version: 1.10.0 resolution: "@emnapi/runtime@npm:1.10.0" @@ -661,7 +671,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.7.0": +"@emnapi/runtime@npm:1.11.0, @emnapi/runtime@npm:^1.7.0": version: 1.11.0 resolution: "@emnapi/runtime@npm:1.11.0" dependencies: @@ -679,6 +689,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/wasi-threads@npm:1.2.2": + version: 1.2.2 + resolution: "@emnapi/wasi-threads@npm:1.2.2" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/f0dc8269d6b20ae5a7c7b36e7a6a333452009d461038ef4febb29da2f3f78c1e2b1576d7e8970a5c5789ed3caedc1f80f5b0c2a5373bdaf8d03b20432bb55747 + languageName: node + linkType: hard + "@es-joy/jsdoccomment@npm:~0.87.0": version: 0.87.0 resolution: "@es-joy/jsdoccomment@npm:0.87.0" @@ -1672,6 +1691,18 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^1.1.5": + version: 1.1.5 + resolution: "@napi-rs/wasm-runtime@npm:1.1.5" + dependencies: + "@tybys/wasm-util": "npm:^0.10.2" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/727f2b6ae0e68bbe5d39aeb68aa6f183314e9f03dc50bb55a962849535b2db53ecc3fbf1554d8656a54488a608df5a2634670595cf5874dc4af2ee59f817c65d + languageName: node + linkType: hard + "@next/env@npm:16.2.7": version: 16.2.7 resolution: "@next/env@npm:16.2.7" @@ -1742,17 +1773,10 @@ __metadata: languageName: node linkType: hard -"@oxc-project/types@npm:=0.133.0": - version: 0.133.0 - resolution: "@oxc-project/types@npm:0.133.0" - checksum: 10c0/70c57ba58644f7ec217b670c301801f4d06995f4ccdba6b2bd106ea3e5ee49d616573e6ef8d55530b87571a960696543687f3850e87ad173d3f88965c30cdd63 - languageName: node - linkType: hard - -"@oxc-project/types@npm:=0.134.0": - version: 0.134.0 - resolution: "@oxc-project/types@npm:0.134.0" - checksum: 10c0/d90ee664206091d539a24de6ec703bdb2124e1181680609ac3c27326273a3189c5bb6e0177f3171c85a78e8c01eb43a9903cbbe13257053c7df141c030579c16 +"@oxc-project/types@npm:=0.135.0": + version: 0.135.0 + resolution: "@oxc-project/types@npm:0.135.0" + checksum: 10c0/7ae7e65fb044733c6016381b27e84868bbf2fcba85f7e2f7dc28caf59cb7303b60b736e709d599a4495e7297aa7183b5e6366f96f2f692455d1b6d225cfe79b4 languageName: node linkType: hard @@ -1841,220 +1865,111 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-android-arm64@npm:1.0.3" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-android-arm64@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-android-arm64@npm:1.1.0" +"@rolldown/binding-android-arm64@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-android-arm64@npm:1.1.1" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-darwin-arm64@npm:1.0.3" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-darwin-arm64@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-darwin-arm64@npm:1.1.0" +"@rolldown/binding-darwin-arm64@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-darwin-arm64@npm:1.1.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-darwin-x64@npm:1.0.3" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@rolldown/binding-darwin-x64@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-darwin-x64@npm:1.1.0" +"@rolldown/binding-darwin-x64@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-darwin-x64@npm:1.1.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-freebsd-x64@npm:1.0.3" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"@rolldown/binding-freebsd-x64@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-freebsd-x64@npm:1.1.0" +"@rolldown/binding-freebsd-x64@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-freebsd-x64@npm:1.1.1" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.3" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@rolldown/binding-linux-arm-gnueabihf@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.1.0" +"@rolldown/binding-linux-arm-gnueabihf@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.1.1" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.3" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-arm64-gnu@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.1.0" +"@rolldown/binding-linux-arm64-gnu@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.1.1" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.3" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"@rolldown/binding-linux-arm64-musl@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.1.0" +"@rolldown/binding-linux-arm64-musl@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.1.1" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rolldown/binding-linux-ppc64-gnu@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.3" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-ppc64-gnu@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.1.0" +"@rolldown/binding-linux-ppc64-gnu@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.1.1" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-s390x-gnu@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.3" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-s390x-gnu@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.1.0" +"@rolldown/binding-linux-s390x-gnu@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.1.1" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.3" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-x64-gnu@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.1.0" +"@rolldown/binding-linux-x64-gnu@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.1.1" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.3" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"@rolldown/binding-linux-x64-musl@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.1.0" +"@rolldown/binding-linux-x64-musl@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.1.1" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.3" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-openharmony-arm64@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.1.0" +"@rolldown/binding-openharmony-arm64@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.1.1" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-wasm32-wasi@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.3" - dependencies: - "@emnapi/core": "npm:1.10.0" - "@emnapi/runtime": "npm:1.10.0" - "@napi-rs/wasm-runtime": "npm:^1.1.4" - conditions: cpu=wasm32 - languageName: node - linkType: hard - -"@rolldown/binding-wasm32-wasi@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.1.0" +"@rolldown/binding-wasm32-wasi@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.1.1" dependencies: - "@emnapi/core": "npm:1.10.0" - "@emnapi/runtime": "npm:1.10.0" - "@napi-rs/wasm-runtime": "npm:^1.1.4" + "@emnapi/core": "npm:1.11.0" + "@emnapi/runtime": "npm:1.11.0" + "@napi-rs/wasm-runtime": "npm:^1.1.5" conditions: cpu=wasm32 languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.3" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-win32-arm64-msvc@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.1.0" +"@rolldown/binding-win32-arm64-msvc@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.1.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.0.3": - version: 1.0.3 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.3" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@rolldown/binding-win32-x64-msvc@npm:1.1.0": - version: 1.1.0 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.1.0" +"@rolldown/binding-win32-x64-msvc@npm:1.1.1": + version: 1.1.1 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.1.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -2365,7 +2280,7 @@ __metadata: languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.10.1": +"@tybys/wasm-util@npm:^0.10.1, @tybys/wasm-util@npm:^0.10.2": version: 0.10.2 resolution: "@tybys/wasm-util@npm:0.10.2" dependencies: @@ -9795,84 +9710,26 @@ __metadata: languageName: node linkType: hard -"rolldown@npm:1.0.3": - version: 1.0.3 - resolution: "rolldown@npm:1.0.3" - dependencies: - "@oxc-project/types": "npm:=0.133.0" - "@rolldown/binding-android-arm64": "npm:1.0.3" - "@rolldown/binding-darwin-arm64": "npm:1.0.3" - "@rolldown/binding-darwin-x64": "npm:1.0.3" - "@rolldown/binding-freebsd-x64": "npm:1.0.3" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.3" - "@rolldown/binding-linux-arm64-gnu": "npm:1.0.3" - "@rolldown/binding-linux-arm64-musl": "npm:1.0.3" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.3" - "@rolldown/binding-linux-s390x-gnu": "npm:1.0.3" - "@rolldown/binding-linux-x64-gnu": "npm:1.0.3" - "@rolldown/binding-linux-x64-musl": "npm:1.0.3" - "@rolldown/binding-openharmony-arm64": "npm:1.0.3" - "@rolldown/binding-wasm32-wasi": "npm:1.0.3" - "@rolldown/binding-win32-arm64-msvc": "npm:1.0.3" - "@rolldown/binding-win32-x64-msvc": "npm:1.0.3" - "@rolldown/pluginutils": "npm:^1.0.0" - dependenciesMeta: - "@rolldown/binding-android-arm64": - optional: true - "@rolldown/binding-darwin-arm64": - optional: true - "@rolldown/binding-darwin-x64": - optional: true - "@rolldown/binding-freebsd-x64": - optional: true - "@rolldown/binding-linux-arm-gnueabihf": - optional: true - "@rolldown/binding-linux-arm64-gnu": - optional: true - "@rolldown/binding-linux-arm64-musl": - optional: true - "@rolldown/binding-linux-ppc64-gnu": - optional: true - "@rolldown/binding-linux-s390x-gnu": - optional: true - "@rolldown/binding-linux-x64-gnu": - optional: true - "@rolldown/binding-linux-x64-musl": - optional: true - "@rolldown/binding-openharmony-arm64": - optional: true - "@rolldown/binding-wasm32-wasi": - optional: true - "@rolldown/binding-win32-arm64-msvc": - optional: true - "@rolldown/binding-win32-x64-msvc": - optional: true - bin: - rolldown: ./bin/cli.mjs - checksum: 10c0/5f9dd47b7abf203b16bc600db68542f245e974c800e59ff50b76157d1dada1403657690435b036fabca88e93d13a67c31abe5cfaa6f61ce33717f61720204cdf - languageName: node - linkType: hard - -"rolldown@npm:~1.1.0": - version: 1.1.0 - resolution: "rolldown@npm:1.1.0" - dependencies: - "@oxc-project/types": "npm:=0.134.0" - "@rolldown/binding-android-arm64": "npm:1.1.0" - "@rolldown/binding-darwin-arm64": "npm:1.1.0" - "@rolldown/binding-darwin-x64": "npm:1.1.0" - "@rolldown/binding-freebsd-x64": "npm:1.1.0" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.1.0" - "@rolldown/binding-linux-arm64-gnu": "npm:1.1.0" - "@rolldown/binding-linux-arm64-musl": "npm:1.1.0" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.1.0" - "@rolldown/binding-linux-s390x-gnu": "npm:1.1.0" - "@rolldown/binding-linux-x64-gnu": "npm:1.1.0" - "@rolldown/binding-linux-x64-musl": "npm:1.1.0" - "@rolldown/binding-openharmony-arm64": "npm:1.1.0" - "@rolldown/binding-wasm32-wasi": "npm:1.1.0" - "@rolldown/binding-win32-arm64-msvc": "npm:1.1.0" - "@rolldown/binding-win32-x64-msvc": "npm:1.1.0" +"rolldown@npm:1.1.1": + version: 1.1.1 + resolution: "rolldown@npm:1.1.1" + dependencies: + "@oxc-project/types": "npm:=0.135.0" + "@rolldown/binding-android-arm64": "npm:1.1.1" + "@rolldown/binding-darwin-arm64": "npm:1.1.1" + "@rolldown/binding-darwin-x64": "npm:1.1.1" + "@rolldown/binding-freebsd-x64": "npm:1.1.1" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.1.1" + "@rolldown/binding-linux-arm64-gnu": "npm:1.1.1" + "@rolldown/binding-linux-arm64-musl": "npm:1.1.1" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.1.1" + "@rolldown/binding-linux-s390x-gnu": "npm:1.1.1" + "@rolldown/binding-linux-x64-gnu": "npm:1.1.1" + "@rolldown/binding-linux-x64-musl": "npm:1.1.1" + "@rolldown/binding-openharmony-arm64": "npm:1.1.1" + "@rolldown/binding-wasm32-wasi": "npm:1.1.1" + "@rolldown/binding-win32-arm64-msvc": "npm:1.1.1" + "@rolldown/binding-win32-x64-msvc": "npm:1.1.1" "@rolldown/pluginutils": "npm:^1.0.0" dependenciesMeta: "@rolldown/binding-android-arm64": @@ -9907,7 +9764,7 @@ __metadata: optional: true bin: rolldown: ./bin/cli.mjs - checksum: 10c0/9502a829af6aa274d4b93944f737b5c65b929b39ce67f5fed0f48235b1bf6d0806d54b4e26eb13dff311518afb8b4d60d503e96156cc941c1ed5afed37dac031 + checksum: 10c0/85500de91284b4abbffb3e8c68b2538d9d766ed9482bc4fd59247c8d1eb42d71ef00f44be56642dca298e2ea00b12208dde5478746fbc8777fde05a0720d0e6d languageName: node linkType: hard