diff --git a/.eslintrc.js b/.eslintrc.js index 5cf0ccd..4ec4384 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,7 +17,6 @@ module.exports = { ], root: true, rules: { - '@stylistic/array-bracket-spacing': [ 'error', 'always' ], '@stylistic/indent': [ 'error', 2 ], '@stylistic/object-curly-spacing': [ 'error', 'always' ], '@stylistic/quotes': [ 'error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': true } ], diff --git a/src/commands/sourcemaps.ts b/src/commands/sourcemaps.ts index a67c5a9..afb5b5b 100644 --- a/src/commands/sourcemaps.ts +++ b/src/commands/sourcemaps.ts @@ -15,9 +15,10 @@ */ import { Command } from 'commander'; -import { runSourcemapInject, SourceMapInjectOptions } from '../sourcemaps'; +import { runSourcemapInject, runSourcemapUpload, SourceMapInjectOptions } from '../sourcemaps'; import { UserFriendlyError } from '../utils/userFriendlyErrors'; import { createLogger, LogLevel } from '../utils/logger'; +import { createSpinner } from '../utils/spinner'; export const sourcemapsCommand = new Command('sourcemaps'); @@ -44,10 +45,22 @@ After running this command successfully: - deploy the injected JavaScript files to your production environment `; +const uploadDescription = +`Uploads source maps to Splunk Observability Cloud. + +This command will recursively search the provided path for source map files (.js.map, .cjs.map, .mjs.map) +and upload them. You can specify optional metadata (application name, version) that will be attached to +each uploaded source map. + +This command should be run after "sourcemaps inject". Once the injected JavaScript files have been deployed +to your environment, any reported stack traces will be automatically symbolicated using these +uploaded source maps. +`; + sourcemapsCommand .command('inject') .showHelpAfterError(true) - .usage('--directory path/to/dist') + .usage('--directory ') .summary(`Inject a code snippet into your JavaScript bundles to allow for automatic source mapping of errors`) .description(injectDescription) .requiredOption( @@ -82,13 +95,61 @@ sourcemapsCommand sourcemapsCommand .command('upload') - .requiredOption('--app-name ', 'Application name') - .requiredOption('--app-version ', 'Application version') - .requiredOption('--directory ', 'Path to the directory containing source maps') - .description('Upload source maps') - .action((options) => { - console.log(`Uploading source maps: - App Name: ${options.appName} - App Version: ${options.appVersion} - Directory: ${options.directory}`); - }); + .showHelpAfterError(true) + .usage('--directory --realm --token ') + .summary(`Upload source maps to Splunk Observability Cloud`) + .description(uploadDescription) + .requiredOption( + '--directory ', + 'Path to the directory containing source maps for your production JavaScript bundles' + ) + .requiredOption( + '--realm ', + 'Realm for your organization (example: us0). Can also be set using the environment variable O11Y_REALM', + process.env.O11Y_REALM + ) + .requiredOption( + '--token ', + 'API access token. Can also be set using the environment variable O11Y_TOKEN', + process.env.O11Y_TOKEN + ) + .option( + '--app-name ', + 'The application name used in your agent configuration' + ) + .option( + '--app-version ', + 'The application version used in your agent configuration' + ) + .option( + '--debug', + 'Enable debug logs' + ) + .action( + async (options: SourcemapsUploadCliOptions) => { + const logger = createLogger(options.debug ? LogLevel.DEBUG : LogLevel.INFO); + const spinner = createSpinner(); + try { + await runSourcemapUpload(options, { logger, spinner }); + } catch (e) { + if (e instanceof UserFriendlyError) { + logger.debug(e.originalError); + logger.error(e.message); + } else { + logger.error('Exiting due to an unexpected error:'); + logger.error(e); + } + sourcemapsCommand.error(''); + } + } + ); + +interface SourcemapsUploadCliOptions { + directory: string; + realm: string; + token: string; + appName?: string; + appVersion?: string; + dryRun?: boolean; + debug?: boolean; +} diff --git a/src/sourcemaps/index.ts b/src/sourcemaps/index.ts index ba64c42..1b33325 100644 --- a/src/sourcemaps/index.ts +++ b/src/sourcemaps/index.ts @@ -25,13 +25,31 @@ import { throwAsUserFriendlyErrnoException } from '../utils/userFriendlyErrors'; import { discoverJsMapFilePath } from './discoverJsMapFilePath'; import { computeSourceMapId } from './computeSourceMapId'; import { injectFile } from './injectFile'; +import { Logger } from '../utils/logger'; +import { Spinner } from '../utils/spinner'; +import { mockUploadFile } from '../utils/httpUtils'; export type SourceMapInjectOptions = { directory: string; - dryRun: boolean; + dryRun?: boolean; debug?: boolean; }; +export type SourceMapUploadOptions = { + token: string; + realm: string; + directory: string; + appName?: string; + appVersion?: string; + dryRun?: boolean; + debug?: boolean; +}; + +export type Context = { + logger: Logger; + spinner: Spinner; +}; + /** * Inject sourceMapIds into all applicable JavaScript files inside the given directory. * @@ -50,7 +68,7 @@ export async function runSourcemapInject(options: SourceMapInjectOptions) { try { filePaths = await readdirRecursive(directory); } catch (err) { - throwDirectoryReadError(err, directory); + throwDirectoryReadErrorDuringInject(err, directory); } const jsFilePaths = filePaths.filter(isJsFilePath); @@ -92,7 +110,90 @@ export async function runSourcemapInject(options: SourceMapInjectOptions) { } -function throwDirectoryReadError(err: unknown, directory: string): never { +/** + * Upload all source map files in the provided directory. + * + * For each source map file in the directory: + * 1. Compute the sourceMapId (by hashing the file) + * 2. Upload the file to the appropriate URL + */ +export async function runSourcemapUpload(options: SourceMapUploadOptions, ctx: Context) { + const { logger, spinner } = ctx; + const { directory, realm, appName, appVersion } = options; + + /* + * Read the provided directory to collect a list of all possible files the script will be working with. + */ + let filePaths; + try { + filePaths = await readdirRecursive(directory); + } catch (err) { + throwDirectoryReadErrorDuringUpload(err, directory); + } + const jsMapFilePaths = filePaths.filter(isJsMapFilePath); + + /* + * Upload files to the server + */ + let success = 0; + let failed = 0; + + logger.info('Upload URL: %s', `https://api.${realm}.signalfx.com/v1/sourcemap/id/{id}`); + logger.info('Found %s source maps to upload', jsMapFilePaths.length); + spinner.start(''); + for (let i = 0; i < jsMapFilePaths.length; i++) { + const filesRemaining = jsMapFilePaths.length - i; + const path = jsMapFilePaths[i]; + const sourceMapId = await computeSourceMapId(path, { directory }); + const url = `https://api.${realm}.signalfx.com/v1/sourcemap/id/${sourceMapId}`; + const file = { + filePath: path, + fieldName: 'file' + }; + const parameters = Object.fromEntries([ + ['appName', appName], + ['appVersion', appVersion], + ['sourceMapId', sourceMapId], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ].filter(([_, value]) => typeof value !== 'undefined')); + + spinner.interrupt(() => { + logger.debug('Uploading %s', path); + logger.debug('POST', url); + }); + + // upload a single file + try { + await mockUploadFile({ + url, + file, + onProgress: ({ loaded, total }) => { + spinner.updateText(`Uploading ${loaded} of ${total} bytes for ${path} (${filesRemaining} files remaining)`); + }, + parameters + }); + success++; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + logger.error('Upload failed for %s', path); + failed++; + } + } + spinner.stop(); + + /* + * Print summary of results + */ + logger.info(`${success} source maps were uploaded successfully`); + if (failed > 0) { + logger.info(`${failed} source maps could not be uploaded`); + } + if (jsMapFilePaths.length === 0) { + logger.warn(`No source map files were found. Verify that ${directory} is the correct directory for your source map files.`); + } +} + +function throwDirectoryReadErrorDuringInject(err: unknown, directory: string): never { throwAsUserFriendlyErrnoException( err, { @@ -103,3 +204,13 @@ function throwDirectoryReadError(err: unknown, directory: string): never { ); } +function throwDirectoryReadErrorDuringUpload(err: unknown, directory: string): never { + throwAsUserFriendlyErrnoException( + err, + { + EACCES: `Failed to upload the source map files in "${directory} because of missing permissions.\nMake sure that the CLI tool will have "read" and "write" access to the directory and all files inside it, then rerun the upload command.`, + ENOENT: `Unable to start the upload command because the directory "${directory}" does not exist.\nMake sure the correct path is being passed to --directory, then rerun the upload command.`, + ENOTDIR: `Unable to start the upload command because the path "${directory}" is not a directory.\nMake sure a valid directory path is being passed to --directory, then rerun the upload command.`, + } + ); +} diff --git a/src/sourcemaps/utils.ts b/src/sourcemaps/utils.ts index 7684cf0..236b3d1 100644 --- a/src/sourcemaps/utils.ts +++ b/src/sourcemaps/utils.ts @@ -33,8 +33,8 @@ export function throwJsMapFileReadError(err: unknown, sourceMapFilePath: string, throwAsUserFriendlyErrnoException( err, { - ENOENT: `Failed to open the source map file "${sourceMapFilePath}" because the file does not exist.\nMake sure that your source map files are being emitted to "${options.directory}". Regenerate your source map files, then rerun the inject command.`, - EACCES: `Failed to open the source map file "${sourceMapFilePath}" because of missing file permissions.\nMake sure that the CLI tool will have both "read" and "write" access to all files inside "${options.directory}", then rerun the inject command.` + ENOENT: `Failed to open the source map file "${sourceMapFilePath}" because the file does not exist.\nMake sure that your source map files are being emitted to "${options.directory}". Regenerate your source map files, then rerun the command.`, + EACCES: `Failed to open the source map file "${sourceMapFilePath}" because of missing file permissions.\nMake sure that the CLI tool will have both "read" and "write" access to all files inside "${options.directory}", then rerun the command.` } ); } diff --git a/src/utils/httpUtils.ts b/src/utils/httpUtils.ts index 61d673c..6916fe1 100644 --- a/src/utils/httpUtils.ts +++ b/src/utils/httpUtils.ts @@ -26,6 +26,7 @@ interface FileUpload { interface UploadOptions { url: string; file: FileUpload; + method?: 'PUT' | 'POST'; parameters: { [key: string]: string | number }; onProgress?: (progressInfo: { progress: number; loaded: number; total: number }) => void; } @@ -65,4 +66,33 @@ export const uploadFile = async ({ url, file, parameters, onProgress }: UploadOp } }, }); -}; \ No newline at end of file +}; + +// temporary function +// mockUploadFile can be used when the endpoint for the real uploadFile call is not ready + +export const mockUploadFile = async ({ file, onProgress }: UploadOptions): Promise => { + const fileSizeInBytes = fs.statSync(file.filePath).size; + + return new Promise((resolve) => { + const mbps = 25; + const bytes_to_megabits = (bytes: number) => bytes * 8 / 1000 / 1000; + + // simulate axios progress events + const tick = 50; + let msElapsed = 0; + const intervalId = setInterval(() => { + msElapsed += tick; + const loaded = Math.floor((msElapsed / 1000) * mbps / 8 * 1024 * 1024); + const total = fileSizeInBytes; + const progress = (loaded / total) * 100; + onProgress?.({ loaded, total, progress }); + }, tick); + + // simulate axios completion + setTimeout(() => { + clearInterval(intervalId); + resolve(); + }, bytes_to_megabits(fileSizeInBytes) / mbps * 1000); + }); +}; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index d86033c..7ec130f 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -36,7 +36,7 @@ export function createLogger(logLevel: LogLevel): Logger { return { error: (msg, ...params) => LogLevel.ERROR >= logLevel && prefixedConsoleError(chalk.stderr.red('ERROR '), msg, ...params), warn: (msg, ...params) => LogLevel.WARN >= logLevel && prefixedConsoleError(chalk.stderr.yellow('WARN '), msg, ...params), - info: (msg, ...params) => LogLevel.INFO >= logLevel && console.log(msg, params), + info: (msg, ...params) => LogLevel.INFO >= logLevel && console.log(msg, ...params), debug: (msg, ...params) => LogLevel.DEBUG >= logLevel && prefixedConsoleError(chalk.stderr.gray('DEBUG '), msg, ...params), } as Logger; } diff --git a/src/utils/spinner.ts b/src/utils/spinner.ts index e3785be..aec7edb 100644 --- a/src/utils/spinner.ts +++ b/src/utils/spinner.ts @@ -16,7 +16,7 @@ import ora from 'ora'; -interface Spinner { +export interface Spinner { /** Start sending the spinner animation to stderr */ start: (text: string) => void;