@neomanex/analytics-nuxt 1.0.2
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 +54 -0
- package/dist/module.cjs +5 -0
- package/dist/module.d.mts +6 -0
- package/dist/module.d.ts +6 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +86 -0
- package/dist/runtime/app/composables/useAnalytics.d.ts +0 -0
- package/dist/runtime/app/composables/useAnalytics.js +8 -0
- package/dist/runtime/client.d.ts +0 -0
- package/dist/runtime/client.js +170 -0
- package/dist/runtime/config.d.ts +0 -0
- package/dist/runtime/config.js +20 -0
- package/dist/runtime/imports-stub.d.ts +16 -0
- package/dist/runtime/imports-stub.js +42 -0
- package/dist/runtime/plugin.client.d.ts +0 -0
- package/dist/runtime/plugin.client.js +98 -0
- package/dist/runtime/plugin.server.d.ts +0 -0
- package/dist/runtime/plugin.server.js +23 -0
- package/dist/runtime/server/api/_analytics.post.d.ts +0 -0
- package/dist/runtime/server/api/_analytics.post.js +60 -0
- package/dist/runtime/server/middleware/page-visit.d.ts +0 -0
- package/dist/runtime/server/middleware/page-visit.js +67 -0
- package/dist/runtime/types.d.ts +0 -0
- package/dist/runtime/types.js +0 -0
- package/dist/types.d.mts +7 -0
- package/dist/types.d.ts +7 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @neomanex/analytics
|
|
2
|
+
|
|
3
|
+
Nuxt module for analytics tracking with SSR middleware, browser plugin, and server proxy.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @neomanex/analytics
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// nuxt.config.ts
|
|
13
|
+
export default defineNuxtConfig({
|
|
14
|
+
modules: ['@neomanex/analytics'],
|
|
15
|
+
|
|
16
|
+
analytics: {
|
|
17
|
+
apiUrl: 'https://analytics-api.neomanex.com',
|
|
18
|
+
source: 'my-app',
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- **Server-side tracking** - Accurate analytics even with ad blockers
|
|
26
|
+
- **Automatic events** - `page.visit`, `page.view`, `session.start`, `page.leave`
|
|
27
|
+
- **Manual tracking** - `useAnalytics()` composable for custom events
|
|
28
|
+
- **Correlation IDs** - Links SSR and browser events across the session
|
|
29
|
+
- **Secure proxy** - API URL never exposed to browser
|
|
30
|
+
|
|
31
|
+
## Full Documentation
|
|
32
|
+
|
|
33
|
+
For complete documentation including installation, configuration, API reference, and guides:
|
|
34
|
+
|
|
35
|
+
**[View Full Documentation →](./docs/01-overview/01-introduction.md)**
|
|
36
|
+
|
|
37
|
+
Key sections:
|
|
38
|
+
- [Introduction](./docs/01-overview/01-introduction.md) - What, why, and architecture
|
|
39
|
+
- [Installation](./docs/01-overview/02-installation.md) - Setup and configuration
|
|
40
|
+
- [API Reference](./docs/02-api/) - Coming soon
|
|
41
|
+
- [Guides](./docs/03-guides/) - Coming soon
|
|
42
|
+
|
|
43
|
+
## Development
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install # Install dependencies
|
|
47
|
+
npm test # Run tests
|
|
48
|
+
npm run dev # Dev playground
|
|
49
|
+
npm run build # Build module
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
UNLICENSED
|
package/dist/module.cjs
ADDED
package/dist/module.d.ts
ADDED
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addServerHandler, addPlugin, addImports, addTypeTemplate } from '@nuxt/kit';
|
|
2
|
+
import { defu } from 'defu';
|
|
3
|
+
|
|
4
|
+
const module = defineNuxtModule({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "@neomanex/analytics",
|
|
7
|
+
configKey: "analytics",
|
|
8
|
+
compatibility: {
|
|
9
|
+
nuxt: ">=3.10.0"
|
|
10
|
+
},
|
|
11
|
+
version: "1.0.0"
|
|
12
|
+
},
|
|
13
|
+
defaults: {
|
|
14
|
+
apiUrl: "",
|
|
15
|
+
source: "",
|
|
16
|
+
trackPageVisits: true,
|
|
17
|
+
trackPageViews: true,
|
|
18
|
+
trackPageLeave: true,
|
|
19
|
+
excludePaths: []
|
|
20
|
+
},
|
|
21
|
+
setup(options, nuxt) {
|
|
22
|
+
const resolver = createResolver(import.meta.url);
|
|
23
|
+
nuxt.options.runtimeConfig.analytics = defu(
|
|
24
|
+
nuxt.options.runtimeConfig.analytics || {},
|
|
25
|
+
{
|
|
26
|
+
apiUrl: options.apiUrl,
|
|
27
|
+
source: options.source,
|
|
28
|
+
trackPageVisits: options.trackPageVisits,
|
|
29
|
+
trackPageViews: options.trackPageViews,
|
|
30
|
+
trackPageLeave: options.trackPageLeave,
|
|
31
|
+
excludePaths: options.excludePaths
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
nuxt.options.runtimeConfig.public.analytics = defu(
|
|
35
|
+
nuxt.options.runtimeConfig.public.analytics || {},
|
|
36
|
+
{
|
|
37
|
+
source: options.source,
|
|
38
|
+
trackPageViews: options.trackPageViews,
|
|
39
|
+
trackPageLeave: options.trackPageLeave,
|
|
40
|
+
excludePaths: options.excludePaths
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
addServerHandler({
|
|
44
|
+
handler: resolver.resolve("./runtime/server/middleware/page-visit"),
|
|
45
|
+
middleware: true
|
|
46
|
+
});
|
|
47
|
+
addServerHandler({
|
|
48
|
+
route: "/api/_analytics",
|
|
49
|
+
handler: resolver.resolve("./runtime/server/api/_analytics.post")
|
|
50
|
+
});
|
|
51
|
+
addPlugin({
|
|
52
|
+
src: resolver.resolve("./runtime/plugin.server"),
|
|
53
|
+
mode: "server"
|
|
54
|
+
});
|
|
55
|
+
addPlugin({
|
|
56
|
+
src: resolver.resolve("./runtime/plugin.client"),
|
|
57
|
+
mode: "client"
|
|
58
|
+
});
|
|
59
|
+
addImports({
|
|
60
|
+
name: "useAnalytics",
|
|
61
|
+
from: resolver.resolve("./runtime/app/composables/useAnalytics")
|
|
62
|
+
});
|
|
63
|
+
addTypeTemplate({
|
|
64
|
+
filename: "types/analytics.d.ts",
|
|
65
|
+
getContents: () => `
|
|
66
|
+
import type { AnalyticsClient } from '${resolver.resolve("./runtime/client")}'
|
|
67
|
+
|
|
68
|
+
declare module '#app' {
|
|
69
|
+
interface NuxtApp {
|
|
70
|
+
$analytics: AnalyticsClient
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
declare module 'vue' {
|
|
75
|
+
interface ComponentCustomProperties {
|
|
76
|
+
$analytics: AnalyticsClient
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export {}
|
|
81
|
+
`
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export { module as default };
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { $fetch } from "ofetch";
|
|
2
|
+
import { resolveConfig, OVERFLOW_WARNING_DEBOUNCE_MS } from "./config.js";
|
|
3
|
+
export class AnalyticsClient {
|
|
4
|
+
config;
|
|
5
|
+
queue = [];
|
|
6
|
+
flushTimer = null;
|
|
7
|
+
isFlushing = false;
|
|
8
|
+
isShutdown = false;
|
|
9
|
+
/** Debounce state for queue overflow warnings */
|
|
10
|
+
_lastOverflowWarning = 0;
|
|
11
|
+
_overflowWarningShown = false;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = resolveConfig(config);
|
|
14
|
+
this.startFlushTimer();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Add an event to the queue. Returns immediately and never throws.
|
|
18
|
+
*/
|
|
19
|
+
track(eventType, metadata, options) {
|
|
20
|
+
if (this.isShutdown) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const event = {
|
|
25
|
+
event_type: eventType,
|
|
26
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
27
|
+
source: this.config.source,
|
|
28
|
+
metadata: metadata ?? void 0,
|
|
29
|
+
account_id: options?.account_id,
|
|
30
|
+
user_id: options?.user_id
|
|
31
|
+
};
|
|
32
|
+
if (this.queue.length >= this.config.maxQueueSize) {
|
|
33
|
+
this.handleQueueOverflow(event);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.queue.push(event);
|
|
37
|
+
if (this.queue.length >= this.config.flushSize) {
|
|
38
|
+
void this.flush();
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Manually flush all queued events to the batch endpoint.
|
|
45
|
+
*/
|
|
46
|
+
async flush() {
|
|
47
|
+
if (this.isFlushing || this.queue.length === 0) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this.isFlushing = true;
|
|
51
|
+
const events = this.queue.splice(0, this.queue.length);
|
|
52
|
+
try {
|
|
53
|
+
await this.flushWithRetry(events);
|
|
54
|
+
} catch {
|
|
55
|
+
} finally {
|
|
56
|
+
this.isFlushing = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Flush remaining events and stop the timer. Call on process exit.
|
|
61
|
+
*/
|
|
62
|
+
async shutdown() {
|
|
63
|
+
this.isShutdown = true;
|
|
64
|
+
this.stopFlushTimer();
|
|
65
|
+
if (this.queue.length > 0) {
|
|
66
|
+
await this.flush();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get current queue length (for testing/monitoring).
|
|
71
|
+
*/
|
|
72
|
+
get queueLength() {
|
|
73
|
+
return this.queue.length;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build an event payload for sendBeacon usage (browser page.leave).
|
|
77
|
+
* Returns the JSON string to send via navigator.sendBeacon.
|
|
78
|
+
*/
|
|
79
|
+
buildBeaconPayload(eventType, metadata) {
|
|
80
|
+
const event = {
|
|
81
|
+
event_type: eventType,
|
|
82
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
83
|
+
source: this.config.source,
|
|
84
|
+
metadata
|
|
85
|
+
};
|
|
86
|
+
return JSON.stringify({ events: [event] });
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Send a batch of events to the API with exponential backoff retry.
|
|
90
|
+
*/
|
|
91
|
+
async flushWithRetry(events) {
|
|
92
|
+
let attempt = 0;
|
|
93
|
+
let delay = this.config.retryDelay;
|
|
94
|
+
const maxRetries = this.config.maxRetries;
|
|
95
|
+
const backoffMultiplier = this.config.retryBackoffMultiplier;
|
|
96
|
+
while (attempt <= maxRetries) {
|
|
97
|
+
try {
|
|
98
|
+
await this.sendBatch(events);
|
|
99
|
+
return;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
attempt++;
|
|
102
|
+
if (attempt > maxRetries) {
|
|
103
|
+
if (this.config.onError) {
|
|
104
|
+
this.config.onError(error, events);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
109
|
+
delay *= backoffMultiplier;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Send a batch of events via ofetch.
|
|
115
|
+
*/
|
|
116
|
+
async sendBatch(events) {
|
|
117
|
+
await $fetch("/api/v1/events/batch", {
|
|
118
|
+
baseURL: this.config.apiUrl,
|
|
119
|
+
method: "POST",
|
|
120
|
+
body: { events }
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Handle queue overflow: drop oldest event, push new one, warn with debounce.
|
|
125
|
+
*/
|
|
126
|
+
handleQueueOverflow(event) {
|
|
127
|
+
this.queue.shift();
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
if (!this._overflowWarningShown || now - this._lastOverflowWarning > OVERFLOW_WARNING_DEBOUNCE_MS) {
|
|
130
|
+
console.warn(
|
|
131
|
+
"[Analytics] Queue overflow: dropping events. Increase maxQueueSize or reduce event frequency."
|
|
132
|
+
);
|
|
133
|
+
this._lastOverflowWarning = now;
|
|
134
|
+
this._overflowWarningShown = true;
|
|
135
|
+
}
|
|
136
|
+
this.queue.push(event);
|
|
137
|
+
}
|
|
138
|
+
startFlushTimer() {
|
|
139
|
+
this.flushTimer = setInterval(() => {
|
|
140
|
+
void this.flush();
|
|
141
|
+
}, this.config.flushInterval);
|
|
142
|
+
}
|
|
143
|
+
stopFlushTimer() {
|
|
144
|
+
if (this.flushTimer) {
|
|
145
|
+
clearInterval(this.flushTimer);
|
|
146
|
+
this.flushTimer = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
let clientInstance = null;
|
|
151
|
+
export function init(config) {
|
|
152
|
+
if (clientInstance) {
|
|
153
|
+
console.warn("[Analytics] Already initialized");
|
|
154
|
+
return clientInstance;
|
|
155
|
+
}
|
|
156
|
+
clientInstance = new AnalyticsClient(config);
|
|
157
|
+
return clientInstance;
|
|
158
|
+
}
|
|
159
|
+
export function getInstance() {
|
|
160
|
+
if (!clientInstance) {
|
|
161
|
+
throw new Error("[Analytics] Not initialized. Call init() first.");
|
|
162
|
+
}
|
|
163
|
+
return clientInstance;
|
|
164
|
+
}
|
|
165
|
+
export function resetInstance() {
|
|
166
|
+
if (clientInstance) {
|
|
167
|
+
void clientInstance.shutdown();
|
|
168
|
+
}
|
|
169
|
+
clientInstance = null;
|
|
170
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const DEFAULT_FLUSH_INTERVAL = 1e4;
|
|
2
|
+
export const DEFAULT_FLUSH_SIZE = 10;
|
|
3
|
+
export const DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
4
|
+
export const DEFAULT_MAX_RETRIES = 3;
|
|
5
|
+
export const DEFAULT_RETRY_DELAY = 1e3;
|
|
6
|
+
export const DEFAULT_RETRY_BACKOFF_MULTIPLIER = 2;
|
|
7
|
+
export const OVERFLOW_WARNING_DEBOUNCE_MS = 6e4;
|
|
8
|
+
export function resolveConfig(config) {
|
|
9
|
+
return {
|
|
10
|
+
apiUrl: config.apiUrl,
|
|
11
|
+
source: config.source,
|
|
12
|
+
flushInterval: config.flushInterval ?? DEFAULT_FLUSH_INTERVAL,
|
|
13
|
+
flushSize: config.flushSize ?? DEFAULT_FLUSH_SIZE,
|
|
14
|
+
maxQueueSize: config.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
|
|
15
|
+
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
16
|
+
retryDelay: config.retryDelay ?? DEFAULT_RETRY_DELAY,
|
|
17
|
+
retryBackoffMultiplier: config.retryBackoffMultiplier ?? DEFAULT_RETRY_BACKOFF_MULTIPLIER,
|
|
18
|
+
onError: config.onError
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for #imports stub.
|
|
3
|
+
*
|
|
4
|
+
* Provides type signatures matching Nuxt's auto-imports for development
|
|
5
|
+
* and type checking outside of a Nuxt context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export declare function defineEventHandler<T>(handler: (event: any) => T | Promise<T>): (event: any) => T | Promise<T>
|
|
9
|
+
export declare function defineNuxtPlugin(factory: (nuxtApp?: any) => any): any
|
|
10
|
+
export declare function getRequestHeader(event: any, name: string): string | undefined
|
|
11
|
+
export declare function setResponseHeader(event: any, name: string, value: string): void
|
|
12
|
+
export declare function useRuntimeConfig(): any
|
|
13
|
+
export declare function getRequestURL(event: any): URL
|
|
14
|
+
export declare function readBody<T = any>(event: any): Promise<T>
|
|
15
|
+
export declare function useRouter(): any
|
|
16
|
+
export declare function useNuxtApp(): any
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function defineEventHandler(handler) {
|
|
2
|
+
return handler;
|
|
3
|
+
}
|
|
4
|
+
export function defineNuxtPlugin(factory) {
|
|
5
|
+
return factory;
|
|
6
|
+
}
|
|
7
|
+
export function getRequestHeader(_event, _name) {
|
|
8
|
+
return void 0;
|
|
9
|
+
}
|
|
10
|
+
export function setResponseHeader(_event, _name, _value) {
|
|
11
|
+
}
|
|
12
|
+
export function useRuntimeConfig() {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
export function getRequestURL(_event) {
|
|
16
|
+
return new URL("http://localhost/");
|
|
17
|
+
}
|
|
18
|
+
export function readBody(_event) {
|
|
19
|
+
return Promise.resolve({});
|
|
20
|
+
}
|
|
21
|
+
export function useRouter() {
|
|
22
|
+
return {
|
|
23
|
+
afterEach: () => {
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function useNuxtApp() {
|
|
28
|
+
return {
|
|
29
|
+
$analytics: {}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function addServerHandler(_options) {
|
|
33
|
+
}
|
|
34
|
+
export function addPlugin(_options) {
|
|
35
|
+
}
|
|
36
|
+
export function addImports(_options) {
|
|
37
|
+
}
|
|
38
|
+
export function addTypeTemplate(_options) {
|
|
39
|
+
}
|
|
40
|
+
export function createResolver(_url) {
|
|
41
|
+
return { resolve: (path) => path };
|
|
42
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useRouter, useRuntimeConfig } from "#imports";
|
|
2
|
+
import { init, getInstance } from "./client.js";
|
|
3
|
+
const SESSION_ID_KEY = "neomanex_analytics_session_id";
|
|
4
|
+
const PAGE_ENTRY_KEY = "neomanex_analytics_page_entry";
|
|
5
|
+
function generateUUID() {
|
|
6
|
+
return self.crypto.randomUUID();
|
|
7
|
+
}
|
|
8
|
+
function getOrCreateSessionId() {
|
|
9
|
+
let sessionId = sessionStorage.getItem(SESSION_ID_KEY);
|
|
10
|
+
if (!sessionId) {
|
|
11
|
+
sessionId = generateUUID();
|
|
12
|
+
sessionStorage.setItem(SESSION_ID_KEY, sessionId);
|
|
13
|
+
}
|
|
14
|
+
return sessionId;
|
|
15
|
+
}
|
|
16
|
+
function getCorrelationId() {
|
|
17
|
+
const meta = document.querySelector('meta[name="x-correlation-id"]');
|
|
18
|
+
return meta?.getAttribute("content") ?? void 0;
|
|
19
|
+
}
|
|
20
|
+
function getScrollDepthPct() {
|
|
21
|
+
const docHeight = document.documentElement.scrollHeight;
|
|
22
|
+
const winHeight = window.innerHeight;
|
|
23
|
+
const scrollTop = window.scrollY;
|
|
24
|
+
if (docHeight <= winHeight) {
|
|
25
|
+
return 100;
|
|
26
|
+
}
|
|
27
|
+
return Math.min(100, Math.round((scrollTop + winHeight) / docHeight * 100));
|
|
28
|
+
}
|
|
29
|
+
function shouldExclude(path, excludePaths) {
|
|
30
|
+
return excludePaths.some((pattern) => path.startsWith(pattern));
|
|
31
|
+
}
|
|
32
|
+
export default defineNuxtPlugin(() => {
|
|
33
|
+
const runtimeConfig = useRuntimeConfig();
|
|
34
|
+
const publicConfig = runtimeConfig.public.analytics;
|
|
35
|
+
let client;
|
|
36
|
+
try {
|
|
37
|
+
client = getInstance();
|
|
38
|
+
} catch {
|
|
39
|
+
client = init({
|
|
40
|
+
apiUrl: "/api/_analytics",
|
|
41
|
+
source: publicConfig.source,
|
|
42
|
+
flushInterval: 1e4,
|
|
43
|
+
flushSize: 5
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const router = useRouter();
|
|
47
|
+
const sessionId = getOrCreateSessionId();
|
|
48
|
+
const correlationId = getCorrelationId();
|
|
49
|
+
let isFirstPageView = !sessionStorage.getItem(PAGE_ENTRY_KEY);
|
|
50
|
+
sessionStorage.setItem(PAGE_ENTRY_KEY, Date.now().toString());
|
|
51
|
+
if (isFirstPageView) {
|
|
52
|
+
client.track("session.start", {
|
|
53
|
+
entry_path: window.location.pathname,
|
|
54
|
+
screen_width: window.screen.width,
|
|
55
|
+
screen_height: window.screen.height,
|
|
56
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
57
|
+
language: navigator.language,
|
|
58
|
+
correlation_id: correlationId,
|
|
59
|
+
session_id: sessionId
|
|
60
|
+
});
|
|
61
|
+
isFirstPageView = false;
|
|
62
|
+
}
|
|
63
|
+
if (publicConfig.trackPageViews) {
|
|
64
|
+
router.afterEach((to, from) => {
|
|
65
|
+
if (shouldExclude(to.path, publicConfig.excludePaths ?? [])) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
sessionStorage.setItem(PAGE_ENTRY_KEY, Date.now().toString());
|
|
69
|
+
client.track("page.view", {
|
|
70
|
+
path: to.path,
|
|
71
|
+
query: to.query,
|
|
72
|
+
title: typeof document !== "undefined" ? document.title : void 0,
|
|
73
|
+
from_path: from.path,
|
|
74
|
+
correlation_id: getCorrelationId(),
|
|
75
|
+
session_id: sessionId
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (publicConfig.trackPageLeave) {
|
|
80
|
+
window.addEventListener("beforeunload", () => {
|
|
81
|
+
const entryTime = parseInt(sessionStorage.getItem(PAGE_ENTRY_KEY) ?? "0", 10);
|
|
82
|
+
const durationMs = entryTime > 0 ? Date.now() - entryTime : 0;
|
|
83
|
+
const payload = client.buildBeaconPayload("page.leave", {
|
|
84
|
+
path: window.location.pathname,
|
|
85
|
+
duration_ms: durationMs,
|
|
86
|
+
scroll_depth_pct: getScrollDepthPct(),
|
|
87
|
+
correlation_id: getCorrelationId(),
|
|
88
|
+
session_id: sessionId
|
|
89
|
+
});
|
|
90
|
+
navigator.sendBeacon("/api/_analytics", payload);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
provide: {
|
|
95
|
+
analytics: client
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
});
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useRuntimeConfig } from "#imports";
|
|
2
|
+
import { init, getInstance } from "./client.js";
|
|
3
|
+
export default defineNuxtPlugin(() => {
|
|
4
|
+
const config = useRuntimeConfig();
|
|
5
|
+
const analyticsConfig = config.analytics;
|
|
6
|
+
let client;
|
|
7
|
+
try {
|
|
8
|
+
client = getInstance();
|
|
9
|
+
} catch {
|
|
10
|
+
client = init({
|
|
11
|
+
apiUrl: analyticsConfig.apiUrl,
|
|
12
|
+
source: analyticsConfig.source,
|
|
13
|
+
// Server-side uses longer flush interval (events are fewer but more valuable)
|
|
14
|
+
flushInterval: 5e3,
|
|
15
|
+
flushSize: 20
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
provide: {
|
|
20
|
+
analytics: client
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
});
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { defineEventHandler, readBody, getRequestHeader, useRuntimeConfig } from "#imports";
|
|
2
|
+
import { $fetch } from "ofetch";
|
|
3
|
+
function extractClientIp(event) {
|
|
4
|
+
const forwarded = getRequestHeader(event, "x-forwarded-for");
|
|
5
|
+
if (forwarded) {
|
|
6
|
+
return forwarded.split(",")[0]?.trim();
|
|
7
|
+
}
|
|
8
|
+
const realIp = getRequestHeader(event, "x-real-ip");
|
|
9
|
+
if (realIp) {
|
|
10
|
+
return realIp;
|
|
11
|
+
}
|
|
12
|
+
const nodeReq = event.node?.req;
|
|
13
|
+
if (nodeReq?.socket?.remoteAddress) {
|
|
14
|
+
return nodeReq.socket.remoteAddress;
|
|
15
|
+
}
|
|
16
|
+
return void 0;
|
|
17
|
+
}
|
|
18
|
+
export default defineEventHandler(async (event) => {
|
|
19
|
+
const config = useRuntimeConfig();
|
|
20
|
+
const analyticsConfig = config.analytics;
|
|
21
|
+
let body;
|
|
22
|
+
try {
|
|
23
|
+
body = await readBody(event);
|
|
24
|
+
} catch {
|
|
25
|
+
return { ok: false, error: "Invalid request body" };
|
|
26
|
+
}
|
|
27
|
+
if (!body?.events || !Array.isArray(body.events) || body.events.length === 0) {
|
|
28
|
+
return { ok: false, error: "No events provided" };
|
|
29
|
+
}
|
|
30
|
+
const clientIp = extractClientIp(event);
|
|
31
|
+
const userAgent = getRequestHeader(event, "user-agent");
|
|
32
|
+
const referer = getRequestHeader(event, "referer");
|
|
33
|
+
const acceptLanguage = getRequestHeader(event, "accept-language");
|
|
34
|
+
const origin = getRequestHeader(event, "origin");
|
|
35
|
+
const correlationId = getRequestHeader(event, "x-correlation-id");
|
|
36
|
+
const enrichedEvents = body.events.map((evt) => ({
|
|
37
|
+
...evt,
|
|
38
|
+
source: evt.source || analyticsConfig.source,
|
|
39
|
+
client_ip: clientIp,
|
|
40
|
+
user_agent: userAgent,
|
|
41
|
+
referer,
|
|
42
|
+
accept_language: acceptLanguage,
|
|
43
|
+
metadata: {
|
|
44
|
+
...evt.metadata,
|
|
45
|
+
origin,
|
|
46
|
+
correlation_id: correlationId ?? evt.metadata?.correlation_id
|
|
47
|
+
}
|
|
48
|
+
}));
|
|
49
|
+
try {
|
|
50
|
+
const response = await $fetch("/api/v1/events/batch", {
|
|
51
|
+
baseURL: analyticsConfig.apiUrl,
|
|
52
|
+
method: "POST",
|
|
53
|
+
body: { events: enrichedEvents }
|
|
54
|
+
});
|
|
55
|
+
return { ok: true, ingested: response.ingested };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error("[Analytics] Failed to forward events:", error);
|
|
58
|
+
return { ok: false, error: "Analytics service unavailable" };
|
|
59
|
+
}
|
|
60
|
+
});
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineEventHandler, getRequestHeader, setResponseHeader, useRuntimeConfig, getRequestURL } from "#imports";
|
|
2
|
+
import { getInstance } from "../../client.js";
|
|
3
|
+
const EXCLUDED_PREFIXES = ["/api/", "/_nuxt/", "/__nuxt_error"];
|
|
4
|
+
const EXCLUDED_EXACT = ["/favicon.ico", "/_health", "/healthz"];
|
|
5
|
+
function extractClientIp(event) {
|
|
6
|
+
const forwarded = getRequestHeader(event, "x-forwarded-for");
|
|
7
|
+
if (forwarded) {
|
|
8
|
+
return forwarded.split(",")[0]?.trim();
|
|
9
|
+
}
|
|
10
|
+
const realIp = getRequestHeader(event, "x-real-ip");
|
|
11
|
+
if (realIp) {
|
|
12
|
+
return realIp;
|
|
13
|
+
}
|
|
14
|
+
const nodeReq = event.node?.req;
|
|
15
|
+
if (nodeReq?.socket?.remoteAddress) {
|
|
16
|
+
return nodeReq.socket.remoteAddress;
|
|
17
|
+
}
|
|
18
|
+
return void 0;
|
|
19
|
+
}
|
|
20
|
+
function generateCorrelationId() {
|
|
21
|
+
return crypto.randomUUID();
|
|
22
|
+
}
|
|
23
|
+
export default defineEventHandler((event) => {
|
|
24
|
+
const config = useRuntimeConfig();
|
|
25
|
+
const analyticsConfig = config.analytics;
|
|
26
|
+
if (!analyticsConfig.trackPageVisits) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const url = getRequestURL(event);
|
|
30
|
+
const path = url.pathname;
|
|
31
|
+
if (EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (EXCLUDED_EXACT.includes(path)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (analyticsConfig.excludePaths?.some((p) => path.startsWith(p))) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const ext = path.split(".").pop();
|
|
41
|
+
if (ext && ["js", "css", "png", "jpg", "jpeg", "gif", "svg", "ico", "woff", "woff2", "ttf", "eot", "map"].includes(ext)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const correlationId = generateCorrelationId();
|
|
45
|
+
setResponseHeader(event, "X-Correlation-Id", correlationId);
|
|
46
|
+
const clientIp = extractClientIp(event);
|
|
47
|
+
const userAgent = getRequestHeader(event, "user-agent");
|
|
48
|
+
const referer = getRequestHeader(event, "referer");
|
|
49
|
+
const acceptLanguage = getRequestHeader(event, "accept-language");
|
|
50
|
+
const query = {};
|
|
51
|
+
url.searchParams.forEach((value, key) => {
|
|
52
|
+
query[key] = value;
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
const client = getInstance();
|
|
56
|
+
client.track("page.visit", {
|
|
57
|
+
path,
|
|
58
|
+
query: Object.keys(query).length > 0 ? query : void 0,
|
|
59
|
+
correlation_id: correlationId,
|
|
60
|
+
client_ip: clientIp,
|
|
61
|
+
user_agent: userAgent,
|
|
62
|
+
referer,
|
|
63
|
+
accept_language: acceptLanguage
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
});
|
|
File without changes
|
|
File without changes
|
package/dist/types.d.mts
ADDED
package/dist/types.d.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neomanex/analytics-nuxt",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Nuxt module for analytics tracking with SSR middleware, browser plugin, and server proxy",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/types.d.ts",
|
|
10
|
+
"import": "./dist/module.mjs",
|
|
11
|
+
"require": "./dist/module.cjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/module.cjs",
|
|
15
|
+
"types": "./dist/types.d.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"prepack": "nuxt-module-build build",
|
|
21
|
+
"dev": "nuxi dev playground",
|
|
22
|
+
"dev:build": "nuxi build playground",
|
|
23
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxi prepare playground",
|
|
24
|
+
"build": "nuxt-module-build build",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest watch",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@nuxt/kit": "^3.10.0",
|
|
31
|
+
"defu": "^6.1.4",
|
|
32
|
+
"ofetch": "^1.4.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@nuxt/module-builder": "^0.8.0",
|
|
36
|
+
"@nuxt/schema": "^3.10.0",
|
|
37
|
+
"nuxt": "^3.10.0",
|
|
38
|
+
"typescript": "^5.5.0",
|
|
39
|
+
"vitest": "^2.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"nuxt": ">=3.10.0 || >=4.0.0"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://gitlab.com/neomanex/neoanalytics/analytics-nuxt.git"
|
|
47
|
+
}
|
|
48
|
+
}
|