diff --git a/README.md b/README.md index 9fb91758..7bda1a9f 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,9 @@ A set of plugins to interact with Datadog directly from your builds. - [Webpack](#-webpack) - [Features](#features) - [RUM](#rum----) - - [Telemetry](#telemetry--) + - [Telemetry](#telemetry----) - [Configuration](#configuration) - [`auth.apiKey`](#authapikey) - - [`auth.endPoint`](#authendpoint) - [`logLevel`](#loglevel) - [Contributing](#contributing) - [License](#license) @@ -216,7 +215,7 @@ datadogWebpackPlugin({ [📝 Full documentation ➡️](./packages/plugins/rum#readme) -### Telemetry ESBuild Webpack +### Telemetry ESBuild Rollup Vite Webpack > Display and send telemetry data as metrics to Datadog. @@ -224,15 +223,14 @@ datadogWebpackPlugin({ datadogWebpackPlugin({ telemetry?: { disabled?: boolean, + enableTracing?: boolean, + endPoint?: string, output?: boolean | string | { destination: string, timings?: boolean, - dependencies?: boolean, - bundler?: boolean, metrics?: boolean, - logs?: boolean, }, prefix?: string, tags?: string[], @@ -268,15 +266,14 @@ datadogWebpackPlugin({ }; telemetry?: { disabled?: boolean; + enableTracing?: boolean; + endPoint?: string; output?: boolean | string | { destination: string; timings?: boolean; - dependencies?: boolean; - bundler?: boolean; metrics?: boolean; - logs?: boolean; }; prefix?: string; tags?: string[]; @@ -293,12 +290,6 @@ datadogWebpackPlugin({ In order to interact with Datadog, you have to use [your own API Key](https://app.datadoghq.com/organization-settings/api-keys). -### `auth.endPoint` - -> default: `"app.datadoghq.com"` - -To which endpoint will the metrics be sent. - ### `logLevel` > default: `'warn'` diff --git a/lint-staged.config.js b/lint-staged.config.js index 707a129e..a02583c2 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -3,6 +3,11 @@ // Copyright 2019-Present Datadog, Inc. module.exports = { - '*.{ts,tsx}': () => ['yarn typecheck:all', 'yarn format', 'git add'], + '*.{ts,tsx}': (filenames) => [ + 'yarn typecheck:all', + `eslint ${filenames.join(' ')} --quiet --fix`, + 'git add', + ], + '*.{js,mjs}': (filenames) => [`eslint ${filenames.join(' ')} --quiet --fix`, 'git add'], relative: 'true', }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7a916e80..2cfa425d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -17,7 +17,7 @@ import type { UnpluginContextMeta, UnpluginOptions } from 'unplugin'; import type { TrackedFilesMatcher } from './plugins/git/trackedFilesMatcher'; -type Assign = Omit & B; +export type Assign = Omit & B; export interface RepositoryData { hash: string; @@ -88,7 +88,6 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none'; export type AuthOptions = { apiKey?: string; - endPoint?: string; }; export interface GetPluginsOptions { diff --git a/packages/plugins/telemetry/README.md b/packages/plugins/telemetry/README.md index 9f103b3f..8c2ba4ff 100644 --- a/packages/plugins/telemetry/README.md +++ b/packages/plugins/telemetry/README.md @@ -15,6 +15,8 @@ Display and send telemetry data as metrics to Datadog. - [Configuration](#configuration) - [`disabled`](#disabled) + - [`enableTracing`](#enabletracing) + - [`endPoint`](#endpoint) - [`output`](#output) - [`prefix`](#prefix) - [`tags`](#tags) @@ -29,15 +31,14 @@ Display and send telemetry data as metrics to Datadog. ```ts telemetry?: { disabled?: boolean; + enableTracing?: boolean; + endPoint?: string; output?: boolean | string | { destination: string; timings?: boolean; - dependencies?: boolean; - bundler?: boolean; metrics?: boolean; - logs?: boolean; }; prefix?: string; tags?: string[]; @@ -52,30 +53,46 @@ telemetry?: { Plugin will be disabled and won't track anything. +### `enableTracing` + +> default: `false` + +> [!NOTE] +> This option is **deprecated** and will most likely be removed in the future. + +If `true`, it will add tracing data to the metrics. + +But it is way more time consuming on the build. + +And only supports Webpack Webpack and Esbuild Esbuild (for now). + +### `endPoint` + +> default: `"app.datadoghq.com"` + +To which endpoint will the metrics be sent. + ### `output` > default: `true` -If `true`, you'll get all outputs in the logs and the creation of the json files. -If a path, you'll also save json files at this location: - -- `dependencies.json`: track all dependencies and dependents of your modules. +If `true`, you'll get the creation of both json files: - `metrics.json`: an array of all the metrics that would be sent to Datadog. -- `bundler.json`: some 'stats' from your bundler. - `timings.json`: timing data for modules, loaders and plugins. +If a path, it will save the files at this location. + You can also pass an object of the form: ```javascript { destination: 'path/to/destination', timings: true, - // This will only output the metrics file and nothing in the logs. - logs: false, + metrics: false, } ``` -To only output a specified file. +To only output a specific file. ### `prefix` @@ -97,7 +114,7 @@ Which timestamp to use when submitting your metrics. ### `filters` -> default: [`[ filterTreeMetrics, filterSourcemapsAndNodeModules, filterMetricsOnThreshold ]`](../../core/src/helpers.ts) +> default: [`[ filterTreeMetrics, filterSourcemapsAndNodeModules, filterMetricsOnThreshold ]`](packages/telemetry/src/common/filters.ts) You can add as many filters as you want. They are just functions getting the `metric` as an argument. @@ -157,38 +174,40 @@ datadogWebpackPlugin({ ## Metrics > [!CAUTION] -> Please note that this plugin can generate a lot of metrics, you can greatly reduce their number by tweaking the [`datadog.filters`](./#datadogfilters). - -Here's the list of metrics and their corresponding tags: - -| Metric | Tags | Type | Description | -| :---------------------------------- | :----------------------------------------- | :----------- | :----------------------------------------------------- | -| `${prefix}.assets.count` | `[]` | count | Number of assets. | -| `${prefix}.assets.size` | `[assetName:${name}, assetType:${type}]` | bytes | Size of an asset file. | -| `${prefix}.chunks.count` | `[]` | count | Number of chunks. | -| `${prefix}.chunks.modules.count` | `[chunkName:${name}]` | count | Number of modules in a chunk. | -| `${prefix}.chunks.size` | `[chunkName:${name}]` | bytes | Size of a chunk. | -| `${prefix}.compilation.duration` | `[]` | milliseconds | Duration of the build. | -| `${prefix}.entries.assets.count` | `[entryName:${name}]` | count | Number of assets from an entry. | -| `${prefix}.entries.chunks.count` | `[entryName:${name}]` | count | Number of chunks from an entry. | -| `${prefix}.entries.count` | `[]` | count | Number of entries. | -| `${prefix}.entries.modules.count` | `[entryName:${name}]` | count | Number of modules from an entry. | -| `${prefix}.entries.size` | `[entryName:${name}]` | bytes | Size of an entry file. | -| `${prefix}.errors.count` | `[]` | count | Number of errors generated by the build. | -| `${prefix}.loaders.count` | `[]` | count | Number of loaders. | -| `${prefix}.loaders.duration` | `[loaderName:${name}]` | milliseconds | Runtime duration of a loader. | -| `${prefix}.loaders.increment` | `[loaderName:${name}]` | count | Number of hit a loader had. | -| `${prefix}.modules.count` | `[]` | count | Number of modules. | -| `${prefix}.modules.dependencies` | `[moduleName:${name}, moduleType:${type}]` | count | Number of dependencies a module has. | -| `${prefix}.modules.dependents` | `[moduleName:${name}, moduleType:${type}]` | count | Number of dependents a module has. | -| `${prefix}.modules.size` | `[moduleName:${name}, moduleType:${type}]` | bytes | Size of a module. | -| `${prefix}.plugins.count` | `[]` | count | Number of plugins. | -| `${prefix}.plugins.duration` | `[pluginName:${name}]` | milliseconds | Total runtime duration of a plugin. | -| `${prefix}.plugins.hooks.duration` | `[pluginName:${name}, hookName:${name}]` | milliseconds | Runtime duration of a hook. | -| `${prefix}.plugins.hooks.increment` | `[pluginName:${name}, hookName:${name}]` | count | Number of hit a hook had. | -| `${prefix}.plugins.increment` | `[pluginName:${name}]` | count | Number of hit a plugin had. | -| `${prefix}.plugins.meta.duration` | `[pluginName:datadogwebpackplugin]` | milliseconds | Duration of the process of the Webpack Datadog plugin. | -| `${prefix}.warnings.count` | `[]` | count | Number of warnings generated by the build. | +> Please note that this plugin can generate a lot of metrics, you can greatly reduce their number by tweaking the [`datadog.filters`](./#filters). + +Here's the list of the metrics that are computed by default: + +| Metric | Tags | Type | Description | +| :---------------------------------- | :------------------------------------------------------------ | :----------- | :----------------------------------------------------- | +| `${prefix}.assets.count` | `[]` | count | Number of assets. | +| `${prefix}.assets.size` | `[assetName:${name}, assetType:${type}, entryName:${name}]` | bytes | Size of an asset file. | +| `${prefix}.assets.modules.count` | `[assetName:${name}, assetType:${type}, entryName:${name}]` | count | Number of modules in a chunk. | +| `${prefix}.compilation.duration` | `[]` | milliseconds | Duration of the build. | +| `${prefix}.entries.assets.count` | `[entryName:${name}]` | count | Number of assets from an entry. | +| `${prefix}.entries.count` | `[]` | count | Number of entries. | +| `${prefix}.entries.modules.count` | `[entryName:${name}]` | count | Number of modules from an entry. | +| `${prefix}.entries.size` | `[entryName:${name}]` | bytes | Total size of an entry (and all its assets). | +| `${prefix}.errors.count` | `[]` | count | Number of errors generated by the build. | +| `${prefix}.modules.count` | `[]` | count | Number of modules. | +| `${prefix}.modules.dependencies` | `[moduleName:${name}, moduleType:${type}, assetName:${name}, entryName:${name}]` | count | Number of dependencies a module has. | +| `${prefix}.modules.dependents` | `[moduleName:${name}, moduleType:${type}, assetName:${name}, entryName:${name}]` | count | Number of dependents a module has. | +| `${prefix}.modules.size` | `[moduleName:${name}, moduleType:${type}, assetName:${name}, entryName:${name}]` | bytes | Size of a module. | +| `${prefix}.plugins.meta.duration` | `[pluginName:datadogwebpackplugin]` | milliseconds | Duration of the process of the Webpack Datadog plugin. | +| `${prefix}.warnings.count` | `[]` | count | Number of warnings generated by the build. | + +We also have some metrics that are only available to `esbuild` and `webpack` when the [`enableTracing`](#enableTracing) option is set to `true`: + +| Metric | Tags | Type | Description | +| :---------------------------------- | :--------------------------------------- | :----------- | :---------------------------------- | +| `${prefix}.loaders.count` | `[]` | count | Number of loaders. | +| `${prefix}.loaders.duration` | `[loaderName:${name}]` | milliseconds | Runtime duration of a loader. | +| `${prefix}.loaders.increment` | `[loaderName:${name}]` | count | Number of hit a loader had. | +| `${prefix}.plugins.count` | `[]` | count | Number of plugins. | +| `${prefix}.plugins.duration` | `[pluginName:${name}]` | milliseconds | Total runtime duration of a plugin. | +| `${prefix}.plugins.hooks.duration` | `[pluginName:${name}, hookName:${name}]` | milliseconds | Runtime duration of a hook. | +| `${prefix}.plugins.hooks.increment` | `[pluginName:${name}, hookName:${name}]` | count | Number of hit a hook had. | +| `${prefix}.plugins.increment` | `[pluginName:${name}]` | count | Number of hit a plugin had. | ## Dashboard diff --git a/packages/plugins/telemetry/src/common/aggregator.ts b/packages/plugins/telemetry/src/common/aggregator.ts index 06435f3a..c2208517 100644 --- a/packages/plugins/telemetry/src/common/aggregator.ts +++ b/packages/plugins/telemetry/src/common/aggregator.ts @@ -2,78 +2,208 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { - Report, - StatsJson, - BundlerStats, - EsbuildStats, - Metric, - MetricToSend, - OptionsDD, -} from '../types'; +import type { Entry, File, GlobalContext, Output } from '@dd/core/types'; + +import type { Metric, MetricToSend, OptionsDD, Report } from '../types'; import { getMetric } from './helpers'; -import { - getGenerals, - getGeneralReport, - getPlugins, - getLoaders, - getDependencies, -} from './metrics/common'; -import * as es from './metrics/esbuild'; -import * as wp from './metrics/webpack'; - -const getWebpackMetrics = (statsJson: StatsJson, cwd: string) => { - const metrics: Metric[] = []; - const indexed = wp.getIndexed(statsJson, cwd); - metrics.push(...wp.getModules(statsJson, indexed, cwd)); - metrics.push(...wp.getChunks(statsJson, indexed)); - metrics.push(...wp.getAssets(statsJson, indexed)); - metrics.push(...wp.getEntries(statsJson, indexed)); - return metrics; +import { getPlugins, getLoaders } from './metrics/common'; + +const getModuleEntryTags = (file: File, entries: Entry[]) => { + const entryNames: string[] = entries + .filter((entry) => { + const foundModules = entry.inputs.filter((input) => { + return input.name === file.name; + }); + return foundModules.length; + }) + .map((entry) => entry.name); + + return Array.from(new Set(entryNames)).map((entryName) => `entryName:${entryName}`); +}; + +const getAssetEntryTags = (file: File, entries: Entry[]) => { + // Include sourcemaps in the tagging. + const cleanAssetName = file.name.replace(/\.map$/, ''); + const entryNames: string[] = entries + .filter((entry) => { + const foundModules = entry.outputs.filter((output) => { + return output.name === cleanAssetName; + }); + return foundModules.length; + }) + .map((entry) => entry.name); + + return Array.from(new Set(entryNames)).map((entryName) => `entryName:${entryName}`); +}; + +const getModuleAssetTags = (file: File, outputs: Output[]) => { + const assetNames: string[] = outputs + .filter((output) => { + return output.inputs.find((input) => input.filepath === file.filepath); + }) + .map((output) => output.name); + + return Array.from(new Set(assetNames)).map((assetName) => `assetName:${assetName}`); }; -const getEsbuildMetrics = (stats: EsbuildStats, cwd: string) => { +const getUniversalMetrics = (globalContext: GlobalContext) => { const metrics: Metric[] = []; - const indexed = es.getIndexed(stats, cwd); - metrics.push(...es.getModules(stats, indexed, cwd)); - metrics.push(...es.getAssets(stats, indexed, cwd)); - metrics.push(...es.getEntries(stats, indexed, cwd)); + const inputs = globalContext.build.inputs || []; + const outputs = globalContext.build.outputs || []; + const entries = globalContext.build.entries || []; + const nbWarnings = globalContext.build.warnings.length; + const nbErrors = globalContext.build.errors.length; + const duration = globalContext.build.duration; + + // Counts + metrics.push( + { + metric: 'assets.count', + type: 'count', + value: outputs.length, + tags: [], + }, + { + metric: 'entries.count', + type: 'count', + value: entries.length, + tags: [], + }, + { + metric: 'errors.count', + type: 'count', + value: nbErrors, + tags: [], + }, + { + metric: 'modules.count', + type: 'count', + value: inputs.length, + tags: [], + }, + { + metric: 'warnings.count', + type: 'count', + value: nbWarnings, + tags: [], + }, + ); + + if (duration) { + metrics.push({ + metric: 'compilation.duration', + type: 'duration', + value: duration, + tags: [], + }); + } + + // Modules + for (const input of inputs) { + const tags = [ + `moduleName:${input.name}`, + `moduleType:${input.type}`, + ...getModuleEntryTags(input, entries), + ...getModuleAssetTags(input, outputs), + ]; + metrics.push( + { + metric: 'modules.size', + type: 'size', + value: input.size, + tags, + }, + { + metric: 'modules.dependencies', + type: 'count', + value: input.dependencies.length, + tags, + }, + { + metric: 'modules.dependents', + type: 'count', + value: input.dependents.length, + tags, + }, + ); + } + + // Assets + for (const output of outputs) { + metrics.push( + { + metric: 'assets.size', + type: 'size', + value: output.size, + tags: [ + `assetName:${output.name}`, + `assetType:${output.type}`, + ...getAssetEntryTags(output, entries), + ], + }, + { + metric: 'assets.modules.count', + type: 'count', + value: output.inputs.length, + tags: [ + `assetName:${output.name}`, + `assetType:${output.type}`, + ...getAssetEntryTags(output, entries), + ], + }, + ); + } + + // Entries + for (const entry of entries) { + const tags = [`entryName:${entry.name}`]; + metrics.push( + { + metric: 'entries.size', + type: 'size', + value: entry.size, + tags, + }, + { + metric: 'entries.modules.count', + type: 'count', + value: entry.inputs.length, + tags, + }, + { + metric: 'entries.assets.count', + type: 'count', + value: entry.outputs.length, + tags, + }, + ); + } + return metrics; }; export const getMetrics = ( + globalContext: GlobalContext, optionsDD: OptionsDD, - report: Report, - bundler: BundlerStats, - cwd: string, + report?: Report, ): MetricToSend[] => { - const { timings, dependencies } = report; const metrics: Metric[] = []; - metrics.push(...getGenerals(getGeneralReport(report, bundler))); + if (report) { + const { timings } = report; - if (timings) { - if (timings.tapables) { - metrics.push(...getPlugins(timings.tapables)); - } - if (timings.loaders) { - metrics.push(...getLoaders(timings.loaders)); + if (timings) { + if (timings.tapables) { + metrics.push(...getPlugins(timings.tapables)); + } + if (timings.loaders) { + metrics.push(...getLoaders(timings.loaders)); + } } } - if (dependencies) { - metrics.push(...getDependencies(Object.values(dependencies))); - } - - if (bundler.webpack) { - const statsJson = bundler.webpack.toJson({ children: false }); - metrics.push(...getWebpackMetrics(statsJson, cwd)); - } - - if (bundler.esbuild) { - metrics.push(...getEsbuildMetrics(bundler.esbuild, cwd)); - } + metrics.push(...getUniversalMetrics(globalContext)); // Format metrics to be DD ready and apply filters const metricsToSend: MetricToSend[] = metrics diff --git a/packages/plugins/telemetry/src/common/helpers.ts b/packages/plugins/telemetry/src/common/helpers.ts index 3e480523..836c6fc4 100644 --- a/packages/plugins/telemetry/src/common/helpers.ts +++ b/packages/plugins/telemetry/src/common/helpers.ts @@ -14,13 +14,23 @@ import type { Module, Compilation, ValueContext, + TelemetryOptionsWithDefaults, } from '../types'; import { defaultFilters } from './filters'; -export const validateOptions = (options: OptionsWithTelemetry): TelemetryOptions => { - const validatedOptions: TelemetryOptions = options[CONFIG_KEY] || { disabled: false }; - return validatedOptions; +export const validateOptions = (opts: OptionsWithTelemetry): TelemetryOptionsWithDefaults => { + const options: TelemetryOptions = opts[CONFIG_KEY] || {}; + return { + disabled: false, + enableTracing: false, + endPoint: 'app.datadoghq.com', + filters: defaultFilters, + output: false, + prefix: '', + tags: [], + ...options, + }; }; export const getMetric = (metric: Metric, opts: OptionsDD): MetricToSend => ({ @@ -30,16 +40,12 @@ export const getMetric = (metric: Metric, opts: OptionsDD): MetricToSend => ({ points: [[opts.timestamp, metric.value]], }); -export const flattened = (arr: any[]) => [].concat(...arr); - -export const getType = (name: string) => (name.includes('.') ? name.split('.').pop() : 'unknown'); - -export const getOptionsDD = (options: TelemetryOptions): OptionsDD => { +export const getOptionsDD = (options: TelemetryOptionsWithDefaults): OptionsDD => { return { timestamp: Math.floor((options.timestamp || Date.now()) / 1000), - tags: options.tags || [], - prefix: options.prefix || '', - filters: options.filters || defaultFilters, + tags: options.tags, + prefix: options.prefix, + filters: options.filters, }; }; @@ -109,17 +115,6 @@ export const getModuleName = (module: Module, compilation: Compilation, context? return formatModuleName(name || 'no-name', context); }; -export const getModuleSize = (module?: Module): number => { - if (!module) { - return 0; - } - - if (typeof module.size === 'function') { - return module.size(); - } - return module.size; -}; - // Format the loader's name by extracting it from the query. // "[...]/node_modules/babel-loader/lib/index.js" => babel-loader const formatLoaderName = (loader: string) => diff --git a/packages/plugins/telemetry/src/common/metrics/common.ts b/packages/plugins/telemetry/src/common/metrics/common.ts index b7a7c960..e7e21bc8 100644 --- a/packages/plugins/telemetry/src/common/metrics/common.ts +++ b/packages/plugins/telemetry/src/common/metrics/common.ts @@ -2,88 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { Report, LocalModule, TimingsMap, BundlerStats, Metric } from '../../types'; -import { flattened, getType } from '../helpers'; - -interface GeneralReport { - modules?: number; - chunks?: number; - assets?: number; - errors?: number; - warnings?: number; - entries?: number; - duration?: number; -} - -export const getGeneralReport = (report: Report, bundler: BundlerStats): GeneralReport => { - if (bundler.webpack) { - const stats = bundler.webpack.toJson({ children: false }); - return { - modules: stats.modules.length, - chunks: stats.chunks.length, - assets: stats.assets.length, - warnings: stats.warnings.length, - errors: stats.errors.length, - entries: Object.keys(stats.entrypoints).length, - duration: stats.time, - }; - } else if (bundler.esbuild) { - const stats = bundler.esbuild; - return { - modules: report.dependencies ? Object.keys(report.dependencies).length : 0, - chunks: undefined, - assets: stats.outputs ? Object.keys(stats.outputs).length : 0, - warnings: stats.warnings.length, - errors: stats.errors.length, - entries: stats.entrypoints ? Object.keys(stats.entrypoints).length : undefined, - duration: stats.duration, - }; - } - return {}; -}; - -export const getGenerals = (report: GeneralReport): Metric[] => { - const { duration, ...extracted } = report; - const metrics: Metric[] = []; - - for (const [key, value] of Object.entries(extracted)) { - metrics.push({ - metric: `${key}.count`, - type: 'count', - value, - tags: [], - }); - } - - if (report.duration) { - metrics.push({ - metric: 'compilation.duration', - type: 'duration', - value: report.duration, - tags: [], - }); - } - - return metrics; -}; - -export const getDependencies = (modules: LocalModule[]): Metric[] => - flattened( - modules.map((m) => [ - { - metric: 'modules.dependencies', - type: 'count', - value: m.dependencies.length, - tags: [`moduleName:${m.name}`, `moduleType:${getType(m.name)}`], - }, - { - metric: 'modules.dependents', - type: 'count', - value: m.dependents.length, - tags: [`moduleName:${m.name}`, `moduleType:${getType(m.name)}`], - }, - ]), - ); +import type { TimingsMap, Metric } from '../../types'; export const getPlugins = (plugins: TimingsMap): Metric[] => { const metrics: Metric[] = []; diff --git a/packages/plugins/telemetry/src/common/metrics/esbuild.ts b/packages/plugins/telemetry/src/common/metrics/esbuild.ts deleted file mode 100644 index f11cc1b8..00000000 --- a/packages/plugins/telemetry/src/common/metrics/esbuild.ts +++ /dev/null @@ -1,199 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { Metafile } from 'esbuild'; -import path from 'path'; - -import type { EsbuildStats, EsbuildIndexedObject, Metric } from '../../types'; -import { getDisplayName, flattened, getType } from '../helpers'; - -export const getInputsDependencies = ( - list: Metafile['inputs'], - moduleName: string, - deps: Set = new Set(), -) => { - const module = list[moduleName]; - for (const dep of module.imports) { - if (deps.has(dep.path)) { - continue; - } - deps.add(dep.path); - if (list[dep.path]) { - getInputsDependencies(list, dep.path, deps); - } - } - - return deps; -}; - -const getModulePath = (fullPath: string, cwd: string): string => { - const filePath = fullPath.replace('pnp:', '').replace(cwd, ''); - return getDisplayName(path.resolve(cwd, filePath), cwd); -}; - -// Get some indexed data to ease the metrics aggregation. -export const getIndexed = (stats: EsbuildStats, cwd: string): EsbuildIndexedObject => { - const inputsDependencies: { [key: string]: Set } = {}; - const outputsDependencies: { [key: string]: Set } = {}; - - const entryNames = new Map(); - if (Array.isArray(stats.entrypoints)) { - // We don't have an indexed object as entry, so we can't get an entry name from it. - for (const entry of stats.entrypoints) { - const fullPath = entry && typeof entry === 'object' ? entry.in : entry; - const realEntry = getModulePath(fullPath, cwd); - entryNames.set(realEntry, realEntry); - } - } else if (stats.entrypoints) { - const entrypoints = stats.entrypoints ? Object.entries(stats.entrypoints) : []; - for (const [entryName, entryPath] of entrypoints) { - entryNames.set(getModulePath(entryPath, cwd), entryName); - } - } - // First loop to index inputs dependencies. - const outputs = stats.outputs ? Object.entries(stats.outputs) : []; - for (const [outputName, output] of outputs) { - if (output.entryPoint) { - const entryName = entryNames.get(getDisplayName(output.entryPoint, cwd)); - const inputs = output.inputs ? Object.keys(output.inputs) : []; - inputsDependencies[entryName] = new Set(inputs); - outputsDependencies[entryName] = new Set([outputName]); - for (const input of inputs) { - if (stats.inputs[input]) { - const imports = stats.inputs[input].imports.map((imp) => imp.path); - inputsDependencies[entryName] = new Set([ - ...inputsDependencies[entryName], - ...imports, - ]); - } - } - } - } - - // Second loop to index output dependencies. - // Input dependencies are needed, hence the second loop. - for (const [outputName, output] of outputs) { - // Check which entry has generated this output. - const inputs = output.inputs ? Object.keys(output.inputs) : []; - for (const inputName of inputs) { - for (const [entryName, entry] of Object.entries(inputsDependencies)) { - if (entry.has(inputName)) { - outputsDependencies[entryName].add(outputName); - } - } - } - } - - return { - inputsDependencies, - outputsDependencies, - entryNames, - }; -}; - -export const formatEntryTag = (entryName: string, cwd: string): string => { - return `entryName:${getDisplayName(entryName, cwd)}`; -}; - -export const getEntryTags = (module: string, indexed: EsbuildIndexedObject, cwd: string) => { - const tags: string[] = []; - const inputsDependencies = indexed.inputsDependencies - ? Object.entries(indexed.inputsDependencies) - : []; - for (const [entryName, entryDeps] of inputsDependencies) { - if (entryDeps.has(module)) { - tags.push(formatEntryTag(entryName, cwd)); - } - } - return tags; -}; - -export const getModules = ( - stats: EsbuildStats, - indexed: EsbuildIndexedObject, - cwd: string, -): Metric[] => { - const metrics: Metric[] = []; - - const inputs = stats.inputs ? Object.entries(stats.inputs) : []; - for (const [rawModuleName, module] of inputs) { - const moduleName = getDisplayName(rawModuleName, cwd); - const entryTags = getEntryTags(rawModuleName, indexed, cwd); - - metrics.push({ - metric: 'module.size', - type: 'size', - value: module.bytes, - tags: [`moduleName:${moduleName}`, `moduleType:${getType(moduleName)}`, ...entryTags], - }); - } - - return metrics; -}; - -export const getAssets = ( - stats: EsbuildStats, - indexed: EsbuildIndexedObject, - cwd: string, -): Metric[] => { - const outputs = stats.outputs ? Object.entries(stats.outputs) : []; - return outputs.map(([rawAssetName, asset]) => { - const assetName = getDisplayName(rawAssetName, cwd); - const entryTags = Array.from( - new Set( - flattened( - Object.keys(asset.inputs).map((modulePath) => - getEntryTags(modulePath, indexed, cwd), - ), - ), - ), - ); - - return { - metric: 'assets.size', - type: 'size', - value: asset.bytes, - tags: [`assetName:${assetName}`, `assetType:${getType(assetName)}`, ...entryTags], - }; - }); -}; - -export const getEntries = ( - stats: EsbuildStats, - indexed: EsbuildIndexedObject, - cwd: string, -): Metric[] => { - const metrics: Metric[] = []; - const outputs = stats.outputs ? Object.entries(stats.outputs) : []; - for (const [, output] of outputs) { - if (output.entryPoint) { - const entryName = indexed.entryNames.get(getModulePath(output.entryPoint, cwd)); - if (entryName) { - const inputs = getInputsDependencies(stats.inputs, output.entryPoint); - const tags = [formatEntryTag(entryName, cwd)]; - metrics.push( - { - metric: 'entries.size', - type: 'size', - value: output.bytes, - tags, - }, - { - metric: 'entries.modules.count', - type: 'count', - value: inputs.size, - tags, - }, - { - metric: 'entries.assets.count', - type: 'count', - value: indexed.outputsDependencies[entryName].size, - tags, - }, - ); - } - } - } - return metrics; -}; diff --git a/packages/plugins/telemetry/src/common/metrics/webpack.ts b/packages/plugins/telemetry/src/common/metrics/webpack.ts deleted file mode 100644 index 732c24bb..00000000 --- a/packages/plugins/telemetry/src/common/metrics/webpack.ts +++ /dev/null @@ -1,276 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { Chunk, StatsJson, Module, Entry, WebpackIndexedObject, Metric } from '../../types'; -import { formatModuleName, getDisplayName, flattened, getType } from '../helpers'; - -export const getFromId = (coll: any[], id: string) => coll.find((c) => c.id === id); - -export const foundInModules = (input: { modules?: Module[] }, identifier?: string): boolean => { - if (!identifier || !input.modules || !input.modules.length) { - return false; - } - - return !!input.modules.find((m) => { - if (m.identifier && m.identifier === identifier) { - return true; - // eslint-disable-next-line no-underscore-dangle - } else if (m._identifier && m._identifier === identifier) { - return true; - } - - if (m.modules && m.modules.length) { - return foundInModules(m, identifier); - } - }); -}; - -export const computeEntriesFromChunk = ( - chunk: Chunk, - indexed: WebpackIndexedObject, - parentEntries: Set = new Set(), - parentChunks: Set = new Set(), -): Set => { - const entry = indexed.entriesPerChunkId[chunk.id]; - - if (entry) { - parentEntries.add(entry.name); - } - - // Escape cyclic dependencies. - if (parentChunks.has(chunk.id)) { - return parentEntries; - } - - parentChunks.add(chunk.id); - chunk.parents.forEach((p: string) => { - const parentChunk = indexed.chunksPerId[p]; - if (parentChunk) { - computeEntriesFromChunk(parentChunk, indexed, parentEntries, parentChunks); - } - }); - return parentEntries; -}; - -export const getEntryTags = (entries: Set): string[] => - Array.from(entries).map((e) => `entryName:${e}`); - -export const getChunkTags = (chunks: Chunk[]): string[] => - flattened( - chunks - .map((c) => { - if (c.names && c.names.length) { - return c.names.map((n) => `chunkName:${n}`); - } - }) - .filter((c) => c), - ); - -export const getChunksFromModule = ( - stats: StatsJson, - chunksPerId: { [key: string]: Chunk }, - module: Module, -) => { - if (module.chunks.length) { - return module.chunks.map((c) => chunksPerId[c]); - } - - // Find the chunks from the chunk list directly. - // Webpack may not have registered module's chunks in some cases. - // eslint-disable-next-line no-underscore-dangle - return stats.chunks.filter((c) => foundInModules(c, module.identifier || module._identifier)); -}; - -const getMetricsFromModule = ( - stats: StatsJson, - indexed: WebpackIndexedObject, - cwd: string, - module: Module, -) => { - const chunks = getChunksFromModule(stats, indexed.chunksPerId, module); - const entries: Set = new Set(); - for (const chunk of chunks) { - computeEntriesFromChunk(chunk, indexed, entries); - } - const chunkTags = getChunkTags(chunks); - const entryTags = getEntryTags(entries); - const moduleName = getDisplayName(module.name, cwd); - - return [ - { - metric: 'modules.size', - type: 'size', - value: module.size, - tags: [ - `moduleName:${moduleName}`, - `moduleType:${getType(moduleName)}`, - ...entryTags, - ...chunkTags, - ], - }, - ]; -}; - -export const getModules = ( - stats: StatsJson, - indexed: WebpackIndexedObject, - cwd: string, -): Metric[] => { - return flattened( - Object.values(indexed.modulesPerName).map((module) => { - return getMetricsFromModule(stats, indexed, cwd, module); - }), - ); -}; - -// Find in entries.chunks -export const getChunks = (stats: StatsJson, indexed: WebpackIndexedObject): Metric[] => { - const chunks = stats.chunks; - - return flattened( - chunks.map((chunk) => { - const entryTags = getEntryTags(computeEntriesFromChunk(chunk, indexed)); - const chunkName = chunk.names.length ? chunk.names.join(' ') : chunk.id; - - return [ - { - metric: 'chunks.size', - type: 'size', - value: chunk.size, - tags: [`chunkName:${chunkName}`, ...entryTags], - }, - { - metric: 'chunks.modules.count', - type: 'count', - value: chunk.modules.length, - tags: [`chunkName:${chunkName}`, ...entryTags], - }, - ]; - }), - ); -}; - -export const getAssets = (stats: StatsJson, indexed: WebpackIndexedObject): Metric[] => { - const assets = stats.assets; - return assets.map((asset) => { - const chunks = asset.chunks.map((c) => indexed.chunksPerId[c]); - const entries: Set = new Set(); - for (const chunk of chunks) { - computeEntriesFromChunk(chunk, indexed, entries); - } - const chunkTags = getChunkTags(chunks); - const entryTags = getEntryTags(entries); - const assetName = asset.name; - - return { - metric: 'assets.size', - type: 'size', - value: asset.size, - tags: [ - `assetName:${assetName}`, - `assetType:${getType(assetName)}`, - ...chunkTags, - ...entryTags, - ], - }; - }); -}; - -export const getEntries = (stats: StatsJson, indexed: WebpackIndexedObject): Metric[] => - flattened( - Object.keys(stats.entrypoints).map((entryName) => { - const entry = stats.entrypoints[entryName]; - const chunks = entry.chunks.map((chunkId) => indexed.chunksPerId[chunkId]!); - - let size = 0; - let moduleCount = 0; - let assetsCount = 0; - - for (const chunk of chunks) { - size += chunk.size; - moduleCount += chunk.modules.length; - assetsCount += chunk.files.length; - } - - return [ - { - metric: 'entries.size', - type: 'size', - value: size, - tags: [`entryName:${entryName}`], - }, - { - metric: 'entries.chunks.count', - type: 'count', - value: chunks.length, - tags: [`entryName:${entryName}`], - }, - { - metric: 'entries.modules.count', - type: 'count', - value: moduleCount, - tags: [`entryName:${entryName}`], - }, - { - metric: 'entries.assets.count', - type: 'count', - value: assetsCount, - tags: [`entryName:${entryName}`], - }, - ]; - }), - ); - -export const getIndexed = (stats: StatsJson, cwd: string): WebpackIndexedObject => { - // Gather all modules. - const modulesPerName: { [key: string]: Module } = {}; - const chunksPerId: { [key: string]: Chunk } = {}; - const entriesPerChunkId: { [key: string]: Entry } = {}; - - const addModule = (module: Module) => { - // No internals. - if (/^webpack\/runtime/.test(module.name)) { - return; - } - // No duplicates. - if (modulesPerName[formatModuleName(module.name, cwd)]) { - return; - } - // Modules are sometimes registered with their loader. - if (module.name.includes('!')) { - return; - } - - modulesPerName[formatModuleName(module.name, cwd)] = module; - }; - - for (const [name, entry] of Object.entries(stats.entrypoints)) { - // In webpack4 we don't have the name of the entry here. - entry.name = name; - for (const chunkId of entry.chunks) { - entriesPerChunkId[chunkId] = entry; - } - } - - for (const chunk of stats.chunks) { - chunksPerId[chunk.id] = chunk; - } - - for (const module of stats.modules) { - // Sometimes modules are grouped together. - if (module.modules && module.modules.length) { - for (const moduleIn of module.modules) { - addModule(moduleIn); - } - } else { - addModule(module); - } - } - - return { - modulesPerName, - chunksPerId, - entriesPerChunkId, - }; -}; diff --git a/packages/plugins/telemetry/src/common/modules.ts b/packages/plugins/telemetry/src/common/modules.ts deleted file mode 100644 index 8c93f3f4..00000000 --- a/packages/plugins/telemetry/src/common/modules.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { Metafile } from 'esbuild'; - -import type { LocalModule } from '../types'; - -import { formatModuleName, getDisplayName } from './helpers'; - -const modulesMap: { [key: string]: LocalModule } = {}; - -const getDefaultLocalModule = (name: string): LocalModule => ({ - name: getDisplayName(name), - chunkNames: [], - size: 0, - dependencies: [], - dependents: [], -}); - -export const getModulesResults = (cwd: string, esbuildMeta?: Metafile) => { - if (!esbuildMeta) { - return {}; - } - - // Indexing chunks so we can access them faster. - const outputs = esbuildMeta.outputs; - const chunkIndexed: Record> = {}; - const parseModules = (chunkName: string, moduleName: string) => { - const formatedModuleName = formatModuleName(moduleName, cwd); - chunkIndexed[formatedModuleName] = chunkIndexed[formatedModuleName] || new Set(); - const formatedChunkName = chunkName.split('/').pop()?.split('.').shift() || 'unknown'; - chunkIndexed[formatedModuleName].add(formatedChunkName); - - if (outputs[moduleName] && outputs[moduleName].inputs.length) { - for (const inputModuleName of Object.keys(outputs[moduleName].inputs)) { - parseModules(moduleName, inputModuleName); - } - } - }; - - for (const [chunkName, chunk] of Object.entries(outputs)) { - for (const moduleName of Object.keys(chunk.inputs)) { - parseModules(chunkName, moduleName); - } - } - - for (const [path, obj] of Object.entries(esbuildMeta.inputs)) { - const moduleName = formatModuleName(path, cwd); - const module: LocalModule = modulesMap[moduleName] || getDefaultLocalModule(moduleName); - - module.size = obj.bytes; - if (chunkIndexed[moduleName]) { - module.chunkNames = Array.from(chunkIndexed[moduleName]); - } - - for (const dependency of obj.imports) { - const depName = formatModuleName(dependency.path, cwd); - module.dependencies.push(depName); - const depObj: LocalModule = modulesMap[depName] || getDefaultLocalModule(depName); - depObj.dependents.push(moduleName); - modulesMap[depName] = depObj; - } - - modulesMap[moduleName] = module; - } - return modulesMap; -}; diff --git a/packages/plugins/telemetry/src/common/output/files.ts b/packages/plugins/telemetry/src/common/output/files.ts index 3bbb74a5..0bcb85f7 100644 --- a/packages/plugins/telemetry/src/common/output/files.ts +++ b/packages/plugins/telemetry/src/common/output/files.ts @@ -4,46 +4,45 @@ import { formatDuration } from '@dd/core/helpers'; import type { Logger } from '@dd/core/log'; +import type { MetricToSend, OutputOptions, Report } from '@dd/telemetry-plugins/types'; import path from 'path'; -import type { BundlerContext, OutputOptions } from '../../types'; import { writeFile } from '../helpers'; -type Files = 'timings' | 'dependencies' | 'bundler' | 'metrics'; +type Files = 'timings' | 'metrics'; type FilesToWrite = { [key in Files]?: { content: any }; }; export const outputFiles = async ( - context: BundlerContext, + data: { + report?: Report; + metrics: MetricToSend[]; + }, outputOptions: OutputOptions, log: Logger, cwd: string, ) => { - const { report, metrics, bundler } = context; - - if (typeof outputOptions !== 'string' && typeof outputOptions !== 'object') { + // Don't write any file if it's not enabled. + if (typeof outputOptions !== 'string' && typeof outputOptions !== 'object' && !outputOptions) { return; } + const { report, metrics } = data; + const startWriting = Date.now(); - let destination; + let destination = ''; const files = { timings: true, - dependencies: true, - bundler: true, metrics: true, - result: true, }; if (typeof outputOptions === 'object') { destination = outputOptions.destination; files.timings = outputOptions.timings || false; - files.dependencies = outputOptions.dependencies || false; - files.bundler = outputOptions.bundler || false; files.metrics = outputOptions.metrics || false; - } else { + } else if (typeof outputOptions === 'string') { destination = outputOptions; } @@ -69,19 +68,6 @@ export const outputFiles = async ( }; } - if (files.dependencies && report?.dependencies) { - filesToWrite.dependencies = { content: report.dependencies }; - } - - if (files.bundler) { - if (bundler.webpack) { - filesToWrite.bundler = { content: bundler.webpack.toJson({ children: false }) }; - } - if (bundler.esbuild) { - filesToWrite.bundler = { content: bundler.esbuild }; - } - } - if (metrics && files.metrics) { filesToWrite.metrics = { content: metrics }; } diff --git a/packages/plugins/telemetry/src/common/output/index.ts b/packages/plugins/telemetry/src/common/output/index.ts deleted file mode 100644 index b8ff7e07..00000000 --- a/packages/plugins/telemetry/src/common/output/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { Logger } from '@dd/core/log'; -import type { GlobalContext } from '@dd/core/types'; - -import type { BundlerContext, TelemetryOptions } from '../../types'; -import { getOptionsDD } from '../helpers'; - -import { outputFiles } from './files'; -import { addMetrics, processMetrics } from './metrics'; -import { outputTexts } from './text'; - -export const output = async ( - bundlerContext: BundlerContext, - options: TelemetryOptions, - logger: Logger, - context: GlobalContext, -) => { - const outputOptions = options.output; - const optionsDD = getOptionsDD(options); - - addMetrics(bundlerContext, optionsDD, logger, context.cwd); - outputTexts(bundlerContext, outputOptions || true, context); - // TODO Handle defaults earlier (outputOptions || true). - await outputFiles(bundlerContext, outputOptions || true, logger, context.cwd); - await processMetrics(bundlerContext, optionsDD, logger); -}; diff --git a/packages/plugins/telemetry/src/common/output/metrics.ts b/packages/plugins/telemetry/src/common/output/metrics.ts deleted file mode 100644 index 041e6ed2..00000000 --- a/packages/plugins/telemetry/src/common/output/metrics.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { formatDuration } from '@dd/core/helpers'; -import type { Logger } from '@dd/core/log'; - -import { PLUGIN_NAME } from '../../constants'; -import type { BundlerContext, OptionsDD } from '../../types'; -import { getMetrics } from '../aggregator'; -import { getMetric } from '../helpers'; - -export const addMetrics = ( - bundlerContext: BundlerContext, - optionsDD: OptionsDD, - log: Logger, - cwd: string, -) => { - const { report, bundler } = bundlerContext; - - bundlerContext.metrics = bundlerContext.metrics || []; - try { - bundlerContext.metrics = getMetrics(optionsDD, report, bundler, cwd); - } catch (e) { - const stack = e instanceof Error ? e.stack : e; - log(`Couldn't aggregate metrics: ${stack}`, 'error'); - } -}; - -export const processMetrics = async ( - bundlerContext: BundlerContext, - optionsDD: OptionsDD, - log: Logger, -) => { - const { start } = bundlerContext; - const duration = Date.now() - start; - - bundlerContext.metrics = bundlerContext.metrics || []; - // We're missing the duration of this hook for our plugin. - bundlerContext.metrics.push( - getMetric( - { - tags: [`pluginName:${PLUGIN_NAME}`], - metric: `plugins.meta.duration`, - type: 'duration', - value: duration, - }, - optionsDD, - ), - ); - - log(`Took ${formatDuration(duration)}.`); -}; diff --git a/packages/plugins/telemetry/src/common/output/text.ts b/packages/plugins/telemetry/src/common/output/text.ts index bcc8bbc6..5cae5b48 100644 --- a/packages/plugins/telemetry/src/common/output/text.ts +++ b/packages/plugins/telemetry/src/common/output/text.ts @@ -3,26 +3,30 @@ // Copyright 2019-Present Datadog, Inc. import { formatDuration } from '@dd/core/helpers'; -import type { GlobalContext } from '@dd/core/types'; +import type { Logger } from '@dd/core/log'; +import type { Entry, GlobalContext, Output } from '@dd/core/types'; import chalk from 'chalk'; import prettyBytes from 'pretty-bytes'; -import type { - BundlerContext, - EsbuildStats, - LocalModule, - LocalModules, - OutputOptions, - Stats, - TimingsMap, -} from '../../types'; +import type { Report, TimingsMap } from '../../types'; +// How many items do we show in the top lists. const TOP = 5; const numColor = chalk.bold.red; const nameColor = chalk.bold.cyan; +type ValuesToPrint = { name: string; top: boolean; values: { name: string; value: string }[] }; + +type FileReport = { + name: string; + aggregatedSize?: number; + size: number; + dependencies: string[]; + dependents: string[]; +}; + // Sort a collection by attribute -const sortDesc = (attr: any) => (a: any, b: any) => { +const sortDesc = (attr: ((arg: any) => any) | string) => (a: any, b: any) => { let aVal; let bVal; @@ -43,224 +47,297 @@ const sortDesc = (attr: any) => (a: any, b: any) => { } }; -const getOutput = (values: any[], renderValue: (arg: any) => string): string => { - let output = ''; - for (const val of values.slice(0, TOP)) { - output += `[${numColor(renderValue(val))}] ${nameColor(val.name)}\n`; +export const getGeneralValues = (context: GlobalContext): ValuesToPrint[] => { + const valuesToPrint: ValuesToPrint = { + name: 'General Numbers', + values: [], + top: false, + }; + + const nbModules = context.build.inputs ? context.build.inputs.length : 0; + const nbAssets = context.build.outputs ? context.build.outputs.length : 0; + const nbWarnings = context.build.warnings.length; + const nbErrors = context.build.errors.length; + const nbEntries = context.build.entries ? context.build.entries.length : 0; + + if (context.build.start) { + valuesToPrint.values.push({ + name: 'Overhead duration', + value: formatDuration(context.build.start - context.start), + }); } - return output; -}; - -const outputTapables = (timings?: TimingsMap): string => { - let output = ''; - if (!timings) { - return output; + if (context.build.duration) { + valuesToPrint.values.push({ + name: 'Build duration', + value: formatDuration(context.build.duration), + }); } - const times = Array.from(timings.values()); - - if (!times.length) { - return output; + if (context.build.writeDuration) { + valuesToPrint.values.push({ + name: 'Write duration', + value: formatDuration(context.build.writeDuration), + }); } - // Output - output += '\n===== Tapables =====\n'; - output += `\n=== Top ${TOP} duration ===\n`; - // Sort by time, longest first - times.sort(sortDesc('duration')); - output += getOutput(times, (time) => formatDuration(time.duration)); - output += `\n=== Top ${TOP} hits ===\n`; - // Sort by time, longest first - times.sort(sortDesc('increment')); - output += getOutput(times, (plugin) => plugin.increment); - - return output; + valuesToPrint.values.push( + { + name: 'Number of modules', + value: nbModules.toString(), + }, + { + name: 'Number of assets', + value: nbAssets.toString(), + }, + { + name: 'Number of entries', + value: nbEntries.toString(), + }, + { + name: 'Number of warnings', + value: nbWarnings.toString(), + }, + { + name: 'Number of errors', + value: nbErrors.toString(), + }, + ); + + return [valuesToPrint]; }; -export const outputWebpack = (stats: Stats): string => { - let output = '\n===== General =====\n'; - // More general stuffs. - const duration = stats.endTime - stats.startTime; - const nbDeps = stats.compilation.fileDependencies.size; - // In Webpack 5, stats.compilation.emittedAssets doesn't exist. - const nbFiles = stats.compilation.assets - ? Object.keys(stats.compilation.assets).length - : stats.compilation.emittedAssets.size; - const nbWarnings = stats.compilation.warnings.length; - // In Webpack 5, stats.compilation.modules is a Set. - const nbModules = - 'size' in stats.compilation.modules - ? stats.compilation.modules.size - : stats.compilation.modules.length; - // In Webpack 5, stats.compilation.chunks is a Set. - const nbChunks = - 'size' in stats.compilation.chunks - ? stats.compilation.chunks.size - : stats.compilation.chunks.length; - // In Webpack 5, stats.compilation.entries is a Map. - const nbEntries = - 'size' in stats.compilation.entries - ? stats.compilation.entries.size - : stats.compilation.entries.length; - output += `duration: ${chalk.bold(formatDuration(duration))} -nbDeps: ${chalk.bold(nbDeps.toString())} -nbFiles: ${chalk.bold(nbFiles.toString())} -nbWarnings: ${chalk.bold(nbWarnings.toString())} -nbModules: ${chalk.bold(nbModules.toString())} -nbChunks: ${chalk.bold(nbChunks.toString())} -nbEntries: ${chalk.bold(nbEntries.toString())} -`; - return output; +const getAssetsValues = (context: GlobalContext): ValuesToPrint[] => { + const assetSizesToPrint: ValuesToPrint = { + name: 'Asset size', + values: (context.build.outputs || []) + .sort(sortDesc((output: Output) => output.size)) + .map((output) => ({ + name: output.name, + value: prettyBytes(output.size), + })), + top: true, + }; + + const entrySizesToPrint: ValuesToPrint = { + name: 'Entry aggregated size', + values: (context.build.entries || []) + .sort(sortDesc((entry: Entry) => entry.size)) + .map((entry) => ({ + name: entry.name, + value: prettyBytes(entry.size), + })), + top: true, + }; + + const entryModulesToPrint: ValuesToPrint = { + name: 'Entry number of modules', + values: + (context.build.entries || []) + .sort(sortDesc((entry: Entry) => entry.size)) + .map((entry) => ({ + name: entry.name, + value: entry.inputs.length.toString(), + })) || [], + top: true, + }; + + return [assetSizesToPrint, entrySizesToPrint, entryModulesToPrint]; }; -export const outputEsbuild = (stats: EsbuildStats) => { - let output = '\n===== General =====\n'; - const nbDeps = stats.inputs ? Object.keys(stats.inputs).length : 0; - const nbFiles = stats.outputs ? Object.keys(stats.outputs).length : 0; - const nbWarnings = stats.warnings.length; - const nbErrors = stats.errors.length; - const nbEntries = stats.entrypoints ? Object.keys(stats.entrypoints).length : 0; - - output += ` -nbDeps: ${chalk.bold(nbDeps.toString())} -nbFiles: ${chalk.bold(nbFiles.toString())} -nbWarnings: ${chalk.bold(nbWarnings.toString())} -nbErrors: ${chalk.bold(nbErrors.toString())} -nbEntries: ${chalk.bold(nbEntries.toString())} -`; - return output; -}; - -const outputLoaders = (timings?: TimingsMap): string => { - let output = ''; - - if (!timings) { - return output; - } - - const times = Array.from(timings.values()); - - if (!times.length) { - return output; +// Crawl through collection to gather all dependencies or dependents. +const getAll = ( + attribute: 'dependents' | 'dependencies', + collection: Record, + filepath: string, + accumulator: string[] = [], +): string[] => { + const reported: string[] = collection[filepath]?.[attribute] || []; + for (const reportedFilename of reported) { + if (accumulator.includes(reportedFilename) || reportedFilename === filepath) { + continue; + } + + accumulator.push(reportedFilename); + getAll(attribute, collection, reportedFilename, accumulator); } - - // Output - output += '\n===== Loaders =====\n'; - output += `\n=== Top ${TOP} duration ===\n`; - // Sort by time, longest first - times.sort(sortDesc('duration')); - output += getOutput(times, (loader) => formatDuration(loader.duration)); - output += `\n=== Top ${TOP} hits ===\n`; - // Sort by hits, biggest first - times.sort(sortDesc('increment')); - output += getOutput(times, (loader) => loader.increment); - - return output; + return accumulator; }; -const outputModulesDependencies = (deps: LocalModules): string => { - let output = ''; - - if (!deps) { - return output; +const getModulesValues = (context: GlobalContext): ValuesToPrint[] => { + const dependentsToPrint: ValuesToPrint = { + name: `Module total dependents`, + values: [], + top: true, + }; + + const dependenciesToPrint: ValuesToPrint = { + name: `Module total dependencies`, + values: [], + top: true, + }; + + const sizesToPrint: ValuesToPrint = { + name: `Module size`, + values: [], + top: true, + }; + + const aggregatedSizesToPrint: ValuesToPrint = { + name: `Module aggregated size`, + values: [], + top: true, + }; + + const dependencies: FileReport[] = []; + + // Build our collections. + const inputs = Object.fromEntries( + (context.build.inputs || []).map((input) => [ + input.filepath, + { + name: input.name, + size: input.size, + dependencies: input.dependencies.map((dep) => dep.filepath), + dependents: input.dependents.map((dep) => dep.filepath), + }, + ]), + ); + + for (const filepath in inputs) { + if (!Object.hasOwn(inputs, filepath)) { + continue; + } + + const fileDependencies = getAll('dependencies', inputs, filepath); + // Aggregate size. + const aggregatedSize = fileDependencies.reduce( + (acc, dep) => acc + inputs[dep].size, + inputs[filepath].size, + ); + + dependencies.push({ + name: inputs[filepath].name, + size: inputs[filepath].size, + aggregatedSize, + dependents: getAll('dependents', inputs, filepath), + dependencies: fileDependencies, + }); } - const dependencies = Object.values(deps); - if (!dependencies.length) { - return output; + return [dependentsToPrint, dependenciesToPrint, sizesToPrint]; } - output += '\n===== Modules =====\n'; // Sort by dependents, biggest first - dependencies.sort(sortDesc((mod: LocalModule) => mod.dependents.length)); - output += `\n=== Top ${TOP} dependents ===\n`; - output += getOutput(dependencies, (module) => module.dependents.length); + dependencies.sort(sortDesc((file: FileReport) => file.dependents.length)); + dependentsToPrint.values = dependencies.map((file) => ({ + name: file.name, + value: file.dependents.length.toString(), + })); // Sort by dependencies, biggest first - dependencies.sort(sortDesc((mod: LocalModule) => mod.dependencies.length)); - output += `\n=== Top ${TOP} dependencies ===\n`; - output += getOutput(dependencies, (module) => module.dependencies.length); + dependencies.sort(sortDesc((file: FileReport) => file.dependencies.length)); + dependenciesToPrint.values = dependencies.map((file) => ({ + name: file.name, + value: file.dependencies.length.toString(), + })); // Sort by size, biggest first dependencies.sort(sortDesc('size')); - output += `\n=== Top ${TOP} size ===\n`; - output += getOutput(dependencies, (module) => prettyBytes(module.size)); - - return output; + sizesToPrint.values = dependencies.map((file) => ({ + name: file.name, + value: prettyBytes(file.size), + })); + // Sort by aggregated size, biggest first + dependencies.sort(sortDesc('aggregatedSize')); + aggregatedSizesToPrint.values = dependencies.map((file) => ({ + name: file.name, + value: prettyBytes(file.aggregatedSize || file.size), + })); + + return [dependentsToPrint, dependenciesToPrint, sizesToPrint, aggregatedSizesToPrint]; }; -const outputModulesTimings = (timings?: TimingsMap): string => { - let output = ''; - - if (!timings) { - return output; +const getTimingValues = (name: string, timings?: TimingsMap): ValuesToPrint[] => { + if (!timings || !timings.size) { + return []; } const times = Array.from(timings.values()); - - if (!times.length) { - return output; - } - - output += '\n===== Modules =====\n'; // Sort by duration, longest first times.sort(sortDesc('duration')); - output += `\n=== Top ${TOP} duration ===\n`; - output += getOutput(times, (module) => formatDuration(module.duration)); - // Sort by increment, longest first + const durationsToPrint: ValuesToPrint = { + name: `${name} duration`, + values: times.map((module) => ({ + name: module.name, + value: formatDuration(module.duration), + })), + top: true, + }; + + // Sort by increment, biggest first times.sort(sortDesc('increment')); - output += `\n=== Top ${TOP} hits ===\n`; - output += getOutput(times, (module) => module.increment); - - return output; + const hitsToPrint: ValuesToPrint = { + name: `${name} hits`, + values: times.map((module) => ({ + name: module.name, + value: module.increment.toString(), + })), + top: true, + }; + + return [durationsToPrint, hitsToPrint]; }; -const shouldShowOutput = (output?: OutputOptions): boolean => { - if (typeof output === 'boolean') { - return output; - } - - // If we passed a path, we should output as stated in the docs. - if (typeof output === 'string') { - return true; - } - - // If we passed nothing, default is true. - if (!output) { - return true; +const renderValues = (values: ValuesToPrint[]): string => { + let outputString = ''; + const titlePadding = 4; + const valuePadding = 4; + const maxTitleWidth = Math.max(...values.map((val) => val.name.length)); + const maxNameWidth = Math.max(...values.flatMap((val) => val.values.map((v) => v.name.length))); + const maxValueWidth = Math.max( + ...values.flatMap((val) => val.values.map((v) => v.value.length)), + ); + const totalWidth = Math.max( + maxTitleWidth + titlePadding, + maxNameWidth + maxValueWidth + valuePadding, + ); + + for (const group of values) { + if (group.values.length === 0) { + continue; + } + + const title = + group.top && group.values.length >= TOP ? `Top ${TOP} ${group.name}` : group.name; + const titlePad = totalWidth - (title.length + titlePadding); + + outputString += `\n== ${title} ${'='.repeat(titlePad)}=\n`; + + const valuesToPrint = group.top ? group.values.slice(0, TOP) : group.values; + for (const value of valuesToPrint) { + const valuePad = maxValueWidth - value.value.length; + outputString += ` [${numColor(value.value)}] ${' '.repeat(valuePad)}${nameColor(value.name)}\n`; + } } - // Finally, if we passed an object, we should check if logs are enabled. - return output.logs !== false; + return outputString; }; -export const outputTexts = ( - bundlerContext: BundlerContext, - output: OutputOptions, - context: GlobalContext, -) => { - const { report, bundler } = bundlerContext; +export const outputTexts = (globalContext: GlobalContext, log: Logger, report?: Report) => { + const valuesToPrint: ValuesToPrint[] = []; - if (!shouldShowOutput(output)) { - return; + if (report) { + // Output legacy/tracing. + valuesToPrint.push(...getTimingValues('Loader', report.timings.loaders)); + valuesToPrint.push(...getTimingValues('Tapable', report.timings.tapables)); + valuesToPrint.push(...getTimingValues('Module', report.timings.modules)); } - let outputString = ''; + valuesToPrint.push(...getModulesValues(globalContext)); + valuesToPrint.push(...getAssetsValues(globalContext)); + valuesToPrint.push(...getGeneralValues(globalContext)); - if (report) { - outputString += outputTapables(report.timings.tapables); - outputString += outputLoaders(report.timings.loaders); - outputString += outputModulesDependencies(report.dependencies); - outputString += outputModulesTimings(report.timings.modules); - } - if (bundler.webpack) { - outputString += outputWebpack(bundler.webpack); - } - if (bundler.esbuild) { - outputString += outputEsbuild(bundler.esbuild); - } + const outputString = renderValues(valuesToPrint); - // We're using console.log here because the configuration expressely asked us to print it. - // eslint-disable-next-line no-console - console.log(outputString); + log(outputString, 'info'); }; diff --git a/packages/plugins/telemetry/src/common/sender.ts b/packages/plugins/telemetry/src/common/sender.ts index a5c026c3..dd5736b7 100644 --- a/packages/plugins/telemetry/src/common/sender.ts +++ b/packages/plugins/telemetry/src/common/sender.ts @@ -7,15 +7,15 @@ import type { Logger } from '@dd/core/log'; import { request } from 'https'; import type { ServerResponse } from 'http'; -import type { MetricToSend, OptionsWithTelemetry } from '../types'; +import type { MetricToSend } from '../types'; export const sendMetrics = ( metrics: MetricToSend[] | undefined, - opts: OptionsWithTelemetry, + auth: { apiKey?: string; endPoint: string }, log: Logger, ) => { const startSending = Date.now(); - if (!opts.auth?.apiKey) { + if (!auth.apiKey) { log(`Won't send metrics to Datadog: missing API Key.`, 'warn'); return; } @@ -37,8 +37,8 @@ Metrics: return new Promise((resolve, reject) => { const req = request({ method: 'POST', - hostname: opts.auth?.endPoint || 'app.datadoghq.com', - path: `/api/v1/series?api_key=${opts.auth?.apiKey}`, + hostname: auth.endPoint, + path: `/api/v1/series?api_key=${auth.apiKey}`, }); req.write( diff --git a/packages/plugins/telemetry/src/esbuild-plugin/index.ts b/packages/plugins/telemetry/src/esbuild-plugin/index.ts index e378466c..7caaa818 100644 --- a/packages/plugins/telemetry/src/esbuild-plugin/index.ts +++ b/packages/plugins/telemetry/src/esbuild-plugin/index.ts @@ -2,59 +2,41 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { getLogger } from '@dd/core/log'; +import type { Logger } from '@dd/core/log'; import type { GlobalContext } from '@dd/core/types'; import type { BuildResult } from 'esbuild'; import type { UnpluginOptions } from 'unplugin'; -import { validateOptions } from '../common/helpers'; -import { output } from '../common/output'; -import { sendMetrics } from '../common/sender'; -import type { BundlerContext, OptionsWithTelemetry } from '../types'; +import type { BundlerContext } from '../types'; -import { getModulesResults } from './modules'; import { wrapPlugins, getResults as getPluginsResults } from './plugins'; export const getEsbuildPlugin = ( - opt: OptionsWithTelemetry, - ctx: GlobalContext, + bundlerContext: BundlerContext, + globalContext: GlobalContext, + logger: Logger, ): UnpluginOptions['esbuild'] => { return { setup: (build) => { - const startBuild = Date.now(); - const logger = getLogger(opt.logLevel, 'telemetry'); - const telemetryOptions = validateOptions(opt); + globalContext.build.start = Date.now(); + // We force esbuild to produce its metafile. build.initialOptions.metafile = true; - wrapPlugins(build, ctx.cwd); + wrapPlugins(build, globalContext.cwd); build.onEnd(async (result: BuildResult) => { + if (!result.metafile) { + logger("Missing metafile, can't proceed with modules data.", 'warn'); + return; + } + const { plugins, modules } = getPluginsResults(); - // We know it exists since we're setting the option earlier. - const metaFile = result.metafile!; - const moduleResults = getModulesResults(ctx.cwd, metaFile); - const bundlerContext: BundlerContext = { - start: startBuild, - report: { - timings: { - tapables: plugins, - modules, - }, - dependencies: moduleResults, - }, - bundler: { - esbuild: { - warnings: result.warnings, - errors: result.errors, - entrypoints: build.initialOptions.entryPoints, - duration: Date.now() - startBuild, - ...metaFile, - }, + bundlerContext.report = { + timings: { + tapables: plugins, + modules, }, }; - - await output(bundlerContext, telemetryOptions, logger, ctx); - await sendMetrics(bundlerContext.metrics, opt, logger); }); }, }; diff --git a/packages/plugins/telemetry/src/esbuild-plugin/modules.ts b/packages/plugins/telemetry/src/esbuild-plugin/modules.ts deleted file mode 100644 index 08b50b61..00000000 --- a/packages/plugins/telemetry/src/esbuild-plugin/modules.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { Metafile } from 'esbuild'; - -import { formatModuleName, getDisplayName } from '../common/helpers'; -import type { LocalModule } from '../types'; - -const modulesMap: { [key: string]: LocalModule } = {}; - -const getDefaultLocalModule = (name: string): LocalModule => ({ - name: getDisplayName(name), - chunkNames: [], - size: 0, - dependencies: [], - dependents: [], -}); - -export const getModulesResults = (cwd: string, esbuildMeta?: Metafile) => { - if (!esbuildMeta) { - return {}; - } - - // Indexing chunks so we can access them faster. - const outputs = esbuildMeta.outputs; - const chunkIndexed: Record> = {}; - const parseModules = (chunkName: string, moduleName: string) => { - const formatedModuleName = formatModuleName(moduleName, cwd); - chunkIndexed[formatedModuleName] = chunkIndexed[formatedModuleName] || new Set(); - const formatedChunkName = chunkName.split('/').pop()?.split('.').shift() || 'unknown'; - chunkIndexed[formatedModuleName].add(formatedChunkName); - - if (outputs[moduleName] && outputs[moduleName].inputs.length) { - for (const inputModuleName of Object.keys(outputs[moduleName].inputs)) { - parseModules(moduleName, inputModuleName); - } - } - }; - - for (const [chunkName, chunk] of Object.entries(outputs)) { - for (const moduleName of Object.keys(chunk.inputs)) { - parseModules(chunkName, moduleName); - } - } - - for (const [path, obj] of Object.entries(esbuildMeta.inputs)) { - const moduleName = formatModuleName(path, cwd); - const module: LocalModule = modulesMap[moduleName] || getDefaultLocalModule(moduleName); - - module.size = obj.bytes; - if (chunkIndexed[moduleName]) { - module.chunkNames = Array.from(chunkIndexed[moduleName]); - } - - for (const dependency of obj.imports) { - const depName = formatModuleName(dependency.path, cwd); - module.dependencies.push(depName); - const depObj: LocalModule = modulesMap[depName] || getDefaultLocalModule(depName); - depObj.dependents.push(moduleName); - modulesMap[depName] = depObj; - } - - modulesMap[moduleName] = module; - } - return modulesMap; -}; diff --git a/packages/plugins/telemetry/src/esbuild-plugin/plugins.ts b/packages/plugins/telemetry/src/esbuild-plugin/plugins.ts index 35ca7b6b..49ea49f4 100644 --- a/packages/plugins/telemetry/src/esbuild-plugin/plugins.ts +++ b/packages/plugins/telemetry/src/esbuild-plugin/plugins.ts @@ -25,7 +25,7 @@ export const wrapPlugins = (build: PluginBuild, context: string) => { }); for (const plugin of plugins) { // Skip the current plugin. - if (plugin.name === PLUGIN_NAME) { + if (plugin.name.includes(PLUGIN_NAME)) { continue; } diff --git a/packages/plugins/telemetry/src/index.ts b/packages/plugins/telemetry/src/index.ts index 43731d8b..e059e51b 100644 --- a/packages/plugins/telemetry/src/index.ts +++ b/packages/plugins/telemetry/src/index.ts @@ -2,12 +2,24 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { GlobalContext, GetPlugins } from '@dd/core/types'; +import { getLogger } from '@dd/core/log'; +import type { GlobalContext, GetPlugins, PluginOptions } from '@dd/core/types'; +import { getMetrics } from './common/aggregator'; import { defaultFilters } from './common/filters'; +import { getOptionsDD, validateOptions } from './common/helpers'; +import { outputFiles } from './common/output/files'; +import { outputTexts } from './common/output/text'; +import { sendMetrics } from './common/sender'; import { PLUGIN_NAME, CONFIG_KEY } from './constants'; import { getEsbuildPlugin } from './esbuild-plugin'; -import type { Filter, Metric, OptionsWithTelemetry, TelemetryOptions } from './types'; +import type { + BundlerContext, + Filter, + Metric, + OptionsWithTelemetry, + TelemetryOptions, +} from './types'; import { getWebpackPlugin } from './webpack-plugin'; export { CONFIG_KEY, PLUGIN_NAME }; @@ -26,11 +38,67 @@ export const getPlugins: GetPlugins = ( options: OptionsWithTelemetry, context: GlobalContext, ) => { - return [ - { - name: PLUGIN_NAME, - esbuild: getEsbuildPlugin(options, context), - webpack: getWebpackPlugin(options, context), + let realBuildEnd: number = 0; + const bundlerContext: BundlerContext = { + start: Date.now(), + }; + + const telemetryOptions = validateOptions(options); + const logger = getLogger(options.logLevel, PLUGIN_NAME); + const plugins: PluginOptions[] = []; + + // Webpack and Esbuild specific plugins. + // LEGACY + const legacyPlugin: PluginOptions = { + name: PLUGIN_NAME, + enforce: 'pre', + esbuild: getEsbuildPlugin(bundlerContext, context, logger), + webpack: getWebpackPlugin(bundlerContext, context), + }; + // Universal plugin. + const universalPlugin: PluginOptions = { + name: 'datadog-universal-telemetry-plugin', + enforce: 'post', + buildStart() { + context.build.start = context.build.start || Date.now(); + }, + buildEnd() { + realBuildEnd = Date.now(); + }, + + // Move as much as possible in the universal plugin. + async writeBundle() { + context.build.end = Date.now(); + context.build.duration = context.build.end - context.build.start!; + context.build.writeDuration = context.build.end - realBuildEnd; + + const metrics = []; + const optionsDD = getOptionsDD(telemetryOptions); + + metrics.push(...getMetrics(context, optionsDD, bundlerContext.report)); + + // TODO Extract the files output in an internal plugin. + await outputFiles( + { report: bundlerContext.report, metrics }, + telemetryOptions.output, + logger, + context.bundler.outDir, + ); + outputTexts(context, logger, bundlerContext.report); + + await sendMetrics( + metrics, + { apiKey: context.auth?.apiKey, endPoint: telemetryOptions.endPoint }, + logger, + ); }, - ]; + }; + + if (telemetryOptions.enableTracing) { + plugins.push(legacyPlugin); + } + + plugins.push(universalPlugin); + + return plugins; }; diff --git a/packages/plugins/telemetry/src/types.ts b/packages/plugins/telemetry/src/types.ts index e9e0be94..9ce35a77 100644 --- a/packages/plugins/telemetry/src/types.ts +++ b/packages/plugins/telemetry/src/types.ts @@ -2,8 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { GetPluginsOptions } from '@dd/core/types'; -import type { Metafile, Message, BuildOptions, BuildResult } from 'esbuild'; +import type { Assign, GetPluginsOptions } from '@dd/core/types'; import type { CONFIG_KEY } from './constants'; @@ -36,54 +35,47 @@ export type OutputOptions = | { destination: string; timings?: boolean; - dependencies?: boolean; - bundler?: boolean; metrics?: boolean; - logs?: boolean; }; export type TelemetryOptions = { disabled?: boolean; + /** @deprecated */ + enableTracing?: boolean; + endPoint?: string; + filters?: Filter[]; output?: OutputOptions; prefix?: string; tags?: string[]; timestamp?: number; - filters?: Filter[]; }; +export type TelemetryOptionsWithDefaults = Assign< + Required, + { + timestamp?: TelemetryOptions['timestamp']; + } +>; + export interface OptionsWithTelemetry extends GetPluginsOptions { [CONFIG_KEY]: TelemetryOptions; } -interface EsbuildBundlerResult extends Metafile { - warnings: BuildResult['warnings']; - errors: BuildResult['errors']; - entrypoints: BuildOptions['entryPoints']; - duration: number; +export interface TimingsReport { + tapables?: TimingsMap; + loaders?: TimingsMap; + modules?: TimingsMap; +} + +export interface Report { + timings: TimingsReport; } export type BundlerContext = { start: number; - report: Report; - metrics?: MetricToSend[]; - bundler: { - esbuild?: EsbuildBundlerResult; - webpack?: Stats; - }; + report?: Report; }; -export interface EsbuildIndexedObject { - entryNames: Map; - inputsDependencies: { [key: string]: Set }; - outputsDependencies: { [key: string]: Set }; -} - -export interface WebpackIndexedObject { - modulesPerName: { [key: string]: Module }; - chunksPerId: { [key: string]: Chunk }; - entriesPerChunkId: { [key: string]: Entry }; -} - export interface ModuleGraph { getModule(dependency: Dependency): Module; getIssuer(module: Module): Module; @@ -105,21 +97,6 @@ export interface Compilation { }; } -export interface Stats { - toJson(opts: { children: boolean }): StatsJson; - endTime: number; - startTime: number; - compilation: { - assets: { [key: string]: { size: number } }; - fileDependencies: Set; - emittedAssets: Set; - warnings: string[]; - modules: Set | Module[]; - chunks: Set | Chunk[]; - entries: any[] | Map; - }; -} - export interface Chunk { id: string; size: number; @@ -129,33 +106,6 @@ export interface Chunk { parents: string[]; } -export interface Asset { - name: string; - size: number; - chunks: string[]; -} - -export interface Entry { - name: string; - chunks: string[]; -} - -export interface Entries { - [key: string]: Entry; -} - -export interface StatsJson { - entrypoints: { - [key: string]: Entry; - }; - chunks: Chunk[]; - modules: Module[]; - assets: Asset[]; - warnings: string[]; - errors: string[]; - time: number; -} - export interface Hook { tap?: Tap; tapAsync?: TapAsync; @@ -170,42 +120,8 @@ export interface Tapable { }; } -export interface Compiler extends Tapable { - options: {}; - hooks: { - thisCompilation: { tap(opts: any, callback: (compilation: Compilation) => void): void }; - done: { - tap(opts: any, callback: (stats: Stats) => void): void; - tapPromise(opts: any, callback: (stats: Stats) => void): Promise; - }; - }; -} - export type TAP_TYPES = 'default' | 'async' | 'promise'; -export interface TimingsReport { - tapables?: TimingsMap; - loaders?: TimingsMap; - modules?: TimingsMap; -} - -export interface Report { - timings: TimingsReport; - dependencies: LocalModules; -} - -export interface EsbuildStats extends Metafile { - warnings: Message[]; - errors: Message[]; - entrypoints: BuildOptions['entryPoints']; - duration: number; -} - -export interface BundlerStats { - webpack?: Stats; - esbuild?: EsbuildStats; -} - export interface ValueContext { type: string; name: string; @@ -281,19 +197,3 @@ export interface Event { timings: Value; loaders: string[]; } - -export interface LocalModule { - name: string; - size: number; - chunkNames: string[]; - dependencies: string[]; - dependents: string[]; -} - -export interface LocalModules { - [key: string]: LocalModule; -} - -export interface ModulesResult { - modules: LocalModules; -} diff --git a/packages/plugins/telemetry/src/webpack-plugin/index.ts b/packages/plugins/telemetry/src/webpack-plugin/index.ts index adc4cc0b..ab44dbbe 100644 --- a/packages/plugins/telemetry/src/webpack-plugin/index.ts +++ b/packages/plugins/telemetry/src/webpack-plugin/index.ts @@ -2,32 +2,26 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { getLogger } from '@dd/core/log'; import type { GlobalContext } from '@dd/core/types'; import type { UnpluginOptions } from 'unplugin'; -import { validateOptions } from '../common/helpers'; -import { output } from '../common/output'; -import { sendMetrics } from '../common/sender'; import { PLUGIN_NAME } from '../constants'; -import type { Compilation, Report, Stats, OptionsWithTelemetry, BundlerContext } from '../types'; +import type { Compilation, BundlerContext } from '../types'; import { Loaders } from './loaders'; -import { Modules } from './modules'; import { Tapables } from './tapables'; export const getWebpackPlugin = ( - opt: OptionsWithTelemetry, - ctx: GlobalContext, + bundlerContext: BundlerContext, + globalContext: GlobalContext, ): UnpluginOptions['webpack'] => { return async (compiler) => { + globalContext.build.start = Date.now(); + const HOOK_OPTIONS = { name: PLUGIN_NAME }; - const options = validateOptions(opt); - const logger = getLogger(opt.logLevel, 'telemetry'); - const modules = new Modules(ctx.cwd, options); - const tapables = new Tapables(ctx.cwd, options); - const loaders = new Loaders(ctx.cwd, options); + const tapables = new Tapables(globalContext.cwd); + const loaders = new Loaders(globalContext.cwd); // @ts-expect-error - webpack 4 and 5 nonsense. tapables.throughHooks(compiler); @@ -43,36 +37,21 @@ export const getWebpackPlugin = ( compilation.hooks.succeedModule.tap(HOOK_OPTIONS, (module) => { loaders.succeedModule(module, compilation); }); - - compilation.hooks.afterOptimizeTree.tap(HOOK_OPTIONS, (chunks, mods) => { - modules.afterOptimizeTree(chunks, mods, compilation); - }); }); - // @ts-expect-error - webpack 4 and 5 nonsense. - compiler.hooks.done.tapPromise(HOOK_OPTIONS, async (stats: Stats) => { - const start = Date.now(); + // We're losing some tracing from plugins by using `afterEmit` instead of `done` but + // it allows us to centralize the common process better. + compiler.hooks.afterEmit.tapPromise(HOOK_OPTIONS, async (compilation) => { const { timings: tapableTimings } = tapables.getResults(); const { loaders: loadersTimings, modules: modulesTimings } = loaders.getResults(); - const { modules: modulesDeps } = modules.getResults(); - const report: Report = { + bundlerContext.report = { timings: { tapables: tapableTimings, loaders: loadersTimings, modules: modulesTimings, }, - dependencies: modulesDeps, }; - - const context: BundlerContext = { - start, - report, - bundler: { webpack: stats }, - }; - - await output(context, options, logger, ctx); - await sendMetrics(context.metrics, opt, logger); }); }; }; diff --git a/packages/plugins/telemetry/src/webpack-plugin/loaders.ts b/packages/plugins/telemetry/src/webpack-plugin/loaders.ts index 24de401d..570f8d5d 100644 --- a/packages/plugins/telemetry/src/webpack-plugin/loaders.ts +++ b/packages/plugins/telemetry/src/webpack-plugin/loaders.ts @@ -5,15 +5,13 @@ import { performance } from 'perf_hooks'; import { getDisplayName, getModuleName, getLoaderNames } from '../common/helpers'; -import type { Module, Event, Timing, Compilation, TimingsMap, TelemetryOptions } from '../types'; +import type { Module, Event, Timing, Compilation, TimingsMap } from '../types'; export class Loaders { - constructor(cwd: string, options: TelemetryOptions) { - this.options = options; + constructor(cwd: string) { this.cwd = cwd; } cwd: string; - options: TelemetryOptions; started: { [key: string]: Event } = {}; finished: Event[] = []; diff --git a/packages/plugins/telemetry/src/webpack-plugin/modules.ts b/packages/plugins/telemetry/src/webpack-plugin/modules.ts deleted file mode 100644 index eaeffb80..00000000 --- a/packages/plugins/telemetry/src/webpack-plugin/modules.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { getDisplayName, getModuleName, getModuleSize } from '../common/helpers'; -import type { - Module, - LocalModule, - ModulesResult, - Compilation, - Dependency, - TelemetryOptions, -} from '../types'; - -export class Modules { - constructor(cwd: string, options: TelemetryOptions) { - this.options = options; - this.cwd = cwd; - } - cwd: string; - options: TelemetryOptions; - storedModules: { [key: string]: LocalModule } = {}; - storedDependents: { [key: string]: Set } = {}; - - // In Webpack 5, using dep.module throws an error. - // It's advised to use ModuleGraph API instead (not available in previous versions). - getModule(dep: Dependency, compilation: Compilation): Module | undefined { - try { - return dep.module; - } catch (e) { - return compilation.moduleGraph?.getModule(dep); - } - } - - getChunks(module: Module, compilation: Compilation): Set { - return module._chunks || compilation.chunkGraph?.getModuleChunks(module); - } - - getLocalModule( - name: string, - module: Module, - compilation: Compilation, - opts?: Partial, - ): LocalModule { - const localModule: LocalModule = { - name: getDisplayName(name), - size: getModuleSize(module), - chunkNames: Array.from(this.getChunks(module, compilation)).map((c) => c.name), - dependencies: [], - dependents: [], - ...opts, - }; - - return localModule; - } - - afterOptimizeTree(chunks: any, modules: Module[], compilation: Compilation) { - const moduleMap: { [key: string]: Module } = {}; - - for (const module of modules) { - const moduleName = getModuleName(module, compilation, this.cwd); - moduleMap[moduleName] = module; - let dependencies = module.dependencies - // Ensure it's a module because webpack register as dependency - // a lot of different stuff that are not modules. - // RequireHeaderDependency, ConstDepependency, ... - .filter((dep) => this.getModule(dep, compilation)) - .map((dep) => - getModuleName(this.getModule(dep, compilation)!, compilation, this.cwd), - ); - - // If we've already encounter this module, merge its dependencies. - if (this.storedModules[moduleName]) { - dependencies = [...dependencies, ...this.storedModules[moduleName].dependencies]; - } - - // Make dependencies unique and format their names. - dependencies = [...new Set(dependencies)]; - - this.storedModules[moduleName] = this.getLocalModule(moduleName, module, compilation, { - dependencies, - }); - - // Update the dependents store once we have all dependencies - for (const dep of dependencies) { - this.storedDependents[dep] = this.storedDependents[dep] || new Set(); - if (!this.storedDependents[dep].has(moduleName)) { - this.storedDependents[dep].add(moduleName); - } - } - } - - // Re-assign dependents to modules. - for (const storedDepName in this.storedDependents) { - if (Object.hasOwn(this.storedDependents, storedDepName)) { - if (!this.storedModules[storedDepName]) { - this.storedModules[storedDepName] = this.getLocalModule( - storedDepName, - moduleMap[storedDepName], - compilation, - ); - } - // Assign dependents. - this.storedModules[storedDepName].dependents = Array.from( - this.storedDependents[storedDepName], - ); - } - } - } - - getResults(): ModulesResult { - return { - modules: this.storedModules, - }; - } -} diff --git a/packages/plugins/telemetry/src/webpack-plugin/tapables.ts b/packages/plugins/telemetry/src/webpack-plugin/tapables.ts index 3b029f88..35dd0d11 100644 --- a/packages/plugins/telemetry/src/webpack-plugin/tapables.ts +++ b/packages/plugins/telemetry/src/webpack-plugin/tapables.ts @@ -5,6 +5,7 @@ import { performance } from 'perf_hooks'; import { getPluginName, getValueContext } from '../common/helpers'; +import { PLUGIN_NAME } from '../constants'; import type { MonitoredTaps, Tapable, @@ -18,16 +19,13 @@ import type { Tap, Hook, Timing, - TelemetryOptions, } from '../types'; export class Tapables { - constructor(cwd: Tapables['cwd'], options: Tapables['options']) { - this.options = options; + constructor(cwd: Tapables['cwd']) { this.cwd = cwd; } cwd: string; - options: TelemetryOptions; monitoredTaps: MonitoredTaps = {}; tapables: Tapable[] = []; hooks: Hooks = {}; @@ -198,6 +196,11 @@ export class Tapables { return; } + // Skip the current plugin. + if (tapableName.includes(PLUGIN_NAME)) { + return; + } + if (!this.hooks[tapableName]) { this.hooks[tapableName] = []; } diff --git a/packages/tests/jest.config.js b/packages/tests/jest.config.js index a938b0a1..72f3f5a8 100644 --- a/packages/tests/jest.config.js +++ b/packages/tests/jest.config.js @@ -12,4 +12,5 @@ module.exports = { testEnvironment: 'node', testMatch: ['**/*.test.*'], roots: ['./src/'], + setupFilesAfterEnv: ['/src/setupTests.ts'], }; diff --git a/packages/tests/src/helpers/runBundlers.ts b/packages/tests/src/helpers/runBundlers.ts index aaa1aaac..bb998e8f 100644 --- a/packages/tests/src/helpers/runBundlers.ts +++ b/packages/tests/src/helpers/runBundlers.ts @@ -122,7 +122,7 @@ export const runRollup = async ( return result; }; -type Bundler = { +export type Bundler = { name: string; run: (opts: Options, config?: any) => Promise; version: string; @@ -172,9 +172,13 @@ export const BUNDLERS: Bundler[] = [ export const runBundlers = async ( pluginOverrides: Partial = {}, bundlerOverrides: Record = {}, + bundlers?: string[], ) => { const results: any[] = []; rmSync(defaultDestination, { recursive: true, force: true, maxRetries: 3 }); + const bundlersToRun = BUNDLERS.filter( + (bundler) => !bundlers || bundlers.includes(bundler.name), + ); // Needed to avoid SIGHUP errors with exit code 129. // Specifically for vite, which is the only one that crashes with this error when ran more than once. // TODO: Investigate why vite crashed when ran more than once. @@ -183,8 +187,8 @@ export const runBundlers = async ( // Running vite and webpack together will crash the process with exit code 129. // Not sure why, but we need to isolate them. // TODO: Investigate why vite and webpack can't run together. - const webpackBundlers = BUNDLERS.filter((bundler) => bundler.name.startsWith('webpack')); - const otherBundlers = BUNDLERS.filter((bundler) => !bundler.name.startsWith('webpack')); + const webpackBundlers = bundlersToRun.filter((bundler) => bundler.name.startsWith('webpack')); + const otherBundlers = bundlersToRun.filter((bundler) => !bundler.name.startsWith('webpack')); const runBundlerFunction = (bundler: Bundler) => { let bundlerOverride = {}; diff --git a/packages/tests/src/helpers/toBeWithinRange.ts b/packages/tests/src/helpers/toBeWithinRange.ts new file mode 100644 index 00000000..54a6c125 --- /dev/null +++ b/packages/tests/src/helpers/toBeWithinRange.ts @@ -0,0 +1,38 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { MatcherFunction } from 'expect'; + +export const toBeWithinRange: MatcherFunction<[floor: unknown, ceiling: unknown]> = + // `floor` and `ceiling` get types from the line above + // it is recommended to type them as `unknown` and to validate the values + function toBeWithinRange(actual, floor, ceiling) { + if ( + typeof actual !== 'number' || + typeof floor !== 'number' || + typeof ceiling !== 'number' + ) { + throw new TypeError('These must be of type number!'); + } + + const pass = actual >= floor && actual <= ceiling; + if (pass) { + return { + message: () => + // `this` context will have correct typings + `expected ${this.utils.printReceived( + actual, + )} not to be within range ${this.utils.printExpected(`${floor} - ${ceiling}`)}`, + pass: true, + }; + } else { + return { + message: () => + `expected ${this.utils.printReceived( + actual, + )} to be within range ${this.utils.printExpected(`${floor} - ${ceiling}`)}`, + pass: false, + }; + } + }; diff --git a/packages/tests/src/plugins/telemetry/common/aggregator.test.ts b/packages/tests/src/plugins/telemetry/common/aggregator.test.ts index 308d8175..25b0b52c 100644 --- a/packages/tests/src/plugins/telemetry/common/aggregator.test.ts +++ b/packages/tests/src/plugins/telemetry/common/aggregator.test.ts @@ -3,12 +3,13 @@ // Copyright 2019-Present Datadog, Inc. import { getMetrics } from '@dd/telemetry-plugins/common/aggregator'; +import { getContextMock } from '@dd/tests/helpers/mocks'; import { mockOptionsDD, mockReport } from '@dd/tests/plugins/telemetry/testHelpers'; describe('Telemetry Aggregator', () => { test('It should aggregate metrics without throwing.', () => { expect(() => { - getMetrics(mockOptionsDD, mockReport, {}, ''); + getMetrics(getContextMock(), mockOptionsDD, mockReport); }).not.toThrow(); }); }); diff --git a/packages/tests/src/plugins/telemetry/common/helpers.test.ts b/packages/tests/src/plugins/telemetry/common/helpers.test.ts index b86e4ca8..b98cb179 100644 --- a/packages/tests/src/plugins/telemetry/common/helpers.test.ts +++ b/packages/tests/src/plugins/telemetry/common/helpers.test.ts @@ -2,11 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { - getModuleName, - getModuleSize, - getValueContext, -} from '@dd/telemetry-plugins/common/helpers'; +import { getModuleName, getValueContext } from '@dd/telemetry-plugins/common/helpers'; import { getMockCompilation, getMockModule, mockCompilation } from '../testHelpers'; @@ -33,21 +29,24 @@ describe('Telemetry Helpers', () => { ).toBe('moduleName'); }); - test('It should return the size of a module', () => { - const module1 = getMockModule({ size: 1 }); - const module2 = getMockModule({ size: () => 2 }); - expect(getModuleSize(module1)).toBe(1); - expect(getModuleSize(module2)).toBe(2); - expect(getModuleSize()).toBe(0); - }); - test('It should getContext with and without constructor', () => { const BasicClass: any = function BasicClass() {}; const instance1 = new BasicClass(); const instance2 = new BasicClass(); instance2.constructor = null; - getValueContext([instance1, instance2]); - expect(() => {}).not.toThrow(); + expect(() => { + getValueContext([instance1, instance2]); + }).not.toThrow(); + + const context = getValueContext([instance1, instance2]); + expect(context).toEqual([ + { + type: 'BasicClass', + }, + { + type: 'object', + }, + ]); }); }); diff --git a/packages/tests/src/plugins/telemetry/common/metrics/esbuild.test.ts b/packages/tests/src/plugins/telemetry/common/metrics/esbuild.test.ts deleted file mode 100644 index 89987614..00000000 --- a/packages/tests/src/plugins/telemetry/common/metrics/esbuild.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { getDisplayName } from '@dd/telemetry-plugins/common/helpers'; -import { - getModules, - getIndexed, - getEntries, - getAssets, -} from '@dd/telemetry-plugins/common/metrics/esbuild'; -import type { Metric, EsbuildStats } from '@dd/telemetry-plugins/types'; -import { PROJECT_ROOT } from '@dd/tests/helpers/mocks'; -import { runEsbuild } from '@dd/tests/helpers/runBundlers'; -import { prefixPath } from '@dd/tests/plugins/telemetry/testHelpers'; -import fs from 'fs'; -import path from 'path'; - -describe('Telemetry ESBuild Metrics', () => { - // This will be removed in a follow-up PR. - describe.skip(`Esbuild`, () => { - let statsJson: EsbuildStats; - const OUTPUT = path.resolve(PROJECT_ROOT, `./esbuild-output/`); - - afterAll(async () => { - // Clean - fs.rmSync(OUTPUT, { force: true, recursive: true }); - }); - - beforeAll(async () => { - await runEsbuild( - { - auth: { - apiKey: '', - }, - telemetry: { - output: OUTPUT, - }, - }, - { - sourcemap: false, - entryPoints: { - yolo: prefixPath('./src/file0001.js'), - cheesecake: prefixPath('./src/file0000.js'), - }, - outdir: prefixPath('./esbuild-output/dist'), - }, - ); - - statsJson = require(path.resolve(OUTPUT, './bundler.json')); - }, 20000); - - describe('Modules', () => { - let metrics: Metric[]; - - beforeAll(() => { - const indexed = getIndexed(statsJson, PROJECT_ROOT); - metrics = getModules(statsJson, indexed, PROJECT_ROOT); - }); - - test('It should give module metrics.', () => { - expect(metrics.length).not.toBe(0); - }); - - test(`It should add tags about the entry and the chunk.`, () => { - for (const metric of metrics) { - expect(metric.tags).toContain('entryName:yolo'); - expect(metric.tags).toContain('entryName:cheesecake'); - } - }); - - test('It should have 1 metric per module.', () => { - const modules = [ - prefixPath('./src/file0000.js'), - prefixPath('./src/file0001.js'), - prefixPath('./workspaces/app/file0000.js'), - prefixPath('./workspaces/app/file0001.js'), - ]; - - for (const module of modules) { - const modulesMetrics = metrics.filter((m) => - m.tags.includes(`moduleName:${getDisplayName(module)}`), - ); - expect(modulesMetrics.length).toBe(1); - } - }); - }); - - describe('Entries', () => { - let metrics: Metric[]; - - beforeAll(() => { - const indexed = getIndexed(statsJson, PROJECT_ROOT); - metrics = getEntries(statsJson, indexed, PROJECT_ROOT); - }); - - test('It should give entries metrics.', () => { - expect(metrics.length).not.toBe(0); - }); - - test('It should give 3 metrics per entry.', () => { - const entries = ['yolo', 'cheesecake']; - - for (const entry of entries) { - const entriesMetrics = metrics.filter((m) => - m.tags.includes(`entryName:${entry}`), - ); - expect(entriesMetrics.length).toBe(3); - } - }); - }); - - describe('Assets', () => { - let metrics: Metric[]; - - beforeAll(() => { - const indexed = getIndexed(statsJson, PROJECT_ROOT); - metrics = getAssets(statsJson, indexed, PROJECT_ROOT); - }); - - test('It should give assets metrics.', () => { - expect(metrics.length).not.toBe(0); - }); - - test('It should give 1 metric per asset.', () => { - const assets = ['yolo\\.js', 'cheesecake\\.js']; - for (const asset of assets) { - const rx = new RegExp(`^assetName:.*${asset}$`); - const assetsMetrics = metrics.filter((m) => - m.tags.some((tag: string) => rx.test(tag)), - ); - expect(assetsMetrics.length).toBe(1); - } - }); - - test(`It should add tags about the entry.`, () => { - for (const metric of metrics) { - expect(metric.tags).toContain('entryName:yolo'); - expect(metric.tags).toContain('entryName:cheesecake'); - } - }); - }); - }); -}); diff --git a/packages/tests/src/plugins/telemetry/common/metrics/webpack.test.ts b/packages/tests/src/plugins/telemetry/common/metrics/webpack.test.ts deleted file mode 100644 index b2245adb..00000000 --- a/packages/tests/src/plugins/telemetry/common/metrics/webpack.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { getDisplayName } from '@dd/telemetry-plugins/common/helpers'; -import { - getModules, - getIndexed, - getEntries, - getChunks, - getAssets, -} from '@dd/telemetry-plugins/common/metrics/webpack'; -import type { StatsJson, Metric } from '@dd/telemetry-plugins/types'; -import { PROJECT_ROOT } from '@dd/tests/helpers/mocks'; -import { runWebpack, runWebpack4 } from '@dd/tests/helpers/runBundlers'; -import fs from 'fs'; -import path from 'path'; - -describe.skip('Telemetry Webpack Metrics', () => { - for (const version of [4, 5]) { - describe(`Webpack ${version}`, () => { - let statsJson: StatsJson; - const OUTPUT = path.resolve(PROJECT_ROOT, `./webpack${version}-output/`); - - afterAll(async () => { - // Clean - fs.rmSync(OUTPUT, { force: true, recursive: true }); - }); - - beforeAll(async () => { - const runFn = version === 5 ? runWebpack : runWebpack4; - await runFn( - { - auth: { - apiKey: '', - }, - telemetry: { - output: OUTPUT, - }, - }, - { - context: PROJECT_ROOT, - devtool: false, - entry: { - cheesecake: './src/file0000.js', - yolo: './src/file0001.js', - }, - output: { - path: path.resolve(PROJECT_ROOT, `./webpack${version}-output/`), - filename: '[name].js', - chunkFilename: '[name].[contenthash].js', - }, - }, - ); - - statsJson = require(path.resolve(OUTPUT, './bundler.json')); - }, 20000); - - describe('Modules', () => { - let metrics: Metric[]; - - beforeAll(() => { - const indexed = getIndexed(statsJson, PROJECT_ROOT); - metrics = getModules(statsJson, indexed, PROJECT_ROOT); - }); - - test('It should give module metrics.', () => { - expect(metrics.length).not.toBe(0); - }); - - test(`It should filter out webpack's modules.`, () => { - expect( - metrics.find((m) => { - return m.tags.find((t) => /^moduleName:webpack\/runtime/.test(t)); - }), - ).toBeUndefined(); - }); - - test(`It should add tags about the entry and the chunk.`, () => { - for (const metric of metrics) { - expect(metric.tags).toContain('entryName:yolo'); - expect(metric.tags).toContain('entryName:cheesecake'); - expect(metric.tags).toContain('chunkName:yolo'); - expect(metric.tags).toContain('chunkName:cheesecake'); - } - }); - - test('It should have 1 metric per module.', () => { - const modules = [ - './src/file0000.js', - './src/file0001.js', - './workspaces/app/file0000.js', - './workspaces/app/file0001.js', - ]; - - for (const module of modules) { - const modulesMetrics = metrics.filter((m) => - m.tags.includes(`moduleName:${getDisplayName(module)}`), - ); - expect(modulesMetrics.length).toBe(1); - } - }); - }); - - describe('Entries', () => { - let metrics: Metric[]; - - beforeAll(() => { - const indexed = getIndexed(statsJson, PROJECT_ROOT); - metrics = getEntries(statsJson, indexed); - }); - - test('It should give entries metrics.', () => { - expect(metrics.length).not.toBe(0); - }); - - test('It should give 4 metrics per entry.', () => { - const entries = ['yolo', 'cheesecake']; - - for (const entry of entries) { - const entriesMetrics = metrics.filter((m) => - m.tags.includes(`entryName:${entry}`), - ); - expect(entriesMetrics.length).toBe(4); - } - }); - }); - - describe('Chunks', () => { - let metrics: Metric[]; - - beforeAll(() => { - const indexed = getIndexed(statsJson, PROJECT_ROOT); - metrics = getChunks(statsJson, indexed); - }); - - test('It should give chunks metrics.', () => { - expect(metrics.length).not.toBe(0); - }); - - test('It should give 2 metrics per chunk.', () => { - const chunks = ['yolo', 'cheesecake']; - - for (const chunk of chunks) { - const chunksMetrics = metrics.filter((m) => - m.tags.includes(`chunkName:${chunk}`), - ); - expect(chunksMetrics.length).toBe(2); - } - }); - - test(`It should add tags about the entry.`, () => { - for (const metric of metrics) { - expect(metric.tags.join(',')).toMatch(/entryName:(yolo|cheesecake)/); - } - }); - }); - - describe('Assets', () => { - let metrics: Metric[]; - - beforeAll(() => { - const indexed = getIndexed(statsJson, PROJECT_ROOT); - metrics = getAssets(statsJson, indexed); - }); - - test('It should give assets metrics.', () => { - expect(metrics.length).not.toBe(0); - }); - - test('It should give 1 metric per asset.', () => { - const assets = ['yolo.js', 'cheesecake.js']; - - for (const asset of assets) { - const assetsMetrics = metrics.filter((m) => - m.tags.includes(`assetName:${asset}`), - ); - expect(assetsMetrics.length).toBe(1); - } - }); - - test(`It should add tags about the entry and the chunk.`, () => { - for (const metric of metrics) { - expect(metric.tags).toContain('entryName:yolo'); - expect(metric.tags).toContain('entryName:cheesecake'); - expect(metric.tags).toContain('chunkName:yolo'); - expect(metric.tags).toContain('chunkName:cheesecake'); - } - }); - }); - }); - } -}); diff --git a/packages/tests/src/plugins/telemetry/common/output/files.test.ts b/packages/tests/src/plugins/telemetry/common/output/files.test.ts index b8852481..d41e291f 100644 --- a/packages/tests/src/plugins/telemetry/common/output/files.test.ts +++ b/packages/tests/src/plugins/telemetry/common/output/files.test.ts @@ -6,17 +6,18 @@ import { outputFiles } from '@dd/telemetry-plugins/common/output/files'; import type { OutputOptions } from '@dd/telemetry-plugins/types'; import { mockLogger, mockReport } from '@dd/tests/plugins/telemetry/testHelpers'; import fs from 'fs-extra'; +import { vol } from 'memfs'; import path from 'path'; +jest.mock('fs', () => require('memfs').fs); + describe('Telemetry Output Files', () => { const directoryName = '/test/'; const init = async (output: OutputOptions, cwd: string) => { await outputFiles( { - start: 0, report: mockReport, metrics: [], - bundler: {}, }, output, mockLogger, @@ -24,29 +25,48 @@ describe('Telemetry Output Files', () => { ); }; - const getExistsProms = (output: string) => { + const getExists = (output: string) => { return [ - fs.pathExists(path.join(output, 'dependencies.json')), - fs.pathExists(path.join(output, 'timings.json')), - fs.pathExists(path.join(output, 'stats.json')), - fs.pathExists(path.join(output, 'metrics.json')), + fs.pathExistsSync(path.join(output, 'timings.json')), + fs.pathExistsSync(path.join(output, 'metrics.json')), ]; }; - afterEach(async () => { - await fs.remove(path.join(__dirname, directoryName)); + afterEach(() => { + vol.reset(); }); - describe('With boolean', () => { - test.each([path.join(__dirname, directoryName), `.${directoryName}`])( - 'It should allow an absolute and relative path', - async (output) => { - await init(output, __dirname); - const exists = await Promise.all(getExistsProms(output)); - expect(exists.reduce((prev, curr) => prev && curr, true)); - }, - ); + describe('With strings', () => { + test.each([ + { type: 'an absolue', dirPath: path.join(__dirname, directoryName) }, + { type: 'a relative', dirPath: `.${directoryName}` }, + ])('It should allow $type path', async ({ type, dirPath }) => { + await init(dirPath, __dirname); + const absolutePath = type === 'an absolue' ? dirPath : path.resolve(__dirname, dirPath); + const exists = getExists(absolutePath); + + expect(exists[0]).toBeTruthy(); + expect(exists[1]).toBeTruthy(); + }); }); + + describe('With booleans', () => { + test('It should output all the files with true.', async () => { + await init(true, __dirname); + const exists = getExists(__dirname); + + expect(exists[0]).toBeTruthy(); + expect(exists[1]).toBeTruthy(); + }); + test('It should output no files with false.', async () => { + await init(false, __dirname); + const exists = getExists(__dirname); + + expect(exists[0]).toBeFalsy(); + expect(exists[1]).toBeFalsy(); + }); + }); + describe('With object', () => { test('It should output a single file', async () => { const output = { @@ -55,12 +75,10 @@ describe('Telemetry Output Files', () => { }; await init(output, __dirname); const destination = output.destination; - const exists = await Promise.all(getExistsProms(destination)); + const exists = getExists(destination); - expect(exists[0]).toBeFalsy(); - expect(exists[1]).toBeTruthy(); - expect(exists[2]).toBeFalsy(); - expect(exists[3]).toBeFalsy(); + expect(exists[0]).toBeTruthy(); + expect(exists[1]).toBeFalsy(); }); }); }); diff --git a/packages/tests/src/plugins/telemetry/common/output/text.test.ts b/packages/tests/src/plugins/telemetry/common/output/text.test.ts deleted file mode 100644 index 15baa7bf..00000000 --- a/packages/tests/src/plugins/telemetry/common/output/text.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -describe('Telemetry Renderer', () => { - test('It should outputWebpack the same with Webpack 5 and 4', () => { - const { outputWebpack } = require('@dd/telemetry-plugins/common/output/text'); - - const ar = [{ name: 'element1' }, { name: 'element2' }]; - const obj = { obj0: ar[0], obj1: ar[1] }; - const set = new Set(ar); - const map = new Map(); - map.set(0, ar[0]); - map.set(1, ar[1]); - - const statsDefault = { - endTime: 0, - startTime: 0, - compilation: { - warnings: ar, - fileDependencies: set, - }, - }; - const statsWebpack4 = { - ...statsDefault, - compilation: { - ...statsDefault.compilation, - assets: obj, - modules: ar, - entries: ar, - chunks: ar, - }, - }; - const statsWebpack5 = { - ...statsDefault, - compilation: { - ...statsDefault.compilation, - emittedAssets: set, - modules: set, - entries: map, - chunks: set, - }, - }; - const outputWebpack4 = outputWebpack(statsWebpack4); - const outputWebpack5 = outputWebpack(statsWebpack5); - - expect(outputWebpack4).toBe(outputWebpack5); - }); -}); diff --git a/packages/tests/src/plugins/telemetry/esbuild-plugin/modules.test.ts b/packages/tests/src/plugins/telemetry/esbuild-plugin/modules.test.ts deleted file mode 100644 index df96d23b..00000000 --- a/packages/tests/src/plugins/telemetry/esbuild-plugin/modules.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { getModulesResults } from '@dd/telemetry-plugins/common/modules'; -import type { LocalModule } from '@dd/telemetry-plugins/types'; -import { mockMetaFile } from '@dd/tests/plugins/telemetry/testHelpers'; - -describe('Telemetry ESBuild Modules', () => { - test('It should add module size to the results', () => { - const results = getModulesResults('', mockMetaFile); - for (const module of Object.values(results) as LocalModule[]) { - expect(module.size).toBeDefined(); - } - }); - - test('It should add chunkNames to the results', () => { - const results = getModulesResults('', mockMetaFile); - for (const module of Object.values(results) as LocalModule[]) { - expect(module.chunkNames.length).not.toBe(0); - } - }); -}); diff --git a/packages/tests/src/plugins/telemetry/index.test.ts b/packages/tests/src/plugins/telemetry/index.test.ts new file mode 100644 index 00000000..68e81e44 --- /dev/null +++ b/packages/tests/src/plugins/telemetry/index.test.ts @@ -0,0 +1,334 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { Options } from '@dd/core/types'; +import { getMetrics } from '@dd/telemetry-plugins/common/aggregator'; +import type { MetricToSend } from '@dd/telemetry-plugins/types'; +import type { Bundler } from '@dd/tests/helpers/runBundlers'; +import { BUNDLERS, runBundlers } from '@dd/tests/helpers/runBundlers'; +import path from 'path'; + +// Used to intercept metrics. +jest.mock('@dd/telemetry-plugins/common/aggregator', () => { + const originalModule = jest.requireActual('@dd/telemetry-plugins/common/aggregator'); + return { + ...originalModule, + getMetrics: jest.fn(), + }; +}); + +const getMetricsMocked = jest.mocked(getMetrics); + +const entries = { + app1: '@dd/tests/fixtures/project/main1.js', + app2: '@dd/tests/fixtures/project/main2.js', +}; + +const bundlerOverrides = { + rollup: { + input: entries, + }, + vite: { + input: entries, + }, + esbuild: { + entryPoints: entries, + }, + webpack5: { entry: entries }, + webpack4: { + // Webpack 4 doesn't support pnp. + entry: Object.fromEntries( + Object.entries(entries).map(([name, filepath]) => [ + name, + `./${path.relative(process.cwd(), require.resolve(filepath))}`, + ]), + ), + }, +}; + +const getGetMetricsImplem: (metrics: Record) => typeof getMetrics = + (metrics) => (context, options, report) => { + const originalModule = jest.requireActual('@dd/telemetry-plugins/common/aggregator'); + const originalResult = originalModule.getMetrics(context, options, report); + metrics[context.bundler.fullName] = originalResult; + return originalResult; + }; + +describe('Telemetry Universal Plugin', () => { + const tracingMetrics = [ + 'loaders.count', + 'loaders.duration', + 'loaders.increment', + 'plugins.count', + 'plugins.duration', + 'plugins.hooks.duration', + 'plugins.hooks.increment', + 'plugins.increment', + ]; + + describe('With enableTracing', () => { + const metrics: Record = {}; + // enableTracing is only supported by esbuild and webpack. + const activeBundlers = ['esbuild', 'webpack4', 'webpack5']; + + const bundlers = BUNDLERS.filter((bundler) => activeBundlers.includes(bundler.name)); + const expectations: (Bundler & { expectedMetrics: string[] })[] = []; + const webpack4 = bundlers.find((bundler) => bundler.name === 'webpack4'); + const webpack5 = bundlers.find((bundler) => bundler.name === 'webpack5'); + const esbuild = bundlers.find((bundler) => bundler.name === 'esbuild'); + + // Doing it this way to prevent failing when running tests with --bundlers. + if (esbuild) { + expectations.push({ + ...esbuild, + // We only have our own plugin, that is skipped, + // so we don't have much data, but having this metrics is enough to assert + // that enableTracing is working. + expectedMetrics: ['plugins.count'], + }); + } + + if (webpack4) { + expectations.push({ + ...webpack4, + expectedMetrics: tracingMetrics, + }); + } + + if (webpack5) { + expectations.push({ + ...webpack5, + expectedMetrics: tracingMetrics, + }); + } + + // We don't want to crash if there are no bundlers to test here. + // Which can happen when using --bundlers. + if (expectations.length > 0) { + beforeAll(async () => { + const pluginConfig: Options = { + telemetry: { + enableTracing: true, + filters: [], + }, + logLevel: 'warn', + }; + // This one is called at initialization, with the initial context. + getMetricsMocked.mockImplementation(getGetMetricsImplem(metrics)); + await runBundlers(pluginConfig, bundlerOverrides, activeBundlers); + }); + + test.each(expectations)( + '$name - $version | Should get the related metrics', + ({ name, expectedMetrics }) => { + const metricNames = metrics[name].map((metric) => metric.metric).sort(); + expect(metricNames).toEqual(expect.arrayContaining(expectedMetrics)); + }, + ); + } + }); + + describe('Without enableTracing', () => { + const metrics: Record = {}; + + beforeAll(async () => { + const pluginConfig: Options = { + telemetry: { + filters: [], + }, + logLevel: 'warn', + }; + // This one is called at initialization, with the initial context. + getMetricsMocked.mockImplementation(getGetMetricsImplem(metrics)); + await runBundlers(pluginConfig, bundlerOverrides); + }); + + const getMetric = ( + metricName: string, + tags: string[] = expect.any(Array), + // Using expect.any(Number) as each bundler will bundled things differently. + value: number = expect.any(Number), + ) => { + return { + type: 'gauge', + tags, + metric: metricName, + points: [[expect.any(Number), value]], + }; + }; + + describe.each(BUNDLERS)('$name - $version', ({ name }) => { + test('Should have no tracing metrics', () => { + const metricNames = metrics[name].map((metric) => metric.metric).sort(); + expect(metricNames).toEqual(expect.not.arrayContaining(tracingMetrics)); + }); + + test('Should have generic metrics', () => { + expect(metrics[name]).toEqual( + expect.arrayContaining([getMetric('modules.count', [], 15)]), + ); + expect(metrics[name]).toEqual( + expect.arrayContaining([getMetric('entries.count', [], 2)]), + ); + expect(metrics[name]).toEqual(expect.arrayContaining([getMetric('assets.count')])); + expect(metrics[name]).toEqual( + // Rollup and Vite have warnings about circular dependencies, where the others don't. + expect.arrayContaining([getMetric('warnings.count')]), + ); + expect(metrics[name]).toEqual( + expect.arrayContaining([getMetric('errors.count', [], 0)]), + ); + expect(metrics[name]).toEqual( + expect.arrayContaining([getMetric('compilation.duration')]), + ); + }); + + test('Should have entry metrics', () => { + const entryMetrics = metrics[name].filter((metric) => + metric.metric.startsWith('entries'), + ); + expect(entryMetrics).toEqual( + expect.arrayContaining([ + getMetric('entries.size', ['entryName:app1']), + getMetric('entries.modules.count', ['entryName:app1'], 14), + getMetric('entries.assets.count', ['entryName:app1']), + getMetric('entries.size', ['entryName:app2']), + getMetric('entries.modules.count', ['entryName:app2'], 5), + getMetric('entries.assets.count', ['entryName:app2']), + ]), + ); + }); + + const getAssetMetric = ( + type: string, + assetName: string, + entryName: string, + value: number = expect.any(Number), + ) => { + return getMetric( + `assets.${type}`, + expect.arrayContaining([`assetName:${assetName}`, `entryName:${entryName}`]), + value, + ); + }; + + test('Should have asset metrics', () => { + const assetMetrics = metrics[name].filter((metric) => + metric.metric.startsWith('assets'), + ); + + expect(assetMetrics).toEqual( + expect.arrayContaining([getAssetMetric('size', 'app1.js', 'app1')]), + ); + expect(assetMetrics).toEqual( + expect.arrayContaining([getAssetMetric('modules.count', 'app1.js', 'app1')]), + ); + expect(assetMetrics).toEqual( + expect.arrayContaining([getAssetMetric('size', 'app2.js', 'app2')]), + ); + expect(assetMetrics).toEqual( + expect.arrayContaining([getAssetMetric('modules.count', 'app2.js', 'app2')]), + ); + expect(assetMetrics).toEqual( + expect.arrayContaining([getAssetMetric('size', 'app1.js.map', 'app1')]), + ); + expect(assetMetrics).toEqual( + expect.arrayContaining([ + getAssetMetric('modules.count', 'app1.js.map', 'app1', 1), + ]), + ); + expect(assetMetrics).toEqual( + expect.arrayContaining([getAssetMetric('size', 'app2.js.map', 'app2')]), + ); + expect(assetMetrics).toEqual( + expect.arrayContaining([ + getAssetMetric('modules.count', 'app2.js.map', 'app2', 1), + ]), + ); + }); + + const getModuleMetric = ( + type: string, + moduleName: string, + entryNames: string[], + value: number = expect.any(Number), + ) => { + return getMetric( + `modules.${type}`, + expect.arrayContaining([ + `moduleName:${moduleName}`, + `moduleType:js`, + ...entryNames.map((entryName) => `entryName:${entryName}`), + ]), + value, + ); + }; + + // [name, entryNames, size, dependencies, dependents]; + const modulesExpectations: [string, string[], number, number, number][] = [ + ['src/fixtures/project/workspaces/app/file0000.js', ['app1', 'app2'], 30048, 1, 2], + ['src/fixtures/project/workspaces/app/file0001.js', ['app1', 'app2'], 4549, 1, 2], + ['src/fixtures/project/src/file0001.js', ['app1', 'app2'], 2203, 2, 2], + ['src/fixtures/project/src/file0000.js', ['app1', 'app2'], 13267, 2, 2], + ['escape-string-regexp/index.js', ['app1'], 226, 0, 1], + ['color-name/index.js', ['app1'], 4617, 0, 1], + ['color-convert/conversions.js', ['app1'], 16850, 1, 2], + ['color-convert/route.js', ['app1'], 2227, 1, 1], + ['color-convert/index.js', ['app1'], 1725, 2, 1], + ['ansi-styles/index.js', ['app1'], 3574, 1, 1], + ['supports-color/browser.js', ['app1'], 67, 0, 1], + ['chalk/templates.js', ['app1'], 3133, 0, 1], + // Somehow rollup and vite are not reporting the same size. + // @ts-ignore - Not sure why it doesn't load the new type. + ['chalk/index.js', ['app1'], expect.toBeWithinRange(6437, 6439), 4, 1], + ['src/fixtures/project/main1.js', ['app1'], 379, 2, 0], + ['src/fixtures/project/main2.js', ['app2'], 272, 1, 0], + ]; + + describe.each(modulesExpectations)( + 'Should have module metrics for %s', + (moduleName, entryNames, size, dependencies, dependents) => { + test('Should have module size metrics', () => { + const moduleMetrics = metrics[name].filter((metric) => + metric.metric.startsWith('modules'), + ); + + expect(moduleMetrics).toEqual( + expect.arrayContaining([ + getModuleMetric('size', moduleName, entryNames, size), + ]), + ); + }); + test('Should have module dependencies metrics', () => { + const moduleMetrics = metrics[name].filter((metric) => + metric.metric.startsWith('modules'), + ); + + expect(moduleMetrics).toEqual( + expect.arrayContaining([ + getModuleMetric( + 'dependencies', + moduleName, + entryNames, + dependencies, + ), + ]), + ); + }); + test('Should have module dependents metrics', () => { + const moduleMetrics = metrics[name].filter((metric) => + metric.metric.startsWith('modules'), + ); + + expect(moduleMetrics).toEqual( + expect.arrayContaining([ + getModuleMetric('dependents', moduleName, entryNames, dependents), + ]), + ); + }); + }, + ); + }); + }); +}); diff --git a/packages/tests/src/plugins/telemetry/testHelpers.ts b/packages/tests/src/plugins/telemetry/testHelpers.ts index 05d737d1..aef19b6b 100644 --- a/packages/tests/src/plugins/telemetry/testHelpers.ts +++ b/packages/tests/src/plugins/telemetry/testHelpers.ts @@ -5,11 +5,8 @@ import type { Logger } from '@dd/core/log'; import type { LogLevel, Options } from '@dd/core/types'; import type { - BundlerStats, - Stats, Report, Compilation, - Compiler, OptionsDD, OptionsWithTelemetry, OutputOptions, @@ -18,12 +15,6 @@ import type { } from '@dd/telemetry-plugins/types'; import type { PluginBuild, Metafile } from 'esbuild'; import esbuild from 'esbuild'; -import path from 'path'; - -// Have a path prefixed with the cwd. -export const prefixPath = (modulePath: string) => { - return path.join('src/fixtures/project', modulePath); -}; export const getMockBuild = (overrides: Partial): PluginBuild => { return { @@ -39,61 +30,6 @@ export const getMockBuild = (overrides: Partial): PluginBuild => { }; }; -export const mockStats: Stats = { - toJson: jest.fn(() => ({ - modules: [], - chunks: [], - assets: [], - entrypoints: {}, - warnings: [], - errors: [], - time: 0, - })), - endTime: 0, - startTime: 0, - compilation: { - assets: {}, - fileDependencies: new Set(), - emittedAssets: new Set(), - warnings: [], - modules: new Set(), - chunks: new Set(), - entries: new Map(), - }, -}; - -export const mockBuild: PluginBuild = { - initialOptions: {}, - esbuild, - resolve: jest.fn(), - onStart: jest.fn(), - onEnd: jest.fn(), - onResolve: jest.fn(), - onDispose: jest.fn(), - onLoad: jest.fn(), -}; - -export const mockBundler: BundlerStats = { - webpack: mockStats, - esbuild: { - warnings: [], - errors: [], - entrypoints: [], - duration: 0, - inputs: {}, - outputs: {}, - }, -}; - -export const mockReport: Report = { - timings: { - tapables: new Map(), - loaders: new Map(), - modules: new Map(), - }, - dependencies: {}, -}; - const mockTapable = { tap: jest.fn() }; export const mockModule: Module = { name: 'module', @@ -141,21 +77,6 @@ export const getMockCompilation = (overrides: Partial): Compilation }, }); -export const mockCompiler: Compiler = { - options: {}, - hooks: { - thisCompilation: { - tap: (opts: any, cb: (c: Compilation) => void) => { - cb(mockCompilation); - }, - }, - done: { - tap: (opts: any, cb: (s: Stats) => void) => cb(mockStats), - tapPromise: (opts: any, cb: any) => cb(mockStats), - }, - }, -}; - export const mockMetaFile: Metafile = { inputs: { module1: { @@ -183,6 +104,14 @@ export const mockMetaFile: Metafile = { }, }; +export const mockReport: Report = { + timings: { + tapables: new Map(), + loaders: new Map(), + modules: new Map(), + }, +}; + export const mockOptions: Options = { auth: { apiKey: '', diff --git a/packages/tests/src/plugins/telemetry/webpack-plugin/modules.test.ts b/packages/tests/src/plugins/telemetry/webpack-plugin/modules.test.ts deleted file mode 100644 index 967457ae..00000000 --- a/packages/tests/src/plugins/telemetry/webpack-plugin/modules.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { LocalModule, Module, Compilation, Chunk } from '@dd/telemetry-plugins/types'; -import { Modules } from '@dd/telemetry-plugins/webpack-plugin/modules'; -import { mockTelemetryOptions } from '@dd/tests/plugins/telemetry/testHelpers'; - -describe('Telemetry Modules', () => { - // Webpack5 is actually throwing an error when using this property. - const getThrowingDependency = (dep: any) => { - Object.defineProperty(dep, 'module', { - get: () => { - throw new Error(); - }, - }); - return dep; - }; - - const getMockedModule = (opts?: Partial): Module => ({ - name: 'Name', - size: 1, - loaders: [], - chunks: [], - _chunks: new Set(), - dependencies: [], - userRequest: '', - ...opts, - }); - - const getMockedChunk = (opts?: { names?: string[] }): Chunk => ({ - id: 'id', - size: 0, - modules: [{}], - files: ['file'], - names: ['name'], - parents: (opts && opts.names) || ['parent'], - }); - - const mockedModules: Module[] = [ - getMockedModule({ - name: 'moduleWebpack4', - size: 50, - _chunks: new Set([ - getMockedChunk({ names: ['chunk1'] }), - getMockedChunk({ names: ['chunk2'] }), - ]), - dependencies: [ - { module: getMockedModule({ name: 'dep1', size: 1 }) }, - { module: getMockedModule({ name: 'dep2', size: 2 }) }, - { module: getMockedModule({ name: 'dep3', size: 3 }) }, - ], - }), - getMockedModule({ - name: 'moduleWebpack5', - size: () => 50, - dependencies: [ - getThrowingDependency({ name: 'dep1', size: () => 1 }), - getThrowingDependency({ name: 'dep2', size: () => 2 }), - getThrowingDependency({ name: 'dep3', size: () => 3 }), - ], - }), - getMockedModule({ name: 'dep1', size: () => 1 }), - getMockedModule({ name: 'dep2', size: () => 2 }), - getMockedModule({ name: 'dep3', size: () => 3 }), - ]; - - const mockCompilation: Compilation = { - options: { context: '' }, - moduleGraph: { - getIssuer: () => getMockedModule(), - issuer: getMockedModule(), - getModule(dep: any) { - return mockedModules[0].dependencies.find( - (d) => d.module.name === dep.name && d.module, - )!.module; - }, - }, - chunkGraph: { - getModuleChunks(module: any) { - return mockedModules[0]._chunks; - }, - }, - hooks: { - buildModule: { tap: () => {} }, - succeedModule: { tap: () => {} }, - afterOptimizeTree: { tap: () => {} }, - }, - }; - - const modules = new Modules('', mockTelemetryOptions); - modules.afterOptimizeTree({}, mockedModules, mockCompilation); - - test('It should filter modules the same with Webpack 5 and 4', () => { - const modulesWebpack4 = modules.storedModules['moduleWebpack4'].dependencies; - const modulesWebpack5 = modules.storedModules['moduleWebpack5'].dependencies; - - expect(modulesWebpack5.length).toBe(modulesWebpack4.length); - }); - - test('It should add module size to the results', () => { - const results = modules.getResults(); - for (const module of Object.values(results.modules) as LocalModule[]) { - expect(module.size).toBeDefined(); - } - }); - - test('It should add chunk names to the results', () => { - const results = modules.getResults(); - for (const module of Object.values(results.modules) as LocalModule[]) { - expect(module.chunkNames).toBeDefined(); - } - }); -}); diff --git a/packages/tests/src/setupTests.ts b/packages/tests/src/setupTests.ts new file mode 100644 index 00000000..4753149c --- /dev/null +++ b/packages/tests/src/setupTests.ts @@ -0,0 +1,25 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import nock from 'nock'; + +import { toBeWithinRange } from './helpers/toBeWithinRange.ts'; + +expect.extend({ + toBeWithinRange, +}); + +declare module 'expect' { + interface AsymmetricMatchers { + toBeWithinRange(floor: number, ceiling: number): void; + } + interface Matchers { + toBeWithinRange(floor: number, ceiling: number): R; + } +} + +global.beforeAll(() => { + // Do not send any HTTP requests. + nock.disableNetConnect(); +});