@edge-base/server 0.1.1
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/admin-build/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogsDO — Analytics log storage Durable Object.
|
|
3
|
+
*
|
|
4
|
+
* Provides SQLite-based log storage for Docker/self-hosted environments
|
|
5
|
+
* where Cloudflare Analytics Engine is not available.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - Single instance per project: `logs:main`
|
|
9
|
+
* - 3-tier pre-aggregation for fast reads:
|
|
10
|
+
* _logs_raw (24h) — exact per-request data
|
|
11
|
+
* _logs_hourly (90d) — hourly aggregates
|
|
12
|
+
* _logs_daily (forever) — daily aggregates
|
|
13
|
+
* - Alarm-based hourly aggregation + cleanup
|
|
14
|
+
*
|
|
15
|
+
* Internal Routes:
|
|
16
|
+
* POST /internal/logs/write — batch insert raw log entries
|
|
17
|
+
* GET /internal/logs/query — query aggregated analytics data
|
|
18
|
+
* GET /internal/logs/recent — query recent raw request logs
|
|
19
|
+
*/
|
|
20
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
21
|
+
|
|
22
|
+
const SERVER_ERROR_STATUS = 500;
|
|
23
|
+
|
|
24
|
+
interface LogsDOEnv {
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class LogsDO extends DurableObject<LogsDOEnv> {
|
|
29
|
+
private initialized = false;
|
|
30
|
+
|
|
31
|
+
// ─── Schema ───
|
|
32
|
+
|
|
33
|
+
private ensureSchema(): void {
|
|
34
|
+
if (this.initialized) return;
|
|
35
|
+
this.initialized = true;
|
|
36
|
+
|
|
37
|
+
const sql = this.ctx.storage.sql;
|
|
38
|
+
|
|
39
|
+
// Raw logs — exact per-request data, kept for 24 hours
|
|
40
|
+
sql.exec(`
|
|
41
|
+
CREATE TABLE IF NOT EXISTS _logs_raw (
|
|
42
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
43
|
+
timestamp INTEGER NOT NULL,
|
|
44
|
+
method TEXT NOT NULL,
|
|
45
|
+
path TEXT NOT NULL,
|
|
46
|
+
status INTEGER NOT NULL,
|
|
47
|
+
duration REAL NOT NULL,
|
|
48
|
+
userId TEXT,
|
|
49
|
+
error TEXT,
|
|
50
|
+
category TEXT,
|
|
51
|
+
subcategory TEXT,
|
|
52
|
+
target1 TEXT,
|
|
53
|
+
target2 TEXT,
|
|
54
|
+
operation TEXT,
|
|
55
|
+
region TEXT,
|
|
56
|
+
requestSize INTEGER DEFAULT 0,
|
|
57
|
+
responseSize INTEGER DEFAULT 0,
|
|
58
|
+
resultCount INTEGER DEFAULT 0
|
|
59
|
+
)
|
|
60
|
+
`);
|
|
61
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_raw_ts ON _logs_raw(timestamp)`);
|
|
62
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_raw_cat ON _logs_raw(category)`);
|
|
63
|
+
|
|
64
|
+
// Hourly aggregates — kept for 90 days
|
|
65
|
+
sql.exec(`
|
|
66
|
+
CREATE TABLE IF NOT EXISTS _logs_hourly (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
hour_ts INTEGER NOT NULL,
|
|
69
|
+
category TEXT NOT NULL DEFAULT '',
|
|
70
|
+
subcategory TEXT NOT NULL DEFAULT '',
|
|
71
|
+
target1 TEXT NOT NULL DEFAULT '',
|
|
72
|
+
target2 TEXT NOT NULL DEFAULT '',
|
|
73
|
+
operation TEXT NOT NULL DEFAULT '',
|
|
74
|
+
region TEXT NOT NULL DEFAULT '',
|
|
75
|
+
request_count INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
error_count INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
avg_duration REAL NOT NULL DEFAULT 0,
|
|
78
|
+
p95_duration REAL NOT NULL DEFAULT 0,
|
|
79
|
+
unique_users INTEGER NOT NULL DEFAULT 0,
|
|
80
|
+
total_request_size INTEGER NOT NULL DEFAULT 0,
|
|
81
|
+
total_response_size INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
total_result_count INTEGER NOT NULL DEFAULT 0
|
|
83
|
+
)
|
|
84
|
+
`);
|
|
85
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_hourly_ts ON _logs_hourly(hour_ts)`);
|
|
86
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_hourly_cat ON _logs_hourly(hour_ts, category)`);
|
|
87
|
+
|
|
88
|
+
// Daily aggregates — kept permanently
|
|
89
|
+
sql.exec(`
|
|
90
|
+
CREATE TABLE IF NOT EXISTS _logs_daily (
|
|
91
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
92
|
+
day_ts INTEGER NOT NULL,
|
|
93
|
+
category TEXT NOT NULL DEFAULT '',
|
|
94
|
+
subcategory TEXT NOT NULL DEFAULT '',
|
|
95
|
+
target1 TEXT NOT NULL DEFAULT '',
|
|
96
|
+
target2 TEXT NOT NULL DEFAULT '',
|
|
97
|
+
operation TEXT NOT NULL DEFAULT '',
|
|
98
|
+
region TEXT NOT NULL DEFAULT '',
|
|
99
|
+
request_count INTEGER NOT NULL DEFAULT 0,
|
|
100
|
+
error_count INTEGER NOT NULL DEFAULT 0,
|
|
101
|
+
avg_duration REAL NOT NULL DEFAULT 0,
|
|
102
|
+
p95_duration REAL NOT NULL DEFAULT 0,
|
|
103
|
+
unique_users INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
total_request_size INTEGER NOT NULL DEFAULT 0,
|
|
105
|
+
total_response_size INTEGER NOT NULL DEFAULT 0,
|
|
106
|
+
total_result_count INTEGER NOT NULL DEFAULT 0
|
|
107
|
+
)
|
|
108
|
+
`);
|
|
109
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_daily_ts ON _logs_daily(day_ts)`);
|
|
110
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_daily_cat ON _logs_daily(day_ts, category)`);
|
|
111
|
+
|
|
112
|
+
// Custom events — raw events (90-day retention)
|
|
113
|
+
sql.exec(`
|
|
114
|
+
CREATE TABLE IF NOT EXISTS _events (
|
|
115
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
116
|
+
timestamp INTEGER NOT NULL,
|
|
117
|
+
userId TEXT,
|
|
118
|
+
eventName TEXT NOT NULL,
|
|
119
|
+
properties TEXT,
|
|
120
|
+
region TEXT
|
|
121
|
+
)
|
|
122
|
+
`);
|
|
123
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_ts ON _events(timestamp)`);
|
|
124
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_name ON _events(eventName)`);
|
|
125
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_user ON _events(userId)`);
|
|
126
|
+
|
|
127
|
+
// Custom events — daily aggregates (permanent)
|
|
128
|
+
sql.exec(`
|
|
129
|
+
CREATE TABLE IF NOT EXISTS _events_daily (
|
|
130
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
131
|
+
day_ts INTEGER NOT NULL,
|
|
132
|
+
eventName TEXT NOT NULL,
|
|
133
|
+
event_count INTEGER NOT NULL DEFAULT 0,
|
|
134
|
+
unique_users INTEGER NOT NULL DEFAULT 0
|
|
135
|
+
)
|
|
136
|
+
`);
|
|
137
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_daily_ts ON _events_daily(day_ts)`);
|
|
138
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_daily_name ON _events_daily(day_ts, eventName)`);
|
|
139
|
+
|
|
140
|
+
// Schedule first alarm if not already scheduled
|
|
141
|
+
this.scheduleNextAlarm();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Alarm ───
|
|
145
|
+
|
|
146
|
+
private scheduleNextAlarm(): void {
|
|
147
|
+
// Next full hour
|
|
148
|
+
const next = new Date();
|
|
149
|
+
next.setMinutes(0, 0, 0);
|
|
150
|
+
next.setHours(next.getHours() + 1);
|
|
151
|
+
this.ctx.storage.setAlarm(next.getTime());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async alarm(): Promise<void> {
|
|
155
|
+
this.ensureSchema();
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
this.aggregateHourly();
|
|
159
|
+
this.aggregateDaily();
|
|
160
|
+
this.aggregateEvents();
|
|
161
|
+
this.cleanup();
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error('[LogsDO] Alarm aggregation failed:', err);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Reschedule
|
|
167
|
+
this.scheduleNextAlarm();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Aggregate raw logs older than 1 hour into _logs_hourly.
|
|
172
|
+
* Groups by (hour, category, subcategory, target1, target2, operation, region).
|
|
173
|
+
*/
|
|
174
|
+
private aggregateHourly(): void {
|
|
175
|
+
const sql = this.ctx.storage.sql;
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
// Aggregate everything older than 1 hour
|
|
178
|
+
const cutoff = now - 3600_000;
|
|
179
|
+
|
|
180
|
+
// Find the oldest raw log timestamp to determine range
|
|
181
|
+
const oldest = sql.exec(`SELECT MIN(timestamp) as min_ts FROM _logs_raw WHERE timestamp < ?`, cutoff).toArray();
|
|
182
|
+
if (!oldest.length || oldest[0].min_ts == null) return;
|
|
183
|
+
|
|
184
|
+
const minTs = oldest[0].min_ts as number;
|
|
185
|
+
|
|
186
|
+
// Process hour by hour
|
|
187
|
+
const startHour = Math.floor(minTs / 3600_000) * 3600_000;
|
|
188
|
+
const endHour = Math.floor(cutoff / 3600_000) * 3600_000;
|
|
189
|
+
|
|
190
|
+
for (let hourTs = startHour; hourTs <= endHour; hourTs += 3600_000) {
|
|
191
|
+
const hourEnd = hourTs + 3600_000;
|
|
192
|
+
|
|
193
|
+
// Check if already aggregated
|
|
194
|
+
const existing = sql.exec(
|
|
195
|
+
`SELECT COUNT(*) as cnt FROM _logs_hourly WHERE hour_ts = ?`,
|
|
196
|
+
hourTs,
|
|
197
|
+
).toArray();
|
|
198
|
+
if (existing.length && (existing[0].cnt as number) > 0) continue;
|
|
199
|
+
|
|
200
|
+
// Aggregate
|
|
201
|
+
sql.exec(`
|
|
202
|
+
INSERT INTO _logs_hourly (hour_ts, category, subcategory, target1, target2, operation, region,
|
|
203
|
+
request_count, error_count, avg_duration, p95_duration, unique_users,
|
|
204
|
+
total_request_size, total_response_size, total_result_count)
|
|
205
|
+
SELECT
|
|
206
|
+
? as hour_ts,
|
|
207
|
+
COALESCE(category, '') as category,
|
|
208
|
+
COALESCE(subcategory, '') as subcategory,
|
|
209
|
+
COALESCE(target1, '') as target1,
|
|
210
|
+
COALESCE(target2, '') as target2,
|
|
211
|
+
COALESCE(operation, '') as operation,
|
|
212
|
+
COALESCE(region, '') as region,
|
|
213
|
+
COUNT(*) as request_count,
|
|
214
|
+
SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1 ELSE 0 END) as error_count,
|
|
215
|
+
AVG(duration) as avg_duration,
|
|
216
|
+
0 as p95_duration,
|
|
217
|
+
COUNT(DISTINCT userId) as unique_users,
|
|
218
|
+
SUM(COALESCE(requestSize, 0)) as total_request_size,
|
|
219
|
+
SUM(COALESCE(responseSize, 0)) as total_response_size,
|
|
220
|
+
SUM(COALESCE(resultCount, 0)) as total_result_count
|
|
221
|
+
FROM _logs_raw
|
|
222
|
+
WHERE timestamp >= ? AND timestamp < ?
|
|
223
|
+
GROUP BY category, subcategory, target1, target2, operation, region
|
|
224
|
+
`, hourTs, hourTs, hourEnd);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Aggregate hourly data older than 90 days into _logs_daily.
|
|
230
|
+
*/
|
|
231
|
+
private aggregateDaily(): void {
|
|
232
|
+
const sql = this.ctx.storage.sql;
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const cutoff90d = now - 90 * 86400_000;
|
|
235
|
+
|
|
236
|
+
const oldest = sql.exec(
|
|
237
|
+
`SELECT MIN(hour_ts) as min_ts FROM _logs_hourly WHERE hour_ts < ?`,
|
|
238
|
+
cutoff90d,
|
|
239
|
+
).toArray();
|
|
240
|
+
if (!oldest.length || oldest[0].min_ts == null) return;
|
|
241
|
+
|
|
242
|
+
const minTs = oldest[0].min_ts as number;
|
|
243
|
+
const startDay = Math.floor(minTs / 86400_000) * 86400_000;
|
|
244
|
+
const endDay = Math.floor(cutoff90d / 86400_000) * 86400_000;
|
|
245
|
+
|
|
246
|
+
for (let dayTs = startDay; dayTs <= endDay; dayTs += 86400_000) {
|
|
247
|
+
const dayEnd = dayTs + 86400_000;
|
|
248
|
+
|
|
249
|
+
const existing = sql.exec(
|
|
250
|
+
`SELECT COUNT(*) as cnt FROM _logs_daily WHERE day_ts = ?`,
|
|
251
|
+
dayTs,
|
|
252
|
+
).toArray();
|
|
253
|
+
if (existing.length && (existing[0].cnt as number) > 0) continue;
|
|
254
|
+
|
|
255
|
+
sql.exec(`
|
|
256
|
+
INSERT INTO _logs_daily (day_ts, category, subcategory, target1, target2, operation, region,
|
|
257
|
+
request_count, error_count, avg_duration, p95_duration, unique_users,
|
|
258
|
+
total_request_size, total_response_size, total_result_count)
|
|
259
|
+
SELECT
|
|
260
|
+
? as day_ts,
|
|
261
|
+
category, subcategory, target1, target2, operation, region,
|
|
262
|
+
SUM(request_count) as request_count,
|
|
263
|
+
SUM(error_count) as error_count,
|
|
264
|
+
CASE WHEN SUM(request_count) > 0
|
|
265
|
+
THEN SUM(avg_duration * request_count) / SUM(request_count)
|
|
266
|
+
ELSE 0 END as avg_duration,
|
|
267
|
+
MAX(p95_duration) as p95_duration,
|
|
268
|
+
SUM(unique_users) as unique_users,
|
|
269
|
+
SUM(total_request_size) as total_request_size,
|
|
270
|
+
SUM(total_response_size) as total_response_size,
|
|
271
|
+
SUM(total_result_count) as total_result_count
|
|
272
|
+
FROM _logs_hourly
|
|
273
|
+
WHERE hour_ts >= ? AND hour_ts < ?
|
|
274
|
+
GROUP BY category, subcategory, target1, target2, operation, region
|
|
275
|
+
`, dayTs, dayTs, dayEnd);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Delete aggregated hourly rows
|
|
279
|
+
sql.exec(`DELETE FROM _logs_hourly WHERE hour_ts < ?`, cutoff90d);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Aggregate custom events older than 90 days into _events_daily.
|
|
284
|
+
*/
|
|
285
|
+
private aggregateEvents(): void {
|
|
286
|
+
const sql = this.ctx.storage.sql;
|
|
287
|
+
const cutoff90d = Date.now() - 90 * 86400_000;
|
|
288
|
+
|
|
289
|
+
const oldest = sql.exec(
|
|
290
|
+
`SELECT MIN(timestamp) as min_ts FROM _events WHERE timestamp < ?`,
|
|
291
|
+
cutoff90d,
|
|
292
|
+
).toArray();
|
|
293
|
+
if (!oldest.length || oldest[0].min_ts == null) return;
|
|
294
|
+
|
|
295
|
+
const minTs = oldest[0].min_ts as number;
|
|
296
|
+
const startDay = Math.floor(minTs / 86400_000) * 86400_000;
|
|
297
|
+
const endDay = Math.floor(cutoff90d / 86400_000) * 86400_000;
|
|
298
|
+
|
|
299
|
+
for (let dayTs = startDay; dayTs <= endDay; dayTs += 86400_000) {
|
|
300
|
+
const dayEnd = dayTs + 86400_000;
|
|
301
|
+
|
|
302
|
+
const existing = sql.exec(
|
|
303
|
+
`SELECT COUNT(*) as cnt FROM _events_daily WHERE day_ts = ?`,
|
|
304
|
+
dayTs,
|
|
305
|
+
).toArray();
|
|
306
|
+
if (existing.length && (existing[0].cnt as number) > 0) continue;
|
|
307
|
+
|
|
308
|
+
sql.exec(`
|
|
309
|
+
INSERT INTO _events_daily (day_ts, eventName, event_count, unique_users)
|
|
310
|
+
SELECT
|
|
311
|
+
? as day_ts,
|
|
312
|
+
eventName,
|
|
313
|
+
COUNT(*) as event_count,
|
|
314
|
+
COUNT(DISTINCT userId) as unique_users
|
|
315
|
+
FROM _events
|
|
316
|
+
WHERE timestamp >= ? AND timestamp < ?
|
|
317
|
+
GROUP BY eventName
|
|
318
|
+
`, dayTs, dayTs, dayEnd);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Delete aggregated events
|
|
322
|
+
sql.exec(`DELETE FROM _events WHERE timestamp < ?`, cutoff90d);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Remove raw logs older than 24 hours (already aggregated into hourly).
|
|
327
|
+
*/
|
|
328
|
+
private cleanup(): void {
|
|
329
|
+
const cutoff24h = Date.now() - 86400_000;
|
|
330
|
+
this.ctx.storage.sql.exec(`DELETE FROM _logs_raw WHERE timestamp < ?`, cutoff24h);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── Request Handler ───
|
|
334
|
+
|
|
335
|
+
async fetch(request: Request): Promise<Response> {
|
|
336
|
+
this.ensureSchema();
|
|
337
|
+
|
|
338
|
+
const url = new URL(request.url);
|
|
339
|
+
const path = url.pathname;
|
|
340
|
+
|
|
341
|
+
if (path === '/internal/logs/write' && request.method === 'POST') {
|
|
342
|
+
return this.handleWrite(request);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (path === '/internal/logs/query' && request.method === 'GET') {
|
|
346
|
+
return this.handleQuery(url);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (path === '/internal/logs/history' && request.method === 'GET') {
|
|
350
|
+
return this.handleHistory(url);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (path === '/internal/logs/recent' && request.method === 'GET') {
|
|
354
|
+
return this.handleRecent(url);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (path === '/internal/events/write' && request.method === 'POST') {
|
|
358
|
+
return this.handleEventsWrite(request);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (path === '/internal/events/query' && request.method === 'GET') {
|
|
362
|
+
return this.handleEventsQuery(url);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ─── Write Handler ───
|
|
369
|
+
|
|
370
|
+
private async handleWrite(request: Request): Promise<Response> {
|
|
371
|
+
try {
|
|
372
|
+
const body = (await request.json()) as { entries: Array<Record<string, unknown>> };
|
|
373
|
+
const entries = body.entries;
|
|
374
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
375
|
+
return new Response(JSON.stringify({ ok: true, count: 0 }), {
|
|
376
|
+
headers: { 'Content-Type': 'application/json' },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const sql = this.ctx.storage.sql;
|
|
381
|
+
|
|
382
|
+
for (const e of entries) {
|
|
383
|
+
sql.exec(`
|
|
384
|
+
INSERT INTO _logs_raw (timestamp, method, path, status, duration, userId, error,
|
|
385
|
+
category, subcategory, target1, target2, operation, region,
|
|
386
|
+
requestSize, responseSize, resultCount)
|
|
387
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
388
|
+
`,
|
|
389
|
+
(e.timestamp as number) || Date.now(),
|
|
390
|
+
(e.method as string) || '',
|
|
391
|
+
(e.path as string) || '',
|
|
392
|
+
(e.status as number) || 0,
|
|
393
|
+
(e.duration as number) || 0,
|
|
394
|
+
(e.userId as string) || null,
|
|
395
|
+
(e.error as string) || null,
|
|
396
|
+
(e.category as string) || '',
|
|
397
|
+
(e.subcategory as string) || '',
|
|
398
|
+
(e.target1 as string) || '',
|
|
399
|
+
(e.target2 as string) || '',
|
|
400
|
+
(e.operation as string) || '',
|
|
401
|
+
(e.region as string) || '',
|
|
402
|
+
(e.requestSize as number) || 0,
|
|
403
|
+
(e.responseSize as number) || 0,
|
|
404
|
+
(e.resultCount as number) || 0,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return new Response(JSON.stringify({ ok: true, count: entries.length }), {
|
|
409
|
+
headers: { 'Content-Type': 'application/json' },
|
|
410
|
+
});
|
|
411
|
+
} catch (err) {
|
|
412
|
+
console.error('[LogsDO] Write failed:', err);
|
|
413
|
+
return new Response(JSON.stringify({ error: 'Write failed' }), { status: 500 });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ─── Query Handler ───
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Query analytics data.
|
|
421
|
+
*
|
|
422
|
+
* Query params:
|
|
423
|
+
* range: '1h'|'6h'|'24h'|'7d'|'30d'|'90d' (default '24h')
|
|
424
|
+
* category: filter by category (optional)
|
|
425
|
+
* metric: 'overview'|'timeSeries'|'breakdown'|'topEndpoints' (default 'overview')
|
|
426
|
+
* groupBy: 'minute'|'tenMinute'|'hour'|'day' (default 'hour')
|
|
427
|
+
*/
|
|
428
|
+
private handleQuery(url: URL): Response {
|
|
429
|
+
try {
|
|
430
|
+
const range = url.searchParams.get('range') || '24h';
|
|
431
|
+
const start = url.searchParams.get('start');
|
|
432
|
+
const end = url.searchParams.get('end');
|
|
433
|
+
const category = url.searchParams.get('category') || '';
|
|
434
|
+
const excludeCategory = url.searchParams.get('excludeCategory') || '';
|
|
435
|
+
const metric = url.searchParams.get('metric') || 'overview';
|
|
436
|
+
const groupBy = url.searchParams.get('groupBy') || 'hour';
|
|
437
|
+
|
|
438
|
+
const { startTs, endTs } = this.parseTimeRange(range, start, end);
|
|
439
|
+
const table = this.selectTable(range);
|
|
440
|
+
const tsCol = table === '_logs_raw' ? 'timestamp' : table === '_logs_hourly' ? 'hour_ts' : 'day_ts';
|
|
441
|
+
|
|
442
|
+
// Build combined category filter
|
|
443
|
+
const catParts: string[] = [];
|
|
444
|
+
if (category) catParts.push(`category = '${escapeSql(category)}'`);
|
|
445
|
+
if (excludeCategory) catParts.push(`category != '${escapeSql(excludeCategory)}'`);
|
|
446
|
+
const catFilter = catParts.length > 0 ? ` AND ${catParts.join(' AND ')}` : '';
|
|
447
|
+
|
|
448
|
+
const sql = this.ctx.storage.sql;
|
|
449
|
+
|
|
450
|
+
if (metric === 'overview') {
|
|
451
|
+
return this.queryOverview(sql, table, tsCol, startTs, endTs, catFilter, groupBy);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (metric === 'timeSeries') {
|
|
455
|
+
return this.queryTimeSeries(sql, table, tsCol, startTs, endTs, catFilter, groupBy);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (metric === 'breakdown') {
|
|
459
|
+
return this.queryBreakdown(sql, table, tsCol, startTs, endTs, catFilter, category);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (metric === 'topEndpoints') {
|
|
463
|
+
return this.queryTopEndpoints(sql, table, tsCol, startTs, endTs, catFilter);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return jsonResponse({ error: 'Unknown metric' }, 400);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
console.error('[LogsDO] Query failed:', err);
|
|
469
|
+
return jsonResponse({ error: 'Query failed' }, 500);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private handleRecent(url: URL): Response {
|
|
474
|
+
try {
|
|
475
|
+
const limit = Math.max(1, Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 200));
|
|
476
|
+
const level = (url.searchParams.get('level') || '').toLowerCase();
|
|
477
|
+
const pathFilter = url.searchParams.get('path') || '';
|
|
478
|
+
const category = (url.searchParams.get('category') || '').toLowerCase();
|
|
479
|
+
|
|
480
|
+
const whereParts: string[] = [];
|
|
481
|
+
const params: Array<string | number> = [];
|
|
482
|
+
|
|
483
|
+
if (level === 'error') {
|
|
484
|
+
whereParts.push('status >= ?');
|
|
485
|
+
params.push(SERVER_ERROR_STATUS);
|
|
486
|
+
} else if (level === 'warn') {
|
|
487
|
+
whereParts.push('status >= ? AND status < ?');
|
|
488
|
+
params.push(300, SERVER_ERROR_STATUS);
|
|
489
|
+
} else if (level === 'info') {
|
|
490
|
+
whereParts.push('status >= ? AND status < ?');
|
|
491
|
+
params.push(200, 300);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (pathFilter.trim()) {
|
|
495
|
+
whereParts.push('path LIKE ?');
|
|
496
|
+
params.push(`%${pathFilter.trim()}%`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (category && category !== 'all') {
|
|
500
|
+
whereParts.push('LOWER(category) = ?');
|
|
501
|
+
params.push(category);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
|
505
|
+
const sql = this.ctx.storage.sql;
|
|
506
|
+
const rows = sql.exec(
|
|
507
|
+
`
|
|
508
|
+
SELECT
|
|
509
|
+
timestamp,
|
|
510
|
+
method,
|
|
511
|
+
path,
|
|
512
|
+
status,
|
|
513
|
+
duration,
|
|
514
|
+
userId,
|
|
515
|
+
error,
|
|
516
|
+
category,
|
|
517
|
+
subcategory,
|
|
518
|
+
target1,
|
|
519
|
+
target2,
|
|
520
|
+
operation,
|
|
521
|
+
region,
|
|
522
|
+
requestSize,
|
|
523
|
+
responseSize,
|
|
524
|
+
resultCount
|
|
525
|
+
FROM _logs_raw
|
|
526
|
+
${whereClause}
|
|
527
|
+
ORDER BY timestamp DESC
|
|
528
|
+
LIMIT ?
|
|
529
|
+
`,
|
|
530
|
+
...params,
|
|
531
|
+
limit,
|
|
532
|
+
).toArray();
|
|
533
|
+
|
|
534
|
+
return jsonResponse({
|
|
535
|
+
logs: rows.map((row) => ({
|
|
536
|
+
timestamp: Number(row.timestamp ?? 0),
|
|
537
|
+
method: String(row.method ?? ''),
|
|
538
|
+
path: String(row.path ?? ''),
|
|
539
|
+
status: Number(row.status ?? 0),
|
|
540
|
+
duration: Number(row.duration ?? 0),
|
|
541
|
+
userId: row.userId ? String(row.userId) : undefined,
|
|
542
|
+
error: row.error ? String(row.error) : undefined,
|
|
543
|
+
category: String(row.category ?? ''),
|
|
544
|
+
subcategory: String(row.subcategory ?? ''),
|
|
545
|
+
target1: String(row.target1 ?? ''),
|
|
546
|
+
target2: String(row.target2 ?? ''),
|
|
547
|
+
operation: String(row.operation ?? ''),
|
|
548
|
+
region: String(row.region ?? ''),
|
|
549
|
+
requestSize: Number(row.requestSize ?? 0),
|
|
550
|
+
responseSize: Number(row.responseSize ?? 0),
|
|
551
|
+
resultCount: Number(row.resultCount ?? 0),
|
|
552
|
+
})),
|
|
553
|
+
total: rows.length,
|
|
554
|
+
});
|
|
555
|
+
} catch (err) {
|
|
556
|
+
console.error('[LogsDO] Recent logs query failed:', err);
|
|
557
|
+
return jsonResponse({ error: 'Recent logs query failed' }, 500);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private handleHistory(url: URL): Response {
|
|
562
|
+
try {
|
|
563
|
+
const category = url.searchParams.get('category') || '';
|
|
564
|
+
const excludeCategory = url.searchParams.get('excludeCategory') || '';
|
|
565
|
+
const catParts: string[] = [];
|
|
566
|
+
if (category) catParts.push(`category = '${escapeSql(category)}'`);
|
|
567
|
+
if (excludeCategory) catParts.push(`category != '${escapeSql(excludeCategory)}'`);
|
|
568
|
+
const whereClause = catParts.length > 0 ? ` WHERE ${catParts.join(' AND ')}` : '';
|
|
569
|
+
|
|
570
|
+
const sql = this.ctx.storage.sql;
|
|
571
|
+
const rows = sql.exec(
|
|
572
|
+
`
|
|
573
|
+
SELECT MIN(ts) as oldestTimestamp
|
|
574
|
+
FROM (
|
|
575
|
+
SELECT MIN(timestamp) as ts FROM _logs_raw${whereClause}
|
|
576
|
+
UNION ALL
|
|
577
|
+
SELECT MIN(hour_ts) as ts FROM _logs_hourly${whereClause}
|
|
578
|
+
UNION ALL
|
|
579
|
+
SELECT MIN(day_ts) as ts FROM _logs_daily${whereClause}
|
|
580
|
+
)
|
|
581
|
+
WHERE ts IS NOT NULL
|
|
582
|
+
`,
|
|
583
|
+
).toArray();
|
|
584
|
+
|
|
585
|
+
const oldestTimestamp = rows[0]?.oldestTimestamp;
|
|
586
|
+
return jsonResponse({
|
|
587
|
+
oldestTimestamp:
|
|
588
|
+
oldestTimestamp == null || !Number.isFinite(Number(oldestTimestamp))
|
|
589
|
+
? null
|
|
590
|
+
: Number(oldestTimestamp),
|
|
591
|
+
});
|
|
592
|
+
} catch (err) {
|
|
593
|
+
console.error('[LogsDO] History query failed:', err);
|
|
594
|
+
return jsonResponse({ error: 'History query failed' }, 500);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ─── Query implementations ───
|
|
599
|
+
|
|
600
|
+
private queryOverview(
|
|
601
|
+
sql: SqlStorage, table: string, tsCol: string,
|
|
602
|
+
startTs: number, endTs: number, catFilter: string, groupBy: string,
|
|
603
|
+
): Response {
|
|
604
|
+
|
|
605
|
+
// Summary
|
|
606
|
+
let summary: Record<string, unknown>;
|
|
607
|
+
if (table === '_logs_raw') {
|
|
608
|
+
const rows = sql.exec(`
|
|
609
|
+
SELECT
|
|
610
|
+
COUNT(*) as totalRequests,
|
|
611
|
+
SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1 ELSE 0 END) as totalErrors,
|
|
612
|
+
AVG(duration) as avgLatency,
|
|
613
|
+
COUNT(DISTINCT userId) as uniqueUsers
|
|
614
|
+
FROM ${table}
|
|
615
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
616
|
+
`, startTs, endTs).toArray();
|
|
617
|
+
summary = rows[0] || { totalRequests: 0, totalErrors: 0, avgLatency: 0, uniqueUsers: 0 };
|
|
618
|
+
} else {
|
|
619
|
+
const rows = sql.exec(`
|
|
620
|
+
SELECT
|
|
621
|
+
SUM(request_count) as totalRequests,
|
|
622
|
+
SUM(error_count) as totalErrors,
|
|
623
|
+
CASE WHEN SUM(request_count) > 0
|
|
624
|
+
THEN SUM(avg_duration * request_count) / SUM(request_count)
|
|
625
|
+
ELSE 0 END as avgLatency,
|
|
626
|
+
SUM(unique_users) as uniqueUsers
|
|
627
|
+
FROM ${table}
|
|
628
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
629
|
+
`, startTs, endTs).toArray();
|
|
630
|
+
summary = rows[0] || { totalRequests: 0, totalErrors: 0, avgLatency: 0, uniqueUsers: 0 };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Time series
|
|
634
|
+
const bucketMs = this.groupByToMs(groupBy);
|
|
635
|
+
let timeSeries: Record<string, unknown>[];
|
|
636
|
+
if (table === '_logs_raw') {
|
|
637
|
+
timeSeries = sql.exec(`
|
|
638
|
+
SELECT
|
|
639
|
+
(CAST(${tsCol} / ? AS INTEGER) * ?) as ts,
|
|
640
|
+
COUNT(*) as value
|
|
641
|
+
FROM ${table}
|
|
642
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
643
|
+
GROUP BY ts
|
|
644
|
+
ORDER BY ts
|
|
645
|
+
`, bucketMs, bucketMs, startTs, endTs).toArray();
|
|
646
|
+
} else {
|
|
647
|
+
timeSeries = sql.exec(`
|
|
648
|
+
SELECT
|
|
649
|
+
(CAST(${tsCol} / ? AS INTEGER) * ?) as ts,
|
|
650
|
+
SUM(request_count) as value
|
|
651
|
+
FROM ${table}
|
|
652
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
653
|
+
GROUP BY ts
|
|
654
|
+
ORDER BY ts
|
|
655
|
+
`, bucketMs, bucketMs, startTs, endTs).toArray();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Breakdown by category
|
|
659
|
+
let breakdown: Record<string, unknown>[];
|
|
660
|
+
if (table === '_logs_raw') {
|
|
661
|
+
breakdown = sql.exec(`
|
|
662
|
+
SELECT
|
|
663
|
+
COALESCE(category, 'other') as label,
|
|
664
|
+
COUNT(*) as count
|
|
665
|
+
FROM ${table}
|
|
666
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
667
|
+
GROUP BY category
|
|
668
|
+
ORDER BY count DESC
|
|
669
|
+
LIMIT 20
|
|
670
|
+
`, startTs, endTs).toArray();
|
|
671
|
+
} else {
|
|
672
|
+
breakdown = sql.exec(`
|
|
673
|
+
SELECT
|
|
674
|
+
category as label,
|
|
675
|
+
SUM(request_count) as count
|
|
676
|
+
FROM ${table}
|
|
677
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
678
|
+
GROUP BY category
|
|
679
|
+
ORDER BY count DESC
|
|
680
|
+
LIMIT 20
|
|
681
|
+
`, startTs, endTs).toArray();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Add percentages
|
|
685
|
+
const totalCount = breakdown.reduce((sum, b) => sum + ((b.count as number) || 0), 0);
|
|
686
|
+
const breakdownWithPct = breakdown.map(b => ({
|
|
687
|
+
...b,
|
|
688
|
+
percentage: totalCount > 0 ? Math.round(((b.count as number) / totalCount) * 1000) / 10 : 0,
|
|
689
|
+
}));
|
|
690
|
+
|
|
691
|
+
// Top endpoints
|
|
692
|
+
let topItems: Record<string, unknown>[];
|
|
693
|
+
if (table === '_logs_raw') {
|
|
694
|
+
topItems = sql.exec(`
|
|
695
|
+
SELECT
|
|
696
|
+
path as label,
|
|
697
|
+
COUNT(*) as count,
|
|
698
|
+
AVG(duration) as avgLatency,
|
|
699
|
+
ROUND(SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100, 1) as errorRate
|
|
700
|
+
FROM ${table}
|
|
701
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
702
|
+
GROUP BY path
|
|
703
|
+
ORDER BY count DESC
|
|
704
|
+
LIMIT 10
|
|
705
|
+
`, startTs, endTs).toArray();
|
|
706
|
+
} else {
|
|
707
|
+
topItems = sql.exec(`
|
|
708
|
+
SELECT
|
|
709
|
+
(category || ':' || operation) as label,
|
|
710
|
+
SUM(request_count) as count,
|
|
711
|
+
CASE WHEN SUM(request_count) > 0
|
|
712
|
+
THEN SUM(avg_duration * request_count) / SUM(request_count)
|
|
713
|
+
ELSE 0 END as avgLatency,
|
|
714
|
+
CASE WHEN SUM(request_count) > 0
|
|
715
|
+
THEN ROUND(SUM(error_count) * 100.0 / SUM(request_count), 1)
|
|
716
|
+
ELSE 0 END as errorRate
|
|
717
|
+
FROM ${table}
|
|
718
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
719
|
+
GROUP BY category, operation
|
|
720
|
+
ORDER BY count DESC
|
|
721
|
+
LIMIT 10
|
|
722
|
+
`, startTs, endTs).toArray();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return jsonResponse({
|
|
726
|
+
timeSeries: timeSeries.map(r => ({ timestamp: r.ts, value: r.value })),
|
|
727
|
+
summary,
|
|
728
|
+
breakdown: breakdownWithPct,
|
|
729
|
+
topItems,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private queryTimeSeries(
|
|
734
|
+
sql: SqlStorage, table: string, tsCol: string,
|
|
735
|
+
startTs: number, endTs: number, catFilter: string, groupBy: string,
|
|
736
|
+
): Response {
|
|
737
|
+
const bucketMs = this.groupByToMs(groupBy);
|
|
738
|
+
|
|
739
|
+
let rows: Record<string, unknown>[];
|
|
740
|
+
if (table === '_logs_raw') {
|
|
741
|
+
rows = sql.exec(`
|
|
742
|
+
SELECT
|
|
743
|
+
(CAST(${tsCol} / ? AS INTEGER) * ?) as ts,
|
|
744
|
+
COUNT(*) as requests,
|
|
745
|
+
SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1 ELSE 0 END) as errors,
|
|
746
|
+
AVG(duration) as avgLatency,
|
|
747
|
+
COUNT(DISTINCT userId) as uniqueUsers
|
|
748
|
+
FROM ${table}
|
|
749
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
750
|
+
GROUP BY ts
|
|
751
|
+
ORDER BY ts
|
|
752
|
+
`, bucketMs, bucketMs, startTs, endTs).toArray();
|
|
753
|
+
} else {
|
|
754
|
+
rows = sql.exec(`
|
|
755
|
+
SELECT
|
|
756
|
+
(CAST(${tsCol} / ? AS INTEGER) * ?) as ts,
|
|
757
|
+
SUM(request_count) as requests,
|
|
758
|
+
SUM(error_count) as errors,
|
|
759
|
+
CASE WHEN SUM(request_count) > 0
|
|
760
|
+
THEN SUM(avg_duration * request_count) / SUM(request_count)
|
|
761
|
+
ELSE 0 END as avgLatency,
|
|
762
|
+
SUM(unique_users) as uniqueUsers
|
|
763
|
+
FROM ${table}
|
|
764
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
765
|
+
GROUP BY ts
|
|
766
|
+
ORDER BY ts
|
|
767
|
+
`, bucketMs, bucketMs, startTs, endTs).toArray();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return jsonResponse({
|
|
771
|
+
timeSeries: rows.map(r => ({
|
|
772
|
+
timestamp: r.ts,
|
|
773
|
+
requests: r.requests,
|
|
774
|
+
errors: r.errors,
|
|
775
|
+
avgLatency: r.avgLatency,
|
|
776
|
+
uniqueUsers: r.uniqueUsers,
|
|
777
|
+
})),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private queryBreakdown(
|
|
782
|
+
sql: SqlStorage, table: string, tsCol: string,
|
|
783
|
+
startTs: number, endTs: number, catFilter: string, category: string,
|
|
784
|
+
): Response {
|
|
785
|
+
|
|
786
|
+
// If filtering by category, break down by subcategory; otherwise by category
|
|
787
|
+
const groupCol = category
|
|
788
|
+
? 'subcategory'
|
|
789
|
+
: 'category';
|
|
790
|
+
|
|
791
|
+
let rows: Record<string, unknown>[];
|
|
792
|
+
if (table === '_logs_raw') {
|
|
793
|
+
rows = sql.exec(`
|
|
794
|
+
SELECT
|
|
795
|
+
COALESCE(${groupCol}, 'other') as label,
|
|
796
|
+
COUNT(*) as count,
|
|
797
|
+
AVG(duration) as avgLatency,
|
|
798
|
+
ROUND(SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100, 1) as errorRate
|
|
799
|
+
FROM ${table}
|
|
800
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
801
|
+
GROUP BY ${groupCol}
|
|
802
|
+
ORDER BY count DESC
|
|
803
|
+
LIMIT 20
|
|
804
|
+
`, startTs, endTs).toArray();
|
|
805
|
+
} else {
|
|
806
|
+
rows = sql.exec(`
|
|
807
|
+
SELECT
|
|
808
|
+
${groupCol} as label,
|
|
809
|
+
SUM(request_count) as count,
|
|
810
|
+
CASE WHEN SUM(request_count) > 0
|
|
811
|
+
THEN SUM(avg_duration * request_count) / SUM(request_count)
|
|
812
|
+
ELSE 0 END as avgLatency,
|
|
813
|
+
CASE WHEN SUM(request_count) > 0
|
|
814
|
+
THEN ROUND(SUM(error_count) * 100.0 / SUM(request_count), 1)
|
|
815
|
+
ELSE 0 END as errorRate
|
|
816
|
+
FROM ${table}
|
|
817
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
818
|
+
GROUP BY ${groupCol}
|
|
819
|
+
ORDER BY count DESC
|
|
820
|
+
LIMIT 20
|
|
821
|
+
`, startTs, endTs).toArray();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const total = rows.reduce((sum, r) => sum + ((r.count as number) || 0), 0);
|
|
825
|
+
const withPct = rows.map(r => ({
|
|
826
|
+
...r,
|
|
827
|
+
percentage: total > 0 ? Math.round(((r.count as number) / total) * 1000) / 10 : 0,
|
|
828
|
+
}));
|
|
829
|
+
|
|
830
|
+
return jsonResponse({ breakdown: withPct });
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private queryTopEndpoints(
|
|
834
|
+
sql: SqlStorage, table: string, tsCol: string,
|
|
835
|
+
startTs: number, endTs: number, catFilter: string,
|
|
836
|
+
): Response {
|
|
837
|
+
|
|
838
|
+
let rows: Record<string, unknown>[];
|
|
839
|
+
if (table === '_logs_raw') {
|
|
840
|
+
rows = sql.exec(`
|
|
841
|
+
SELECT
|
|
842
|
+
path as label,
|
|
843
|
+
COUNT(*) as count,
|
|
844
|
+
AVG(duration) as avgLatency,
|
|
845
|
+
ROUND(SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100, 1) as errorRate
|
|
846
|
+
FROM ${table}
|
|
847
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
848
|
+
GROUP BY path
|
|
849
|
+
ORDER BY count DESC
|
|
850
|
+
LIMIT 20
|
|
851
|
+
`, startTs, endTs).toArray();
|
|
852
|
+
} else {
|
|
853
|
+
rows = sql.exec(`
|
|
854
|
+
SELECT
|
|
855
|
+
(target1 || '/' || target2) as label,
|
|
856
|
+
SUM(request_count) as count,
|
|
857
|
+
CASE WHEN SUM(request_count) > 0
|
|
858
|
+
THEN SUM(avg_duration * request_count) / SUM(request_count)
|
|
859
|
+
ELSE 0 END as avgLatency,
|
|
860
|
+
CASE WHEN SUM(request_count) > 0
|
|
861
|
+
THEN ROUND(SUM(error_count) * 100.0 / SUM(request_count), 1)
|
|
862
|
+
ELSE 0 END as errorRate
|
|
863
|
+
FROM ${table}
|
|
864
|
+
WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
|
|
865
|
+
GROUP BY target1, target2
|
|
866
|
+
ORDER BY count DESC
|
|
867
|
+
LIMIT 20
|
|
868
|
+
`, startTs, endTs).toArray();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return jsonResponse({ topItems: rows });
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// ─── Events Write Handler ───
|
|
875
|
+
|
|
876
|
+
private async handleEventsWrite(request: Request): Promise<Response> {
|
|
877
|
+
try {
|
|
878
|
+
const body = (await request.json()) as { events: Array<Record<string, unknown>> };
|
|
879
|
+
const events = body.events;
|
|
880
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
881
|
+
return jsonResponse({ ok: true, count: 0 });
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const sql = this.ctx.storage.sql;
|
|
885
|
+
|
|
886
|
+
for (const e of events) {
|
|
887
|
+
sql.exec(`
|
|
888
|
+
INSERT INTO _events (timestamp, userId, eventName, properties, region)
|
|
889
|
+
VALUES (?, ?, ?, ?, ?)
|
|
890
|
+
`,
|
|
891
|
+
(e.timestamp as number) || Date.now(),
|
|
892
|
+
(e.userId as string) || null,
|
|
893
|
+
(e.eventName as string) || '',
|
|
894
|
+
e.properties ? (typeof e.properties === 'string' ? e.properties : JSON.stringify(e.properties)) : null,
|
|
895
|
+
(e.region as string) || '',
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return jsonResponse({ ok: true, count: events.length });
|
|
900
|
+
} catch (err) {
|
|
901
|
+
console.error('[LogsDO] Events write failed:', err);
|
|
902
|
+
return jsonResponse({ error: 'Events write failed' }, 500);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ─── Events Query Handler ───
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Query custom events.
|
|
910
|
+
*
|
|
911
|
+
* Query params:
|
|
912
|
+
* range: '1h'|'6h'|'24h'|'7d'|'30d'|'90d' (default '24h')
|
|
913
|
+
* event: filter by event name (optional)
|
|
914
|
+
* userId: filter by userId (optional)
|
|
915
|
+
* metric: 'list'|'count'|'timeSeries'|'topEvents' (default 'list')
|
|
916
|
+
* groupBy: 'minute'|'tenMinute'|'hour'|'day' (default 'hour')
|
|
917
|
+
* limit: max items for list (default 50)
|
|
918
|
+
* cursor: pagination cursor for list
|
|
919
|
+
*/
|
|
920
|
+
private handleEventsQuery(url: URL): Response {
|
|
921
|
+
try {
|
|
922
|
+
const range = url.searchParams.get('range') || '24h';
|
|
923
|
+
const event = url.searchParams.get('event') || '';
|
|
924
|
+
const userId = url.searchParams.get('userId') || '';
|
|
925
|
+
const metric = url.searchParams.get('metric') || 'list';
|
|
926
|
+
const groupBy = url.searchParams.get('groupBy') || 'hour';
|
|
927
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
928
|
+
const cursor = url.searchParams.get('cursor') || '';
|
|
929
|
+
|
|
930
|
+
const { startTs, endTs } = this.parseTimeRange(range);
|
|
931
|
+
const sql = this.ctx.storage.sql;
|
|
932
|
+
|
|
933
|
+
// Determine if we need _events or _events_daily
|
|
934
|
+
const useDaily = range !== '1h' && range !== '6h' && range !== '24h'
|
|
935
|
+
&& range !== '7d' && range !== '30d' && range !== '90d';
|
|
936
|
+
|
|
937
|
+
if (metric === 'list') {
|
|
938
|
+
return this.queryEventsList(sql, startTs, endTs, event, userId, limit, cursor);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (metric === 'count') {
|
|
942
|
+
return this.queryEventsCount(sql, startTs, endTs, event, userId);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (metric === 'timeSeries') {
|
|
946
|
+
return this.queryEventsTimeSeries(sql, startTs, endTs, event, userId, groupBy, useDaily);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (metric === 'topEvents') {
|
|
950
|
+
return this.queryEventsTop(sql, startTs, endTs, userId, limit, useDaily);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return jsonResponse({ error: 'Unknown metric' }, 400);
|
|
954
|
+
} catch (err) {
|
|
955
|
+
console.error('[LogsDO] Events query failed:', err);
|
|
956
|
+
return jsonResponse({ error: 'Events query failed' }, 500);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
private queryEventsList(
|
|
961
|
+
sql: SqlStorage, startTs: number, endTs: number,
|
|
962
|
+
event: string, userId: string, limit: number, cursor: string,
|
|
963
|
+
): Response {
|
|
964
|
+
let where = `WHERE timestamp >= ? AND timestamp < ?`;
|
|
965
|
+
const params: unknown[] = [startTs, endTs];
|
|
966
|
+
|
|
967
|
+
if (event) {
|
|
968
|
+
where += ` AND eventName = ?`;
|
|
969
|
+
params.push(event);
|
|
970
|
+
}
|
|
971
|
+
if (userId) {
|
|
972
|
+
where += ` AND userId = ?`;
|
|
973
|
+
params.push(userId);
|
|
974
|
+
}
|
|
975
|
+
if (cursor) {
|
|
976
|
+
where += ` AND id < ?`;
|
|
977
|
+
params.push(parseInt(cursor, 10));
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const rows = sql.exec(
|
|
981
|
+
`SELECT id, timestamp, userId, eventName, properties FROM _events ${where} ORDER BY id DESC LIMIT ?`,
|
|
982
|
+
...params, limit + 1,
|
|
983
|
+
).toArray();
|
|
984
|
+
|
|
985
|
+
const hasMore = rows.length > limit;
|
|
986
|
+
const items = rows.slice(0, limit);
|
|
987
|
+
|
|
988
|
+
return jsonResponse({
|
|
989
|
+
events: items.map(r => ({
|
|
990
|
+
id: r.id,
|
|
991
|
+
timestamp: r.timestamp,
|
|
992
|
+
userId: r.userId,
|
|
993
|
+
eventName: r.eventName,
|
|
994
|
+
properties: r.properties ? JSON.parse(r.properties as string) : null,
|
|
995
|
+
})),
|
|
996
|
+
cursor: hasMore && items.length > 0 ? String(items[items.length - 1].id) : undefined,
|
|
997
|
+
hasMore,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private queryEventsCount(
|
|
1002
|
+
sql: SqlStorage, startTs: number, endTs: number,
|
|
1003
|
+
event: string, userId: string,
|
|
1004
|
+
): Response {
|
|
1005
|
+
let where = `WHERE timestamp >= ? AND timestamp < ?`;
|
|
1006
|
+
const params: unknown[] = [startTs, endTs];
|
|
1007
|
+
|
|
1008
|
+
if (event) {
|
|
1009
|
+
where += ` AND eventName = ?`;
|
|
1010
|
+
params.push(event);
|
|
1011
|
+
}
|
|
1012
|
+
if (userId) {
|
|
1013
|
+
where += ` AND userId = ?`;
|
|
1014
|
+
params.push(userId);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const rows = sql.exec(
|
|
1018
|
+
`SELECT COUNT(*) as totalEvents, COUNT(DISTINCT userId) as uniqueUsers FROM _events ${where}`,
|
|
1019
|
+
...params,
|
|
1020
|
+
).toArray();
|
|
1021
|
+
|
|
1022
|
+
const row = rows[0] || { totalEvents: 0, uniqueUsers: 0 };
|
|
1023
|
+
return jsonResponse({
|
|
1024
|
+
totalEvents: Number(row.totalEvents) || 0,
|
|
1025
|
+
uniqueUsers: Number(row.uniqueUsers) || 0,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
private queryEventsTimeSeries(
|
|
1030
|
+
sql: SqlStorage, startTs: number, endTs: number,
|
|
1031
|
+
event: string, userId: string, groupBy: string, useDaily: boolean,
|
|
1032
|
+
): Response {
|
|
1033
|
+
const bucketMs = this.groupByToMs(groupBy);
|
|
1034
|
+
|
|
1035
|
+
if (useDaily) {
|
|
1036
|
+
// Use _events_daily for ranges > 90d
|
|
1037
|
+
let where = `WHERE day_ts >= ? AND day_ts < ?`;
|
|
1038
|
+
const params: unknown[] = [startTs, endTs];
|
|
1039
|
+
if (event) {
|
|
1040
|
+
where += ` AND eventName = ?`;
|
|
1041
|
+
params.push(event);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const rows = sql.exec(`
|
|
1045
|
+
SELECT
|
|
1046
|
+
(CAST(day_ts / ? AS INTEGER) * ?) as ts,
|
|
1047
|
+
SUM(event_count) as count
|
|
1048
|
+
FROM _events_daily
|
|
1049
|
+
${where}
|
|
1050
|
+
GROUP BY ts
|
|
1051
|
+
ORDER BY ts
|
|
1052
|
+
`, bucketMs, bucketMs, ...params).toArray();
|
|
1053
|
+
|
|
1054
|
+
return jsonResponse({
|
|
1055
|
+
timeSeries: rows.map(r => ({ timestamp: r.ts, count: Number(r.count) || 0 })),
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Use _events for ranges ≤ 90d
|
|
1060
|
+
let where = `WHERE timestamp >= ? AND timestamp < ?`;
|
|
1061
|
+
const params: unknown[] = [startTs, endTs];
|
|
1062
|
+
if (event) {
|
|
1063
|
+
where += ` AND eventName = ?`;
|
|
1064
|
+
params.push(event);
|
|
1065
|
+
}
|
|
1066
|
+
if (userId) {
|
|
1067
|
+
where += ` AND userId = ?`;
|
|
1068
|
+
params.push(userId);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const rows = sql.exec(`
|
|
1072
|
+
SELECT
|
|
1073
|
+
(CAST(timestamp / ? AS INTEGER) * ?) as ts,
|
|
1074
|
+
COUNT(*) as count
|
|
1075
|
+
FROM _events
|
|
1076
|
+
${where}
|
|
1077
|
+
GROUP BY ts
|
|
1078
|
+
ORDER BY ts
|
|
1079
|
+
`, bucketMs, bucketMs, ...params).toArray();
|
|
1080
|
+
|
|
1081
|
+
return jsonResponse({
|
|
1082
|
+
timeSeries: rows.map(r => ({ timestamp: r.ts, count: Number(r.count) || 0 })),
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
private queryEventsTop(
|
|
1087
|
+
sql: SqlStorage, startTs: number, endTs: number,
|
|
1088
|
+
userId: string, limit: number, useDaily: boolean,
|
|
1089
|
+
): Response {
|
|
1090
|
+
if (useDaily) {
|
|
1091
|
+
const where = `WHERE day_ts >= ? AND day_ts < ?`;
|
|
1092
|
+
const params: unknown[] = [startTs, endTs];
|
|
1093
|
+
|
|
1094
|
+
const rows = sql.exec(`
|
|
1095
|
+
SELECT
|
|
1096
|
+
eventName,
|
|
1097
|
+
SUM(event_count) as count,
|
|
1098
|
+
SUM(unique_users) as uniqueUsers
|
|
1099
|
+
FROM _events_daily
|
|
1100
|
+
${where}
|
|
1101
|
+
GROUP BY eventName
|
|
1102
|
+
ORDER BY count DESC
|
|
1103
|
+
LIMIT ?
|
|
1104
|
+
`, ...params, limit).toArray();
|
|
1105
|
+
|
|
1106
|
+
return jsonResponse({ topEvents: rows });
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
let where = `WHERE timestamp >= ? AND timestamp < ?`;
|
|
1110
|
+
const params: unknown[] = [startTs, endTs];
|
|
1111
|
+
if (userId) {
|
|
1112
|
+
where += ` AND userId = ?`;
|
|
1113
|
+
params.push(userId);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const rows = sql.exec(`
|
|
1117
|
+
SELECT
|
|
1118
|
+
eventName,
|
|
1119
|
+
COUNT(*) as count,
|
|
1120
|
+
COUNT(DISTINCT userId) as uniqueUsers
|
|
1121
|
+
FROM _events
|
|
1122
|
+
${where}
|
|
1123
|
+
GROUP BY eventName
|
|
1124
|
+
ORDER BY count DESC
|
|
1125
|
+
LIMIT ?
|
|
1126
|
+
`, ...params, limit).toArray();
|
|
1127
|
+
|
|
1128
|
+
return jsonResponse({ topEvents: rows });
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ─── Utility ───
|
|
1132
|
+
|
|
1133
|
+
private parseTimeRange(range: string, start?: string | null, end?: string | null): { startTs: number; endTs: number } {
|
|
1134
|
+
if (start && end) {
|
|
1135
|
+
const startTs = new Date(start).getTime();
|
|
1136
|
+
const endTs = new Date(end).getTime();
|
|
1137
|
+
if (Number.isFinite(startTs) && Number.isFinite(endTs) && endTs >= startTs) {
|
|
1138
|
+
return { startTs, endTs };
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const now = Date.now();
|
|
1143
|
+
const endTs = now;
|
|
1144
|
+
let startTs: number;
|
|
1145
|
+
|
|
1146
|
+
switch (range) {
|
|
1147
|
+
case '1h': startTs = now - 3600_000; break;
|
|
1148
|
+
case '6h': startTs = now - 6 * 3600_000; break;
|
|
1149
|
+
case '24h': startTs = now - 86400_000; break;
|
|
1150
|
+
case '7d': startTs = now - 7 * 86400_000; break;
|
|
1151
|
+
case '30d': startTs = now - 30 * 86400_000; break;
|
|
1152
|
+
case '90d': startTs = now - 90 * 86400_000; break;
|
|
1153
|
+
default: startTs = now - 86400_000; break;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return { startTs, endTs };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Select the appropriate table based on time range:
|
|
1161
|
+
* ≤24h → _logs_raw (exact data)
|
|
1162
|
+
* ≤90d → _logs_hourly (aggregated)
|
|
1163
|
+
* >90d → _logs_daily (long-term)
|
|
1164
|
+
*/
|
|
1165
|
+
private selectTable(range: string): string {
|
|
1166
|
+
switch (range) {
|
|
1167
|
+
case '1h':
|
|
1168
|
+
case '6h':
|
|
1169
|
+
case '24h': return '_logs_raw';
|
|
1170
|
+
case '7d':
|
|
1171
|
+
case '30d':
|
|
1172
|
+
case '90d': return '_logs_hourly';
|
|
1173
|
+
default: return '_logs_daily';
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
private groupByToMs(groupBy: string): number {
|
|
1178
|
+
switch (groupBy) {
|
|
1179
|
+
case 'minute': return 60_000;
|
|
1180
|
+
case 'tenMinute': return 600_000;
|
|
1181
|
+
case 'hour': return 3600_000;
|
|
1182
|
+
case 'day': return 86400_000;
|
|
1183
|
+
default: return 3600_000;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// ─── Helpers ───
|
|
1189
|
+
|
|
1190
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
1191
|
+
return new Response(JSON.stringify(data), {
|
|
1192
|
+
status,
|
|
1193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Basic SQL string escaping to prevent injection in category/subcategory filters */
|
|
1198
|
+
function escapeSql(str: string): string {
|
|
1199
|
+
return str.replace(/'/g, "''");
|
|
1200
|
+
}
|