Inconvenient logging exceptions in SharePoint Framework production bundles

So you optimized your SharePoint Framework bundle for production. Your variable names are shortened and white-spaces are removed. Like a good developer, you instrumented your code with logging, in case something doesn't work as expected. One day you get an exception:

Error: Cannot read property 'getData' of undefined
    at e._loadData (https://publiccdn.sharepointonline.com/contoso.sharepoint.com/sites/apps/ClientSideAssets/487d2dda-1805-4b50-bdd7-4c6de208e3e3/log-exception-web-part_564b4ef2ab9e89ff1a742f60ba8e8830.js:1:11564)
    at e.loadData (https://publiccdn.sharepointonline.com/contoso.sharepoint.com/sites/apps/ClientSideAssets/487d2dda-1805-4b50-bdd7-4c6de208e3e3/log-exception-web-part_564b4ef2ab9e89ff1a742f60ba8e8830.js:1:11498)
    at HTMLAnchorElement.<anonymous> (https://publiccdn.sharepointonline.com/contoso.sharepoint.com/sites/apps/ClientSideAssets/487d2dda-1805-4b50-bdd7-4c6de208e3e3/log-exception-web-part_564b4ef2ab9e89ff1a742f60ba8e8830.js:1:13515)

The stack trace leaves you clueless. You look at the code and finally find column 11564. So what went wrong exactly?

Optimizing JavaScript application for production

Unlike managed languages, JavaScript is parsed in browser on runtime. While you can't quite precompile it to speed it up, there are other techniques that can help you get your JavaScript solution to load and run fast.

When building JavaScript solutions, it's common nowadays to use a build toolchain based on one of the hundreds of available open source tools. And it's no different in the SharePoint Framework, which uses a build toolchain based on gulp and Webpack.

When you build a SharePoint Framework in the debug mode, the TypeScript code is transpiled to JavaScript. Except for multiple files being put into one, the code is very much like the original code you wrote. Using source maps, produced as a part of the build, you can even step through your original TypeScript code in the browser in case something is not working as expected.

In production mode, things look different. When building a SharePoint Framework project for release, the toolchain automatically enables the UglifyJS plugin, which removes all extra white spaces and mangles variable names in the generated TypeScript code. What you get, is a bundle with one line and 80.000 columns of code.

Visual Studio Code window to navigate to specified line of code
Debugging modern JavaScript solutions...

And this is only the beginning of the problems.

Inconvenient logging exceptions in production

When you build a SharePoint Framework project in production mode, the toolchain doesn't generate source maps to translate the generated code to your original source. Partly, this is understandable. If you are an ISV, you might not be willing to ship the source map of your code, which basically means publicly sharing all your source code with everyone. But what if you are an enterprise that already owns the code? We've talked about this previously, and there is a way to generate source maps for production builds. But that only brings you to the next problem.

Source maps are explicitly excluded from the generated solution package. Unfortunately, this configuration is not exposed outside of the SharePoint Framework package task. But if you want to be able to support your application in production, you need to have a way to log meaningful exception messages. And while you could disable the UglifyJS plugin altogether, it's a shame to have to do that kind of a trade-off. So what's a better option?

Logging meaningful exception messages in SharePoint Framework

To get more precise information about any exceptions that occurred in your production code, you need two things: source maps to be deployed along your JavaScript bundles and a way to use source maps to get the real location of the exception in your original code.

Deploying source maps along SharePoint Framework bundles

The first part of the problem is only an issue if you deploy your assets to SharePoint/Office 365 Public CDN. If you deploy them to a different location, there is nothing stopping you from manually deploying source maps along with your JavaScript bundles. But to get source maps deployed to SharePoint, preferably you want them to be included in the solution package. That way, SharePoint will automatically provision them to the right location.

As I mentioned before, source maps are excluded from the .sppkg packaging process and there is nothing you can do about it. What you can do though, is add them to the solution package after it was generated. Following is a sample gulp task that does exactly that.

'use strict';

const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
const webpack = require('webpack');
const path = require('path');
const JSZip = require("jszip");
const fs = require('fs');

// generate source maps
build.configureWebpack.mergeConfig({
  additionalConfiguration: (generatedConfiguration) => {
    generatedConfiguration.devtool = 'source-map';

    for (var i = 0; i < generatedConfiguration.plugins.length; i++) {
      const plugin = generatedConfiguration.plugins[i];
      if (plugin instanceof webpack.optimize.UglifyJsPlugin) {
        plugin.options.sourceMap = true;
        break;
      }
    }

    return generatedConfiguration;
  }
});

gulp.task('package-sourcemaps', function (callback) {
  if (!build.packageSolution.taskConfig.solution.includeClientSideAssets) {
    console.log('No need to update package because assets are not included');
  }

  const { packageDir, zippedPackage } = { ...build.packageSolution.taskConfig.paths };
  const sppkgPath = path.join(packageDir, zippedPackage);
  const distFolder = build.getConfig().distFolder;

  fs.readFile(sppkgPath, function (err, data) {
    if (err) throw err;

    // load .sppkg contents
    JSZip.loadAsync(data).then(function (zip) {
      // get names of all source maps. There can be multiple
      // if the project has multiple bundles
      const mapFiles = fs.readdirSync(distFolder).filter(f => f.endsWith('.map'));

      // add support for map files or SharePoint will fail
      // when trying to extract the .js.map files
      zip
        .file('[Content_Types].xml')
        .async('string')
        .then(contentTypes => {
          contentTypes = contentTypes.replace('</Types>', '<Default Extension="map" ContentType="application/json"></Default></Types>');
          zip.file('[Content_Types].xml', contentTypes);

          return zip.file('_rels/ClientSideAssets.xml.rels').async('string');
        })
        .then(rels => {
          // get the ID of the last rel to generate IDs of
          // additional rels for source maps
          var relId = 0;
          var match;
          const regex = RegExp('"r([^d]+)', 'g')
          while ((match = regex.exec(rels)) !== null) {
            relId = parseInt(match[1]);
          }
          const mapsRels = mapFiles.map(m => `<Relationship Type="http://schemas.microsoft.com/sharepoint/2012/app/relationships/clientsideasset" Target="/ClientSideAssets/${m}" Id="r${++relId}"></Relationship>`).join('');
          rels = rels.replace('</Relationships>', `${mapsRels}</Relationships>`);
          zip.file('_rels/ClientSideAssets.xml.rels', rels);

          // add source maps to the ClientSideAssets folder
          const ClientSideAssets = zip.folder('ClientSideAssets');
          mapFiles.forEach(m => {
            const jsMap = fs.readFileSync(path.join(distFolder, m), 'utf-8');
            ClientSideAssets.file(m, jsMap);
          });

          // regenerate the .sppkg with the above additions
          zip
            .generateNodeStream({ type: 'nodebuffer', streamFiles: true })
            .pipe(fs.createWriteStream(sppkgPath))
            .on('finish', function () {
              console.log("DONE");
              callback();
            });
        });
    });
  });
});
To use this script, install the jszip npm package in your SharePoint Framework project by executing npm install jszip --save-dev.

The first part (lines 11-25) adjust the Webpack configuration so that source maps are generated for production builds. The second part of the script defines a new task named package-sourcemaps which adds the source maps as well as all additional information required for their provisioning to the solution package.

First, you retrieve the different paths where the files are stored (32-34). Since they're configurable, it's better to retrieve them from the build configuration than use fixed values. Next, you load the contents of the generated .sppkg (36) and pass them to JSZip (40) which is capable of reading and manipulating zip archives. Before you start manipulating the package, you get names of all source map files (43). There can be multiple, if your project produces multiple bundles.

The first adjustment you need to do, is to ensure, that SharePoint knows how to handle .map files (48-52). Each extension must be registered in the [Content_Types].xml file or deploying the package in the app catalog will fail.

Next step is to add a relationship entry for each source map file. This is how SharePoint will know which files to deploy to the ClientSideAssets folder in SharePoint. Relationships are stored in the _rels/ClientSideAssets.xml.rels file (54). Each one is defined by a unique ID and points to a file in the ClientSideAssets folder in the package (57-67).

The last thing to do, is to add the source map files to the ClientSideAssets folder in the .sppkg (70-74).

Once all modifications are done, you update the .sppkg with the extra information you have just added (77-83).

To use the package-sourcemaps gulp task, reference it from the standard SharePoint gulpfile.js like:

'use strict';

const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);

require('./gulpfile-package-sourcemaps');

build.initialize(gulp);

Next, execute in sequence:

  • gulp bundle --ship, to build the project in release mode
  • gulp package-solution --ship, to package the project in release mode
  • gulp package-sourcemaps, to add source maps to .sppkg

Now, deploy the solution package the same way you've always done it. Source maps included in the package will be deployed to SharePoint and if you have the Office 365 Public CDN enabled, also to the CDN.

Source maps are pretty large so you should expect the size of your solution packages to increase to a few hundred KBs or maybe even a few MBs. But that's only a concern from the deployment point of view. Source maps won't be loaded until the user opens browser's developer tools or an exception occurs for which you want to retrieve stack trace pointing to the original code.

Now that you're deploying source maps along your JavaScript bundles, the next step is to use their information in logging exceptions.

Translating exception stack traces using source maps

Typically, it's the web browser's developers tools that use source maps to map the production code to its original source and make it easier for you to debug it. But did you know that you can also use source maps to translate the generated stack trace yourself?

Following code snippet shows how to translate exception stack trace using the stacktrace.js package.

Before using the following code snippet, install the stacktrace-js npm package in your SharePoint Framework project by executing npm install stacktrace-js --save.
// ...shortened for brevity
import { AppInsights } from 'applicationinsights-js';
import * as StackTrace from 'stacktrace-js';

export default class LogExceptionWebPart extends BaseClientSideWebPart<ILogExceptionWebPartProps> {
  private service: MeetingsService;

  public onInit(): Promise<void> {
    // setup Application Insights logging
    AppInsights.downloadAndSetup({
      instrumentationKey: "80ab6410-a03a-4f1a-b4a7-8131e9d6da1a",
      // disable automatically logging AJAX calls
      // to prevent logging all standard SharePoint telemetry tracking
      disableAjaxTracking: true
    });
    this.service = new MeetingsService();

    return Promise.resolve();
  }

  private static getErrorWithSourcesMapped(err: Error): Promise<Error> {
    return new Promise<Error>((resolve: (err: Error) => void, reject: (error: any) => void): void => {
      StackTrace
        .fromError(err)
        .then(frames => {
          const stack = `Error: ${err.message}\n` + frames.map(f => {
            return `    at ${f.functionName} (${f.fileName}:${f.lineNumber}:${f.columnNumber})`;
          }).join('\n');
          err.stack = stack;
          resolve(err);
        });
    });
  }

  public render(): void {
    // ...shortened for brevity
    this.domElement.querySelector('a').addEventListener('click', e => {
      e.preventDefault();
      try {
        this.service.loadData();
      }
      catch (e) {
        LogExceptionWebPart
          .getErrorWithSourcesMapped(e)
          .then(err => AppInsights.trackException(err));
      }
    });
  }
  // ...shortened for brevity
}

In the example above, you're using Application Insights to log exceptions, but you can use any logging solution really. Specifically for Application Insights it's important to disable automatically tracking AJAX calls or your log will be filled with the standard SharePoint telemetry tracking (line 14).

You wrap code that could throw an exception in a try..catch block (39-46). In the catch clause, you pass the caught exception to a custom method that uses stacktrace.js to get the information about the location of the exception in the original code using provided source maps (44). Once you have the information, you use it to manually log the exception using Application Insights (45).

In the custom method to translate stack traces using source maps (21-33), you first pass the complete stack trace to stacktrace.js so that it can translate it to frames (23-24). Next, you reconstruct the stack trace using the information from the source maps (26-28). Finally, you replace the original stack trace (29) and resolve the Promise (30).

Remember the exception you logged initially?

Error: Cannot read property 'getData' of undefined
    at e._loadData (https://publiccdn.sharepointonline.com/contoso.sharepoint.com/sites/apps/ClientSideAssets/487d2dda-1805-4b50-bdd7-4c6de208e3e3/log-exception-web-part_564b4ef2ab9e89ff1a742f60ba8e8830.js:1:11564)
    at e.loadData (https://publiccdn.sharepointonline.com/contoso.sharepoint.com/sites/apps/ClientSideAssets/487d2dda-1805-4b50-bdd7-4c6de208e3e3/log-exception-web-part_564b4ef2ab9e89ff1a742f60ba8e8830.js:1:11498)
    at HTMLAnchorElement.<anonymous> (https://publiccdn.sharepointonline.com/contoso.sharepoint.com/sites/apps/ClientSideAssets/487d2dda-1805-4b50-bdd7-4c6de208e3e3/log-exception-web-part_564b4ef2ab9e89ff1a742f60ba8e8830.js:1:13515)

With the adjustments above, what you get instead is:

Error: Cannot read property 'getData' of undefined
    at constructor (webpack:///src/webparts/logException/MeetingsService.ts:10:24)
    at constructor (webpack:///src/webparts/logException/MeetingsService.ts:6:16)
    at loadData (webpack:///src/webparts/logException/LogExceptionWebPart.ts:64:21)

Notice how each line points to the original .ts file with the exact location in its contents. And you get all that, while your production bundles are optimized for performance just as designed.

Summary

Optimizing SharePoint Framework solutions for performance comes by default at the cost of not being able to log meaningful exceptions. With just a few adjustments to the build process and how you log errors, you can log exceptions pointing to your original source code without having to settle on worse performance.

Comments

comments powered by Disqus