@bakery-framework/plugin-analytics 1.0.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/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Kyle Cyrus Santos Obille
2
+
3
+ The Software is provided subject to the standard MIT License, as detailed below, with the addition of the Commons Clause v1.0.
4
+
5
+ The Commons Clause v1.0
6
+
7
+ The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
8
+
9
+ Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software.
10
+
11
+ For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice.
12
+
13
+ Standard MIT License
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software (subject to the Commons Clause condition above), and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @bakery-framework/plugin-analytics
2
+
3
+ Request, route and error metrics for
4
+ [Bakery](https://github.com/obillekyle/bakery), with a live WebSocket feed.
5
+
6
+ ```bash
7
+ bun add @bakery-framework/plugin-analytics
8
+ ```
9
+
10
+ ## Usage
11
+
12
+ ```ts
13
+ // server.config.ts
14
+ import { defineConfig } from '@bakery-framework/core'
15
+ import analyticsPlugin from '@bakery-framework/plugin-analytics'
16
+
17
+ export default defineConfig({
18
+ root: 'src',
19
+ plugins: [analyticsPlugin()],
20
+ })
21
+ ```
22
+
23
+ Rolling histories are exported for reading directly — `history1m`, `history1h`,
24
+ `history1d`, `history7d`, `history30d` — alongside `pageHitsMap`,
25
+ `pageHitsLog` and the `recordRouteHit` / `recordDbHit` / `recordErrorPageHit`
26
+ counters.
27
+
28
+ The stats endpoint is guarded by `DASHPASS`. The safe state is the default:
29
+ with `DASHPASS` unset the guard **denies everyone**, so the endpoint is closed
30
+ until you deliberately open it. Setting `DASHPASS` is what enables access, and a
31
+ caller still needs the matching session flag.
32
+
33
+ ## License
34
+
35
+ MIT with the Commons Clause v1.0 — see [LICENSE](./LICENSE).
36
+
37
+ **Not an OSI-approved licence.** The Commons Clause removes the right to *sell*
38
+ the software — meaning to charge for a product or service whose value derives
39
+ substantially from it, hosting and support included. Everything else the MIT
40
+ licence grants is unchanged: use it, modify it, ship it inside your own product.
41
+ If your organisation only permits OSI-approved dependencies, this will not pass
42
+ that check.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@bakery-framework/plugin-analytics",
3
+ "version": "1.0.0",
4
+ "description": "Bakery analytics plugin.",
5
+ "keywords": [
6
+ "bakery",
7
+ "bun",
8
+ "analytics",
9
+ "metrics",
10
+ "plugin"
11
+ ],
12
+ "author": "obillekyle",
13
+ "license": "SEE LICENSE IN LICENSE",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/obillekyle/bakery.git",
17
+ "directory": "packages/plugins/analytics"
18
+ },
19
+ "homepage": "https://github.com/obillekyle/bakery#readme",
20
+ "bugs": "https://github.com/obillekyle/bakery/issues",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "type": "module",
25
+ "main": "./src/index.ts",
26
+ "exports": {
27
+ ".": "./src/index.ts",
28
+ "./stats": "./src/endpoints/stats.ts",
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "!src/**/*.test.ts",
34
+ "!src/tests"
35
+ ],
36
+ "dependencies": {
37
+ "@bakery-framework/core": "^1.0.0"
38
+ },
39
+ "engines": {
40
+ "bun": ">=1.3.14"
41
+ }
42
+ }
package/src/core.ts ADDED
@@ -0,0 +1,377 @@
1
+ import type { AnalyticsSnapshot } from './types'
2
+
3
+ export const RETENTION_MS = 30 * 24 * 3600 * 1000
4
+ export const BOOT_MAX_ITEMS = 5000
5
+ export const HARD_CAP = 50_000
6
+
7
+ export const history1m: AnalyticsSnapshot[] = []
8
+ export const history1h: AnalyticsSnapshot[] = []
9
+ export const history1d: AnalyticsSnapshot[] = []
10
+ export const history7d: AnalyticsSnapshot[] = []
11
+ export const history30d: AnalyticsSnapshot[] = []
12
+
13
+ export const pageHitsLog: { timestamp: number; path: string }[] = []
14
+ export const pageHitsMap = new Map<string, number>()
15
+
16
+ /**
17
+ * Moved to `@server/logger` — LiveReloadHandler (core) owns membership, so
18
+ * the registry cannot live in a plugin. Re-exported because this plugin's
19
+ * public surface and internals read it (the `activeLoggers` gauge).
20
+ */
21
+ export { connectedLoggers } from '@bakery-framework/core/logger'
22
+
23
+ type TempAccumulator = {
24
+ count: number
25
+ memoryUsed: number
26
+ activeLoggers: number
27
+ activeSessions: number
28
+ routeHits: number
29
+ apiHits: number
30
+ pageHits: number
31
+ uniqueRequests: number
32
+ dbHits: number
33
+ errorPageHits: number
34
+ ping: number
35
+ }
36
+
37
+ function createAccumulator(): TempAccumulator {
38
+ return {
39
+ count: 0,
40
+ memoryUsed: 0,
41
+ activeLoggers: 0,
42
+ activeSessions: 0,
43
+ routeHits: 0,
44
+ apiHits: 0,
45
+ pageHits: 0,
46
+ uniqueRequests: 0,
47
+ dbHits: 0,
48
+ errorPageHits: 0,
49
+ ping: 0,
50
+ }
51
+ }
52
+
53
+ const temp1h = createAccumulator()
54
+ const temp1d = createAccumulator()
55
+ const temp7d = createAccumulator()
56
+ const temp30d = createAccumulator()
57
+
58
+ let routeHitsThisSecond = 0
59
+ let apiHitsThisSecond = 0
60
+ let pageHitsThisSecond = 0
61
+ const uniqueRequestsThisSecond = new Set<string>()
62
+ let dbHitsThisSecond = 0
63
+ let errorPageHitsThisSecond = 0
64
+
65
+ /**
66
+ * Drop the oldest `count` entries from the log and take their paths back out
67
+ * of the per-path tally.
68
+ *
69
+ * `pageHitsMap` is a count per path, so an entry leaving the log has to
70
+ * decrement it — and a count that reaches zero is deleted rather than left at
71
+ * 0, which is what keeps the map from growing one dead path at a time. Both
72
+ * pruning rules below (the retention window and the hard cap) evict from the
73
+ * front, so both need exactly this.
74
+ */
75
+ function dropOldestHits(count: number) {
76
+ if (count <= 0) return
77
+ for (let j = 0; j < count; j++) {
78
+ const p = pageHitsLog[j].path
79
+ const c = pageHitsMap.get(p)
80
+ if (c === 1) pageHitsMap.delete(p)
81
+ else if (c) pageHitsMap.set(p, c - 1)
82
+ }
83
+ pageHitsLog.splice(0, count)
84
+ }
85
+
86
+ function prunePageHitsLog(now: number) {
87
+ let i = 0
88
+ while (
89
+ i < pageHitsLog.length &&
90
+ pageHitsLog[i].timestamp < now - RETENTION_MS
91
+ )
92
+ i++
93
+ dropOldestHits(i)
94
+
95
+ if (pageHitsLog.length > HARD_CAP) {
96
+ dropOldestHits(pageHitsLog.length - HARD_CAP)
97
+ }
98
+ }
99
+
100
+ let _pageHitsLogPruneTimer: ReturnType<typeof setInterval> | null = null
101
+ export function ensurePageHitsLogPruner() {
102
+ if (_pageHitsLogPruneTimer !== null) return
103
+ _pageHitsLogPruneTimer = setInterval(() => {
104
+ try {
105
+ prunePageHitsLog(Date.now())
106
+ } catch (_e) {
107
+ // swallow errors; pruner is best-effort
108
+ }
109
+ }, 60_000)
110
+ }
111
+
112
+ export function stopPageHitsLogPruner() {
113
+ if (_pageHitsLogPruneTimer !== null) {
114
+ clearInterval(_pageHitsLogPruneTimer)
115
+ _pageHitsLogPruneTimer = null
116
+ }
117
+ }
118
+
119
+ function accumulate(temp: TempAccumulator, s: AnalyticsSnapshot) {
120
+ temp.count++
121
+ temp.memoryUsed += s.memoryUsed || 0
122
+ temp.activeLoggers += s.activeLoggers || 0
123
+ temp.activeSessions += s.activeSessions || 0
124
+ temp.routeHits += s.routeHits || 0
125
+ temp.apiHits += s.apiHits || 0
126
+ temp.pageHits += s.pageHits || 0
127
+ temp.uniqueRequests += s.uniqueRequests || 0
128
+ temp.dbHits += s.dbHits || 0
129
+ temp.errorPageHits += s.errorPageHits || 0
130
+ temp.ping += s.ping || 0
131
+ }
132
+
133
+ function finalizeAggregation(
134
+ temp: TempAccumulator,
135
+ timestamp: number,
136
+ ): AnalyticsSnapshot {
137
+ const count = temp.count || 1
138
+ const result: AnalyticsSnapshot = {
139
+ timestamp,
140
+ memoryUsed: Math.round(temp.memoryUsed / count),
141
+ activeLoggers: Math.round(temp.activeLoggers / count),
142
+ activeSessions: Math.round(temp.activeSessions / count),
143
+ routeHits: temp.routeHits,
144
+ apiHits: temp.apiHits,
145
+ pageHits: temp.pageHits,
146
+ uniqueRequests: temp.uniqueRequests,
147
+ dbHits: temp.dbHits,
148
+ errorPageHits: temp.errorPageHits,
149
+ ping: Math.round(temp.ping / count),
150
+ }
151
+ Object.assign(temp, createAccumulator())
152
+ return result
153
+ }
154
+
155
+ function loadAccumulator(target: TempAccumulator, loaded: any) {
156
+ if (!loaded) return
157
+ if (Array.isArray(loaded)) {
158
+ Object.assign(target, createAccumulator())
159
+ target.count = loaded.length
160
+ for (const s of loaded) {
161
+ target.memoryUsed += s.memoryUsed || 0
162
+ target.activeLoggers += s.activeLoggers || 0
163
+ target.activeSessions += s.activeSessions || 0
164
+ target.routeHits += s.routeHits || 0
165
+ target.apiHits += s.apiHits || 0
166
+ target.pageHits += s.pageHits || 0
167
+ target.uniqueRequests += s.uniqueRequests || 0
168
+ target.dbHits += s.dbHits || 0
169
+ target.errorPageHits += s.errorPageHits || 0
170
+ target.ping += s.ping || 0
171
+ }
172
+ } else if (typeof loaded === 'object') {
173
+ Object.assign(target, loaded)
174
+ }
175
+ }
176
+
177
+ export function isAssetPath(path: string): boolean {
178
+ if (!path || typeof path !== 'string') return true
179
+ if (path.startsWith('/_')) return true
180
+ return /\.(css|js|mjs|cjs|ts|tsx|jsx|vue|json|map|png|jpg|jpeg|webp|gif|svg|ico|bmp|woff|woff2|ttf|eot|txt|xml|webmanifest)$/i.test(
181
+ path,
182
+ )
183
+ }
184
+
185
+ export function recordRouteHit(method: string, path: string, search = '') {
186
+ routeHitsThisSecond += 1
187
+ if (path.startsWith('/api/')) {
188
+ apiHitsThisSecond += 1
189
+ } else if (!isAssetPath(path)) {
190
+ pageHitsThisSecond += 1
191
+ pageHitsLog.push({ timestamp: Date.now(), path })
192
+ ensurePageHitsLogPruner()
193
+ pageHitsMap.set(path, (pageHitsMap.get(path) || 0) + 1)
194
+ }
195
+ uniqueRequestsThisSecond.add(`${method} ${path}${search}`)
196
+ }
197
+
198
+ export function recordDbHit() {
199
+ dbHitsThisSecond += 1
200
+ }
201
+
202
+ export function recordErrorPageHit() {
203
+ errorPageHitsThisSecond += 1
204
+ }
205
+
206
+ export function pushAnalyticsSnapshot(snapshot: {
207
+ timestamp: number
208
+ memoryUsed: number
209
+ activeLoggers: number
210
+ activeSessions: number
211
+ ping: number
212
+ }) {
213
+ const fullSnapshot: AnalyticsSnapshot = {
214
+ ...snapshot,
215
+ routeHits: routeHitsThisSecond,
216
+ apiHits: apiHitsThisSecond,
217
+ pageHits: pageHitsThisSecond,
218
+ uniqueRequests: uniqueRequestsThisSecond.size,
219
+ dbHits: dbHitsThisSecond,
220
+ errorPageHits: errorPageHitsThisSecond,
221
+ }
222
+
223
+ history1m.push(fullSnapshot)
224
+ if (history1m.length > 60) history1m.shift()
225
+
226
+ accumulate(temp1h, fullSnapshot)
227
+ accumulate(temp1d, fullSnapshot)
228
+ accumulate(temp7d, fullSnapshot)
229
+ accumulate(temp30d, fullSnapshot)
230
+
231
+ if (temp1h.count >= 60) {
232
+ history1h.push(finalizeAggregation(temp1h, fullSnapshot.timestamp))
233
+ if (history1h.length > 60) history1h.shift()
234
+ }
235
+ if (temp1d.count >= 1800) {
236
+ history1d.push(finalizeAggregation(temp1d, fullSnapshot.timestamp))
237
+ if (history1d.length > 48) history1d.shift()
238
+ }
239
+ if (temp7d.count >= 21600) {
240
+ history7d.push(finalizeAggregation(temp7d, fullSnapshot.timestamp))
241
+ if (history7d.length > 28) history7d.shift()
242
+ }
243
+ if (temp30d.count >= 86400) {
244
+ history30d.push(finalizeAggregation(temp30d, fullSnapshot.timestamp))
245
+ if (history30d.length > 30) history30d.shift()
246
+ }
247
+
248
+ routeHitsThisSecond = 0
249
+ apiHitsThisSecond = 0
250
+ pageHitsThisSecond = 0
251
+ uniqueRequestsThisSecond.clear()
252
+ dbHitsThisSecond = 0
253
+ errorPageHitsThisSecond = 0
254
+ }
255
+
256
+ export function getLatestAnalyticsSnapshot() {
257
+ return (
258
+ history1m[history1m.length - 1] || {
259
+ routeHits: 0,
260
+ apiHits: 0,
261
+ pageHits: 0,
262
+ uniqueRequests: 0,
263
+ dbHits: 0,
264
+ errorPageHits: 0,
265
+ ping: 0,
266
+ }
267
+ )
268
+ }
269
+
270
+ export function getHistoryLimitForTimescale(timescale: string): number {
271
+ switch (timescale) {
272
+ case '30d':
273
+ return 30
274
+ case '7d':
275
+ return 28
276
+ case '1d':
277
+ return 48
278
+ case '1h':
279
+ return 60
280
+ default:
281
+ return 60
282
+ }
283
+ }
284
+
285
+ export function getHistoryForTimescale(timescale: string): AnalyticsSnapshot[] {
286
+ switch (timescale) {
287
+ case '30d':
288
+ return history30d
289
+ case '7d':
290
+ return history7d
291
+ case '1d':
292
+ return history1d
293
+ case '1h':
294
+ return history1h
295
+ default:
296
+ return history1m
297
+ }
298
+ }
299
+
300
+ export function getLatestHistoryPoint(
301
+ timescale: string,
302
+ ): AnalyticsSnapshot | null {
303
+ const history = getHistoryForTimescale(timescale)
304
+ return history[history.length - 1] || null
305
+ }
306
+
307
+ export function getFilledHistoryForTimescale(
308
+ timescale: string,
309
+ ): AnalyticsSnapshot[] {
310
+ const raw = getHistoryForTimescale(timescale)
311
+ if (raw.length <= 1) return [...raw]
312
+
313
+ let interval = 1000
314
+ switch (timescale) {
315
+ case '30d':
316
+ interval = 86400000
317
+ break
318
+ case '7d':
319
+ interval = 21600000
320
+ break
321
+ case '1d':
322
+ interval = 1800000
323
+ break
324
+ case '1h':
325
+ interval = 60000
326
+ break
327
+ default:
328
+ interval = 1000
329
+ break
330
+ }
331
+
332
+ const limit = getHistoryLimitForTimescale(timescale)
333
+ const filled: AnalyticsSnapshot[] = []
334
+ filled.push({ ...raw[0] })
335
+
336
+ for (let i = 1; i < raw.length; i++) {
337
+ const prev = raw[i - 1]
338
+ const curr = raw[i]
339
+ const diff = curr.timestamp - prev.timestamp
340
+
341
+ if (diff > interval * 1.5) {
342
+ const startT = Math.max(
343
+ prev.timestamp + interval,
344
+ curr.timestamp - limit * interval,
345
+ )
346
+ let t = startT
347
+ while (t < curr.timestamp - interval * 0.5) {
348
+ filled.push({
349
+ timestamp: t,
350
+ memoryUsed: null,
351
+ activeLoggers: null,
352
+ activeSessions: null,
353
+ routeHits: null,
354
+ apiHits: null,
355
+ pageHits: null,
356
+ uniqueRequests: null,
357
+ dbHits: null,
358
+ errorPageHits: null,
359
+ ping: null,
360
+ })
361
+ t += interval
362
+ }
363
+ }
364
+ filled.push({ ...curr })
365
+ }
366
+
367
+ if (filled.length > limit) return filled.slice(-limit)
368
+ return filled
369
+ }
370
+
371
+ export function loadTemps(loaded: any) {
372
+ if (!loaded) return
373
+ if (loaded.temp1h) loadAccumulator(temp1h, loaded.temp1h)
374
+ if (loaded.temp1d) loadAccumulator(temp1d, loaded.temp1d)
375
+ if (loaded.temp7d) loadAccumulator(temp7d, loaded.temp7d)
376
+ if (loaded.temp30d) loadAccumulator(temp30d, loaded.temp30d)
377
+ }
@@ -0,0 +1,136 @@
1
+ import { Bakery } from '@bakery-framework/core/core/bakery'
2
+ import { DASHPASS_SESSION_KEY, Session } from '@bakery-framework/core/session'
3
+ import type { JsonResponseData } from '@bakery-framework/core/utils/common'
4
+ import { response } from '@bakery-framework/core/utils/http'
5
+ import * as core from '../core'
6
+ import { saveAnalyticsData } from '../storage-sqlite'
7
+ import { timescaleToMs } from '../timescale'
8
+
9
+ function getFilterCutoff(pagesFilter: string): number {
10
+ if (pagesFilter === 'all') return 0
11
+ return Date.now() - (timescaleToMs(pagesFilter) || 0)
12
+ }
13
+
14
+ function buildAggregatedHits(filterCutoff: number): Map<string, number> {
15
+ const aggregated = new Map<string, number>()
16
+ const hasCompleteWindow =
17
+ core.pageHitsLog.length > 0 && core.pageHitsLog[0].timestamp <= filterCutoff
18
+
19
+ if (filterCutoff > 0 && hasCompleteWindow) {
20
+ for (let i = core.pageHitsLog.length - 1; i >= 0; i--) {
21
+ const hit = core.pageHitsLog[i]
22
+ if (hit.timestamp < filterCutoff) break
23
+ aggregated.set(hit.path, (aggregated.get(hit.path) || 0) + 1)
24
+ }
25
+ return aggregated
26
+ }
27
+
28
+ for (const [k, v] of core.pageHitsMap.entries()) aggregated.set(k, v)
29
+ return aggregated
30
+ }
31
+
32
+ export function computeStats(
33
+ timescale: string,
34
+ excludeHistory: boolean,
35
+ pagesFilter: string,
36
+ ) {
37
+ const mem = process.memoryUsage()
38
+ const uptime = Math.round(process.uptime())
39
+ const latestHistory = core.getLatestAnalyticsSnapshot()
40
+
41
+ const filterCutoff = getFilterCutoff(pagesFilter)
42
+ const aggregated = buildAggregatedHits(filterCutoff)
43
+
44
+ const topPagesFiltered = Array.from(aggregated.entries())
45
+ .filter(([page]) => !core.isAssetPath(page))
46
+ .map(([page, hits]) => ({ page, hits }))
47
+ .sort((a, b) => b.hits - a.hits)
48
+ .slice(0, 10)
49
+
50
+ return {
51
+ uptime: `${uptime}s`,
52
+ uptimeSeconds: uptime,
53
+ pid: process.pid,
54
+ memoryUsed: `${Math.round(mem.rss / 1024 / 1024)} MB`,
55
+ memoryExternal: `${Math.round(mem.external / 1024 / 1024)} MB`,
56
+ bunVersion: Bun.version,
57
+ platform: process.platform,
58
+ arch: process.arch,
59
+ activeLoggers: core.connectedLoggers.size,
60
+ activeSessions: Session.count,
61
+ routeHits: latestHistory.routeHits,
62
+ apiHits: latestHistory.apiHits || 0,
63
+ pageHits: latestHistory.pageHits || 0,
64
+ uniqueRequests: latestHistory.uniqueRequests,
65
+ dbHits: latestHistory.dbHits,
66
+ errorPageHits: latestHistory.errorPageHits,
67
+ ping: latestHistory.ping,
68
+ topPages: topPagesFiltered,
69
+ history: excludeHistory
70
+ ? undefined
71
+ : core.getFilledHistoryForTimescale(timescale),
72
+ latestHistoryPoint: core.getLatestHistoryPoint(timescale),
73
+ }
74
+ }
75
+
76
+ /** The payload `/api/_analytics/stats` puts in the envelope's `data`. */
77
+ export type AnalyticsStats = ReturnType<typeof computeStats>
78
+
79
+ /**
80
+ * True when the caller may read analytics. Fails **closed** when DASHPASS is
81
+ * unset, matching the dashboard's behaviour — this previously returned
82
+ * "authorized" in that case, so the documented "disabled" posture actually
83
+ * exposed process stats and top pages to anyone.
84
+ */
85
+ export function isAnalyticsAuthorized(req: Request): boolean {
86
+ if (!process.env.DASHPASS) return false
87
+ return Boolean(req.session?.get(DASHPASS_SESSION_KEY))
88
+ }
89
+
90
+ /**
91
+ * The rejection envelope, or `null` when the caller may proceed — a guard
92
+ * returns the rejection rather than throwing (convention 2). It is a
93
+ * `JsonResponseData` and never a `Response`; the router serialises it through
94
+ * the one JSON envelope in `processResponse`. The explicit `<undefined>` says
95
+ * the envelope carries no `data`, which is what makes it assignable into every
96
+ * caller's own payload type.
97
+ */
98
+ function checkDashpassAuth(req: Request): JsonResponseData<undefined> | null {
99
+ if (isAnalyticsAuthorized(req)) return null
100
+ return process.env.DASHPASS
101
+ ? response.json.error<undefined>(401, 'Unauthorized')
102
+ : response.json.error<undefined>(404, 'Not Found')
103
+ }
104
+
105
+ export async function handleResetRequest(
106
+ req: Request,
107
+ ): Promise<JsonResponseData<undefined>> {
108
+ const authError = checkDashpassAuth(req)
109
+ if (authError) return authError
110
+
111
+ core.history1m.length = 0
112
+ core.history1h.length = 0
113
+ core.history1d.length = 0
114
+ core.history7d.length = 0
115
+ core.history30d.length = 0
116
+ core.pageHitsMap.clear()
117
+ core.pageHitsLog.length = 0
118
+
119
+ await saveAnalyticsData(Bakery.cacheDir)
120
+ return response.json.success<undefined>('Analytics data reset successfully')
121
+ }
122
+
123
+ export async function handleStatsRequest(
124
+ req: Request,
125
+ url: URL,
126
+ ): Promise<JsonResponseData<AnalyticsStats | undefined>> {
127
+ const authError = checkDashpassAuth(req)
128
+ if (authError) return authError
129
+
130
+ const timescale = url.searchParams.get('timescale') || '1m'
131
+ const excludeHistory = url.searchParams.get('excludeHistory') === 'true'
132
+ const pagesFilter = url.searchParams.get('pagesFilter') || 'all'
133
+
134
+ const stats = computeStats(timescale, excludeHistory, pagesFilter)
135
+ return response.json.success('success', stats)
136
+ }
@@ -0,0 +1,60 @@
1
+ import { WebSocketHandler } from '@bakery-framework/core/handlers'
2
+ import type { ServerWebSocket } from 'bun'
3
+ import { computeStats, isAnalyticsAuthorized } from './stats'
4
+
5
+ export const connectedAnalyticsClients = new Set<any>()
6
+
7
+ export class AnalyticsWSHandler extends WebSocketHandler {
8
+ // The upgrade is dispatched before any plugin hook runs, so the auth check
9
+ // has to live here. Without it this socket served the same payload the HTTP
10
+ // stats endpoint guards — and pushed it live every second.
11
+ static canHandle(path: string, req?: Request): boolean {
12
+ if (path !== '/_analytics_ws') return false
13
+ return req ? isAnalyticsAuthorized(req) : false
14
+ }
15
+
16
+ static open(ws: ServerWebSocket<any>, _data: any) {
17
+ connectedAnalyticsClients.add(ws)
18
+ }
19
+
20
+ static upgrade() {
21
+ return {
22
+ timescale: '1m',
23
+ excludeHistory: true,
24
+ pagesFilter: '1d',
25
+ }
26
+ }
27
+
28
+ static async message(ws: ServerWebSocket<any>, message: any, data: any) {
29
+ try {
30
+ const msg = JSON.parse(String(message))
31
+ if (msg.type === 'subscribe') {
32
+ data.timescale = msg.timescale || '1m'
33
+ data.excludeHistory = !!msg.excludeHistory
34
+ data.pagesFilter = msg.pagesFilter || '1d'
35
+
36
+ const stats = computeStats(
37
+ data.timescale,
38
+ data.excludeHistory,
39
+ data.pagesFilter,
40
+ )
41
+
42
+ ws.send(
43
+ JSON.stringify({
44
+ status: 200,
45
+ excludeHistory: data.excludeHistory,
46
+ data: stats,
47
+ }),
48
+ )
49
+ }
50
+ } catch {
51
+ // The frame came from a client. Malformed JSON, or a subscribe naming a
52
+ // timescale that computes to nothing, must not throw out of the socket
53
+ // handler and take the connection down with it.
54
+ }
55
+ }
56
+
57
+ static close(ws: any) {
58
+ connectedAnalyticsClients.delete(ws)
59
+ }
60
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { definePlugin } from '@bakery-framework/core/plugins'
2
+ import { recordErrorPageHit, recordRouteHit } from './core'
3
+
4
+ export {
5
+ connectedLoggers,
6
+ history1d,
7
+ history1h,
8
+ history1m,
9
+ history7d,
10
+ history30d,
11
+ pageHitsLog,
12
+ pageHitsMap,
13
+ recordDbHit,
14
+ recordErrorPageHit,
15
+ recordRouteHit,
16
+ } from './core'
17
+
18
+ export default function analyticsPlugin() {
19
+ return definePlugin({
20
+ name: 'analytics',
21
+ async setup() {
22
+ const { setupAnalytics } = await import('./setup')
23
+ setupAnalytics()
24
+ },
25
+ onRoute(req) {
26
+ const url: URL = (req as any).__parsedUrl || new URL(req.url)
27
+ recordRouteHit(req.method, url.pathname, url.search)
28
+ },
29
+ onStart: async server => {
30
+ const { startAnalyticsLoop } = await import('./setup')
31
+ startAnalyticsLoop(server)
32
+ },
33
+
34
+ onError: recordErrorPageHit,
35
+ })
36
+ }
package/src/log.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { Logger, messageLogger } from '@bakery-framework/core/logger'
2
+
3
+ /**
4
+ * The plugin's own declared message table (convention 4).
5
+ *
6
+ * Declared here rather than added to core's `pluginLog`, on the same reasoning
7
+ * the ORM uses for `sync/engine.ts` and `backup.ts`: a message belongs to the
8
+ * package that emits it. `ANALYTICS_STORE_ERR` still lives in core's table —
9
+ * moving it is a change to `@bakery-framework/core`, not to this plugin.
10
+ */
11
+ const analyticsMsgs = {
12
+ /**
13
+ * The flush that used to fail silently. Not a `trace`: on the shutdown path
14
+ * this is the last flush there will ever be, so it is a lost write, not a
15
+ * skipped retry.
16
+ */
17
+ SAVE_ERR: 'E Analytics flush failed: %r{error}%*',
18
+ LOOP_ERR: 'W Analytics loop tick failed: %r{error}%*',
19
+ } as const
20
+
21
+ export const analyticsLog = messageLogger(
22
+ new Logger('analytics'),
23
+ analyticsMsgs,
24
+ )
package/src/loop.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { Bakery } from '@bakery-framework/core/core/bakery'
2
+ import { errorMsg, getElapsed } from '@bakery-framework/core/logger'
3
+ import { Session } from '@bakery-framework/core/session'
4
+ import { Try } from '@bakery-framework/core/utils/common'
5
+ import * as core from './core'
6
+ import { computeStats } from './endpoints/stats'
7
+ import { connectedAnalyticsClients } from './endpoints/websocket'
8
+ import { analyticsLog } from './log'
9
+ import { saveAnalyticsData } from './storage-sqlite'
10
+
11
+ const SAVE_THROTTLE_MS = 60000
12
+
13
+ /** One sample per second is what every `history1m` window assumes. */
14
+ export const ANALYTICS_TICK_MS = 1000
15
+
16
+ let lastSaveTime = 0
17
+
18
+ /**
19
+ * Flush at most once per `SAVE_THROTTLE_MS`.
20
+ *
21
+ * The timestamp is stamped when the write *settles*, not when it is
22
+ * dispatched. Stamping first meant a flush that took longer than the window
23
+ * let the next one start on top of it — two writers over the same tables,
24
+ * from a function whose whole job is to be harmless.
25
+ *
26
+ * `finally` rather than the success path: a failed flush that reset nothing
27
+ * would be retried on every following tick, turning one failure into a
28
+ * once-a-second hammer on a database that is already unhappy.
29
+ */
30
+ async function throttleSave() {
31
+ if (Date.now() - lastSaveTime < SAVE_THROTTLE_MS) return
32
+ try {
33
+ await saveAnalyticsData(Bakery.cacheDir)
34
+ } finally {
35
+ lastSaveTime = Date.now()
36
+ }
37
+ }
38
+
39
+ async function runAnalyticsTick(server: any) {
40
+ const activeLoggersCount = core.connectedLoggers.size
41
+ const pingStart = Bun.nanoseconds()
42
+ const pingVal = await Try.return(async function getPing() {
43
+ const res = await server.fetch(`http://localhost/_analytics/ping`)
44
+ return res.status === 200 ? getElapsed(pingStart) : res.status
45
+ }, 0)
46
+
47
+ const mem = process.memoryUsage()
48
+ core.pushAnalyticsSnapshot({
49
+ timestamp: Date.now(),
50
+ memoryUsed: Math.round(mem.rss / 1024 / 1024),
51
+ activeLoggers: activeLoggersCount,
52
+ activeSessions: Session.count,
53
+ ping: pingVal,
54
+ })
55
+
56
+ // Awaited, not floated: a rejected flush used to escape this tick entirely
57
+ // and land nowhere.
58
+ await throttleSave()
59
+
60
+ for (const ws of connectedAnalyticsClients) {
61
+ const opts = ws.data?.data
62
+ if (opts) {
63
+ const stats = computeStats(opts.timescale, true, opts.pagesFilter)
64
+ ws.send(
65
+ JSON.stringify({ status: 200, excludeHistory: true, data: stats }),
66
+ )
67
+ }
68
+ }
69
+ }
70
+
71
+ export let analyticsLoopTimer: ReturnType<typeof setTimeout> | null = null
72
+
73
+ /** False between `stopAnalyticsLoop()` and the next start; gates the re-arm. */
74
+ let loopRunning = false
75
+
76
+ /**
77
+ * Sample the server once every `intervalMs`.
78
+ *
79
+ * A self-rescheduling `setTimeout`, not `setInterval`. The tick body makes a
80
+ * full round trip through the server — `MiddlewareHandler`, so `onRequest` and
81
+ * every app middleware — plus a `Session.count`, which is a SQLite `COUNT(*)`.
82
+ * `setInterval` does not wait for an async body, so once p50 exceeded one
83
+ * second the ticks overlapped and the in-flight pings accumulated, each adding
84
+ * load that lengthened the next: positive feedback, from the telemetry.
85
+ *
86
+ * The overlap was not only a load problem. `pushAnalyticsSnapshot` zeroes the
87
+ * per-second counters at the end of a tick, so a re-entrant call double-advanced
88
+ * the hourly accumulator and emptied the counters belonging to the tick still
89
+ * in flight — drifting the 60-snapshot aggregation boundary off a real hour.
90
+ * Re-arming in `finally` makes the overlap structurally impossible rather than
91
+ * merely unlikely, so neither failure can come back.
92
+ *
93
+ * `intervalMs` is a parameter so a test can drive the scheduler in
94
+ * milliseconds instead of minutes; nothing in the framework passes it.
95
+ */
96
+ export function startAnalyticsLoop(
97
+ server: any,
98
+ intervalMs = ANALYTICS_TICK_MS,
99
+ ) {
100
+ stopAnalyticsLoop()
101
+ loopRunning = true
102
+
103
+ // One throttle window from now, not from the epoch. `lastSaveTime` starting
104
+ // at 0 made the very first tick flush a store that had accumulated exactly
105
+ // one sample, and put a SQLite write on the boot path.
106
+ lastSaveTime = Date.now()
107
+
108
+ const tick = async () => {
109
+ try {
110
+ await runAnalyticsTick(server)
111
+ } catch (e) {
112
+ analyticsLog.LOOP_ERR({ error: errorMsg(e) })
113
+ } finally {
114
+ // The re-arm lives here and only here: the next tick is scheduled once
115
+ // this one has fully settled, so two can never be in flight at once.
116
+ // A tick that was stopped mid-flight must not resurrect the loop.
117
+ if (loopRunning) analyticsLoopTimer = setTimeout(tick, intervalMs)
118
+ }
119
+ }
120
+
121
+ analyticsLoopTimer = setTimeout(tick, intervalMs)
122
+ }
123
+
124
+ export function stopAnalyticsLoop() {
125
+ loopRunning = false
126
+ if (analyticsLoopTimer) {
127
+ clearTimeout(analyticsLoopTimer)
128
+ analyticsLoopTimer = null
129
+ }
130
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { Bakery } from '@bakery-framework/core/core/bakery'
2
+ import { Handler } from '@bakery-framework/core/handlers'
3
+ import {
4
+ type PluginRouteTable,
5
+ routeTable,
6
+ } from '@bakery-framework/core/plugins'
7
+ import { FileSystem as fs } from '@bakery-framework/core/utils'
8
+ import type { JsonResponseData } from '@bakery-framework/core/utils/common'
9
+ import { response } from '@bakery-framework/core/utils/http'
10
+ import * as core from './core'
11
+ import { BOOT_MAX_ITEMS } from './core'
12
+ import type { AnalyticsStats } from './endpoints/stats'
13
+ import { handleResetRequest, handleStatsRequest } from './endpoints/stats'
14
+ import { AnalyticsWSHandler } from './endpoints/websocket'
15
+ import * as storageSqlite from './storage-sqlite'
16
+
17
+ const PAGE_HITS_BOOT_WINDOW_MS = 24 * 3600 * 1000
18
+
19
+ export const pageHitsLog = core.pageHitsLog
20
+ export const pageHitsMap = core.pageHitsMap
21
+ export const history1m = core.history1m
22
+ export const history1h = core.history1h
23
+ export const history1d = core.history1d
24
+ export const history7d = core.history7d
25
+ export const history30d = core.history30d
26
+
27
+ export const recordRouteHit = core.recordRouteHit
28
+ export const recordDbHit = core.recordDbHit
29
+ export const recordErrorPageHit = core.recordErrorPageHit
30
+ export const pushAnalyticsSnapshot = core.pushAnalyticsSnapshot
31
+ export const getLatestAnalyticsSnapshot = core.getLatestAnalyticsSnapshot
32
+ export const getHistoryLimitForTimescale = core.getHistoryLimitForTimescale
33
+ export const getFilledHistoryForTimescale = core.getFilledHistoryForTimescale
34
+ export const getHistoryForTimescale = core.getHistoryForTimescale
35
+ export const getLatestHistoryPoint = core.getLatestHistoryPoint
36
+
37
+ async function saveAnalyticsData() {
38
+ const cacheBase = Bakery.cacheDir
39
+ await storageSqlite.saveAnalyticsData(cacheBase)
40
+ }
41
+
42
+ function syncHistoryArrays(data: any) {
43
+ const syncArr = (source: any[], target: any[]) => {
44
+ if (source) {
45
+ target.length = 0
46
+ target.push(...source)
47
+ }
48
+ }
49
+ syncArr(data.history1m, core.history1m)
50
+ syncArr(data.history1h, core.history1h)
51
+ syncArr(data.history1d, core.history1d)
52
+ syncArr(data.history7d, core.history7d)
53
+ syncArr(data.history30d, core.history30d)
54
+ }
55
+
56
+ function processRawPageHits(pageHitsRaw: any) {
57
+ if (!Array.isArray(pageHitsRaw) || pageHitsRaw.length === 0) return
58
+
59
+ const minTs = Date.now() - PAGE_HITS_BOOT_WINDOW_MS
60
+ const list = pageHitsRaw
61
+ .map((e: any) => ({
62
+ timestamp: Number(e?.timestamp) || 0,
63
+ path: e?.path,
64
+ }))
65
+ .filter(
66
+ (e: any) =>
67
+ Number.isFinite(e.timestamp) &&
68
+ typeof e.path === 'string' &&
69
+ e.path.length > 0 &&
70
+ !core.isAssetPath(e.path) &&
71
+ e.timestamp >= minTs,
72
+ )
73
+ .slice(-BOOT_MAX_ITEMS)
74
+
75
+ pageHitsLog.length = 0
76
+ pageHitsLog.push(...list)
77
+ }
78
+
79
+ async function loadAnalyticsData() {
80
+ const cacheBase = Bakery.cacheDir
81
+ const { coreData, pageHitsRaw } =
82
+ await storageSqlite.loadAnalyticsData(cacheBase)
83
+
84
+ const data = coreData || {}
85
+ if (!coreData && !pageHitsRaw) return
86
+
87
+ syncHistoryArrays(data)
88
+
89
+ if (data.temp1h || data.temp1d || data.temp7d || data.temp30d) {
90
+ core.loadTemps(data)
91
+ }
92
+
93
+ if (data.pageHits && Array.isArray(data.pageHits)) {
94
+ pageHitsMap.clear()
95
+ for (const [k, v] of data.pageHits) {
96
+ if (!core.isAssetPath(k)) {
97
+ const count = typeof v === 'number' && Number.isFinite(v) ? v : 0
98
+
99
+ pageHitsMap.set(k, count > 1_000_000 ? 1 : count)
100
+ }
101
+ }
102
+ }
103
+
104
+ processRawPageHits(pageHitsRaw)
105
+ }
106
+
107
+ import { startAnalyticsLoop, stopAnalyticsLoop } from './loop'
108
+
109
+ export { startAnalyticsLoop }
110
+
111
+ /**
112
+ * The three paths this plugin claims, written once.
113
+ *
114
+ * `canHandle` and `resolveRoute` have to agree exactly: the handler sits at
115
+ * priority 110, above every content handler, so a path only `canHandle` knows
116
+ * about is claimed and then unroutable, and one only `resolveRoute` knows about
117
+ * is never reached. They held two verbatim copies of this list. The dashboard
118
+ * plugin keeps the same rule in `isDashboardPath`, over namespaces rather than
119
+ * exact paths.
120
+ *
121
+ * Exact matches, not prefixes — `/_analytics/pingback` belongs to the app.
122
+ */
123
+ const ANALYTICS_PATHS = new Set([
124
+ '/_analytics/ping',
125
+ '/api/_analytics/stats',
126
+ '/api/_analytics/reset',
127
+ ])
128
+
129
+ function isAnalyticsPath(path: string): boolean {
130
+ return ANALYTICS_PATHS.has(path)
131
+ }
132
+
133
+ class AnalyticsHandler extends Handler {
134
+ static canHandle(path: string) {
135
+ return isAnalyticsPath(path)
136
+ }
137
+ static resolveRoute(path: string): Handler.Route.Info | null {
138
+ if (isAnalyticsPath(path)) {
139
+ return new Handler.Route.Info(fs.resolve(''), path)
140
+ }
141
+ return null
142
+ }
143
+ static async handle(
144
+ _path: string,
145
+ req: Request,
146
+ ): Promise<AnalyticsResponse | undefined> {
147
+ const res = await handleAnalyticsRequest(req)
148
+ return res || undefined
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Auth stays inside each endpoint (`handleStatsRequest` / `handleResetRequest`
154
+ * fail closed on their own) — this table only replaces the path/method chain.
155
+ *
156
+ * `satisfies` rather than an annotation: it checks the table against
157
+ * `PluginRouteTable` while keeping the literal type, so `routeTable` can carry
158
+ * each endpoint's real return type through to `handleAnalyticsRequest`.
159
+ */
160
+ const analyticsRoutes = routeTable({
161
+ '/_analytics/ping': () => response.text('pong'),
162
+ 'POST /api/_analytics/reset': req => handleResetRequest(req),
163
+ '/api/_analytics/stats': (req, url) => handleStatsRequest(req, url),
164
+ } satisfies PluginRouteTable)
165
+
166
+ /**
167
+ * `/_analytics/ping` is a plain `Response`; the two `/api/` endpoints return
168
+ * the JSON envelope, which the router serialises in `processResponse`. Spelling
169
+ * the union out here means adding a route that returns something else is a
170
+ * compile error rather than a silent widening.
171
+ */
172
+ export type AnalyticsResponse =
173
+ | Response
174
+ | JsonResponseData<AnalyticsStats | undefined>
175
+
176
+ export function handleAnalyticsRequest(
177
+ req: Request,
178
+ ): Promise<AnalyticsResponse | null> {
179
+ return analyticsRoutes(req)
180
+ }
181
+
182
+ export function setupAnalytics() {
183
+ Bakery.handlers.fetch.set(AnalyticsHandler, 110)
184
+ Bakery.handlers.websocket.set(AnalyticsWSHandler)
185
+ void loadAnalyticsData()
186
+ Bakery.onShutdown(async () => {
187
+ stopAnalyticsLoop()
188
+ core.stopPageHitsLogPruner()
189
+ await saveAnalyticsData()
190
+ })
191
+ }
@@ -0,0 +1,251 @@
1
+ import type Database from 'bun:sqlite'
2
+ import { cacheDb } from '@bakery-framework/core/cache/shared-db'
3
+ import { errorMsg, pluginLog } from '@bakery-framework/core/logger'
4
+ import { Try } from '@bakery-framework/core/utils/common'
5
+ import {
6
+ BOOT_MAX_ITEMS,
7
+ history1d,
8
+ history1h,
9
+ history1m,
10
+ history7d,
11
+ history30d,
12
+ pageHitsLog,
13
+ pageHitsMap,
14
+ RETENTION_MS,
15
+ } from './core'
16
+ import { analyticsLog } from './log'
17
+ import { timescaleToMs } from './timescale'
18
+ import type { AnalyticsSnapshot } from './types'
19
+
20
+ /** Hard ceiling on persisted page hits, independent of RETENTION_MS. */
21
+ const MAX_PAGE_HIT_ROWS = 200_000
22
+
23
+ let db: Database | null = null
24
+ let lastSavedPageHitTs = 0
25
+
26
+ let stmtInsertPageHit: ReturnType<Database['prepare']> | null = null
27
+ let stmtUpsertCore: ReturnType<Database['prepare']> | null = null
28
+ let stmtSelectCore: ReturnType<Database['prepare']> | null = null
29
+ let stmtSelectPageHits: ReturnType<Database['prepare']> | null = null
30
+ let stmtDeletePageHits: ReturnType<Database['prepare']> | null = null
31
+
32
+ function ensureSchema(d: Database) {
33
+ d.run(`CREATE TABLE IF NOT EXISTS page_hits (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ timestamp INTEGER NOT NULL,
36
+ path TEXT NOT NULL
37
+ );`)
38
+ d.run(
39
+ 'CREATE INDEX IF NOT EXISTS idx_page_hits_timestamp ON page_hits(timestamp);',
40
+ )
41
+ d.run('CREATE INDEX IF NOT EXISTS idx_page_hits_path ON page_hits(path);')
42
+
43
+ d.run(`CREATE TABLE IF NOT EXISTS core (
44
+ key TEXT PRIMARY KEY,
45
+ value JSON
46
+ );`)
47
+ }
48
+
49
+ function initDbInstance() {
50
+ if (db) return db
51
+ db = cacheDb
52
+ try {
53
+ ensureSchema(db)
54
+ } catch (e) {
55
+ db = null
56
+ throw e
57
+ }
58
+ return db
59
+ }
60
+
61
+ export function initSqliteStorage() {
62
+ try {
63
+ return Promise.resolve(initDbInstance())
64
+ } catch (e) {
65
+ pluginLog.ANALYTICS_STORE_ERR({ error: errorMsg(e) })
66
+ return Promise.resolve(null)
67
+ }
68
+ }
69
+
70
+ export function getDb(): Database | null {
71
+ return db || null
72
+ }
73
+
74
+ function resetStatements() {
75
+ stmtInsertPageHit = null
76
+ stmtUpsertCore = null
77
+ stmtSelectCore = null
78
+ stmtSelectPageHits = null
79
+ stmtDeletePageHits = null
80
+ }
81
+
82
+ /**
83
+ * Test seam (convention 9) for the storage handle.
84
+ *
85
+ * The real handle is `cacheDb`, which the whole process shares — closing it to
86
+ * exercise a write failure would take every test file loaded afterwards with
87
+ * it. The prepared statements are reset alongside, since they belong to the
88
+ * connection they were compiled against.
89
+ */
90
+ export function __setTestDb(instance: Database | null) {
91
+ db = instance
92
+ resetStatements()
93
+ // Same schema the real handle gets. A test that injects an already-closed
94
+ // handle to drive the failure path cannot have one, which is the point of
95
+ // injecting it — hence `Try` rather than a throw.
96
+ if (instance) Try(() => ensureSchema(instance))
97
+ }
98
+
99
+ export function __resetTestDb() {
100
+ db = null
101
+ resetStatements()
102
+ }
103
+
104
+ export default {
105
+ initSqliteStorage,
106
+ getDb,
107
+ }
108
+
109
+ export async function saveAnalyticsData(_cacheBase: string) {
110
+ try {
111
+ await initSqliteStorage()
112
+ const d = getDb()
113
+ if (!d) return
114
+
115
+ if (!stmtDeletePageHits)
116
+ stmtDeletePageHits = d.prepare(
117
+ 'DELETE FROM page_hits WHERE timestamp < ?',
118
+ )
119
+ if (!stmtInsertPageHit)
120
+ stmtInsertPageHit = d.prepare(
121
+ 'INSERT INTO page_hits(timestamp,path) VALUES(?,?)',
122
+ )
123
+ if (!stmtUpsertCore)
124
+ stmtUpsertCore = d.prepare(
125
+ 'INSERT OR REPLACE INTO core(key,value) VALUES(?,?)',
126
+ )
127
+
128
+ const now = Date.now()
129
+ const pruneBefore = now - RETENTION_MS
130
+ try {
131
+ stmtDeletePageHits.run(pruneBefore)
132
+ // Age alone doesn't bound this: a crawler hitting distinct URLs can add
133
+ // millions of rows well inside the retention window. Cap the row count too.
134
+ d.run(
135
+ `DELETE FROM page_hits WHERE rowid NOT IN (
136
+ SELECT rowid FROM page_hits ORDER BY timestamp DESC LIMIT ${MAX_PAGE_HIT_ROWS}
137
+ )`,
138
+ )
139
+ } catch {
140
+ // Pruning is best-effort. Failing to trim old rows must not abandon the
141
+ // inserts below, which are the point of this call.
142
+ }
143
+
144
+ if (pageHitsLog.length > 0) {
145
+ const tx = d.transaction((rows: [number, string][]) => {
146
+ for (const r of rows) stmtInsertPageHit!.run(r[0], r[1])
147
+ })
148
+
149
+ const newHits = pageHitsLog.filter(p => p.timestamp > lastSavedPageHitTs)
150
+
151
+ if (newHits.length > 0) {
152
+ const rows: [number, string][] = newHits.map(p => [p.timestamp, p.path])
153
+ const BATCH = 1000
154
+ for (let i = 0; i < rows.length; i += BATCH) {
155
+ tx(rows.slice(i, i + BATCH))
156
+ }
157
+
158
+ lastSavedPageHitTs = newHits[newHits.length - 1].timestamp
159
+ }
160
+ }
161
+
162
+ const coreData: any = {
163
+ history1m: history1m as AnalyticsSnapshot[],
164
+ history1h: history1h as AnalyticsSnapshot[],
165
+ history1d: history1d as AnalyticsSnapshot[],
166
+ history7d: history7d as AnalyticsSnapshot[],
167
+ history30d: history30d as AnalyticsSnapshot[],
168
+ pageHits: Array.from(pageHitsMap.entries()),
169
+ }
170
+ try {
171
+ stmtUpsertCore.run('core', JSON.stringify(coreData))
172
+ } catch {
173
+ // The snapshot is rebuilt from memory on the next flush, so a failed
174
+ // upsert costs one interval of persisted history — not anything the
175
+ // process still holds.
176
+ }
177
+ } catch (e) {
178
+ // Telemetry must never be able to take down what it is measuring, so this
179
+ // still does not rethrow — but it is no longer silent.
180
+ //
181
+ // The comment that used to sit here said the next flush retries. That is
182
+ // false exactly where it mattered: the flush registered in `onShutdown` is
183
+ // the last one there will ever be, so a throw there loses everything since
184
+ // the previous save, and lost it without a line of output. That is how a
185
+ // shared-cache-DB close ordered ahead of this hook stayed invisible.
186
+ analyticsLog.SAVE_ERR({ error: errorMsg(e) })
187
+ }
188
+ }
189
+
190
+ export async function loadAnalyticsData(
191
+ _cacheBase: string,
192
+ timescale: string = '1d',
193
+ ) {
194
+ try {
195
+ await initSqliteStorage()
196
+ const d = getDb()
197
+ if (!d) return { coreData: null, pageHitsRaw: null } as any
198
+
199
+ if (!stmtSelectCore)
200
+ stmtSelectCore = d.prepare('SELECT value FROM core WHERE key = ?')
201
+ if (!stmtSelectPageHits)
202
+ stmtSelectPageHits = d.prepare(
203
+ 'SELECT timestamp, path FROM page_hits WHERE timestamp >= ? ORDER BY timestamp DESC LIMIT ?',
204
+ )
205
+
206
+ let coreData: any = null
207
+ try {
208
+ const row: any = stmtSelectCore.get('core')
209
+ if (row.value) {
210
+ coreData =
211
+ typeof row.value === 'string' ? JSON.parse(row.value) : row.value
212
+ }
213
+ } catch {
214
+ coreData = null
215
+ }
216
+
217
+ const windowMs = timescaleToMs(timescale) || 24 * 3600 * 1000
218
+ const now = Date.now()
219
+ const minTs = now - windowMs
220
+ let pageHitsRaw: any[] = []
221
+ try {
222
+ const rows: any[] = stmtSelectPageHits.all(minTs, BOOT_MAX_ITEMS) || []
223
+
224
+ for (let i = rows.length - 1; i >= 0; i--) {
225
+ const r = rows[i]
226
+ pageHitsRaw.push({ timestamp: r.timestamp, path: r.path })
227
+ }
228
+
229
+ try {
230
+ const maxRow: any = d
231
+ .prepare('SELECT MAX(timestamp) as maxTs FROM page_hits')
232
+ .get()
233
+ if (maxRow && typeof maxRow.maxTs === 'number') {
234
+ lastSavedPageHitTs = maxRow.maxTs
235
+ } else if (pageHitsRaw.length > 0) {
236
+ lastSavedPageHitTs = pageHitsRaw[pageHitsRaw.length - 1].timestamp
237
+ }
238
+ } catch {
239
+ if (pageHitsRaw.length > 0) {
240
+ lastSavedPageHitTs = pageHitsRaw[pageHitsRaw.length - 1].timestamp
241
+ }
242
+ }
243
+ } catch {
244
+ pageHitsRaw = null as any
245
+ }
246
+
247
+ return { coreData, pageHitsRaw } as any
248
+ } catch {
249
+ return { coreData: null, pageHitsRaw: null } as any
250
+ }
251
+ }
@@ -0,0 +1,16 @@
1
+ export function timescaleToMs(timescale: string): number {
2
+ switch (timescale) {
3
+ case '1m':
4
+ return 60_000
5
+ case '1h':
6
+ return 3_600_000
7
+ case '1d':
8
+ return 86_400_000
9
+ case '7d':
10
+ return 7 * 86_400_000
11
+ case '30d':
12
+ return 30 * 86_400_000
13
+ default:
14
+ return 0
15
+ }
16
+ }
package/src/types.ts ADDED
@@ -0,0 +1,13 @@
1
+ export type AnalyticsSnapshot = {
2
+ timestamp: number
3
+ memoryUsed: number | null
4
+ activeLoggers: number | null
5
+ activeSessions: number | null
6
+ routeHits: number | null
7
+ apiHits: number | null
8
+ pageHits: number | null
9
+ uniqueRequests: number | null
10
+ dbHits: number | null
11
+ errorPageHits: number | null
12
+ ping: number | null
13
+ }