@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 +19 -0
- package/README.md +42 -0
- package/package.json +42 -0
- package/src/core.ts +377 -0
- package/src/endpoints/stats.ts +136 -0
- package/src/endpoints/websocket.ts +60 -0
- package/src/index.ts +36 -0
- package/src/log.ts +24 -0
- package/src/loop.ts +130 -0
- package/src/setup.ts +191 -0
- package/src/storage-sqlite.ts +251 -0
- package/src/timescale.ts +16 -0
- package/src/types.ts +13 -0
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
|
+
}
|
package/src/timescale.ts
ADDED
|
@@ -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
|
+
}
|