@seneris/nosework 0.1.0 → 0.2.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 CHANGED
@@ -19,7 +19,7 @@ Privacy-focused, self-hosted analytics for your app suite.
19
19
  └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
20
20
  │ │ │
21
21
  │ import { trackPageView } │
22
- │ from 'nosework'
22
+ │ from '@seneris/nosework'
23
23
  │ │ │
24
24
  └────────────────┼────────────────┘
25
25
 
@@ -81,7 +81,7 @@ If you're not using Vercel, you have options:
81
81
  ### 1. Install
82
82
 
83
83
  ```bash
84
- bun add nosework
84
+ bun add @seneris/nosework
85
85
  ```
86
86
 
87
87
  ### 2. Set Environment Variables
@@ -89,26 +89,23 @@ bun add nosework
89
89
  Add to your `.env`:
90
90
 
91
91
  ```env
92
- DATABASE_URL="postgresql://user:pass@host/dbname"
92
+ ANALYTICS_DATABASE_URL="postgresql://user:pass@host/dbname"
93
93
  ANALYTICS_SITE_ID="my-app" # Unique identifier for this app
94
94
  ```
95
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
96
+ **MoopySuite Users:** Your analytics tables are in the MoopySuite database, so use:
97
+ ```env
98
+ ANALYTICS_DATABASE_URL="${DATABASE_URL}" # Same as MoopySuite DB
99
+ # No ANALYTICS_SITE_ID needed - use MOOPY_CLIENT_ID instead
104
100
  ```
101
+ See [MoopySuite Integration](./docs/MOOPYSUITE_INTEGRATION.md).
105
102
 
106
- ### 4. Add Tracking Endpoint
103
+ ### 3. Add Tracking Endpoint
107
104
 
108
105
  Create `app/api/analytics/track/route.ts`:
109
106
 
110
107
  ```typescript
111
- import { trackPageView } from 'nosework';
108
+ import { trackPageView } from '@seneris/nosework';
112
109
  import { NextRequest, NextResponse } from 'next/server';
113
110
 
114
111
  export async function POST(request: NextRequest) {
@@ -137,7 +134,7 @@ export async function POST(request: NextRequest) {
137
134
  }
138
135
  ```
139
136
 
140
- ### 5. Add Client Component
137
+ ### 4. Add Client Component
141
138
 
142
139
  Create `components/Analytics.tsx`:
143
140
 
@@ -177,7 +174,7 @@ export function Analytics() {
177
174
  }
178
175
  ```
179
176
 
180
- ### 6. Add to Layout
177
+ ### 5. Add to Layout
181
178
 
182
179
  In `app/layout.tsx`:
183
180
 
@@ -210,7 +207,7 @@ If you prefer not to use a client component, you can track in Next.js middleware
210
207
  Create `middleware.ts`:
211
208
 
212
209
  ```typescript
213
- import { trackPageView } from 'nosework';
210
+ import { trackPageView } from '@seneris/nosework';
214
211
  import { NextResponse } from 'next/server';
215
212
  import type { NextRequest } from 'next/server';
216
213
 
