Skip to content

feat(browser): Angular integration (@maple-dev/browser/angular, /angular/server) - #1133

Open
JeremyFunk wants to merge 2 commits into
feat/browser-server-nextjsfrom
feat/browser-angular
Open

JeremyFunk wants to merge 2 commits into
feat/browser-server-nextjsfrom
feat/browser-angular

Conversation

@JeremyFunk

@JeremyFunk JeremyFunk commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

An Angular integration for @maple-dev/browser, replacing the router, resolver, ErrorHandler and SSR glue the Angular frontend guide had customers copy. Stacked on #1128 (/server, serverTiming(), the Next.js entries).

Export Purpose
@maple-dev/browser/angular → provideMapleTracing(): EnvironmentProviders Next to provideRouter: a pageload/navigate span per navigation, named after the matched routes' paths (/projects/:id, /**). Nothing on the server
@maple-dev/browser/angular → tracedResolver(name, fn) A resolver's data loading in a span under the navigation; a thrown RedirectCommand isn't an error
@maple-dev/browser/angular → MapleErrorHandler { provide: ErrorHandler, useClass: MapleErrorHandler }: reports as angular.error, then logs like Angular's own handler
@maple-dev/browser/angular → reportAngularError(error) For an app's own ErrorHandler, onViewError, or a redirecting withNavigationErrorHandler
@maple-dev/browser/angular/server → tracedRender(req, render) Wraps angularApp.handle(req) in server.ts: an ssr span under the request span, plus the Server-Timing header the page load joins

The entry is plain ESM: functions and an undecorated class, no ɵɵngDeclare*, so no compiler or linker pass. MapleErrorHandler works with useClass through Angular's zero-argument-constructor path. /angular/server imports only @opentelemetry/api. @angular/core, @angular/router and @angular/common are optional peers (*); the entry needs Angular 19+ (provideEnvironmentInitializer). The main entry's bundle is unchanged: eager 37.42 kB, first-party 14.30 kB.

