@seneris/nosework 0.1.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,535 @@
1
+ # nosework
2
+
3
+ Privacy-focused, self-hosted analytics for your app suite.
4
+
5
+ ## Features
6
+
7
+ - **Cookieless tracking** - No consent banners needed
8
+ - **City-level geolocation** - Via Vercel's geo headers
9
+ - **GDPR compliant** - No PII stored, IPs are hashed
10
+ - **Multi-site support** - Track all your apps with one shared database
11
+ - **Full API** - Query your analytics data programmatically
12
+
13
+ ## Architecture
14
+
15
+ ```
16
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
17
+ │ App 1 │ │ App 2 │ │ App N │
18
+ │ (Next.js) │ │ (Next.js) │ │ (Next.js) │
19
+ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
20
+ │ │ │
21
+ │ import { trackPageView } │
22
+ │ from 'nosework' │
23
+ │ │ │
24
+ └────────────────┼────────────────┘
25
+
26
+ ┌─────────▼─────────┐
27
+ │ Shared Neon DB │
28
+ │ (PostgreSQL) │
29
+ └───────────────────┘
30
+ ```
31
+
32
+ Each app imports nosework and writes directly to a shared PostgreSQL database. No intermediate service needed.
33
+
34
+ ### Why Each App Needs a Tracking Endpoint
35
+
36
+ The browser (client-side) cannot:
37
+ - Connect directly to PostgreSQL
38
+ - Access HTTP headers (IP address, User-Agent, geo data)
39
+
40
+ So each app needs a small API endpoint that:
41
+ 1. Receives tracking requests from the browser
42
+ 2. Reads IP/User-Agent/geo from HTTP headers
43
+ 3. Calls `trackPageView()` to write to the shared DB
44
+
45
+ This is typically ~20 lines of code per app.
46
+
47
+ ---
48
+
49
+ ## Geolocation: Vercel Headers
50
+
51
+ **Important:** This package is designed for apps hosted on **Vercel**.
52
+
53
+ Vercel automatically adds geolocation headers to every request:
54
+
55
+ | Header | Example | Description |
56
+ |--------|---------|-------------|
57
+ | `x-vercel-ip-country` | `US` | Country code |
58
+ | `x-vercel-ip-country-region` | `CA` | Region/state |
59
+ | `x-vercel-ip-city` | `San Francisco` | City name |
60
+
61
+ These headers are:
62
+ - **Free** - No third-party accounts or API keys needed
63
+ - **Automatic** - Available on every request when deployed to Vercel
64
+ - **Accurate** - Powered by Vercel's edge network
65
+
66
+ ### Local Development
67
+
68
+ Geo headers are **not available** in local development (`localhost`). Location data will be `null` when developing locally - this is expected and won't affect functionality.
69
+
70
+ ### Non-Vercel Hosting
71
+
72
+ If you're not using Vercel, you have options:
73
+ 1. Skip location data (everything else works fine)
74
+ 2. Use a GeoIP service/database and pass the data to `trackPageView()`
75
+ 3. Check if your hosting provider offers similar geo headers
76
+
77
+ ---
78
+
79
+ ## Quick Start
80
+
81
+ ### 1. Install
82
+
83
+ ```bash
84
+ bun add nosework
85
+ ```
86
+
87
+ ### 2. Set Environment Variables
88
+
89
+ Add to your `.env`:
90
+
91
+ ```env
92
+ DATABASE_URL="postgresql://user:pass@host/dbname"
93
+ ANALYTICS_SITE_ID="my-app" # Unique identifier for this app
94
+ ```
95
+
96
+ **MoopySuite Users:** If you're using MoopySuite OAuth, use your existing `MOOPY_CLIENT_ID` as the site identifier - no new env var needed! See [MoopySuite Integration](./docs/MOOPYSUITE_INTEGRATION.md).
97
+
98
+ ### 3. Run Database Migration
99
+
100
+ First time only (or when updating nosework):
101
+
102
+ ```bash
103
+ bunx prisma migrate deploy --schema=./node_modules/nosework/prisma/schema.prisma
104
+ ```
105
+
106
+ ### 4. Add Tracking Endpoint
107
+
108
+ Create `app/api/analytics/track/route.ts`:
109
+
110
+ ```typescript
111
+ import { trackPageView } from 'nosework';
112
+ import { NextRequest, NextResponse } from 'next/server';
113
+
114
+ export async function POST(request: NextRequest) {
115
+ try {
116
+ const { url, referrer } = await request.json();
117
+
118
+ await trackPageView({
119
+ siteId: process.env.ANALYTICS_SITE_ID!,
120
+ url,
121
+ referrer,
122
+ // For visitor hashing
123
+ ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
124
+ userAgent: request.headers.get('user-agent'),
125
+ // Vercel geo headers (automatically provided when deployed)
126
+ country: request.headers.get('x-vercel-ip-country'),
127
+ countryCode: request.headers.get('x-vercel-ip-country'),
128
+ region: request.headers.get('x-vercel-ip-country-region'),
129
+ city: request.headers.get('x-vercel-ip-city'),
130
+ });
131
+
132
+ return NextResponse.json({ ok: true });
133
+ } catch (error) {
134
+ console.error('Analytics error:', error);
135
+ return NextResponse.json({ ok: false }, { status: 500 });
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### 5. Add Client Component
141
+
142
+ Create `components/Analytics.tsx`:
143
+
144
+ ```typescript
145
+ 'use client';
146
+
147
+ import { usePathname, useSearchParams } from 'next/navigation';
148
+ import { useEffect, useRef } from 'react';
149
+
150
+ export function Analytics() {
151
+ const pathname = usePathname();
152
+ const searchParams = useSearchParams();
153
+ const initialLoad = useRef(true);
154
+
155
+ useEffect(() => {
156
+ // Track page view
157
+ const trackPageView = () => {
158
+ fetch('/api/analytics/track', {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({
162
+ url: window.location.href,
163
+ referrer: initialLoad.current ? document.referrer : null,
164
+ }),
165
+ keepalive: true, // Ensures request completes even on navigation
166
+ }).catch(() => {
167
+ // Silently fail - analytics should never break the app
168
+ });
169
+
170
+ initialLoad.current = false;
171
+ };
172
+
173
+ trackPageView();
174
+ }, [pathname, searchParams]);
175
+
176
+ return null;
177
+ }
178
+ ```
179
+
180
+ ### 6. Add to Layout
181
+
182
+ In `app/layout.tsx`:
183
+
184
+ ```typescript
185
+ import { Analytics } from '@/components/Analytics';
186
+ import { Suspense } from 'react';
187
+
188
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
189
+ return (
190
+ <html>
191
+ <body>
192
+ {children}
193
+ <Suspense fallback={null}>
194
+ <Analytics />
195
+ </Suspense>
196
+ </body>
197
+ </html>
198
+ );
199
+ }
200
+ ```
201
+
202
+ That's it! Your app is now tracking page views with location data.
203
+
204
+ ---
205
+
206
+ ## Alternative: Middleware-Only Tracking
207
+
208
+ If you prefer not to use a client component, you can track in Next.js middleware. This is simpler but won't capture client-side navigations in SPAs.
209
+
210
+ Create `middleware.ts`:
211
+
212
+ ```typescript
213
+ import { trackPageView } from 'nosework';
214
+ import { NextResponse } from 'next/server';
215
+ import type { NextRequest } from 'next/server';
216
+
217
+ export async function middleware(request: NextRequest) {
218
+ // Only track page requests, not API/static files
219
+ const { pathname } = request.nextUrl;
220
+
221
+ if (
222
+ pathname.startsWith('/api') ||
223
+ pathname.startsWith('/_next') ||
224
+ pathname.includes('.')
225
+ ) {
226
+ return NextResponse.next();
227
+ }
228
+
229
+ // Fire and forget - don't block the response
230
+ trackPageView({
231
+ siteId: process.env.ANALYTICS_SITE_ID!,
232
+ url: request.url,
233
+ referrer: request.headers.get('referer'),
234
+ ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
235
+ userAgent: request.headers.get('user-agent'),
236
+ country: request.headers.get('x-vercel-ip-country'),
237
+ countryCode: request.headers.get('x-vercel-ip-country'),
238
+ region: request.headers.get('x-vercel-ip-country-region'),
239
+ city: request.headers.get('x-vercel-ip-city'),
240
+ }).catch(() => {});
241
+
242
+ return NextResponse.next();
243
+ }
244
+
245
+ export const config = {
246
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
247
+ };
248
+ ```
249
+
250
+ **Tradeoffs:**
251
+
252
+ | Approach | Pros | Cons |
253
+ |----------|------|------|
254
+ | Client + API | Catches all navigations, including SPA | More setup (~2 files) |
255
+ | Middleware only | Single file, simpler | Misses client-side navigations |
256
+
257
+ ---
258
+
259
+ ## Tracking Custom Events
260
+
261
+ Beyond page views, you can track custom events:
262
+
263
+ ```typescript
264
+ import { trackEvent } from 'nosework';
265
+
266
+ // In an API route or server action
267
+ await trackEvent({
268
+ siteId: process.env.ANALYTICS_SITE_ID!,
269
+ name: 'signup',
270
+ properties: { plan: 'pro', source: 'landing-page' },
271
+ ip: request.headers.get('x-forwarded-for'),
272
+ userAgent: request.headers.get('user-agent'),
273
+ userId: user?.id, // Optional - link to your user
274
+ });
275
+ ```
276
+
277
+ Common events to track:
278
+ - `signup` - User registration
279
+ - `login` - User authentication
280
+ - `purchase` - Completed transaction
281
+ - `button_click` - Important UI interactions
282
+ - `form_submit` - Form completions
283
+ - `error` - Client-side errors
284
+
285
+ ---
286
+
287
+ ## Querying Analytics
288
+
289
+ ### Basic Stats
290
+
291
+ ```typescript
292
+ import { getStats } from 'nosework';
293
+
294
+ const stats = await getStats({
295
+ siteId: 'my-app',
296
+ startDate: new Date('2024-01-01'),
297
+ endDate: new Date(),
298
+ });
299
+
300
+ // Returns:
301
+ // {
302
+ // pageViews: 12345,
303
+ // visitors: 5678,
304
+ // sessions: 7890,
305
+ // bounceRate: 42.5
306
+ // }
307
+ ```
308
+
309
+ ### Top Pages
310
+
311
+ ```typescript
312
+ import { getTopPages } from 'nosework';
313
+
314
+ const pages = await getTopPages({
315
+ siteId: 'my-app',
316
+ startDate,
317
+ endDate,
318
+ limit: 10,
319
+ });
320
+
321
+ // Returns:
322
+ // [
323
+ // { pathname: '/', pageViews: 5000, visitors: 3000 },
324
+ // { pathname: '/pricing', pageViews: 2000, visitors: 1500 },
325
+ // ...
326
+ // ]
327
+ ```
328
+
329
+ ### Location Breakdown
330
+
331
+ ```typescript
332
+ import { getLocations } from 'nosework';
333
+
334
+ const locations = await getLocations({
335
+ siteId: 'my-app',
336
+ startDate,
337
+ endDate,
338
+ limit: 20,
339
+ });
340
+
341
+ // Returns:
342
+ // [
343
+ // { country: 'United States', countryCode: 'US', city: 'New York', pageViews: 1000, visitors: 500 },
344
+ // { country: 'Netherlands', countryCode: 'NL', city: 'Amsterdam', pageViews: 800, visitors: 400 },
345
+ // ...
346
+ // ]
347
+ ```
348
+
349
+ ### Traffic Sources
350
+
351
+ ```typescript
352
+ import { getReferrers } from 'nosework';
353
+
354
+ const referrers = await getReferrers({
355
+ siteId: 'my-app',
356
+ startDate,
357
+ endDate,
358
+ limit: 10,
359
+ });
360
+ ```
361
+
362
+ ### Device/Browser Breakdown
363
+
364
+ ```typescript
365
+ import { getDevices } from 'nosework';
366
+
367
+ const devices = await getDevices({
368
+ siteId: 'my-app',
369
+ startDate,
370
+ endDate,
371
+ });
372
+ ```
373
+
374
+ ### Time Series (for charts)
375
+
376
+ ```typescript
377
+ import { getTimeSeries } from 'nosework';
378
+
379
+ const data = await getTimeSeries({
380
+ siteId: 'my-app',
381
+ startDate,
382
+ endDate,
383
+ interval: 'day', // 'hour' | 'day' | 'week' | 'month'
384
+ });
385
+
386
+ // Returns:
387
+ // [
388
+ // { date: '2024-01-01', pageViews: 100, visitors: 50 },
389
+ // { date: '2024-01-02', pageViews: 120, visitors: 60 },
390
+ // ...
391
+ // ]
392
+ ```
393
+
394
+ ### Site Management
395
+
396
+ ```typescript
397
+ import { listSites, getOrCreateSite } from 'nosework';
398
+
399
+ // List all tracked sites
400
+ const sites = await listSites();
401
+
402
+ // Get or create a site (useful for initial setup)
403
+ const site = await getOrCreateSite('example.com', 'My Example Site');
404
+ ```
405
+
406
+ ---
407
+
408
+ ## Privacy Design
409
+
410
+ nosework is designed to be privacy-friendly by default:
411
+
412
+ ### No Cookies
413
+ Visitors are identified by a hash of IP + User-Agent + daily salt. This hash rotates daily, so you can't track users across days (by design).
414
+
415
+ ### No PII Storage
416
+ - IP addresses are **never stored** - only used for hashing
417
+ - User-Agent strings are parsed into categories (e.g., "Chrome", "Windows") - raw strings are not stored
418
+ - The visitor hash cannot be reversed to identify the original IP
419
+
420
+ ### Session Inference
421
+ Sessions are inferred using a 30-minute window hash. No session cookies needed.
422
+
423
+ ### Bot Filtering
424
+ Known bots (Googlebot, crawlers, etc.) are automatically flagged and excluded from statistics.
425
+
426
+ ### GDPR Compliance
427
+ Because no cookies are used and no PII is stored, you typically don't need:
428
+ - Cookie consent banners
429
+ - Privacy policy updates for analytics
430
+ - Data processing agreements
431
+
432
+ *Note: Consult with a legal professional for your specific situation.*
433
+
434
+ ---
435
+
436
+ ## API Reference
437
+
438
+ ### Tracking Functions
439
+
440
+ #### `trackPageView(options)`
441
+ Track a page view.
442
+
443
+ ```typescript
444
+ interface TrackPageViewOptions {
445
+ siteId: string; // Required: Your site identifier
446
+ url: string; // Required: Full URL of the page
447
+ referrer?: string; // Optional: Referring URL
448
+ ip?: string; // Optional: Visitor IP (for hashing)
449
+ userAgent?: string; // Optional: Browser user agent
450
+ userId?: string; // Optional: Your app's user ID
451
+ // Geo data (from Vercel headers)
452
+ country?: string; // Optional: Country name
453
+ countryCode?: string; // Optional: Country code (e.g., "US")
454
+ region?: string; // Optional: Region/state
455
+ city?: string; // Optional: City name
456
+ }
457
+ ```
458
+
459
+ #### `trackEvent(options)`
460
+ Track a custom event.
461
+
462
+ ```typescript
463
+ interface TrackEventOptions {
464
+ siteId: string; // Required: Your site identifier
465
+ name: string; // Required: Event name
466
+ properties?: object; // Optional: Event metadata
467
+ url?: string; // Optional: Page URL
468
+ ip?: string; // Optional: Visitor IP
469
+ userAgent?: string; // Optional: Browser user agent
470
+ userId?: string; // Optional: Your app's user ID
471
+ }
472
+ ```
473
+
474
+ ### Query Functions
475
+
476
+ All query functions accept:
477
+ ```typescript
478
+ interface QueryOptions {
479
+ siteId: string;
480
+ startDate: Date;
481
+ endDate: Date;
482
+ }
483
+
484
+ interface PaginatedQueryOptions extends QueryOptions {
485
+ limit?: number; // Default: 10-20 depending on function
486
+ offset?: number;
487
+ }
488
+ ```
489
+
490
+ | Function | Returns |
491
+ |----------|---------|
492
+ | `getStats(options)` | `{ pageViews, visitors, sessions, bounceRate }` |
493
+ | `getTopPages(options)` | `[{ pathname, pageViews, visitors }]` |
494
+ | `getLocations(options)` | `[{ country, countryCode, city, pageViews, visitors }]` |
495
+ | `getReferrers(options)` | `[{ referrer, pageViews, visitors }]` |
496
+ | `getDevices(options)` | `[{ device, browser, os, pageViews, visitors }]` |
497
+ | `getTimeSeries(options)` | `[{ date, pageViews, visitors }]` |
498
+ | `listSites()` | `[{ id, name, domain, createdAt }]` |
499
+ | `getOrCreateSite(domain, name?)` | `{ id, name, domain }` |
500
+
501
+ ### Utility Functions
502
+
503
+ | Function | Description |
504
+ |----------|-------------|
505
+ | `getClient()` | Get the Prisma client for custom queries |
506
+ | `disconnect()` | Disconnect from the database |
507
+ | `isBot(userAgent)` | Check if a user-agent is a bot |
508
+ | `parseUserAgent(ua)` | Parse a user-agent string |
509
+ | `cleanupOldSalts()` | Remove daily salts older than 7 days |
510
+
511
+ ---
512
+
513
+ ## Database Schema
514
+
515
+ The package uses these tables (auto-created via Prisma migrations):
516
+
517
+ - **Site** - Your tracked sites/apps
518
+ - **PageView** - Individual page view events
519
+ - **Event** - Custom events
520
+ - **DailySalt** - Rotating salts for visitor hashing (privacy)
521
+
522
+ See `prisma/schema.prisma` for the full schema.
523
+
524
+ ---
525
+
526
+ ## Limitations
527
+
528
+ - **Vercel hosting required for geo data** - Location headers are provided by Vercel. Other hosts won't have geo data unless you add a third-party GeoIP service.
529
+ - **No local geo data** - When running locally, geo headers are not available. Location will be `null` in development.
530
+
531
+ ---
532
+
533
+ ## License
534
+
535
+ MIT
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Client-side error capture for nosework
3
+ *
4
+ * This is a lightweight (~2KB) script that captures unhandled errors
5
+ * and sends them to your tracking endpoint.
6
+ *
7
+ * Usage:
8
+ * ```typescript
9
+ * import { initErrorTracking } from '@seneris/nosework/client/errors'
10
+ *
11
+ * initErrorTracking({
12
+ * endpoint: '/api/analytics/error',
13
+ * siteId: process.env.NEXT_PUBLIC_ANALYTICS_SITE_ID!,
14
+ * })
15
+ * ```
16
+ */
17
+ export interface ErrorTrackingConfig {
18
+ /** The endpoint to send errors to (e.g., '/api/analytics/error') */
19
+ endpoint: string;
20
+ /** Your site ID for analytics */
21
+ siteId: string;
22
+ /** Optional: custom metadata to include with every error */
23
+ metadata?: Record<string, unknown>;
24
+ /** Optional: function to filter errors (return false to skip) */
25
+ filter?: (error: CapturedError) => boolean;
26
+ /** Optional: max errors to send per minute (default: 10) */
27
+ rateLimit?: number;
28
+ }
29
+ export interface CapturedError {
30
+ message: string;
31
+ stack?: string;
32
+ url: string;
33
+ type: "error" | "unhandledrejection";
34
+ }
35
+ /**
36
+ * Initialize error tracking
37
+ * Call this once when your app starts
38
+ */
39
+ export declare function initErrorTracking(options: ErrorTrackingConfig): void;
40
+ /**
41
+ * Stop error tracking and remove event listeners
42
+ */
43
+ export declare function stopErrorTracking(): void;
44
+ /**
45
+ * Manually track an error
46
+ * Useful for caught exceptions you still want to track
47
+ */
48
+ export declare function captureError(error: Error | string, metadata?: Record<string, unknown>): void;
49
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,iEAAiE;IACjE,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,OAAO,CAAC;IAC3C,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,OAAO,GAAG,oBAAoB,CAAC;CACtC;AAsHD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI,CAWpE;AAED;;GAEG;AACH,wBAAgB,iBAAiB,IAAI,IAAI,CAMxC;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,KAAK,GAAG,MAAM,EACrB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACjC,IAAI,CAiCN"}