@@ -261,7 +258,7 @@ export const config = {
261
258
  Beyond page views, you can track custom events:
262
259
 
263
260
  ```typescript
264
- import { trackEvent } from 'nosework';
261
+ import { trackEvent } from '@seneris/nosework';
265
262
 
266
263
  // In an API route or server action
267
264
  await trackEvent({
@@ -289,7 +286,7 @@ Common events to track:
289
286
  ### Basic Stats
290
287
 
291
288
  ```typescript
292
- import { getStats } from 'nosework';
289
+ import { getStats } from '@seneris/nosework';
293
290
 
294
291
  const stats = await getStats({
295
292
  siteId: 'my-app',
@@ -309,7 +306,7 @@ const stats = await getStats({
309
306
  ### Top Pages
310
307
 
311
308
  ```typescript
312
- import { getTopPages } from 'nosework';
309
+ import { getTopPages } from '@seneris/nosework';
313
310
 
314
311
  const pages = await getTopPages({
315
312
  siteId: 'my-app',
@@ -329,7 +326,7 @@ const pages = await getTopPages({
329
326
  ### Location Breakdown
330
327
 
331
328
  ```typescript
332
- import { getLocations } from 'nosework';
329
+ import { getLocations } from '@seneris/nosework';
333
330
 
334
331
  const locations = await getLocations({
335
332
  siteId: 'my-app',
@@ -349,7 +346,7 @@ const locations = await getLocations({
349
346
  ### Traffic Sources
350
347
 
351
348
  ```typescript
352
- import { getReferrers } from 'nosework';
349
+ import { getReferrers } from '@seneris/nosework';
353
350
 
354
351
  const referrers = await getReferrers({
355
352
  siteId: 'my-app',
@@ -362,7 +359,7 @@ const referrers = await getReferrers({
362
359
  ### Device/Browser Breakdown
363
360
 
364
361
  ```typescript
365
- import { getDevices } from 'nosework';
362
+ import { getDevices } from '@seneris/nosework';
366
363
 
367
364
  const devices = await getDevices({
368
365
  siteId: 'my-app',
@@ -374,7 +371,7 @@ const devices = await getDevices({
374
371
  ### Time Series (for charts)
375
372
 
376
373
  ```typescript
377
- import { getTimeSeries } from 'nosework';
374
+ import { getTimeSeries } from '@seneris/nosework';
378
375
 
379
376
  const data = await getTimeSeries({
380
377
  siteId: 'my-app',
@@ -391,18 +388,6 @@ const data = await getTimeSeries({
391
388
  // ]
392
389
  ```
393
390
 
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
391
  ---
407
392
 
408
393
  ## Privacy Design
@@ -495,14 +480,12 @@ interface PaginatedQueryOptions extends QueryOptions {
495
480
  | `getReferrers(options)` | `[{ referrer, pageViews, visitors }]` |
496
481
  | `getDevices(options)` | `[{ device, browser, os, pageViews, visitors }]` |
497
482
  | `getTimeSeries(options)` | `[{ date, pageViews, visitors }]` |
498
- | `listSites()` | `[{ id, name, domain, createdAt }]` |
499
- | `getOrCreateSite(domain, name?)` | `{ id, name, domain }` |
500
483
 
501
484
  ### Utility Functions
502
485
 
503
486
  | Function | Description |
504
487
  |----------|-------------|
505
- | `getClient()` | Get the Prisma client for custom queries |
488
+ | `getClient()` | Get the Drizzle client for custom queries |
506
489
  | `disconnect()` | Disconnect from the database |
507
490
  | `isBot(userAgent)` | Check if a user-agent is a bot |
508
491
  | `parseUserAgent(ua)` | Parse a user-agent string |
@@ -512,14 +495,15 @@ interface PaginatedQueryOptions extends QueryOptions {
512
495
 
513
496
  ## Database Schema
514
497
 
515
- The package uses these tables (auto-created via Prisma migrations):
498
+ The package uses these tables (created by MoopySuite migrations):
516
499
 
517
- - **Site** - Your tracked sites/apps
518
- - **PageView** - Individual page view events
519
- - **Event** - Custom events
520
- - **DailySalt** - Rotating salts for visitor hashing (privacy)
500
+ - **page_views** - Individual page view events with location, device info
501
+ - **events** - Custom events with properties
502
+ - **daily_salts** - Rotating salts for visitor hashing (privacy)
503
+ - **analytics_errors** - Individual error occurrences
504
+ - **error_groups** - Aggregated errors by fingerprint
521
505
 
522
- See `prisma/schema.prisma` for the full schema.
506
+ See `src/schema.ts` for the Drizzle schema definitions.
523
507
 
524
508
  ---
525
509
 
package/dist/client.d.ts CHANGED
@@ -1,7 +1,13 @@
1
- import { PrismaClient } from "@prisma/client";
1
+ import { drizzle } from "drizzle-orm/postgres-js";
2
+ import postgres from "postgres";
3
+ import * as schema from "./schema.js";
2
4
  declare global {
3
- var __noseworkPrisma: PrismaClient | undefined;
5
+ var __noseworkDb: ReturnType<typeof drizzle<typeof schema>> | undefined;
6
+ var __noseworkSql: ReturnType<typeof postgres> | undefined;
4
7
  }
5
- export declare function getClient(): PrismaClient;
8
+ export declare function getClient(): import("drizzle-orm/postgres-js").PostgresJsDatabase<typeof schema> & {
9
+ $client: postgres.Sql<{}>;
10
+ };
6
11
  export declare function disconnect(): Promise<void>;
12
+ export { schema };
7
13
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,OAAO,CAAC,MAAM,CAAC;IAEb,IAAI,gBAAgB,EAAE,YAAY,GAAG,SAAS,CAAC;CAChD;AAID,wBAAgB,SAAS,IAAI,YAAY,CAoBxC;AAED,wBAAsB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAKhD"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AAClD,OAAO,QAAQ,MAAM,UAAU,CAAC;AAChC,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAEtC,OAAO,CAAC,MAAM,CAAC;IAEb,IAAI,YAAY,EAAE,UAAU,CAAC,OAAO,OAAO,CAAC,OAAO,MAAM,CAAC,CAAC,GAAG,SAAS,CAAC;IAExE,IAAI,aAAa,EAAE,UAAU,CAAC,OAAO,QAAQ,CAAC,GAAG,SAAS,CAAC;CAC5D;AAKD,wBAAgB,SAAS;;EAwBxB;AAED,wBAAsB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAMhD;AAGD,OAAO,EAAE,MAAM,EAAE,CAAC"}
package/dist/client.js CHANGED
@@ -1,29 +1,38 @@
1
- import { PrismaClient } from "@prisma/client";
2
- let prismaClient = null;
1
+ import { drizzle } from "drizzle-orm/postgres-js";
2
+ import postgres from "postgres";
3
+ import * as schema from "./schema.js";
4
+ let db = null;
5
+ let sql = null;
3
6
  export function getClient() {
4
- if (prismaClient) {
5
- return prismaClient;
7
+ if (db) {
8
+ return db;
6
9
  }
7
- // In development, use global to preserve client across hot reloads
10
+ const connectionString = process.env.ANALYTICS_DATABASE_URL;
11
+ if (!connectionString) {
12
+ throw new Error("ANALYTICS_DATABASE_URL environment variable is not set");
13
+ }
14
+ // In development, use global to preserve connection across hot reloads
8
15
  if (process.env.NODE_ENV === "development") {
9
- if (!global.__noseworkPrisma) {
10
- global.__noseworkPrisma = new PrismaClient({
11
- datasourceUrl: process.env.ANALYTICS_DATABASE_URL,
12
- });
16
+ if (!global.__noseworkSql) {
17
+ global.__noseworkSql = postgres(connectionString);
18
+ global.__noseworkDb = drizzle(global.__noseworkSql, { schema });
13
19
  }
14
- prismaClient = global.__noseworkPrisma;
20
+ sql = global.__noseworkSql;
21
+ db = global.__noseworkDb;
15
22
  }
16
23
  else {
17
- prismaClient = new PrismaClient({
18
- datasourceUrl: process.env.ANALYTICS_DATABASE_URL,
19
- });
24
+ sql = postgres(connectionString);
25
+ db = drizzle(sql, { schema });
20
26
  }
21
- return prismaClient;
27
+ return db;
22
28
  }
23
29
  export async function disconnect() {
24
- if (prismaClient) {
25
- await prismaClient.$disconnect();
26
- prismaClient = null;
30
+ if (sql) {
31
+ await sql.end();
32
+ sql = null;
33
+ db = null;
27
34
  }
28
35
  }
36
+ // Re-export schema for convenience
37
+ export { schema };
29
38
  //# sourceMappingURL=client.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAO9C,IAAI,YAAY,GAAwB,IAAI,CAAC;AAE7C,MAAM,UAAU,SAAS;IACvB,IAAI,YAAY,EAAE,CAAC;QACjB,OAAO,YAAY,CAAC;IACtB,CAAC;IAED,mEAAmE;IACnE,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,aAAa,EAAE,CAAC;QAC3C,IAAI,CAAC,MAAM,CAAC,gBAAgB,EAAE,CAAC;YAC7B,MAAM,CAAC,gBAAgB,GAAG,IAAI,YAAY,CAAC;gBACzC,aAAa,EAAE,OAAO,CAAC,GAAG,CAAC,sBAAsB;aAClD,CAAC,CAAC;QACL,CAAC;QACD,YAAY,GAAG,MAAM,CAAC,gBAAgB,CAAC;IACzC,CAAC;SAAM,CAAC;QACN,YAAY,GAAG,IAAI,YAAY,CAAC;YAC9B,aAAa,EAAE,OAAO,CAAC,GAAG,CAAC,sBAAsB;SAClD,CAAC,CAAC;IACL,CAAC;IAED,OAAO,YAAY,CAAC;AACtB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU;IAC9B,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,YAAY,CAAC,WAAW,EAAE,CAAC;QACjC,YAAY,GAAG,IAAI,CAAC;IACtB,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AAClD,OAAO,QAAQ,MAAM,UAAU,CAAC;AAChC,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAStC,IAAI,EAAE,GAAqD,IAAI,CAAC;AAChE,IAAI,GAAG,GAAuC,IAAI,CAAC;AAEnD,MAAM,UAAU,SAAS;IACvB,IAAI,EAAE,EAAE,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,MAAM,gBAAgB,GAAG,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC;IAC5D,IAAI,CAAC,gBAAgB,EAAE,CAAC;QACtB,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;IAC5E,CAAC;IAED,uEAAuE;IACvE,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,aAAa,EAAE,CAAC;QAC3C,IAAI,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC;YAC1B,MAAM,CAAC,aAAa,GAAG,QAAQ,CAAC,gBAAgB,CAAC,CAAC;YAClD,MAAM,CAAC,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;QAClE,CAAC;QACD,GAAG,GAAG,MAAM,CAAC,aAAa,CAAC;QAC3B,EAAE,GAAG,MAAM,CAAC,YAAa,CAAC;IAC5B,CAAC;SAAM,CAAC;QACN,GAAG,GAAG,QAAQ,CAAC,gBAAgB,CAAC,CAAC;QACjC,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;IAChC,CAAC;IAED,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU;IAC9B,IAAI,GAAG,EAAE,CAAC;QACR,MAAM,GAAG,CAAC,GAAG,EAAE,CAAC;QAChB,GAAG,GAAG,IAAI,CAAC;QACX,EAAE,GAAG,IAAI,CAAC;IACZ,CAAC;AACH,CAAC;AAED,mCAAmC;AACnC,OAAO,EAAE,MAAM,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,iBAAiB,EACjB,UAAU,EACV,cAAc,EACd,aAAa,EACb,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAgDpB;;GAEG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoD1E;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,IAAI,CAAC;IAAC,OAAO,CAAC,EAAE,IAAI,CAAA;CAAE,GAC7C,OAAO,CAAC,UAAU,CAAC,CA2CrB;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IACR,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GACA,OAAO,CAAC,cAAc,EAAE,CAAC,CAyB3B;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,aAAa,EAAE,CAAC,CA4B1B;AAED;;GAEG;AACH,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,gBAAgB,GACvB,OAAO,CAAC,IAAI,CAAC,CASf;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,IAAI,GACd,OAAO,CAAC;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,CAAC,CAwC3D"}
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,iBAAiB,EACjB,UAAU,EACV,cAAc,EACd,aAAa,EACb,gBAAgB,EACjB,MAAM,YAAY,CAAC;AA8CpB;;GAEG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD1E;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,IAAI,CAAC;IAAC,OAAO,CAAC,EAAE,IAAI,CAAA;CAAE,GAC7C,OAAO,CAAC,UAAU,CAAC,CA4CrB;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IACR,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GACA,OAAO,CAAC,cAAc,EAAE,CAAC,CA4B3B;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,aAAa,EAAE,CAAC,CA6B1B;AAED;;GAEG;AACH,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,gBAAgB,GACvB,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,IAAI,GACd,OAAO,CAAC;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,CAAC,CA0C3D"}
package/dist/error.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { getClient } from "./client.js";
2
+ import { analyticsErrors, errorGroups } from "./schema.js";
3
+ import { eq, and, gte, lte, lt, count, countDistinct, desc, inArray } from "drizzle-orm";
2
4
  import { createHash } from "crypto";
3
5
  /**
4
6
  * Create a fingerprint for grouping similar errors
@@ -16,14 +18,12 @@ function createFingerprint(message, stack) {
16
18
  let topFrame = "";
17
19
  if (stack) {
18
20
  const lines = stack.split("\n");
19
- // Find first line that looks like a stack frame (starts with "at " or similar)
20
21
  for (const line of lines.slice(1)) {
21
22
  const trimmed = line.trim();
22
23
  if (trimmed.startsWith("at ") || trimmed.match(/^\w+@/)) {
23
- // Normalize file paths and line numbers
24
24
  topFrame = trimmed
25
- .replace(/:\d+:\d+/g, ":L:C") // Line:column → L:C
26
- .replace(/\?.*/g, ""); // Remove query strings
25
+ .replace(/:\d+:\d+/g, ":L:C")
26
+ .replace(/\?.*/g, "");
27
27
  break;
28
28
  }
29
29
  }
@@ -52,89 +52,93 @@ export async function trackError(options) {
52
52
  const pathname = extractPathname(options.url);
53
53
  const now = new Date();
54
54
  // Create the error record
55
- await db.error.create({
56
- data: {
57
- siteId: options.siteId,
58
- message: options.message,
59
- stack: options.stack,
60
- fingerprint,
61
- url: options.url,
62
- pathname,
63
- visitorHash: options.visitorHash,
64
- sessionId: options.sessionId,
65
- userId: options.userId,
66
- browser: options.browser,
67
- browserVer: options.browserVer,
68
- os: options.os,
69
- device: options.device,
70
- metadata: options.metadata ?? undefined,
71
- },
55
+ await db.insert(analyticsErrors).values({
56
+ siteId: options.siteId,
57
+ message: options.message,
58
+ stack: options.stack ?? null,
59
+ fingerprint,
60
+ url: options.url,
61
+ pathname,
62
+ visitorHash: options.visitorHash ?? null,
63
+ sessionId: options.sessionId ?? null,
64
+ userId: options.userId ?? null,
65
+ browser: options.browser ?? null,
66
+ browserVer: options.browserVer ?? null,
67
+ os: options.os ?? null,
68
+ device: options.device ?? null,
69
+ metadata: options.metadata ?? null,
72
70
  });
73
- // Upsert the error group
74
- await db.errorGroup.upsert({
75
- where: {
76
- siteId_fingerprint: {
77
- siteId: options.siteId,
78
- fingerprint,
79
- },
80
- },
81
- create: {
71
+ // Check if error group exists
72
+ const [existingGroup] = await db
73
+ .select()
74
+ .from(errorGroups)
75
+ .where(and(eq(errorGroups.siteId, options.siteId), eq(errorGroups.fingerprint, fingerprint)))
76
+ .limit(1);
77
+ if (existingGroup) {
78
+ // Update existing group
79
+ await db
80
+ .update(errorGroups)
81
+ .set({
82
+ count: existingGroup.count + 1,
83
+ lastSeen: now,
84
+ ...(options.stack && { stack: options.stack }),
85
+ })
86
+ .where(eq(errorGroups.id, existingGroup.id));
87
+ }
88
+ else {
89
+ // Create new group
90
+ await db.insert(errorGroups).values({
82
91
  siteId: options.siteId,
83
92
  fingerprint,
84
93
  message: options.message,
85
- stack: options.stack,
94
+ stack: options.stack ?? null,
86
95
  count: 1,
87
96
  firstSeen: now,
88
97
  lastSeen: now,
89
98
  status: "open",
90
- },
91
- update: {
92
- count: { increment: 1 },
93
- lastSeen: now,
94
- // Update message/stack if this is a clearer example
95
- ...(options.stack && { stack: options.stack }),
96
- },
97
- });
99
+ });
100
+ }
98
101
  }
99
102
  /**
100
103
  * Get error statistics
101
104
  */
102
105
  export async function getErrorStats(siteId, options) {
103
106
  const db = getClient();
104
- const where = {
105
- siteId,
106
- ...(options?.startDate || options?.endDate
107
- ? {
108
- timestamp: {
109
- ...(options?.startDate && { gte: options.startDate }),
110
- ...(options?.endDate && { lte: options.endDate }),
111
- },
112
- }
113
- : {}),
114
- };
115
- const totalErrors = await db.error.count({ where });
116
- const uniqueResult = await db.error.groupBy({
117
- by: ["fingerprint"],
118
- where,
119
- });
120
- const uniqueErrors = uniqueResult.length;
107
+ // Build where conditions
108
+ const whereConditions = [eq(analyticsErrors.siteId, siteId)];
109
+ if (options?.startDate) {
110
+ whereConditions.push(gte(analyticsErrors.timestamp, options.startDate));
111
+ }
112
+ if (options?.endDate) {
113
+ whereConditions.push(lte(analyticsErrors.timestamp, options.endDate));
114
+ }
115
+ const where = and(...whereConditions);
116
+ // Total errors
117
+ const [totalResult] = await db
118
+ .select({ count: count() })
119
+ .from(analyticsErrors)
120
+ .where(where);
121
+ // Unique errors (by fingerprint)
122
+ const [uniqueResult] = await db
123
+ .select({ count: countDistinct(analyticsErrors.fingerprint) })
124
+ .from(analyticsErrors)
125
+ .where(where);
121
126
  // Errors in last 24 hours
122
127
  const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
123
- const errorsToday = await db.error.count({
124
- where: {
125
- siteId,
126
- timestamp: { gte: oneDayAgo },
127
- },
128
- });
128
+ const [todayResult] = await db
129
+ .select({ count: count() })
130
+ .from(analyticsErrors)
131
+ .where(and(eq(analyticsErrors.siteId, siteId), gte(analyticsErrors.timestamp, oneDayAgo)));
129
132
  // Open error groups
130
- const openGroups = await db.errorGroup.count({
131
- where: { siteId, status: "open" },
132
- });
133
+ const [openResult] = await db
134
+ .select({ count: count() })
135
+ .from(errorGroups)
136
+ .where(and(eq(errorGroups.siteId, siteId), eq(errorGroups.status, "open")));
133
137
  return {
134
- totalErrors,
135
- uniqueErrors,
136
- errorsToday,
137
- openGroups,
138
+ totalErrors: totalResult?.count ?? 0,
139
+ uniqueErrors: uniqueResult?.count ?? 0,
140
+ errorsToday: todayResult?.count ?? 0,
141
+ openGroups: openResult?.count ?? 0,
138
142
  };
139
143
  }
140
144
  /**
@@ -144,15 +148,17 @@ export async function getErrorGroups(siteId, options) {
144
148
  const db = getClient();
145
149
  const limit = options?.limit ?? 50;
146
150
  const offset = options?.offset ?? 0;
147
- const groups = await db.errorGroup.findMany({
148
- where: {
149
- siteId,
150
- ...(options?.status && { status: options.status }),
151
- },
152
- orderBy: { lastSeen: "desc" },
153
- take: limit,
154
- skip: offset,
155
- });
151
+ const whereConditions = [eq(errorGroups.siteId, siteId)];
152
+ if (options?.status) {
153
+ whereConditions.push(eq(errorGroups.status, options.status));
154
+ }
155
+ const groups = await db
156
+ .select()
157
+ .from(errorGroups)
158
+ .where(and(...whereConditions))
159
+ .orderBy(desc(errorGroups.lastSeen))
160
+ .limit(limit)
161
+ .offset(offset);
156
162
  return groups.map((g) => ({
157
163
  id: g.id,
158
164
  fingerprint: g.fingerprint,
@@ -171,12 +177,13 @@ export async function getErrorInstances(siteId, fingerprint, options) {
171
177
  const db = getClient();
172
178
  const limit = options?.limit ?? 50;
173
179
  const offset = options?.offset ?? 0;
174
- const errors = await db.error.findMany({
175
- where: { siteId, fingerprint },
176
- orderBy: { timestamp: "desc" },
177
- take: limit,
178
- skip: offset,
179
- });
180
+ const errors = await db
181
+ .select()
182
+ .from(analyticsErrors)
183
+ .where(and(eq(analyticsErrors.siteId, siteId), eq(analyticsErrors.fingerprint, fingerprint)))
184
+ .orderBy(desc(analyticsErrors.timestamp))
185
+ .limit(limit)
186
+ .offset(offset);
180
187
  return errors.map((e) => ({
181
188
  id: e.id,
182
189
  message: e.message,
@@ -199,12 +206,10 @@ export async function getErrorInstances(siteId, fingerprint, options) {
199
206
  */
200
207
  export async function updateErrorGroupStatus(siteId, fingerprint, status) {
201
208
  const db = getClient();
202
- await db.errorGroup.update({
203
- where: {
204
- siteId_fingerprint: { siteId, fingerprint },
205
- },
206
- data: { status },
207
- });
209
+ await db
210
+ .update(errorGroups)
211
+ .set({ status })
212
+ .where(and(eq(errorGroups.siteId, siteId), eq(errorGroups.fingerprint, fingerprint)));
208
213
  }
209
214
  /**
210
215
  * Delete errors older than a certain date
@@ -213,36 +218,37 @@ export async function updateErrorGroupStatus(siteId, fingerprint, status) {
213
218
  export async function deleteOldErrors(siteId, olderThan) {
214
219
  const db = getClient();
215
220
  // Delete old error instances
216
- const deletedErrors = await db.error.deleteMany({
217
- where: {
218
- siteId,
219
- timestamp: { lt: olderThan },
220
- },
221
- });
222
- // Delete error groups with no remaining errors
223
- // First, get fingerprints that still have errors
224
- const remainingFingerprints = await db.error.groupBy({
225
- by: ["fingerprint"],
226
- where: { siteId },
227
- });
221
+ const deletedErrors = await db
222
+ .delete(analyticsErrors)
223
+ .where(and(eq(analyticsErrors.siteId, siteId), lt(analyticsErrors.timestamp, olderThan)))
224
+ .returning({ id: analyticsErrors.id });
225
+ // Get fingerprints that still have errors
226
+ const remainingFingerprints = await db
227
+ .select({ fingerprint: analyticsErrors.fingerprint })
228
+ .from(analyticsErrors)
229
+ .where(eq(analyticsErrors.siteId, siteId))
230
+ .groupBy(analyticsErrors.fingerprint);
228
231
  const activeFingerprints = new Set(remainingFingerprints.map((r) => r.fingerprint));
229
- // Delete groups not in active fingerprints
230
- const allGroups = await db.errorGroup.findMany({
231
- where: { siteId },
232
- select: { fingerprint: true },
233
- });
234
- const groupsToDelete = allGroups
232
+ // Get all groups for this site
233
+ const allGroups = await db
234
+ .select({ id: errorGroups.id, fingerprint: errorGroups.fingerprint })
235
+ .from(errorGroups)
236
+ .where(eq(errorGroups.siteId, siteId));
237
+ // Find groups to delete
238
+ const groupIdsToDelete = allGroups
235
239
  .filter((g) => !activeFingerprints.has(g.fingerprint))
236
- .map((g) => g.fingerprint);
237
- const deletedGroups = await db.errorGroup.deleteMany({
238
- where: {
239
- siteId,
240
- fingerprint: { in: groupsToDelete },
241
- },
242
- });
240
+ .map((g) => g.id);
241
+ let deletedGroupCount = 0;
242
+ if (groupIdsToDelete.length > 0) {
243
+ const deletedGroups = await db
244
+ .delete(errorGroups)
245
+ .where(inArray(errorGroups.id, groupIdsToDelete))
246
+ .returning({ id: errorGroups.id });
247
+ deletedGroupCount = deletedGroups.length;
248
+ }
243
249
  return {
244
- deletedErrors: deletedErrors.count,
245
- deletedGroups: deletedGroups.count,
250
+ deletedErrors: deletedErrors.length,
251
+ deletedGroups: deletedGroupCount,
246
252
  };
247
253
  }
248
254
  //# sourceMappingURL=error.js.map