Skip to content

Commit

Permalink
Add "sourcemaps upload" command using a mock call (#54)
Browse files Browse the repository at this point in the history
  • Loading branch information
mvirgil authored Dec 17, 2024
1 parent f17c820 commit d1518e0
Show file tree
Hide file tree
Showing 7 changed files with 222 additions and 21 deletions.
1 change: 0 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } ],
Expand Down
85 changes: 73 additions & 12 deletions src/commands/sourcemaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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 <path>')
.summary(`Inject a code snippet into your JavaScript bundles to allow for automatic source mapping of errors`)
.description(injectDescription)
.requiredOption(
Expand Down Expand Up @@ -82,13 +95,61 @@ sourcemapsCommand

sourcemapsCommand
.command('upload')
.requiredOption('--app-name <appName>', 'Application name')
.requiredOption('--app-version <appVersion>', 'Application version')
.requiredOption('--directory <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 <path> --realm <value> --token <value>')
.summary(`Upload source maps to Splunk Observability Cloud`)
.description(uploadDescription)
.requiredOption(
'--directory <path>',
'Path to the directory containing source maps for your production JavaScript bundles'
)
.requiredOption(
'--realm <value>',
'Realm for your organization (example: us0). Can also be set using the environment variable O11Y_REALM',
process.env.O11Y_REALM
)
.requiredOption(
'--token <value>',
'API access token. Can also be set using the environment variable O11Y_TOKEN',
process.env.O11Y_TOKEN
)
.option(
'--app-name <value>',
'The application name used in your agent configuration'
)
.option(
'--app-version <value>',
'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;
}
117 changes: 114 additions & 3 deletions src/sourcemaps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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);
Expand Down Expand Up @@ -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,
{
Expand All @@ -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.`,
}
);
}
4 changes: 2 additions & 2 deletions src/sourcemaps/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
}
);
}
Expand Down
32 changes: 31 additions & 1 deletion src/utils/httpUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -65,4 +66,33 @@ export const uploadFile = async ({ url, file, parameters, onProgress }: UploadOp
}
},
});
};
};

// 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<void> => {
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);
});
};
2 changes: 1 addition & 1 deletion src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/utils/spinner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import ora from 'ora';

interface Spinner {
export interface Spinner {
/** Start sending the spinner animation to stderr */
start: (text: string) => void;

Expand Down

0 comments on commit d1518e0

Please sign in to comment.