@flareapp/inertia 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # @flareapp/inertia
2
+
3
+ Performance tracing for [Inertia.js](https://inertiajs.com) apps, reporting to
4
+ [flareapp.io](https://flareapp.io).
5
+
6
+ Every Inertia visit opens a `browser_navigation` span named after the page component, so navigations show up
7
+ in Flare's performance monitoring the same way a server request does. Works with every Inertia adapter: the
8
+ router is passed in, so the Vue, React and Svelte adapters all use the identical call.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install @flareapp/inertia
14
+ ```
15
+
16
+ `@flareapp/js` is a peer dependency and must be installed and initialized separately.
17
+
18
+ ## Usage
19
+
20
+ Call `traceInertiaRouter` once at boot, **before** `createInertiaApp` runs.
21
+
22
+ Vue:
23
+
24
+ ```js
25
+ import { createInertiaApp, router } from '@inertiajs/vue3';
26
+ import { flare } from '@flareapp/js';
27
+ import { traceInertiaRouter } from '@flareapp/inertia';
28
+ import { createApp, h } from 'vue';
29
+
30
+ flare.configure({ enableTracing: true, tracesSampleRate: 1 });
31
+ flare.light('YOUR_FLARE_API_KEY');
32
+
33
+ traceInertiaRouter(router);
34
+
35
+ createInertiaApp({
36
+ resolve: (name) => {
37
+ const pages = import.meta.glob('./Pages/**/*.vue');
38
+ return pages[`./Pages/${name}.vue`]();
39
+ },
40
+ setup({ el, App, props, plugin }) {
41
+ createApp({ render: () => h(App, props) })
42
+ .use(plugin)
43
+ .mount(el);
44
+ },
45
+ });
46
+ ```
47
+
48
+ React, identical apart from the import:
49
+
50
+ ```jsx
51
+ import { createInertiaApp, router } from '@inertiajs/react';
52
+ import { flare } from '@flareapp/js';
53
+ import { traceInertiaRouter } from '@flareapp/inertia';
54
+ import { createRoot } from 'react-dom/client';
55
+
56
+ flare.configure({ enableTracing: true, tracesSampleRate: 1 });
57
+ flare.light('YOUR_FLARE_API_KEY');
58
+
59
+ traceInertiaRouter(router);
60
+
61
+ createInertiaApp({
62
+ resolve: (name) => {
63
+ const pages = import.meta.glob('./Pages/**/*.jsx', { eager: true });
64
+ return pages[`./Pages/${name}.jsx`];
65
+ },
66
+ setup({ el, App, props }) {
67
+ createRoot(el).render(<App {...props} />);
68
+ },
69
+ });
70
+ ```
71
+
72
+ ## Why the call has to come first
73
+
74
+ Inertia fires a `navigate` event for the initial page load. That event is how the integration learns the name
75
+ of the page the browser landed on. Calling `traceInertiaRouter` after Inertia has booted misses it, and the
76
+ first back/forward step is then mistaken for the initial load.
77
+
78
+ ## Span naming
79
+
80
+ Navigation spans are named after the page component from Inertia's page object, for example `Products/Show`.
81
+ That keeps names low-cardinality so Flare can aggregate them, unlike the raw URL `/products/42`. When a
82
+ response carries no component name, the URL path is used instead.
83
+
84
+ ## What does not get a navigation span
85
+
86
+ Inertia sends a request for plenty of things that do not move you to another page. None of these open a
87
+ navigation span:
88
+
89
+ - prefetches, including `<Link prefetch>` on hover, mount or viewport
90
+ - deferred props loaded after the page arrives
91
+ - polling, via `usePoll` or `router.poll`
92
+ - infinite scroll fetching the next page
93
+ - any other `router.reload()`
94
+
95
+ The requests themselves are still traced. Inertia sends them over XHR, which Flare instruments, so each one
96
+ shows up as a child span under whichever page it belongs to, with its real timing.
97
+
98
+ An asynchronous visit that does take you to a different page, such as
99
+ `router.visit('/cart', { async: true })`, is a navigation and does open a span.
100
+
101
+ Clicking a second link before the first page arrives is also one navigation, not two. The span runs from the
102
+ first click to the page that actually loaded, which is what the person waiting for it experienced.
103
+
104
+ ## Prefetched navigations report near-zero duration
105
+
106
+ A click on a `<Link prefetch>` that is served from Inertia's prefetch cache is currently reported as an instant
107
+ navigation. Inertia fires neither `start` nor `finish` for it, only `navigate` and `success`, so the integration
108
+ cannot tell it apart from a back/forward step and opens and settles the span in the same tick. The navigation
109
+ still shows up as a `browser_navigation` span, but its duration should not be read as the time the user actually
110
+ waited. `<Link prefetch>` defaults to hover as its trigger, so this is a common path, not an edge case.
111
+
112
+ ## Cleanup
113
+
114
+ `traceInertiaRouter` returns a function that removes its listeners:
115
+
116
+ ```js
117
+ const stopTracing = traceInertiaRouter(router);
118
+
119
+ stopTracing();
120
+ ```
121
+
122
+ Calling `traceInertiaRouter` twice on the same router replaces the first instrumentation rather than stacking
123
+ a second set of listeners, so Vite HMR does not accumulate them.
124
+
125
+ ## Requirements
126
+
127
+ - `@flareapp/js` with `enableTracing: true`
128
+ - Inertia v2 (any adapter). v1 works, but it has no `prefetch` or `async` visit flags, so background work
129
+ driven by `router.reload()` (polling, deferred props, infinite scroll) cannot be told apart from a real
130
+ navigation, and each tick reports its own `browser_navigation` span.
131
+
132
+ ## Documentation
133
+
134
+ Full documentation on performance tracing is available at
135
+ [flareapp.io/docs/javascript/general/installation](https://flareapp.io/docs/javascript/general/installation).
136
+
137
+ ## License
138
+
139
+ The MIT License (MIT). Please see [License File](../../LICENSE.md) for more information.
package/dist/index.cjs ADDED
@@ -0,0 +1,128 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ let _flareapp_js_browser = require("@flareapp/js/browser");
3
+
4
+ //#region src/traceInertiaRouter.ts
5
+ /** Resolve a router-reported location (a `URL` on visits, a relative string on pages) to a full href
6
+ * plus its path. Both are undefined outside a browser or for an unparseable value. */
7
+ function locationOf(raw) {
8
+ if (raw == null) return {};
9
+ const url = (0, _flareapp_js_browser.absoluteUrl)(String(raw));
10
+ if (!url) return {};
11
+ return {
12
+ href: url.href,
13
+ path: url.pathname
14
+ };
15
+ }
16
+ /** Background work fires the same `start` and `finish` a real visit does, so opening a root for one both
17
+ * invents a navigation and ends the root that was live. */
18
+ function isBackgroundVisit(visit) {
19
+ if (!visit) return false;
20
+ if (visit.prefetch) return true;
21
+ const { path } = locationOf(visit.url);
22
+ return !!visit.async && path !== void 0 && path === (0, _flareapp_js_browser.currentPath)();
23
+ }
24
+ /** `component` ('Products/Show') is Inertia's route identifier, and there is a small fixed set of them,
25
+ * so reports group by it the way the other integrations' route templates do. */
26
+ function routeNameFor(page) {
27
+ const { href, path } = locationOf(page?.url);
28
+ return (0, _flareapp_js_browser.routeName)(() => page?.component, path ?? (0, _flareapp_js_browser.currentPath)(), href);
29
+ }
30
+ /**
31
+ * Trace an Inertia router: open a held `browser_navigation` root per visit, settled once the page
32
+ * arrives. Call it before Inertia boots, so the initial `navigate` is seen. Returns a cleanup that
33
+ * removes the listeners and unregisters. The runtime guard protects untyped JavaScript callers.
34
+ */
35
+ function traceInertiaRouter(router) {
36
+ if (!isInertiaRouter(router)) return () => {};
37
+ return (0, _flareapp_js_browser.instrumentOnce)(router, (track) => install(router, track));
38
+ }
39
+ function isInertiaRouter(router) {
40
+ return !!router && typeof router.on === "function";
41
+ }
42
+ function install(router, track) {
43
+ const nav = (0, _flareapp_js_browser.registerNavigationSource)();
44
+ track(() => nav.unregister());
45
+ let inFlight = false;
46
+ let inFlightPath;
47
+ let sawInitial = false;
48
+ let lastComponent;
49
+ const nameFor = (page) => {
50
+ const route = routeNameFor(page);
51
+ if (route.source === "route") lastComponent = {
52
+ path: locationOf(page?.url).path,
53
+ name: route.name
54
+ };
55
+ return route;
56
+ };
57
+ const settle = (page) => {
58
+ inFlight = false;
59
+ inFlightPath = void 0;
60
+ nav.settleNavigation(nameFor(page));
61
+ };
62
+ const on = (event, handler) => track(router.on(event, (0, _flareapp_js_browser.insulate)(handler)));
63
+ on("start", (event) => {
64
+ const visit = event?.detail?.visit;
65
+ if (isBackgroundVisit(visit)) return;
66
+ const { href, path } = locationOf(visit?.url);
67
+ if (inFlight) {
68
+ inFlightPath = path;
69
+ nav.setActiveRouteName({
70
+ name: path ?? (0, _flareapp_js_browser.currentPath)(),
71
+ source: "url",
72
+ url: href
73
+ });
74
+ return;
75
+ }
76
+ sawInitial = true;
77
+ inFlight = true;
78
+ inFlightPath = path;
79
+ nav.startNavigation({
80
+ path,
81
+ url: href,
82
+ hold: true
83
+ });
84
+ });
85
+ on("navigate", (event) => {
86
+ const page = event?.detail?.page;
87
+ if (inFlight) {
88
+ settle(page);
89
+ return;
90
+ }
91
+ if (!sawInitial) {
92
+ sawInitial = true;
93
+ nav.setActiveRouteName(nameFor(page));
94
+ return;
95
+ }
96
+ const { href, path } = locationOf(page?.url);
97
+ nav.startNavigation({
98
+ path,
99
+ url: href
100
+ });
101
+ settle(page);
102
+ });
103
+ on("success", (event) => {
104
+ if (!inFlight) return;
105
+ const page = event?.detail?.page;
106
+ if (locationOf(page?.url).path !== inFlightPath) return;
107
+ settle(page);
108
+ });
109
+ /** Whether `visit` is the one that opened the currently held root, i.e. `finish` may settle it. */
110
+ const belongsToThisNavigation = (visit) => {
111
+ if (isBackgroundVisit(visit)) return false;
112
+ if (visit && locationOf(visit.url).path !== inFlightPath) return false;
113
+ if (visit?.interrupted) return false;
114
+ return true;
115
+ };
116
+ on("finish", (event) => {
117
+ if (!inFlight) return;
118
+ if (!belongsToThisNavigation(event?.detail?.visit)) return;
119
+ inFlight = false;
120
+ inFlightPath = void 0;
121
+ const { href, path } = locationOf((0, _flareapp_js_browser.currentPath)());
122
+ const knownComponentName = lastComponent?.path === path ? lastComponent?.name : void 0;
123
+ nav.settleNavigation((0, _flareapp_js_browser.routeName)(() => knownComponentName, path ?? (0, _flareapp_js_browser.currentPath)(), href));
124
+ });
125
+ }
126
+
127
+ //#endregion
128
+ exports.traceInertiaRouter = traceInertiaRouter;
@@ -0,0 +1,38 @@
1
+ //#region src/vendor/inertiaTypes.d.ts
2
+ /** The page object Inertia ships with every response. `component` is the page component name
3
+ * ('Products/Show'), `url` a relative string ('/products/42'). */
4
+ type InertiaPageLike = {
5
+ component?: string;
6
+ url?: string;
7
+ };
8
+ /** `visit.url` is a `URL` instance in @inertiajs/core. A string is tolerated for older versions.
9
+ * `prefetch` and `async` are how a background visit gives itself away: see `isBackgroundVisit`.
10
+ * `interrupted` and `cancelled` are set on the visit `finish` carries, and only the first of the two
11
+ * means a successor visit is already on its way. */
12
+ type InertiaVisitLike = {
13
+ url?: URL | string;
14
+ prefetch?: boolean;
15
+ async?: boolean;
16
+ interrupted?: boolean;
17
+ cancelled?: boolean;
18
+ };
19
+ type InertiaEventLike = {
20
+ detail?: {
21
+ visit?: InertiaVisitLike;
22
+ page?: InertiaPageLike;
23
+ };
24
+ };
25
+ type InertiaEventName = 'start' | 'navigate' | 'success' | 'finish';
26
+ type InertiaRouterLike = {
27
+ on(event: InertiaEventName, callback: (event: InertiaEventLike) => void): () => void;
28
+ };
29
+ //#endregion
30
+ //#region src/traceInertiaRouter.d.ts
31
+ /**
32
+ * Trace an Inertia router: open a held `browser_navigation` root per visit, settled once the page
33
+ * arrives. Call it before Inertia boots, so the initial `navigate` is seen. Returns a cleanup that
34
+ * removes the listeners and unregisters. The runtime guard protects untyped JavaScript callers.
35
+ */
36
+ declare function traceInertiaRouter(router: InertiaRouterLike): () => void;
37
+ //#endregion
38
+ export { type InertiaEventLike, type InertiaEventName, type InertiaPageLike, type InertiaRouterLike, type InertiaVisitLike, traceInertiaRouter };
@@ -0,0 +1,38 @@
1
+ //#region src/vendor/inertiaTypes.d.ts
2
+ /** The page object Inertia ships with every response. `component` is the page component name
3
+ * ('Products/Show'), `url` a relative string ('/products/42'). */
4
+ type InertiaPageLike = {
5
+ component?: string;
6
+ url?: string;
7
+ };
8
+ /** `visit.url` is a `URL` instance in @inertiajs/core. A string is tolerated for older versions.
9
+ * `prefetch` and `async` are how a background visit gives itself away: see `isBackgroundVisit`.
10
+ * `interrupted` and `cancelled` are set on the visit `finish` carries, and only the first of the two
11
+ * means a successor visit is already on its way. */
12
+ type InertiaVisitLike = {
13
+ url?: URL | string;
14
+ prefetch?: boolean;
15
+ async?: boolean;
16
+ interrupted?: boolean;
17
+ cancelled?: boolean;
18
+ };
19
+ type InertiaEventLike = {
20
+ detail?: {
21
+ visit?: InertiaVisitLike;
22
+ page?: InertiaPageLike;
23
+ };
24
+ };
25
+ type InertiaEventName = 'start' | 'navigate' | 'success' | 'finish';
26
+ type InertiaRouterLike = {
27
+ on(event: InertiaEventName, callback: (event: InertiaEventLike) => void): () => void;
28
+ };
29
+ //#endregion
30
+ //#region src/traceInertiaRouter.d.ts
31
+ /**
32
+ * Trace an Inertia router: open a held `browser_navigation` root per visit, settled once the page
33
+ * arrives. Call it before Inertia boots, so the initial `navigate` is seen. Returns a cleanup that
34
+ * removes the listeners and unregisters. The runtime guard protects untyped JavaScript callers.
35
+ */
36
+ declare function traceInertiaRouter(router: InertiaRouterLike): () => void;
37
+ //#endregion
38
+ export { type InertiaEventLike, type InertiaEventName, type InertiaPageLike, type InertiaRouterLike, type InertiaVisitLike, traceInertiaRouter };
package/dist/index.mjs ADDED
@@ -0,0 +1,127 @@
1
+ import { absoluteUrl, currentPath, instrumentOnce, insulate, registerNavigationSource, routeName } from "@flareapp/js/browser";
2
+
3
+ //#region src/traceInertiaRouter.ts
4
+ /** Resolve a router-reported location (a `URL` on visits, a relative string on pages) to a full href
5
+ * plus its path. Both are undefined outside a browser or for an unparseable value. */
6
+ function locationOf(raw) {
7
+ if (raw == null) return {};
8
+ const url = absoluteUrl(String(raw));
9
+ if (!url) return {};
10
+ return {
11
+ href: url.href,
12
+ path: url.pathname
13
+ };
14
+ }
15
+ /** Background work fires the same `start` and `finish` a real visit does, so opening a root for one both
16
+ * invents a navigation and ends the root that was live. */
17
+ function isBackgroundVisit(visit) {
18
+ if (!visit) return false;
19
+ if (visit.prefetch) return true;
20
+ const { path } = locationOf(visit.url);
21
+ return !!visit.async && path !== void 0 && path === currentPath();
22
+ }
23
+ /** `component` ('Products/Show') is Inertia's route identifier, and there is a small fixed set of them,
24
+ * so reports group by it the way the other integrations' route templates do. */
25
+ function routeNameFor(page) {
26
+ const { href, path } = locationOf(page?.url);
27
+ return routeName(() => page?.component, path ?? currentPath(), href);
28
+ }
29
+ /**
30
+ * Trace an Inertia router: open a held `browser_navigation` root per visit, settled once the page
31
+ * arrives. Call it before Inertia boots, so the initial `navigate` is seen. Returns a cleanup that
32
+ * removes the listeners and unregisters. The runtime guard protects untyped JavaScript callers.
33
+ */
34
+ function traceInertiaRouter(router) {
35
+ if (!isInertiaRouter(router)) return () => {};
36
+ return instrumentOnce(router, (track) => install(router, track));
37
+ }
38
+ function isInertiaRouter(router) {
39
+ return !!router && typeof router.on === "function";
40
+ }
41
+ function install(router, track) {
42
+ const nav = registerNavigationSource();
43
+ track(() => nav.unregister());
44
+ let inFlight = false;
45
+ let inFlightPath;
46
+ let sawInitial = false;
47
+ let lastComponent;
48
+ const nameFor = (page) => {
49
+ const route = routeNameFor(page);
50
+ if (route.source === "route") lastComponent = {
51
+ path: locationOf(page?.url).path,
52
+ name: route.name
53
+ };
54
+ return route;
55
+ };
56
+ const settle = (page) => {
57
+ inFlight = false;
58
+ inFlightPath = void 0;
59
+ nav.settleNavigation(nameFor(page));
60
+ };
61
+ const on = (event, handler) => track(router.on(event, insulate(handler)));
62
+ on("start", (event) => {
63
+ const visit = event?.detail?.visit;
64
+ if (isBackgroundVisit(visit)) return;
65
+ const { href, path } = locationOf(visit?.url);
66
+ if (inFlight) {
67
+ inFlightPath = path;
68
+ nav.setActiveRouteName({
69
+ name: path ?? currentPath(),
70
+ source: "url",
71
+ url: href
72
+ });
73
+ return;
74
+ }
75
+ sawInitial = true;
76
+ inFlight = true;
77
+ inFlightPath = path;
78
+ nav.startNavigation({
79
+ path,
80
+ url: href,
81
+ hold: true
82
+ });
83
+ });
84
+ on("navigate", (event) => {
85
+ const page = event?.detail?.page;
86
+ if (inFlight) {
87
+ settle(page);
88
+ return;
89
+ }
90
+ if (!sawInitial) {
91
+ sawInitial = true;
92
+ nav.setActiveRouteName(nameFor(page));
93
+ return;
94
+ }
95
+ const { href, path } = locationOf(page?.url);
96
+ nav.startNavigation({
97
+ path,
98
+ url: href
99
+ });
100
+ settle(page);
101
+ });
102
+ on("success", (event) => {
103
+ if (!inFlight) return;
104
+ const page = event?.detail?.page;
105
+ if (locationOf(page?.url).path !== inFlightPath) return;
106
+ settle(page);
107
+ });
108
+ /** Whether `visit` is the one that opened the currently held root, i.e. `finish` may settle it. */
109
+ const belongsToThisNavigation = (visit) => {
110
+ if (isBackgroundVisit(visit)) return false;
111
+ if (visit && locationOf(visit.url).path !== inFlightPath) return false;
112
+ if (visit?.interrupted) return false;
113
+ return true;
114
+ };
115
+ on("finish", (event) => {
116
+ if (!inFlight) return;
117
+ if (!belongsToThisNavigation(event?.detail?.visit)) return;
118
+ inFlight = false;
119
+ inFlightPath = void 0;
120
+ const { href, path } = locationOf(currentPath());
121
+ const knownComponentName = lastComponent?.path === path ? lastComponent?.name : void 0;
122
+ nav.settleNavigation(routeName(() => knownComponentName, path ?? currentPath(), href));
123
+ });
124
+ }
125
+
126
+ //#endregion
127
+ export { traceInertiaRouter };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@flareapp/inertia",
3
+ "version": "2.8.0",
4
+ "description": "Inertia.js performance tracing for flareapp.io",
5
+ "homepage": "https://flareapp.io",
6
+ "bugs": "https://github.com/spatie/flare-client-js/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/spatie/flare-client-js.git"
10
+ },
11
+ "license": "MIT",
12
+ "author": {
13
+ "name": "Spatie",
14
+ "email": "info@spatie.be"
15
+ },
16
+ "contributors": [
17
+ "Dries Heyninck <dries@spatie.be>"
18
+ ],
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "sideEffects": false,
23
+ "main": "./dist/index.cjs",
24
+ "module": "./dist/index.mjs",
25
+ "types": "./dist/index.d.cts",
26
+ "exports": {
27
+ ".": {
28
+ "import": {
29
+ "types": "./dist/index.d.mts",
30
+ "default": "./dist/index.mjs"
31
+ },
32
+ "require": {
33
+ "types": "./dist/index.d.cts",
34
+ "default": "./dist/index.cjs"
35
+ }
36
+ }
37
+ },
38
+ "scripts": {
39
+ "prepublishOnly": "npm run build",
40
+ "build": "tsdown src/index.ts --format cjs,esm --dts --clean",
41
+ "typescript": "tsc --noEmit",
42
+ "test": "vitest run",
43
+ "release": "release-it"
44
+ },
45
+ "devDependencies": {
46
+ "@flareapp/js": "file:../js",
47
+ "@flareapp/test-helpers": "*",
48
+ "@inertiajs/core": "^2.3.27",
49
+ "jsdom": "^26.1.0",
50
+ "tsdown": "^0.20.3",
51
+ "typescript": "^5.7.0",
52
+ "vitest": "^4.0.0"
53
+ },
54
+ "peerDependencies": {
55
+ "@flareapp/js": "^2.8.0"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }