Share on Twitter
Share on Facebook
Share on HackerNews

Compiling Angular Libraries Properly in a Multi-Framework SDK Monorepo

Bugs don’t always emerge because we made mistakes, but often because of regressions in libraries or updates to frameworks, languages, platforms, or even browsers. And at Sentry, we’re frequently adding support for new frameworks in addition to maintaining and updating existing ones. As you can imagine, finding bugs in our SDKs is a critical piece of maintaining support for as many frameworks, platforms, and languages as possible.

A few months ago, we set out to fix a bug that was reported in our GitHub repository by multiple users. The thing all reporters had in common was that they used a piece of our Angular SDK called TraceDirective. The SDK was breaking user setups, making this a top priority for our team. However, what initially seemed like an easy fix and a quick win, turned out to be much more complex in nature.

After the JavaScript SDK v7 release and a ton of learnings later, we could finally call this bug fixed and - more importantly - we significantly improved our Angular SDK. In this blog post, we tell you all about the bug and how it led us to the decision to change the compilation process of our Angular SDK.

What is TraceDirective?

Besides error monitoring, Sentry also offers performance monitoring. As an addition to our default performance monitoring features, our Angular SDK supports component tracking. What this means is that with a few simple steps, Sentry instruments your components to keep track of their life cycles. We automatically measure the duration between certain component lifecycle events and record them as spans which are added to an ongoing transaction. When you as a Sentry user analyze your transactions, you’ll be able to see how long your components took to initialize. This gives you a drilled down and tailored view into the performance of your Angular app.

One of the possibilities to integrate component tracking with Sentry in Angular projects is TraceDirective. You can use TraceDirective like any other directive in your Angular project:

  1. Simply import the directive’s module TraceModule
import { TraceModule } from "@sentry/angular";

@NgModule({
  imports: [TraceModule],
  // ...
})
export class AppModule {}
  1. Add the trace attribute to the components you’d like to be tracked
<app-header trace="header"></app-header>
<articles-list trace="articles-list"></articles-list>

What our users then reported, however, was that by using it in this exact way, their projects would crash on compile time with the following error message:

Error: Class TraceDirective is neither an Angular component nor directive nor pipe.

The Root Cause

We discovered the meaning behind the error and why it was happening: Angular uses its own CLI. Among other things, the Angular CLI compiles Angular projects when calling ng build (or slightly differently with ng serve). Under the hood, the Angular CLI uses its own compiler (ngc) that wraps functionality around TSC to:

  1. Type-check and transpile the TypeScript code to JavaScript
  2. Translate what Angular developers write as components, directives, or modules to a form of JavaScript that is understood and evaluated by the Angular runtime and rendering engine. Angular calls this “Ahead of time” (or AoT) compilation.

Let’s take a simple example like TraceDirective. It’s a small directive with just a few lines of code (full code example is available on GitHub):

@Directive({ selector: '[trace]' })
export class TraceDirective implements OnInit, AfterViewInit {

  @Input('trace') public componentName: string = UNKNOWN_COMPONENT;

  public ngOnInit(): void {
    // start span...
  }

  public ngAfterViewInit(): void {
    // end span...
  }
}

When we compiled this code with TSC, we got the following output code (with the experimentalDecorators flag set to true):

var TraceDirective = /** @class */ (function () {
    // ...
    tslib_1.__decorate([
        core_1.Input('trace')
    ], TraceDirective.prototype, "componentName", void 0);
    TraceDirective = tslib_1.__decorate([
        core_1.Directive({ selector: '[trace]' })
    ], TraceDirective);
    return TraceDirective;
}());
exports.TraceDirective = TraceDirective;

However, when the directive is compiled with the Angular compiler, what you get is AOT-compiled code. Due to the stepwise introduction of the new compilation and rendering pipeline “Ivy” in recent Angular versions, the produced JS looks a little different for different Angular versions. Here’s the Angular 10 result (using the pre-Ivy, “View Engine” mode):

class TraceDirective {...}
TraceDirective.decorators = [
    { type: Directive, args: [{ selector: '[trace]' },] }
];
TraceDirective.propDecorators = {
    componentName: [{ type: Input, args: ['trace',] }]
};

With Angular 13 (and Ivy enabled), you can see the output is a little different:

class TraceDirective {...}
TraceDirective.ɵfac = i0.ɵɵngDeclareFactory(...);
TraceDirective.ɵdir = i0.ɵɵngDeclareDirective(...);
i0.ɵɵngDeclareClassMetadata(...);

Overall though, Angular 10 without Ivy and Angular 13 with Ivy are very different compared to the TSC output. In a nutshell, this is what causes the compiler error: at project compile time, the Angular compiler expects ngc-compiled JavaScript instead of the TSC-generated one. It cannot process the latter one correctly and thus it throws the error.

This means that our SDK had one fundamental flaw: It simply was compiled with the wrong tool.

How we fixed it

Initially, the fix seemed pretty straightforward: Just switch from TSC to using the Angular CLI and ngc under the hood. And that was exactly what we did. However, while doing so, we encountered some challenges we had to overcome first…the devil is in the details, as they say.

Project Structure

All of our JavaScript SDKs live in a mono-repo and they share a lot of code (e.g. for core functionality, type definitions, etc.). All in all, we have managed to keep a very consistent project, build, and tarball structure across all packages. Angular’s documentation for Angular library creation suggests using the Angular CLI to first set up an app project. Then, a library is added as a sub-project. However, adding an empty Angular project would have meant a lot of clutter and overhead for something we did not need at all. Additionally, this would make the structure of our Angular SDK significantly different from our other JavaScript SDKs. To maintain consistency, we decided that the suggested way wasn’t an option for us, at least not out of the box.

Consequently, we implemented our SDK the way the Angular docs suggested, but managed to cut down the clutter and the unnecessary files to end up with a package structure that was almost identical to our other SDKs. This required some modifications to the Angular config (angular.json), the Angular library packager (ng-packagr) configuration, and Angular’s default tsconfig.json. Ultimately, we managed to have a standalone Angular library project, without an app wrapper around it.

Build Structure

One thing that we learned to appreciate from the Angular compiler and packager was that all build-related output files were written to one central directory (which we called build). Previous to version 7 of our JavaScript SDKs, our build configs would create multiple directories in our individual SDK projects or even just scatter files around in the package root (e.g. the CDN bundles we create for the @sentry/browser SDK). Instead of trying to force ng-packagr to somehow do this as well, we opted to do it the other way around in this case and bring the Angular approach to most of our other SDKs.

Overall, we ended up with a build structure that conforms with the Angular 10 package format (APF) for the Angular SDK and a centralized build directory for each of our other SDKs.

Tarball Structure

As a consequence of adopting the APF, it was inevitable that the contents of our tarball would change. While for most users, this change would go unnoticed (due to the package.json entry points) this meant that the compiler change was in fact a breaking change. Some of our users chose to import from explicit SDK paths:

// this import would break with a tarball structure change
import { TraceClassDecorator } from '@sentry/angular/esm/tracing'

which consequently would be different after the compiler change:

// Same bad import with the updated structure
import { TraceClassDecorator } from '@sentry/angular/fesm2015/sentry-angular'

We, therefore, decided to ship the compiler change and hence the bug fix with the next major version of our JavaSscript SDKs, version 7.

What We Learned from this Adventure

Given that this just started out as a simple bug fix, we learned a ton of things while working on it:

  • Sometimes, you have to branch out: While it is a nice and desirable goal that all projects in your JavaScript mono-repo are compiled in the same way, it just isn’t always possible. We’ve opted for individual solutions before (when we introduced our Ember SDK) and in the case of the Angular SDK, such a solution was necessary again. This is okay. You can’t standardize everything. In fact, the compiler change will help us support Angular better in the future and this is worth the deviation.
  • Angular library creation is simple - for Angular libraries. If you are in our situation, in which you want to maintain multiple SDKs with first-class support for their respective framework within a single codebase, things get a little harder. We could not find many meaningful example projects with similar requirements so we had to come up with our own solution. Overall, we think that we achieved a nice compromise between consistency across all SDKs and individualism of some of them
  • Making changes to your build process is tricky. Remember that “central build directory” I talked about earlier? We introduced it while improving the build process in our other SDK projects. It turned out that this change led to multiple regressions and problems down the line, which gave us a nice extra challenge while working on v7 of the JavaScript SDK. So while it did improve our build structure, we will definitely think twice next time before making such changes.

What’s up Next?

With this compiler change, we can now truly offer first-class support for Angular with our SDK. This means that in the future we will be able to do the following:

  • Native Ivy Support: Angular 14 was recently released and we’ve just updated our SDK to support it in version 7.2.0. Since we want to support Angular 10-14, we still ship “View Engine” compatible code which means that ngcc has to step in and convert it to Ivy compatible code at project build time. This is fine but Angular 13 and 14 users will see a one-time warning that the conversion was performed after adding our Angular SDK to their projects. We’d like to change that by supporting Ivy natively and we’re currently evaluating how to do that best.
  • Add more Angular-specific features. Using the Angular compiler gives us the possibility to write more directives (or even components) in the future. This means we can increase our first-class Angular support by adding Angular-native features to our SDK.
  • You decide! Let us know what you would like to see in the Angular SDK. What would make your life easier? Since this bug was first filed we’ve gotten better at responding to feedback, so head on over to GitHub and let us know what you think. #PRsWelcome
Your code is broken. Let's Fix it.
Get Started

More from the Sentry blog

ChangelogDashboardsDiscoverDogfooding ChroniclesEcosystemError MonitoringEventsGuest PostsMobileMoonlightingOpen SourcePerformance MonitoringRelease HealthSDK UpdatesSentry

Do you like corporate newsletters?

Neither do we. Sign up anyway.

© 2022 • Sentry is a registered Trademark
of Functional Software, Inc.