Behaviour notes (vs the guide's code)

  • The router subscription is an environment initializer, like the router's own withDebugTracing, not an app initializer. It runs before every app initializer, so it also sees a navigation that one of them starts. The guide's provideAppInitializer version misses it when that initializer is listed first (test covers it).
  • Fix: a guard or resolver redirect to the URL on screen emits NavigationSkipped, not a NavigationStart. The guide left redirecting set, so the user's next click started no span. That span now ends named after the route on screen, and the flag resets.
  • A navigation replaced by a click on the URL on screen now ends as app.navigation.interrupted, like any replaced navigation. The guide ended it unmarked.
  • Router events are handled outside Angular's zone. In a zone.js app, ending a span inside the zone starts the exporter's 2 s timer as a zone macrotask, and that keeps ApplicationRef.isStable false. This also affected the guide's code (test with zone.js covers it).
  • reportAngularError skips the Error that provideBrowserGlobalErrorListeners() builds from an error event with no error object (cause is the ErrorEvent, such as a cross-origin "Script error."). The SDK's own window handler already handled that event.
  • tracedRender reads the Server-Timing value before awaiting the render, so it names the ssr span even when async context doesn't survive await (zone.js on the server). It records a render error on the span and rethrows it. It leaves headers it can't change (immutable) alone. The span ends when handle() resolves, before the response is written.

Verification

  • packages/browser: typecheck, 175 tests, build, size budget. The Angular tests use the real router (createApplication, provideLocationMocks, JIT compiler in Chromium) and one real bootstrapApplication. Cases: naming, nested and layout routes, **, query and hash, guard redirect, redirect to the URL on screen, resolver RedirectCommand returned and thrown, superseded navigations, same-URL supersede, guard rejection, resolver error recorded once through MapleErrorHandler, back/forward, initializer-started navigation, double provide, server platform, "Script error.", and zone.js stability. Server tests cover the Node request shape and fetch Request URLs, lost async context, immutable headers and errors. Every fix above was mutation-checked: its test fails without it.
  • End to end: the Angular 22.2 SSR sample instrumented with the old guide code was switched to the packed tarball (ng build, node --import ./telemetry.mjs dist/.../server.mjs), then driven with the same Playwright script (22 scenarios). ng build consumed the entry without linker errors.
Scenario Old guide code This PR
Full load /projects/1 GET → ssr → loader + 2 fetch → API; pageload /projects/:id under ssr; reload gives a new trace same
Full load /old (302), /slow, ?tab=members pageload /projects/:id, /slow (1.5 s), /projects/:id same
Full load /projects/missing, /does-not-exist pageload /not-found, pageload /**, no error spans same
Full load /broken-loader 404, server loader span Error, recorded once same (status message now set)
Full load /broken-render angular.error in ssr trace + once on hydration same
Click nav, param change navigate /projects/:id → loader → fetch → API same
Query link, hash link navigate /projects/:id, navigate / same
Guard redirect /old one navigate /projects/:id, url.path=/old same
Interrupted (/slow then /projects/1) navigate INTERRUPTED + navigate /projects/:id same
Interrupted by link to URL on screen navigate, not marked navigate INTERRUPTED
Back/forward navigate /, navigate /projects/:id same
Not found (client) navigate /not-found, navigate /** same
Broken loader / render / click handler (client) loader Error once; angular.error once; angular.error once same
/api/boom fetch 500 + API Error, nothing twice same
Propagation first-party API gets traceparent, third party none same
Assets no server-timing on JS/CSS same

Known limits

  • Routes matched by a matcher function have no path, so they add nothing to the span name.
  • url.path is the router's URL: it excludes the <base href> and keeps matrix params.
  • A failed navigation's span is named but not marked Error. The error is on the resolver's span, or on an angular.error span from the ErrorHandler.
  • In a zone.js app, a resolver's span still ends inside the zone, so a navigation with resolvers keeps the app unstable until the next export (up to 2 s). This is true of any span the app ends inside the zone. The fix belongs in the SDK's export scheduling, not in this entry.
  • tracedRender opens an ssr span for requests Angular doesn't render (handle() resolves null), as the guide's middleware did.
  • Behind a CDN that caches HTML, pages must not go through tracedRender, or every visitor joins one trace.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Devin Review

@maple-dev/browser/angular: provideMapleTracing() subscribes to the router
events from an environment initializer (browser only) and names each span after
the matched routes' paths; tracedResolver() excludes a thrown RedirectCommand;
MapleErrorHandler and reportAngularError() report what Angular's ErrorHandler
sees. No decorators, so no compiler or linker pass is needed.

@maple-dev/browser/angular/server: tracedRender() runs the @angular/ssr render
in an ssr span and adds the Server-Timing header the page load joins.
- End spans outside Angular's zone, so a zone.js app still becomes stable
  after a navigation instead of waiting for the exporter's timer.
- reportAngularError skips the errors Angular's global listeners build from an
  error event without an error object (cross-origin "Script error."), which the
  SDK's own window handler already handled.
- tracedRender reads the Server-Timing value before the render's await, so it
  names the ssr span even where async context is lost across await.
- Docs: the example works on Angular 19, and notes withFetch() for Angular 21
  and older.
@coderabbitai

coderabbitai Bot commented Sep 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 7a1e98e8-2d33-4c1e-b57c-ffe23fe26ad6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
New span-producing integration in a published SDK; the router state machine is well tested, the zone.js workaround is not complete.
quality 90/100 · 1 warning · tests covered · risk medium · 4/4 new units observable

Adds two Angular subpaths to @maple-dev/browser: router navigation and resolver spans, an ErrorHandler, and an SSR render wrapper that writes Server-Timing. The router bookkeeping holds up under supersede, redirect and skip, but the zone.js workaround only covers the navigation span.

  • provideMapleTracing() spans each navigation as pageload/navigate, named after the matched route template
  • tracedResolver(name, fn) spans resolver loading under the navigation; RedirectCommand is not a failure
  • MapleErrorHandler and reportAngularError report as angular.error
  • tracedRender(req, render) wraps angularApp.handle() in an ssr span and appends the page's Server-Timing

Findings

Warning · F1 · tracedResolver ends its span inside NgZone, defeating the zone.js fix

correctness · packages/browser/src/angular/index.ts:121-123

provideMapleTracing ends the navigation span outside Angular's zone (index.ts:53) because, as its own comment says, a span that ends in the zone starts the exporter's flush timer as a zone macrotask and keeps ApplicationRef.isStable false. A resolver's span is not covered: traced runs at navigation.ts:91 and runTraced ends the span at failures.ts:79, both inside the zone the router runs resolvers in, so on a zone.js app every route with a resolver (or any traced call during the navigation) is back to waiting on the export timer after the navigation — the exact behaviour zone.browser.test.ts:37 asserts the fix removes, tested only on a route table with no resolver. The angular.error span has the same problem.

End resolver spans outside the zone as well: keep the `NgZone` captured in `provideMapleTracing` and run resolver span endings (or `tracedResolver`'s whole `traced` call, via `inject(NgZone, { optional: true })?.runOutsideAngular(...)`) through it. Worth one zone.js test with a resolver on the route.
What was checked
  • Superseded navigations end through startNavigation's interruptNavigation (navigation.ts:60), so the id check at angular/index.ts:95 does not double-end them
  • pathOf on absolute, origin-only, * and relative URLs (server.test.ts:99)
  • No PII in the new spans: event.url.split(/[?#]/)[0] and scrubUrl strip query and hash before url.path
Observability coverage: 4 of 4 changes observable
Change Kind Observable Evidence
Angular router navigations (provideMapleTracing) client span yes angular/index.ts:68 starts pageload/navigate, ended at :97 by routeTemplate
Resolver data loading (tracedResolver) client span yes angular/index.ts:122 delegates to traced (navigation.ts:91)
Angular errors (MapleErrorHandler, reportAngularError) error span yes angular/index.ts:134 captureException(error, { name: "angular.error" })
Angular SSR render (tracedRender) server span yes angular/server.ts:24 startActiveSpan("ssr", { attributes }) with url.path
Copy all findings (1)
Findings from an automated review of commit 16f3767d77b0eb92632195d9b8c9be8b3a17e5e1. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Warning · correctness · packages/browser/src/angular/index.ts:121-123
`tracedResolver` ends its span inside NgZone, defeating the zone.js fix
`provideMapleTracing` ends the navigation span outside Angular's zone (`index.ts:53`) because, as its own comment says, a span that ends in the zone starts the exporter's flush timer as a zone macrotask and keeps `ApplicationRef.isStable` false. A resolver's span is not covered: `traced` runs at `navigation.ts:91` and `runTraced` ends the span at `failures.ts:79`, both inside the zone the router runs resolvers in, so on a zone.js app every route with a resolver (or any `traced` call during the navigation) is back to waiting on the export timer after the navigation — the exact behaviour `zone.browser.test.ts:37` asserts the fix removes, tested only on a route table with no resolver. The `angular.error` span has the same problem.
Suggested fix: End resolver spans outside the zone as well: keep the `NgZone` captured in `provideMapleTracing` and run resolver span endings (or `tracedResolver`'s whole `traced` call, via `inject(NgZone, { optional: true })?.runOutsideAngular(...)`) through it. Worth one zone.js test with a resolver on the route.

16f3767 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Devin Review

Comment on lines +110 to +113
for (let route: ActivatedRouteSnapshot | null = root; route; route = route.firstChild) {
// Layout routes and `children` wrappers have an empty path
if (route.routeConfig?.path) paths.push(route.routeConfig.path)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Secondary-outlet routes lose their names

When a navigation changes a named outlet alongside the primary outlet, routeTemplate follows only firstChild. Distinct secondary routes receive the same navigation span name.

Learn more

An Angular route snapshot can have several active children when the application uses named outlets. The current traversal reads only firstChild, so it cannot distinguish routes that differ only in another outlet. Navigation spans therefore group distinct user actions under one route template.

Example: With a primary inbox route and a sidebar outlet, /inbox(sidebar:compose) and /inbox(sidebar:settings) both produce navigate /inbox when the primary child appears first. Their templates must remain distinguishable.

Recommended fix: Traverse ActivatedRouteSnapshot.children and include the configured path for each active outlet in a stable, outlet-qualified route template. Cover a primary route plus a named outlet and a navigation that changes only the named outlet.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

* Run a resolver's data loading in a span under the navigation. Errors are recorded once and
* rethrown; a thrown `RedirectCommand` isn't one. Only requests started before `fn`'s first `await` nest under it.
*/
export function tracedResolver<T>(name: string, fn: () => Promise<T>): Promise<T> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tracedResolver ends its span inside NgZone, defeating the zone.js fix

F1 · Warning · correctness

provideMapleTracing ends the navigation span outside Angular's zone (index.ts:53) because, as its own comment says, a span that ends in the zone starts the exporter's flush timer as a zone macrotask and keeps ApplicationRef.isStable false. A resolver's span is not covered: traced runs at navigation.ts:91 and runTraced ends the span at failures.ts:79, both inside the zone the router runs resolvers in, so on a zone.js app every route with a resolver (or any traced call during the navigation) is back to waiting on the export timer after the navigation — the exact behaviour zone.browser.test.ts:37 asserts the fix removes, tested only on a route table with no resolver. The angular.error span has the same problem.

End resolver spans outside the zone as well: keep the `NgZone` captured in `provideMapleTracing` and run resolver span endings (or `tracedResolver`'s whole `traced` call, via `inject(NgZone, { optional: true })?.runOutsideAngular(...)`) through it. Worth one zone.js test with a resolver on the route.
Prompt for an AI agent
In `packages/browser/src/angular/index.ts:121-123`: `tracedResolver` ends its span inside NgZone, defeating the zone.js fix.

`provideMapleTracing` ends the navigation span outside Angular's zone (`index.ts:53`) because, as its own comment says, a span that ends in the zone starts the exporter's flush timer as a zone macrotask and keeps `ApplicationRef.isStable` false. A resolver's span is not covered: `traced` runs at `navigation.ts:91` and `runTraced` ends the span at `failures.ts:79`, both inside the zone the router runs resolvers in, so on a zone.js app every route with a resolver (or any `traced` call during the navigation) is back to waiting on the export timer after the navigation — the exact behaviour `zone.browser.test.ts:37` asserts the fix removes, tested only on a route table with no resolver. The `angular.error` span has the same problem.

Suggested fix: End resolver spans outside the zone as well: keep the `NgZone` captured in `provideMapleTracing` and run resolver span endings (or `tracedResolver`'s whole `traced` call, via `inject(NgZone, { optional: true })?.runOutsideAngular(...)`) through it. Worth one zone.js test with a resolver on the route.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant