@hasna/logs 0.3.26 → 0.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -10
- package/dashboard/dist/assets/index-C0wZYq1m.js +53 -0
- package/dashboard/dist/assets/index-DGNrK5qb.css +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/cli/index.js +8511 -177
- package/dist/count-bmj4r2zb.js +10 -0
- package/dist/{diagnose-e0w5rwbc.js → diagnose-3q5cy9ra.js} +2 -2
- package/dist/{export-c3eqjste.js → export-cngdb9fh.js} +1 -1
- package/dist/{http-zm3ph78w.js → http-r0xc3d2s.js} +79 -8
- package/dist/index-931pbyn5.js +141 -0
- package/dist/index-b5c72f1p.js +7 -0
- package/dist/{index-7w7v7hnr.js → index-by1pdzbr.js} +14 -5
- package/dist/{index-3dr7d80h.js → index-e1930v9b.js} +12 -8
- package/dist/{index-eh9bkbpa.js → index-e72k53yq.js} +10 -2
- package/dist/{index-edn08m6f.js → index-gcd14q2f.js} +9 -6
- package/dist/index-hq6kzaah.js +26 -0
- package/dist/index-j34f36wy.js +5672 -0
- package/dist/{index-5qznfyah.js → index-q27bgpr1.js} +1086 -1646
- package/dist/index-qk8dbvbc.js +1859 -0
- package/dist/index-t3x838zw.js +2583 -0
- package/dist/{index-gc0zvs88.js → index-y2y0mdtd.js} +596 -37
- package/dist/{index-ww5ggfv3.js → index-zkb3z95a.js} +12 -9
- package/dist/index.js +2990 -22
- package/dist/{jobs-ypmmc2ma.js → jobs-hsgyhfvm.js} +2 -1
- package/dist/mcp/index.js +1473 -4286
- package/dist/{query-7jwj05er.js → query-c5a43zx3.js} +3 -2
- package/dist/server/index.js +2944 -417
- package/dist/storage.js +50 -0
- package/package.json +27 -8
- package/biome.json +0 -13
- package/bun.lock +0 -376
- package/dashboard/README.md +0 -73
- package/dashboard/bun.lock +0 -526
- package/dashboard/eslint.config.js +0 -23
- package/dashboard/index.html +0 -13
- package/dashboard/package.json +0 -32
- package/dashboard/src/App.css +0 -184
- package/dashboard/src/App.tsx +0 -49
- package/dashboard/src/api.ts +0 -33
- package/dashboard/src/assets/hero.png +0 -0
- package/dashboard/src/assets/react.svg +0 -1
- package/dashboard/src/assets/vite.svg +0 -1
- package/dashboard/src/index.css +0 -111
- package/dashboard/src/main.tsx +0 -10
- package/dashboard/src/pages/Alerts.tsx +0 -69
- package/dashboard/src/pages/Issues.tsx +0 -50
- package/dashboard/src/pages/Perf.tsx +0 -75
- package/dashboard/src/pages/Projects.tsx +0 -67
- package/dashboard/src/pages/Summary.tsx +0 -67
- package/dashboard/src/pages/Tail.tsx +0 -65
- package/dashboard/tsconfig.app.json +0 -28
- package/dashboard/tsconfig.json +0 -7
- package/dashboard/tsconfig.node.json +0 -26
- package/dashboard/vite.config.ts +0 -14
- package/dist/count-x3n7qg3c.js +0 -9
- package/dist/index-997bkzr2.js +0 -15
- package/dist/index-pen6t0yc.js +0 -10794
- package/sdk/package.json +0 -27
- package/sdk/src/index.ts +0 -143
- package/sdk/src/types.ts +0 -56
- package/src/cli/entrypoints.test.ts +0 -63
- package/src/cli/index.ts +0 -471
- package/src/db/index.test.ts +0 -33
- package/src/db/index.ts +0 -189
- package/src/db/migrations/001_alert_rules.ts +0 -21
- package/src/db/migrations/002_issues.ts +0 -21
- package/src/db/migrations/003_retention.ts +0 -15
- package/src/db/migrations/004_page_auth.ts +0 -13
- package/src/db/pg-migrations.ts +0 -167
- package/src/index.ts +0 -1
- package/src/lib/alerts.test.ts +0 -67
- package/src/lib/alerts.ts +0 -117
- package/src/lib/browser-script.test.ts +0 -35
- package/src/lib/browser-script.ts +0 -31
- package/src/lib/compare.test.ts +0 -52
- package/src/lib/compare.ts +0 -85
- package/src/lib/count.test.ts +0 -44
- package/src/lib/count.ts +0 -55
- package/src/lib/diagnose.test.ts +0 -55
- package/src/lib/diagnose.ts +0 -91
- package/src/lib/export.test.ts +0 -66
- package/src/lib/export.ts +0 -65
- package/src/lib/github.ts +0 -38
- package/src/lib/health.test.ts +0 -48
- package/src/lib/health.ts +0 -51
- package/src/lib/ingest.test.ts +0 -57
- package/src/lib/ingest.ts +0 -78
- package/src/lib/issues.test.ts +0 -79
- package/src/lib/issues.ts +0 -70
- package/src/lib/jobs.test.ts +0 -69
- package/src/lib/jobs.ts +0 -63
- package/src/lib/lighthouse.ts +0 -65
- package/src/lib/package-meta.test.ts +0 -43
- package/src/lib/package-meta.ts +0 -80
- package/src/lib/page-auth.test.ts +0 -54
- package/src/lib/page-auth.ts +0 -48
- package/src/lib/parse-time.test.ts +0 -37
- package/src/lib/parse-time.ts +0 -14
- package/src/lib/perf.test.ts +0 -45
- package/src/lib/perf.ts +0 -46
- package/src/lib/projects.test.ts +0 -73
- package/src/lib/projects.ts +0 -69
- package/src/lib/query.test.ts +0 -104
- package/src/lib/query.ts +0 -84
- package/src/lib/retention.test.ts +0 -42
- package/src/lib/retention.ts +0 -62
- package/src/lib/rotate.test.ts +0 -37
- package/src/lib/rotate.ts +0 -27
- package/src/lib/scanner.ts +0 -131
- package/src/lib/scheduler.ts +0 -63
- package/src/lib/session-context.ts +0 -28
- package/src/lib/summarize.test.ts +0 -38
- package/src/lib/summarize.ts +0 -23
- package/src/mcp/http.test.ts +0 -92
- package/src/mcp/http.ts +0 -135
- package/src/mcp/index.test.ts +0 -27
- package/src/mcp/index.ts +0 -444
- package/src/server/index.ts +0 -61
- package/src/server/routes/alerts.ts +0 -32
- package/src/server/routes/issues.ts +0 -43
- package/src/server/routes/jobs.ts +0 -32
- package/src/server/routes/logs.ts +0 -113
- package/src/server/routes/perf.ts +0 -23
- package/src/server/routes/projects.ts +0 -67
- package/src/server/routes/stream.ts +0 -43
- package/src/server/server.test.ts +0 -194
- package/src/types/index.ts +0 -119
- package/tsconfig.json +0 -22
- /package/dashboard/{public → dist}/favicon.svg +0 -0
- /package/dashboard/{public → dist}/icons.svg +0 -0
package/dist/server/index.js
CHANGED
|
@@ -5,12 +5,17 @@ import {
|
|
|
5
5
|
runRetentionForProject,
|
|
6
6
|
setPageAuth,
|
|
7
7
|
setRetentionPolicy,
|
|
8
|
-
startScheduler
|
|
9
|
-
|
|
8
|
+
startScheduler,
|
|
9
|
+
structuredLogPayloadToEntries,
|
|
10
|
+
validateStructuredLogReferences
|
|
11
|
+
} from "../index-y2y0mdtd.js";
|
|
12
|
+
import {
|
|
13
|
+
countLogs
|
|
14
|
+
} from "../index-gcd14q2f.js";
|
|
10
15
|
import {
|
|
11
16
|
exportToCsv,
|
|
12
17
|
exportToJson
|
|
13
|
-
} from "../index-
|
|
18
|
+
} from "../index-e72k53yq.js";
|
|
14
19
|
import {
|
|
15
20
|
getHealth
|
|
16
21
|
} from "../index-cpvq9np9.js";
|
|
@@ -20,43 +25,60 @@ import {
|
|
|
20
25
|
createProject,
|
|
21
26
|
deleteAlertRule,
|
|
22
27
|
exitIfMetadataRequest,
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
exportEventsToJson,
|
|
29
|
+
getEvent,
|
|
25
30
|
getLatestSnapshot,
|
|
26
31
|
getPerfTrend,
|
|
27
32
|
getProject,
|
|
33
|
+
getTestReport,
|
|
34
|
+
hasBufferedEventCatalogEvent,
|
|
35
|
+
hasBufferedLogEvent,
|
|
36
|
+
hasOption,
|
|
28
37
|
ingestBatch,
|
|
29
38
|
ingestLog,
|
|
39
|
+
ingestUniversalEvent,
|
|
30
40
|
listAlertRules,
|
|
31
|
-
listIssues,
|
|
32
41
|
listPages,
|
|
33
42
|
listProjects,
|
|
34
43
|
readOptionValue,
|
|
35
44
|
resolveProjectId,
|
|
45
|
+
searchEvents,
|
|
46
|
+
searchTestReports,
|
|
47
|
+
subscribeEventCatalogEvents,
|
|
48
|
+
subscribeLogEvents,
|
|
36
49
|
summarizeLogs,
|
|
37
50
|
updateAlertRule,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} from "../index-
|
|
51
|
+
updateProject,
|
|
52
|
+
validateUniversalEventInput
|
|
53
|
+
} from "../index-qk8dbvbc.js";
|
|
54
|
+
import {
|
|
55
|
+
getDb,
|
|
56
|
+
getIssue,
|
|
57
|
+
listIssues,
|
|
58
|
+
updateIssueStatus
|
|
59
|
+
} from "../index-t3x838zw.js";
|
|
41
60
|
import {
|
|
42
61
|
createJob,
|
|
43
62
|
deleteJob,
|
|
44
63
|
listJobs,
|
|
45
64
|
updateJob
|
|
46
|
-
} from "../index-
|
|
65
|
+
} from "../index-e1930v9b.js";
|
|
47
66
|
import {
|
|
48
67
|
getLogContext,
|
|
49
68
|
searchLogs,
|
|
50
69
|
tailLogs
|
|
51
|
-
} from "../index-
|
|
52
|
-
import
|
|
53
|
-
countLogs
|
|
54
|
-
} from "../index-edn08m6f.js";
|
|
70
|
+
} from "../index-zkb3z95a.js";
|
|
71
|
+
import"../index-b5c72f1p.js";
|
|
55
72
|
import {
|
|
56
73
|
parseTime
|
|
57
|
-
} from "../index-
|
|
74
|
+
} from "../index-hq6kzaah.js";
|
|
58
75
|
import"../index-re3ntm60.js";
|
|
59
76
|
|
|
77
|
+
// src/server/index.ts
|
|
78
|
+
import { existsSync } from "fs";
|
|
79
|
+
import { dirname as dirname2, resolve } from "path";
|
|
80
|
+
import { fileURLToPath } from "url";
|
|
81
|
+
|
|
60
82
|
// node_modules/hono/dist/compose.js
|
|
61
83
|
var compose = (middleware, onError, onNotFound) => {
|
|
62
84
|
return (context, next) => {
|
|
@@ -429,7 +451,7 @@ var HonoRequest = class {
|
|
|
429
451
|
return headerData;
|
|
430
452
|
}
|
|
431
453
|
async parseBody(options) {
|
|
432
|
-
return
|
|
454
|
+
return parseBody(this, options);
|
|
433
455
|
}
|
|
434
456
|
#cachedBody = (key) => {
|
|
435
457
|
const { bodyCache, raw } = this;
|
|
@@ -457,6 +479,9 @@ var HonoRequest = class {
|
|
|
457
479
|
arrayBuffer() {
|
|
458
480
|
return this.#cachedBody("arrayBuffer");
|
|
459
481
|
}
|
|
482
|
+
bytes() {
|
|
483
|
+
return this.#cachedBody("arrayBuffer").then((buffer) => new Uint8Array(buffer));
|
|
484
|
+
}
|
|
460
485
|
blob() {
|
|
461
486
|
return this.#cachedBody("blob");
|
|
462
487
|
}
|
|
@@ -793,7 +818,7 @@ var Hono = class _Hono {
|
|
|
793
818
|
handler = async (c, next) => (await compose([], app.errorHandler)(c, () => r.handler(c, next))).res;
|
|
794
819
|
handler[COMPOSED_HANDLER] = r.handler;
|
|
795
820
|
}
|
|
796
|
-
subApp.#addRoute(r.method, r.path, handler);
|
|
821
|
+
subApp.#addRoute(r.method, r.path, handler, r.basePath);
|
|
797
822
|
});
|
|
798
823
|
return this;
|
|
799
824
|
}
|
|
@@ -840,7 +865,7 @@ var Hono = class _Hono {
|
|
|
840
865
|
const pathPrefixLength = mergedPath === "/" ? 0 : mergedPath.length;
|
|
841
866
|
return (request) => {
|
|
842
867
|
const url = new URL(request.url);
|
|
843
|
-
url.pathname =
|
|
868
|
+
url.pathname = this.getPath(request).slice(pathPrefixLength) || "/";
|
|
844
869
|
return new Request(url, request);
|
|
845
870
|
};
|
|
846
871
|
})();
|
|
@@ -854,10 +879,15 @@ var Hono = class _Hono {
|
|
|
854
879
|
this.#addRoute(METHOD_NAME_ALL, mergePath(path, "*"), handler);
|
|
855
880
|
return this;
|
|
856
881
|
}
|
|
857
|
-
#addRoute(method, path, handler) {
|
|
882
|
+
#addRoute(method, path, handler, baseRoutePath) {
|
|
858
883
|
method = method.toUpperCase();
|
|
859
884
|
path = mergePath(this._basePath, path);
|
|
860
|
-
const r = {
|
|
885
|
+
const r = {
|
|
886
|
+
basePath: baseRoutePath !== undefined ? mergePath(this._basePath, baseRoutePath) : this._basePath,
|
|
887
|
+
path,
|
|
888
|
+
method,
|
|
889
|
+
handler
|
|
890
|
+
};
|
|
861
891
|
this.router.add(method, path, [handler, r]);
|
|
862
892
|
this.routes.push(r);
|
|
863
893
|
}
|
|
@@ -1595,97 +1625,12 @@ var Hono2 = class extends Hono {
|
|
|
1595
1625
|
}
|
|
1596
1626
|
};
|
|
1597
1627
|
|
|
1598
|
-
// node_modules/hono/dist/middleware/cors/index.js
|
|
1599
|
-
var cors = (options) => {
|
|
1600
|
-
const defaults = {
|
|
1601
|
-
origin: "*",
|
|
1602
|
-
allowMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"],
|
|
1603
|
-
allowHeaders: [],
|
|
1604
|
-
exposeHeaders: []
|
|
1605
|
-
};
|
|
1606
|
-
const opts = {
|
|
1607
|
-
...defaults,
|
|
1608
|
-
...options
|
|
1609
|
-
};
|
|
1610
|
-
const findAllowOrigin = ((optsOrigin) => {
|
|
1611
|
-
if (typeof optsOrigin === "string") {
|
|
1612
|
-
if (optsOrigin === "*") {
|
|
1613
|
-
return () => optsOrigin;
|
|
1614
|
-
} else {
|
|
1615
|
-
return (origin) => optsOrigin === origin ? origin : null;
|
|
1616
|
-
}
|
|
1617
|
-
} else if (typeof optsOrigin === "function") {
|
|
1618
|
-
return optsOrigin;
|
|
1619
|
-
} else {
|
|
1620
|
-
return (origin) => optsOrigin.includes(origin) ? origin : null;
|
|
1621
|
-
}
|
|
1622
|
-
})(opts.origin);
|
|
1623
|
-
const findAllowMethods = ((optsAllowMethods) => {
|
|
1624
|
-
if (typeof optsAllowMethods === "function") {
|
|
1625
|
-
return optsAllowMethods;
|
|
1626
|
-
} else if (Array.isArray(optsAllowMethods)) {
|
|
1627
|
-
return () => optsAllowMethods;
|
|
1628
|
-
} else {
|
|
1629
|
-
return () => [];
|
|
1630
|
-
}
|
|
1631
|
-
})(opts.allowMethods);
|
|
1632
|
-
return async function cors2(c, next) {
|
|
1633
|
-
function set(key, value) {
|
|
1634
|
-
c.res.headers.set(key, value);
|
|
1635
|
-
}
|
|
1636
|
-
const allowOrigin = await findAllowOrigin(c.req.header("origin") || "", c);
|
|
1637
|
-
if (allowOrigin) {
|
|
1638
|
-
set("Access-Control-Allow-Origin", allowOrigin);
|
|
1639
|
-
}
|
|
1640
|
-
if (opts.credentials) {
|
|
1641
|
-
set("Access-Control-Allow-Credentials", "true");
|
|
1642
|
-
}
|
|
1643
|
-
if (opts.exposeHeaders?.length) {
|
|
1644
|
-
set("Access-Control-Expose-Headers", opts.exposeHeaders.join(","));
|
|
1645
|
-
}
|
|
1646
|
-
if (c.req.method === "OPTIONS") {
|
|
1647
|
-
if (opts.origin !== "*") {
|
|
1648
|
-
set("Vary", "Origin");
|
|
1649
|
-
}
|
|
1650
|
-
if (opts.maxAge != null) {
|
|
1651
|
-
set("Access-Control-Max-Age", opts.maxAge.toString());
|
|
1652
|
-
}
|
|
1653
|
-
const allowMethods = await findAllowMethods(c.req.header("origin") || "", c);
|
|
1654
|
-
if (allowMethods.length) {
|
|
1655
|
-
set("Access-Control-Allow-Methods", allowMethods.join(","));
|
|
1656
|
-
}
|
|
1657
|
-
let headers = opts.allowHeaders;
|
|
1658
|
-
if (!headers?.length) {
|
|
1659
|
-
const requestHeaders = c.req.header("Access-Control-Request-Headers");
|
|
1660
|
-
if (requestHeaders) {
|
|
1661
|
-
headers = requestHeaders.split(/\s*,\s*/);
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
if (headers?.length) {
|
|
1665
|
-
set("Access-Control-Allow-Headers", headers.join(","));
|
|
1666
|
-
c.res.headers.append("Vary", "Access-Control-Request-Headers");
|
|
1667
|
-
}
|
|
1668
|
-
c.res.headers.delete("Content-Length");
|
|
1669
|
-
c.res.headers.delete("Content-Type");
|
|
1670
|
-
return new Response(null, {
|
|
1671
|
-
headers: c.res.headers,
|
|
1672
|
-
status: 204,
|
|
1673
|
-
statusText: "No Content"
|
|
1674
|
-
});
|
|
1675
|
-
}
|
|
1676
|
-
await next();
|
|
1677
|
-
if (opts.origin !== "*") {
|
|
1678
|
-
c.header("Vary", "Origin", { append: true });
|
|
1679
|
-
}
|
|
1680
|
-
};
|
|
1681
|
-
};
|
|
1682
|
-
|
|
1683
1628
|
// node_modules/hono/dist/adapter/bun/serve-static.js
|
|
1684
1629
|
import { stat } from "fs/promises";
|
|
1685
1630
|
import { join } from "path";
|
|
1686
1631
|
|
|
1687
1632
|
// node_modules/hono/dist/utils/compress.js
|
|
1688
|
-
var COMPRESSIBLE_CONTENT_TYPE_REGEX = /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i;
|
|
1633
|
+
var COMPRESSIBLE_CONTENT_TYPE_REGEX = /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|msgpack|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|vnd\.msgpack|wasm|x-httpd-php|x-javascript|x-msgpack|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml|msgpack))(?:[;\s]|$)/i;
|
|
1689
1634
|
|
|
1690
1635
|
// node_modules/hono/dist/utils/mime.js
|
|
1691
1636
|
var getMimeType = (filename, mimes = baseMimes) => {
|
|
@@ -1694,11 +1639,7 @@ var getMimeType = (filename, mimes = baseMimes) => {
|
|
|
1694
1639
|
if (!match2) {
|
|
1695
1640
|
return;
|
|
1696
1641
|
}
|
|
1697
|
-
|
|
1698
|
-
if (mimeType && mimeType.startsWith("text")) {
|
|
1699
|
-
mimeType += "; charset=utf-8";
|
|
1700
|
-
}
|
|
1701
|
-
return mimeType;
|
|
1642
|
+
return mimes[match2[1].toLowerCase()];
|
|
1702
1643
|
};
|
|
1703
1644
|
var _baseMimes = {
|
|
1704
1645
|
aac: "audio/aac",
|
|
@@ -1707,25 +1648,25 @@ var _baseMimes = {
|
|
|
1707
1648
|
av1: "video/av1",
|
|
1708
1649
|
bin: "application/octet-stream",
|
|
1709
1650
|
bmp: "image/bmp",
|
|
1710
|
-
css: "text/css",
|
|
1711
|
-
csv: "text/csv",
|
|
1651
|
+
css: "text/css; charset=utf-8",
|
|
1652
|
+
csv: "text/csv; charset=utf-8",
|
|
1712
1653
|
eot: "application/vnd.ms-fontobject",
|
|
1713
1654
|
epub: "application/epub+zip",
|
|
1714
1655
|
gif: "image/gif",
|
|
1715
1656
|
gz: "application/gzip",
|
|
1716
|
-
htm: "text/html",
|
|
1717
|
-
html: "text/html",
|
|
1657
|
+
htm: "text/html; charset=utf-8",
|
|
1658
|
+
html: "text/html; charset=utf-8",
|
|
1718
1659
|
ico: "image/x-icon",
|
|
1719
|
-
ics: "text/calendar",
|
|
1660
|
+
ics: "text/calendar; charset=utf-8",
|
|
1720
1661
|
jpeg: "image/jpeg",
|
|
1721
1662
|
jpg: "image/jpeg",
|
|
1722
|
-
js: "text/javascript",
|
|
1663
|
+
js: "text/javascript; charset=utf-8",
|
|
1723
1664
|
json: "application/json",
|
|
1724
1665
|
jsonld: "application/ld+json",
|
|
1725
1666
|
map: "application/json",
|
|
1726
1667
|
mid: "audio/x-midi",
|
|
1727
1668
|
midi: "audio/x-midi",
|
|
1728
|
-
mjs: "text/javascript",
|
|
1669
|
+
mjs: "text/javascript; charset=utf-8",
|
|
1729
1670
|
mp3: "audio/mpeg",
|
|
1730
1671
|
mp4: "video/mp4",
|
|
1731
1672
|
mpeg: "video/mpeg",
|
|
@@ -1737,12 +1678,12 @@ var _baseMimes = {
|
|
|
1737
1678
|
pdf: "application/pdf",
|
|
1738
1679
|
png: "image/png",
|
|
1739
1680
|
rtf: "application/rtf",
|
|
1740
|
-
svg: "image/svg+xml",
|
|
1681
|
+
svg: "image/svg+xml; charset=utf-8",
|
|
1741
1682
|
tif: "image/tiff",
|
|
1742
1683
|
tiff: "image/tiff",
|
|
1743
1684
|
ts: "video/mp2t",
|
|
1744
1685
|
ttf: "font/ttf",
|
|
1745
|
-
txt: "text/plain",
|
|
1686
|
+
txt: "text/plain; charset=utf-8",
|
|
1746
1687
|
wasm: "application/wasm",
|
|
1747
1688
|
webm: "video/webm",
|
|
1748
1689
|
weba: "audio/webm",
|
|
@@ -1750,8 +1691,8 @@ var _baseMimes = {
|
|
|
1750
1691
|
webp: "image/webp",
|
|
1751
1692
|
woff: "font/woff",
|
|
1752
1693
|
woff2: "font/woff2",
|
|
1753
|
-
xhtml: "application/xhtml+xml",
|
|
1754
|
-
xml: "application/xml",
|
|
1694
|
+
xhtml: "application/xhtml+xml; charset=utf-8",
|
|
1695
|
+
xml: "application/xml; charset=utf-8",
|
|
1755
1696
|
zip: "application/zip",
|
|
1756
1697
|
"3gp": "video/3gpp",
|
|
1757
1698
|
"3g2": "video/3gpp2",
|
|
@@ -1798,7 +1739,7 @@ var serveStatic = (options) => {
|
|
|
1798
1739
|
} else {
|
|
1799
1740
|
try {
|
|
1800
1741
|
filename = tryDecodeURI(c.req.path);
|
|
1801
|
-
if (/(?:^|[\/\\])
|
|
1742
|
+
if (/(?:^|[\/\\])\.{1,2}(?:$|[\/\\])|[\/\\]{2,}|\\/.test(filename)) {
|
|
1802
1743
|
throw new Error;
|
|
1803
1744
|
}
|
|
1804
1745
|
} catch {
|
|
@@ -1843,7 +1784,7 @@ var serveStatic = (options) => {
|
|
|
1843
1784
|
};
|
|
1844
1785
|
|
|
1845
1786
|
// node_modules/hono/dist/adapter/bun/serve-static.js
|
|
1846
|
-
var serveStatic2 = (options) => {
|
|
1787
|
+
var serveStatic2 = (options = {}) => {
|
|
1847
1788
|
return async function serveStatic22(c, next) {
|
|
1848
1789
|
const getContent = async (path) => {
|
|
1849
1790
|
const file = Bun.file(path);
|
|
@@ -1951,14 +1892,96 @@ var upgradeWebSocket = defineWebSocketHelper((c, events) => {
|
|
|
1951
1892
|
return;
|
|
1952
1893
|
});
|
|
1953
1894
|
|
|
1895
|
+
// node_modules/hono/dist/middleware/cors/index.js
|
|
1896
|
+
var cors = (options) => {
|
|
1897
|
+
const opts = {
|
|
1898
|
+
origin: "*",
|
|
1899
|
+
allowMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"],
|
|
1900
|
+
allowHeaders: [],
|
|
1901
|
+
exposeHeaders: [],
|
|
1902
|
+
...options
|
|
1903
|
+
};
|
|
1904
|
+
const findAllowOrigin = ((optsOrigin) => {
|
|
1905
|
+
if (typeof optsOrigin === "string") {
|
|
1906
|
+
if (optsOrigin === "*") {
|
|
1907
|
+
return () => optsOrigin;
|
|
1908
|
+
} else {
|
|
1909
|
+
return (origin) => optsOrigin === origin ? origin : null;
|
|
1910
|
+
}
|
|
1911
|
+
} else if (typeof optsOrigin === "function") {
|
|
1912
|
+
return optsOrigin;
|
|
1913
|
+
} else {
|
|
1914
|
+
return (origin) => optsOrigin.includes(origin) ? origin : null;
|
|
1915
|
+
}
|
|
1916
|
+
})(opts.origin);
|
|
1917
|
+
const findAllowMethods = ((optsAllowMethods) => {
|
|
1918
|
+
if (typeof optsAllowMethods === "function") {
|
|
1919
|
+
return optsAllowMethods;
|
|
1920
|
+
} else if (Array.isArray(optsAllowMethods)) {
|
|
1921
|
+
return () => optsAllowMethods;
|
|
1922
|
+
} else {
|
|
1923
|
+
return () => [];
|
|
1924
|
+
}
|
|
1925
|
+
})(opts.allowMethods);
|
|
1926
|
+
return async function cors2(c, next) {
|
|
1927
|
+
function set(key, value) {
|
|
1928
|
+
c.res.headers.set(key, value);
|
|
1929
|
+
}
|
|
1930
|
+
const allowOrigin = await findAllowOrigin(c.req.header("origin") || "", c);
|
|
1931
|
+
if (allowOrigin) {
|
|
1932
|
+
set("Access-Control-Allow-Origin", allowOrigin);
|
|
1933
|
+
}
|
|
1934
|
+
if (opts.credentials) {
|
|
1935
|
+
set("Access-Control-Allow-Credentials", "true");
|
|
1936
|
+
}
|
|
1937
|
+
if (opts.exposeHeaders?.length) {
|
|
1938
|
+
set("Access-Control-Expose-Headers", opts.exposeHeaders.join(","));
|
|
1939
|
+
}
|
|
1940
|
+
if (c.req.method === "OPTIONS") {
|
|
1941
|
+
if (opts.origin !== "*") {
|
|
1942
|
+
set("Vary", "Origin");
|
|
1943
|
+
}
|
|
1944
|
+
if (opts.maxAge != null) {
|
|
1945
|
+
set("Access-Control-Max-Age", opts.maxAge.toString());
|
|
1946
|
+
}
|
|
1947
|
+
const allowMethods = await findAllowMethods(c.req.header("origin") || "", c);
|
|
1948
|
+
if (allowMethods.length) {
|
|
1949
|
+
set("Access-Control-Allow-Methods", allowMethods.join(","));
|
|
1950
|
+
}
|
|
1951
|
+
let headers = opts.allowHeaders;
|
|
1952
|
+
if (!headers?.length) {
|
|
1953
|
+
const requestHeaders = c.req.header("Access-Control-Request-Headers");
|
|
1954
|
+
if (requestHeaders) {
|
|
1955
|
+
headers = requestHeaders.split(/\s*,\s*/);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
if (headers?.length) {
|
|
1959
|
+
set("Access-Control-Allow-Headers", headers.join(","));
|
|
1960
|
+
c.res.headers.append("Vary", "Access-Control-Request-Headers");
|
|
1961
|
+
}
|
|
1962
|
+
c.res.headers.delete("Content-Length");
|
|
1963
|
+
c.res.headers.delete("Content-Type");
|
|
1964
|
+
return new Response(null, {
|
|
1965
|
+
headers: c.res.headers,
|
|
1966
|
+
status: 204,
|
|
1967
|
+
statusText: "No Content"
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
await next();
|
|
1971
|
+
if (opts.origin !== "*") {
|
|
1972
|
+
c.header("Vary", "Origin", { append: true });
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
};
|
|
1976
|
+
|
|
1954
1977
|
// src/lib/browser-script.ts
|
|
1955
1978
|
function getBrowserScript(serverUrl) {
|
|
1956
1979
|
return `(function(){
|
|
1957
|
-
var cfg={url:'${serverUrl}',projectId:null};
|
|
1980
|
+
var cfg={url:'${serverUrl}',projectId:null,browserToken:null};
|
|
1958
1981
|
var el=document.currentScript;
|
|
1959
|
-
if(el){cfg.projectId=el.getAttribute('data-project')||null;}
|
|
1982
|
+
if(el){cfg.projectId=el.getAttribute('data-project')||null;cfg.browserToken=el.getAttribute('data-browser-token')||el.getAttribute('data-write-token')||null;}
|
|
1960
1983
|
var q=[];
|
|
1961
|
-
function flush(){if(!q.length)return;var b=q.splice(0);fetch(cfg.url+'/api/logs',{method:'POST',headers:
|
|
1984
|
+
function flush(){if(!q.length)return;var b=q.splice(0);var h={'Content-Type':'application/json'};if(cfg.browserToken)h['X-Logs-Browser-Token']=cfg.browserToken;fetch(cfg.url+'/api/logs',{method:'POST',headers:h,body:JSON.stringify(b),keepalive:true}).catch(function(){});}
|
|
1962
1985
|
setInterval(flush,2000);
|
|
1963
1986
|
function push(level,msg,extra){
|
|
1964
1987
|
q.push(Object.assign({level:level,message:String(msg),source:'script',url:location.href,timestamp:new Date().toISOString()},cfg.projectId?{project_id:cfg.projectId}:{},extra||{}));
|
|
@@ -1975,112 +1998,1421 @@ window.__logs={push:push,flush:flush,config:cfg};
|
|
|
1975
1998
|
})();`;
|
|
1976
1999
|
}
|
|
1977
2000
|
|
|
1978
|
-
// src/
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
if (!updated)
|
|
1995
|
-
return c.json({ error: "not found" }, 404);
|
|
1996
|
-
return c.json(updated);
|
|
1997
|
-
});
|
|
1998
|
-
app.delete("/:id", (c) => {
|
|
1999
|
-
deleteAlertRule(db, c.req.param("id"));
|
|
2000
|
-
return c.json({ deleted: true });
|
|
2001
|
+
// src/lib/browser-ingest-tokens.ts
|
|
2002
|
+
import { createHash, randomBytes } from "crypto";
|
|
2003
|
+
function createBrowserIngestToken(db, projectId, opts = {}) {
|
|
2004
|
+
const token = `olb_${randomBytes(32).toString("hex")}`;
|
|
2005
|
+
const tokenHash = hashBrowserToken(token);
|
|
2006
|
+
const allowedOrigins = normalizeAllowedOrigins(opts.allowed_origins ?? []);
|
|
2007
|
+
const row = db.prepare(`
|
|
2008
|
+
INSERT INTO browser_ingest_tokens (project_id, token_hash, token_prefix, name, allowed_origins)
|
|
2009
|
+
VALUES ($project_id, $token_hash, $token_prefix, $name, $allowed_origins)
|
|
2010
|
+
RETURNING id, project_id, token_prefix, name, allowed_origins, enabled, created_at, last_used_at
|
|
2011
|
+
`).get({
|
|
2012
|
+
$project_id: projectId,
|
|
2013
|
+
$token_hash: tokenHash,
|
|
2014
|
+
$token_prefix: token.slice(0, 12),
|
|
2015
|
+
$name: opts.name ?? null,
|
|
2016
|
+
$allowed_origins: allowedOrigins.length > 0 ? JSON.stringify(allowedOrigins) : null
|
|
2001
2017
|
});
|
|
2002
|
-
return
|
|
2018
|
+
return { ...row, token };
|
|
2003
2019
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
});
|
|
2012
|
-
app.get("/:id", (c) => {
|
|
2013
|
-
const issue = getIssue(db, c.req.param("id"));
|
|
2014
|
-
if (!issue)
|
|
2015
|
-
return c.json({ error: "not found" }, 404);
|
|
2016
|
-
return c.json(issue);
|
|
2017
|
-
});
|
|
2018
|
-
app.get("/:id/logs", (c) => {
|
|
2019
|
-
const issue = getIssue(db, c.req.param("id"));
|
|
2020
|
-
if (!issue)
|
|
2021
|
-
return c.json({ error: "not found" }, 404);
|
|
2022
|
-
const rows = searchLogs(db, {
|
|
2023
|
-
project_id: issue.project_id ?? undefined,
|
|
2024
|
-
level: issue.level,
|
|
2025
|
-
service: issue.service ?? undefined,
|
|
2026
|
-
text: issue.message_template.slice(0, 50),
|
|
2027
|
-
limit: 50
|
|
2028
|
-
});
|
|
2029
|
-
return c.json(rows);
|
|
2030
|
-
});
|
|
2031
|
-
app.put("/:id", async (c) => {
|
|
2032
|
-
const { status } = await c.req.json();
|
|
2033
|
-
if (!["open", "resolved", "ignored"].includes(status))
|
|
2034
|
-
return c.json({ error: "invalid status" }, 422);
|
|
2035
|
-
const updated = updateIssueStatus(db, c.req.param("id"), status);
|
|
2036
|
-
if (!updated)
|
|
2037
|
-
return c.json({ error: "not found" }, 404);
|
|
2038
|
-
return c.json(updated);
|
|
2039
|
-
});
|
|
2040
|
-
return app;
|
|
2020
|
+
function listBrowserIngestTokens(db, projectId) {
|
|
2021
|
+
return db.prepare(`
|
|
2022
|
+
SELECT id, project_id, token_prefix, name, allowed_origins, enabled, created_at, last_used_at
|
|
2023
|
+
FROM browser_ingest_tokens
|
|
2024
|
+
WHERE project_id = ?
|
|
2025
|
+
ORDER BY created_at DESC
|
|
2026
|
+
`).all(projectId);
|
|
2041
2027
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2028
|
+
function revokeBrowserIngestToken(db, projectId, tokenId) {
|
|
2029
|
+
const result = db.prepare("UPDATE browser_ingest_tokens SET enabled = 0 WHERE id = ? AND project_id = ? AND enabled = 1").run(tokenId, projectId);
|
|
2030
|
+
return result.changes > 0;
|
|
2031
|
+
}
|
|
2032
|
+
function validateBrowserIngestToken(db, token, origin) {
|
|
2033
|
+
if (!token || !token.startsWith("olb_"))
|
|
2034
|
+
return null;
|
|
2035
|
+
const row = db.prepare(`
|
|
2036
|
+
SELECT id, project_id, token_prefix, allowed_origins
|
|
2037
|
+
FROM browser_ingest_tokens
|
|
2038
|
+
WHERE token_hash = ? AND enabled = 1
|
|
2039
|
+
`).get(hashBrowserToken(token));
|
|
2040
|
+
if (!row)
|
|
2041
|
+
return null;
|
|
2042
|
+
const allowedOrigins = parseAllowedOrigins(row.allowed_origins);
|
|
2043
|
+
if (allowedOrigins.length > 0) {
|
|
2044
|
+
if (!origin)
|
|
2045
|
+
return null;
|
|
2046
|
+
const normalizedOrigin = normalizeOrigin(origin);
|
|
2047
|
+
if (!normalizedOrigin || !allowedOrigins.includes(normalizedOrigin))
|
|
2048
|
+
return null;
|
|
2049
|
+
}
|
|
2050
|
+
return {
|
|
2051
|
+
id: row.id,
|
|
2052
|
+
project_id: row.project_id,
|
|
2053
|
+
token_prefix: row.token_prefix,
|
|
2054
|
+
allowed_origins: allowedOrigins
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
function touchBrowserIngestToken(db, tokenId) {
|
|
2058
|
+
db.prepare("UPDATE browser_ingest_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?").run(tokenId);
|
|
2059
|
+
}
|
|
2060
|
+
function normalizeAllowedOrigins(origins) {
|
|
2061
|
+
const normalized = new Set;
|
|
2062
|
+
for (const origin of origins) {
|
|
2063
|
+
const value = normalizeOrigin(origin);
|
|
2064
|
+
if (value)
|
|
2065
|
+
normalized.add(value);
|
|
2066
|
+
}
|
|
2067
|
+
return [...normalized];
|
|
2068
|
+
}
|
|
2069
|
+
function parseAllowedOrigins(value) {
|
|
2070
|
+
if (!value)
|
|
2071
|
+
return [];
|
|
2072
|
+
try {
|
|
2073
|
+
const parsed = JSON.parse(value);
|
|
2074
|
+
return Array.isArray(parsed) ? normalizeAllowedOrigins(parsed.filter((item) => typeof item === "string")) : [];
|
|
2075
|
+
} catch {
|
|
2076
|
+
return [];
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
function normalizeOrigin(origin) {
|
|
2080
|
+
try {
|
|
2081
|
+
return new URL(origin).origin;
|
|
2082
|
+
} catch {
|
|
2083
|
+
return null;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
function hashBrowserToken(token) {
|
|
2087
|
+
return createHash("sha256").update(token).digest("hex");
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// src/server/auth.ts
|
|
2091
|
+
var TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
2092
|
+
function getConfiguredApiToken() {
|
|
2093
|
+
const token = process.env.HASNA_LOGS_API_TOKEN?.trim() || process.env.LOGS_API_TOKEN?.trim();
|
|
2094
|
+
return token || null;
|
|
2095
|
+
}
|
|
2096
|
+
function isApiRequestAuthorized(c) {
|
|
2097
|
+
const token = getConfiguredApiToken();
|
|
2098
|
+
if (!token)
|
|
2099
|
+
return isLocalOpenModeEnabled() && isTrustedLocalRequest(c);
|
|
2100
|
+
const authorization = c.req.header("authorization") ?? "";
|
|
2101
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(authorization)?.[1];
|
|
2102
|
+
const headerToken = c.req.header("x-logs-token");
|
|
2103
|
+
return bearer === token || headerToken === token;
|
|
2104
|
+
}
|
|
2105
|
+
function apiUnauthorizedResponse(c) {
|
|
2106
|
+
return c.json({
|
|
2107
|
+
error: "Unauthorized. Configure HASNA_LOGS_API_TOKEN/LOGS_API_TOKEN or explicitly enable trusted local mode with --local-open."
|
|
2108
|
+
}, 401);
|
|
2109
|
+
}
|
|
2110
|
+
function requireApiTokenOrBrowserIngest(db) {
|
|
2111
|
+
return async (c, next) => {
|
|
2112
|
+
if (isApiRequestAuthorized(c)) {
|
|
2113
|
+
await next();
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
if (isBrowserWriteRequest(c) && getBrowserIngestAuthorization(db, c)) {
|
|
2117
|
+
await next();
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
return apiUnauthorizedResponse(c);
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
function authorizeLogIngest(db, c) {
|
|
2124
|
+
const token = getConfiguredApiToken();
|
|
2125
|
+
if (!token) {
|
|
2126
|
+
const browserToken2 = getBrowserIngestAuthorization(db, c);
|
|
2127
|
+
if (browserToken2)
|
|
2128
|
+
return { kind: "browser-token", token: browserToken2 };
|
|
2129
|
+
return isLocalOpenModeEnabled() && isTrustedLocalRequest(c) ? { kind: "trusted-local" } : null;
|
|
2130
|
+
}
|
|
2131
|
+
if (isApiRequestAuthorized(c))
|
|
2132
|
+
return { kind: "api-token" };
|
|
2133
|
+
const browserToken = getBrowserIngestAuthorization(db, c);
|
|
2134
|
+
return browserToken ? { kind: "browser-token", token: browserToken } : null;
|
|
2135
|
+
}
|
|
2136
|
+
function getBrowserIngestAuthorization(db, c) {
|
|
2137
|
+
const token = c.req.header("x-logs-browser-token") ?? c.req.header("x-logs-write-token");
|
|
2138
|
+
return validateBrowserIngestToken(db, token, c.req.header("origin"));
|
|
2139
|
+
}
|
|
2140
|
+
function isBrowserWriteRequest(c) {
|
|
2141
|
+
if (c.req.method.toUpperCase() !== "POST")
|
|
2142
|
+
return false;
|
|
2143
|
+
const path = new URL(c.req.url).pathname.replace(/\/+$/, "");
|
|
2144
|
+
return path === "/api/logs" || path === "/api/events";
|
|
2145
|
+
}
|
|
2146
|
+
function isLocalOpenModeEnabled() {
|
|
2147
|
+
return ["HASNA_LOGS_LOCAL_OPEN", "LOGS_LOCAL_OPEN"].some((name) => TRUE_ENV_VALUES.has(process.env[name]?.trim().toLowerCase() ?? ""));
|
|
2148
|
+
}
|
|
2149
|
+
function isTrustedLocalRequest(c) {
|
|
2150
|
+
const url = new URL(c.req.url);
|
|
2151
|
+
const host = forwardedHost(c.req.header("x-forwarded-host")) ?? hostWithoutPort(c.req.header("host")) ?? url.hostname;
|
|
2152
|
+
return isLocalHost(host) && isLocalOrigin(c.req.header("origin"));
|
|
2153
|
+
}
|
|
2154
|
+
function forwardedHost(value) {
|
|
2155
|
+
const first = value?.split(",")[0]?.trim();
|
|
2156
|
+
return first ? hostWithoutPort(first) : null;
|
|
2157
|
+
}
|
|
2158
|
+
function hostWithoutPort(value) {
|
|
2159
|
+
if (!value)
|
|
2160
|
+
return null;
|
|
2161
|
+
if (value.startsWith("["))
|
|
2162
|
+
return value.slice(1, value.indexOf("]"));
|
|
2163
|
+
return value.split(":")[0] || null;
|
|
2164
|
+
}
|
|
2165
|
+
function isLocalOrigin(origin) {
|
|
2166
|
+
if (!origin)
|
|
2167
|
+
return true;
|
|
2168
|
+
try {
|
|
2169
|
+
return isLocalHost(new URL(origin).hostname);
|
|
2170
|
+
} catch {
|
|
2171
|
+
return false;
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
function isLocalHost(host) {
|
|
2175
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// src/server/cors.ts
|
|
2179
|
+
function resolveCorsOrigin(origin) {
|
|
2180
|
+
if (!origin)
|
|
2181
|
+
return "";
|
|
2182
|
+
const configured = readCorsOrigins();
|
|
2183
|
+
if (configured.includes("*"))
|
|
2184
|
+
return origin;
|
|
2185
|
+
if (configured.includes(origin))
|
|
2186
|
+
return origin;
|
|
2187
|
+
if (isLocalOrigin2(origin))
|
|
2188
|
+
return origin;
|
|
2189
|
+
return "";
|
|
2190
|
+
}
|
|
2191
|
+
function readCorsOrigins() {
|
|
2192
|
+
const value = process.env.HASNA_LOGS_CORS_ORIGINS ?? process.env.LOGS_CORS_ORIGINS ?? "";
|
|
2193
|
+
return value.split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
2194
|
+
}
|
|
2195
|
+
function isLocalOrigin2(origin) {
|
|
2196
|
+
try {
|
|
2197
|
+
const parsed = new URL(origin);
|
|
2198
|
+
return ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
|
|
2199
|
+
} catch {
|
|
2200
|
+
return false;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// src/server/request.ts
|
|
2205
|
+
async function readJsonObject(c, opts = {}) {
|
|
2206
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
2207
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
2208
|
+
return {
|
|
2209
|
+
ok: false,
|
|
2210
|
+
status: 415,
|
|
2211
|
+
message: "Content-Type must be application/json"
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
const maxPayloadBytes = opts.maxPayloadBytes ?? readPositiveInt("HASNA_LOGS_MAX_PAYLOAD_BYTES", 1048576);
|
|
2215
|
+
const contentLength = Number(c.req.header("content-length") ?? "0");
|
|
2216
|
+
if (Number.isFinite(contentLength) && contentLength > maxPayloadBytes) {
|
|
2217
|
+
return {
|
|
2218
|
+
ok: false,
|
|
2219
|
+
status: 413,
|
|
2220
|
+
message: `Payload exceeds ${maxPayloadBytes} bytes`
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
let raw2 = "";
|
|
2224
|
+
try {
|
|
2225
|
+
raw2 = await c.req.text();
|
|
2226
|
+
} catch {
|
|
2227
|
+
return { ok: false, status: 400, message: "Unable to read request body" };
|
|
2228
|
+
}
|
|
2229
|
+
if (Buffer.byteLength(raw2, "utf8") > maxPayloadBytes) {
|
|
2230
|
+
return {
|
|
2231
|
+
ok: false,
|
|
2232
|
+
status: 413,
|
|
2233
|
+
message: `Payload exceeds ${maxPayloadBytes} bytes`
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
let body;
|
|
2237
|
+
try {
|
|
2238
|
+
body = JSON.parse(raw2);
|
|
2239
|
+
} catch {
|
|
2240
|
+
return { ok: false, status: 400, message: "Invalid JSON body" };
|
|
2241
|
+
}
|
|
2242
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
2243
|
+
return { ok: false, status: 422, message: "body must be an object" };
|
|
2244
|
+
}
|
|
2245
|
+
const objectBody = body;
|
|
2246
|
+
if (opts.allowedKeys) {
|
|
2247
|
+
const allowed = new Set(opts.allowedKeys);
|
|
2248
|
+
for (const key of Object.keys(objectBody)) {
|
|
2249
|
+
if (!allowed.has(key)) {
|
|
2250
|
+
return {
|
|
2251
|
+
ok: false,
|
|
2252
|
+
status: 422,
|
|
2253
|
+
message: `body.${key} is not a supported field`
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
return { ok: true, value: objectBody };
|
|
2259
|
+
}
|
|
2260
|
+
function requiredString(body, key) {
|
|
2261
|
+
const value = body[key];
|
|
2262
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
2263
|
+
return {
|
|
2264
|
+
ok: false,
|
|
2265
|
+
status: 422,
|
|
2266
|
+
message: `body.${key} must be a non-empty string`
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
return { ok: true, value };
|
|
2270
|
+
}
|
|
2271
|
+
function optionalString(body, key) {
|
|
2272
|
+
const value = body[key];
|
|
2273
|
+
if (value === undefined)
|
|
2274
|
+
return { ok: true, value: undefined };
|
|
2275
|
+
if (typeof value !== "string") {
|
|
2276
|
+
return { ok: false, status: 422, message: `body.${key} must be a string` };
|
|
2277
|
+
}
|
|
2278
|
+
return { ok: true, value };
|
|
2279
|
+
}
|
|
2280
|
+
function optionalStringArray(body, key, opts = {}) {
|
|
2281
|
+
const value = body[key];
|
|
2282
|
+
if (value === undefined)
|
|
2283
|
+
return { ok: true, value: undefined };
|
|
2284
|
+
if (!Array.isArray(value)) {
|
|
2285
|
+
return {
|
|
2286
|
+
ok: false,
|
|
2287
|
+
status: 422,
|
|
2288
|
+
message: `body.${key} must be an array of strings`
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
if (opts.maxItems !== undefined && value.length > opts.maxItems) {
|
|
2292
|
+
return {
|
|
2293
|
+
ok: false,
|
|
2294
|
+
status: 422,
|
|
2295
|
+
message: `body.${key} must have at most ${opts.maxItems} item(s)`
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
const strings = [];
|
|
2299
|
+
for (const item of value) {
|
|
2300
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
2301
|
+
return {
|
|
2302
|
+
ok: false,
|
|
2303
|
+
status: 422,
|
|
2304
|
+
message: `body.${key} must be an array of non-empty strings`
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
strings.push(item);
|
|
2308
|
+
}
|
|
2309
|
+
return { ok: true, value: strings };
|
|
2310
|
+
}
|
|
2311
|
+
function optionalNumber(body, key, opts = {}) {
|
|
2312
|
+
const value = body[key];
|
|
2313
|
+
if (value === undefined)
|
|
2314
|
+
return { ok: true, value: undefined };
|
|
2315
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2316
|
+
return { ok: false, status: 422, message: `body.${key} must be a number` };
|
|
2317
|
+
}
|
|
2318
|
+
if (opts.integer && !Number.isInteger(value)) {
|
|
2319
|
+
return {
|
|
2320
|
+
ok: false,
|
|
2321
|
+
status: 422,
|
|
2322
|
+
message: `body.${key} must be an integer`
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
if (opts.min !== undefined && value < opts.min) {
|
|
2326
|
+
return {
|
|
2327
|
+
ok: false,
|
|
2328
|
+
status: 422,
|
|
2329
|
+
message: `body.${key} must be >= ${opts.min}`
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
if (opts.max !== undefined && value > opts.max) {
|
|
2333
|
+
return {
|
|
2334
|
+
ok: false,
|
|
2335
|
+
status: 422,
|
|
2336
|
+
message: `body.${key} must be <= ${opts.max}`
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
return { ok: true, value };
|
|
2340
|
+
}
|
|
2341
|
+
function optionalEnum(body, key, values) {
|
|
2342
|
+
const value = body[key];
|
|
2343
|
+
if (value === undefined)
|
|
2344
|
+
return { ok: true, value: undefined };
|
|
2345
|
+
if (typeof value !== "string" || !values.includes(value)) {
|
|
2346
|
+
return {
|
|
2347
|
+
ok: false,
|
|
2348
|
+
status: 422,
|
|
2349
|
+
message: `body.${key} must be one of ${values.join(", ")}`
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
return { ok: true, value };
|
|
2353
|
+
}
|
|
2354
|
+
function requiredEnum(body, key, values) {
|
|
2355
|
+
const value = body[key];
|
|
2356
|
+
if (typeof value !== "string" || !values.includes(value)) {
|
|
2357
|
+
return {
|
|
2358
|
+
ok: false,
|
|
2359
|
+
status: 422,
|
|
2360
|
+
message: `body.${key} must be one of ${values.join(", ")}`
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
return { ok: true, value };
|
|
2364
|
+
}
|
|
2365
|
+
function readPositiveInt(name, fallback) {
|
|
2366
|
+
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
2367
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// src/server/routes/alerts.ts
|
|
2371
|
+
var ALERT_CREATE_KEYS = [
|
|
2372
|
+
"project_id",
|
|
2373
|
+
"name",
|
|
2374
|
+
"service",
|
|
2375
|
+
"level",
|
|
2376
|
+
"threshold_count",
|
|
2377
|
+
"window_seconds",
|
|
2378
|
+
"action",
|
|
2379
|
+
"webhook_url"
|
|
2380
|
+
];
|
|
2381
|
+
var ALERT_UPDATE_KEYS = [
|
|
2382
|
+
"enabled",
|
|
2383
|
+
"threshold_count",
|
|
2384
|
+
"window_seconds",
|
|
2385
|
+
"webhook_url"
|
|
2386
|
+
];
|
|
2387
|
+
var LOG_LEVELS = ["debug", "info", "warn", "error", "fatal"];
|
|
2388
|
+
var ALERT_ACTIONS = ["webhook", "log"];
|
|
2389
|
+
function alertsRoutes(db) {
|
|
2390
|
+
const app = new Hono2;
|
|
2391
|
+
app.post("/", async (c) => {
|
|
2392
|
+
const parsed = await readJsonObject(c, { allowedKeys: ALERT_CREATE_KEYS });
|
|
2393
|
+
if (!parsed.ok)
|
|
2394
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
2395
|
+
const alertInput = validateAlertCreate(parsed.value);
|
|
2396
|
+
if (!alertInput.ok)
|
|
2397
|
+
return c.json({ error: alertInput.message }, alertInput.status);
|
|
2398
|
+
const body = alertInput.value;
|
|
2399
|
+
return c.json(createAlertRule(db, body), 201);
|
|
2400
|
+
});
|
|
2401
|
+
app.get("/", (c) => {
|
|
2402
|
+
const { project_id } = c.req.query();
|
|
2403
|
+
return c.json(listAlertRules(db, project_id || undefined));
|
|
2404
|
+
});
|
|
2405
|
+
app.put("/:id", async (c) => {
|
|
2406
|
+
const parsed = await readJsonObject(c, { allowedKeys: ALERT_UPDATE_KEYS });
|
|
2407
|
+
if (!parsed.ok)
|
|
2408
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
2409
|
+
const alertInput = validateAlertUpdate(parsed.value);
|
|
2410
|
+
if (!alertInput.ok)
|
|
2411
|
+
return c.json({ error: alertInput.message }, alertInput.status);
|
|
2412
|
+
const body = alertInput.value;
|
|
2413
|
+
const updated = updateAlertRule(db, c.req.param("id"), body);
|
|
2414
|
+
if (!updated)
|
|
2415
|
+
return c.json({ error: "not found" }, 404);
|
|
2416
|
+
return c.json(updated);
|
|
2417
|
+
});
|
|
2418
|
+
app.delete("/:id", (c) => {
|
|
2419
|
+
deleteAlertRule(db, c.req.param("id"));
|
|
2420
|
+
return c.json({ deleted: true });
|
|
2421
|
+
});
|
|
2422
|
+
return app;
|
|
2423
|
+
}
|
|
2424
|
+
function validateAlertCreate(body) {
|
|
2425
|
+
const project_id = requiredString(body, "project_id");
|
|
2426
|
+
if (!project_id.ok)
|
|
2427
|
+
return project_id;
|
|
2428
|
+
const name = requiredString(body, "name");
|
|
2429
|
+
if (!name.ok)
|
|
2430
|
+
return name;
|
|
2431
|
+
const service = optionalString(body, "service");
|
|
2432
|
+
if (!service.ok)
|
|
2433
|
+
return service;
|
|
2434
|
+
const level = optionalEnum(body, "level", LOG_LEVELS);
|
|
2435
|
+
if (!level.ok)
|
|
2436
|
+
return level;
|
|
2437
|
+
const threshold_count = optionalNumber(body, "threshold_count", {
|
|
2438
|
+
integer: true,
|
|
2439
|
+
min: 1
|
|
2440
|
+
});
|
|
2441
|
+
if (!threshold_count.ok)
|
|
2442
|
+
return threshold_count;
|
|
2443
|
+
const window_seconds = optionalNumber(body, "window_seconds", {
|
|
2444
|
+
integer: true,
|
|
2445
|
+
min: 1
|
|
2446
|
+
});
|
|
2447
|
+
if (!window_seconds.ok)
|
|
2448
|
+
return window_seconds;
|
|
2449
|
+
const action = optionalEnum(body, "action", ALERT_ACTIONS);
|
|
2450
|
+
if (!action.ok)
|
|
2451
|
+
return action;
|
|
2452
|
+
const webhook_url = optionalString(body, "webhook_url");
|
|
2453
|
+
if (!webhook_url.ok)
|
|
2454
|
+
return webhook_url;
|
|
2455
|
+
if (webhook_url.value !== undefined && !isUrl(webhook_url.value)) {
|
|
2456
|
+
return {
|
|
2457
|
+
ok: false,
|
|
2458
|
+
status: 422,
|
|
2459
|
+
message: "body.webhook_url must be a valid URL"
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
return {
|
|
2463
|
+
ok: true,
|
|
2464
|
+
value: {
|
|
2465
|
+
project_id: project_id.value,
|
|
2466
|
+
name: name.value,
|
|
2467
|
+
service: service.value,
|
|
2468
|
+
level: level.value,
|
|
2469
|
+
threshold_count: threshold_count.value,
|
|
2470
|
+
window_seconds: window_seconds.value,
|
|
2471
|
+
action: action.value,
|
|
2472
|
+
webhook_url: webhook_url.value
|
|
2473
|
+
}
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
function validateAlertUpdate(body) {
|
|
2477
|
+
const enabled = optionalNumber(body, "enabled", {
|
|
2478
|
+
integer: true,
|
|
2479
|
+
min: 0,
|
|
2480
|
+
max: 1
|
|
2481
|
+
});
|
|
2482
|
+
if (!enabled.ok)
|
|
2483
|
+
return enabled;
|
|
2484
|
+
const threshold_count = optionalNumber(body, "threshold_count", {
|
|
2485
|
+
integer: true,
|
|
2486
|
+
min: 1
|
|
2487
|
+
});
|
|
2488
|
+
if (!threshold_count.ok)
|
|
2489
|
+
return threshold_count;
|
|
2490
|
+
const window_seconds = optionalNumber(body, "window_seconds", {
|
|
2491
|
+
integer: true,
|
|
2492
|
+
min: 1
|
|
2493
|
+
});
|
|
2494
|
+
if (!window_seconds.ok)
|
|
2495
|
+
return window_seconds;
|
|
2496
|
+
const webhook_url = optionalString(body, "webhook_url");
|
|
2497
|
+
if (!webhook_url.ok)
|
|
2498
|
+
return webhook_url;
|
|
2499
|
+
if (webhook_url.value !== undefined && webhook_url.value !== "" && !isUrl(webhook_url.value)) {
|
|
2500
|
+
return {
|
|
2501
|
+
ok: false,
|
|
2502
|
+
status: 422,
|
|
2503
|
+
message: "body.webhook_url must be a valid URL"
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
return {
|
|
2507
|
+
ok: true,
|
|
2508
|
+
value: {
|
|
2509
|
+
enabled: enabled.value,
|
|
2510
|
+
threshold_count: threshold_count.value,
|
|
2511
|
+
window_seconds: window_seconds.value,
|
|
2512
|
+
webhook_url: webhook_url.value
|
|
2513
|
+
}
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
function isUrl(value) {
|
|
2517
|
+
try {
|
|
2518
|
+
new URL(value);
|
|
2519
|
+
return true;
|
|
2520
|
+
} catch {
|
|
2521
|
+
return false;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// src/server/routes/events.ts
|
|
2526
|
+
import { createHash as createHash2 } from "crypto";
|
|
2527
|
+
|
|
2528
|
+
// node_modules/hono/dist/utils/stream.js
|
|
2529
|
+
var StreamingApi = class {
|
|
2530
|
+
writer;
|
|
2531
|
+
encoder;
|
|
2532
|
+
writable;
|
|
2533
|
+
abortSubscribers = [];
|
|
2534
|
+
responseReadable;
|
|
2535
|
+
aborted = false;
|
|
2536
|
+
closed = false;
|
|
2537
|
+
constructor(writable, _readable) {
|
|
2538
|
+
this.writable = writable;
|
|
2539
|
+
this.writer = writable.getWriter();
|
|
2540
|
+
this.encoder = new TextEncoder;
|
|
2541
|
+
const reader = _readable.getReader();
|
|
2542
|
+
this.abortSubscribers.push(async () => {
|
|
2543
|
+
await reader.cancel();
|
|
2544
|
+
});
|
|
2545
|
+
this.responseReadable = new ReadableStream({
|
|
2546
|
+
async pull(controller) {
|
|
2547
|
+
const { done, value } = await reader.read();
|
|
2548
|
+
done ? controller.close() : controller.enqueue(value);
|
|
2549
|
+
},
|
|
2550
|
+
cancel: () => {
|
|
2551
|
+
if (!this.closed) {
|
|
2552
|
+
this.abort();
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
async write(input) {
|
|
2558
|
+
try {
|
|
2559
|
+
if (typeof input === "string") {
|
|
2560
|
+
input = this.encoder.encode(input);
|
|
2561
|
+
}
|
|
2562
|
+
await this.writer.write(input);
|
|
2563
|
+
} catch {}
|
|
2564
|
+
return this;
|
|
2565
|
+
}
|
|
2566
|
+
async writeln(input) {
|
|
2567
|
+
await this.write(input + `
|
|
2568
|
+
`);
|
|
2569
|
+
return this;
|
|
2570
|
+
}
|
|
2571
|
+
sleep(ms) {
|
|
2572
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
2573
|
+
}
|
|
2574
|
+
async close() {
|
|
2575
|
+
this.closed = true;
|
|
2576
|
+
try {
|
|
2577
|
+
await this.writer.close();
|
|
2578
|
+
} catch {}
|
|
2579
|
+
}
|
|
2580
|
+
async pipe(body) {
|
|
2581
|
+
this.writer.releaseLock();
|
|
2582
|
+
await body.pipeTo(this.writable, { preventClose: true });
|
|
2583
|
+
this.writer = this.writable.getWriter();
|
|
2584
|
+
}
|
|
2585
|
+
onAbort(listener) {
|
|
2586
|
+
this.abortSubscribers.push(listener);
|
|
2587
|
+
}
|
|
2588
|
+
abort() {
|
|
2589
|
+
if (!this.aborted) {
|
|
2590
|
+
this.aborted = true;
|
|
2591
|
+
this.abortSubscribers.forEach((subscriber) => subscriber());
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
};
|
|
2595
|
+
|
|
2596
|
+
// node_modules/hono/dist/helper/streaming/utils.js
|
|
2597
|
+
var isOldBunVersion = () => {
|
|
2598
|
+
const version = typeof Bun !== "undefined" ? Bun.version : undefined;
|
|
2599
|
+
if (version === undefined) {
|
|
2600
|
+
return false;
|
|
2601
|
+
}
|
|
2602
|
+
const result = version.startsWith("1.1") || version.startsWith("1.0") || version.startsWith("0.");
|
|
2603
|
+
isOldBunVersion = () => result;
|
|
2604
|
+
return result;
|
|
2605
|
+
};
|
|
2606
|
+
|
|
2607
|
+
// node_modules/hono/dist/helper/streaming/sse.js
|
|
2608
|
+
var SSEStreamingApi = class extends StreamingApi {
|
|
2609
|
+
constructor(writable, readable) {
|
|
2610
|
+
super(writable, readable);
|
|
2611
|
+
}
|
|
2612
|
+
async writeSSE(message) {
|
|
2613
|
+
const data = await resolveCallback(message.data, HtmlEscapedCallbackPhase.Stringify, false, {});
|
|
2614
|
+
const dataLines = data.split(/\r\n|\r|\n/).map((line) => {
|
|
2615
|
+
return `data: ${line}`;
|
|
2616
|
+
}).join(`
|
|
2617
|
+
`);
|
|
2618
|
+
for (const key of ["event", "id", "retry"]) {
|
|
2619
|
+
if (message[key] && /[\r\n]/.test(message[key])) {
|
|
2620
|
+
throw new Error(`${key} must not contain "\\r" or "\\n"`);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
const sseData = [
|
|
2624
|
+
message.event && `event: ${message.event}`,
|
|
2625
|
+
dataLines,
|
|
2626
|
+
message.id && `id: ${message.id}`,
|
|
2627
|
+
message.retry && `retry: ${message.retry}`
|
|
2628
|
+
].filter(Boolean).join(`
|
|
2629
|
+
`) + `
|
|
2630
|
+
|
|
2631
|
+
`;
|
|
2632
|
+
await this.write(sseData);
|
|
2633
|
+
}
|
|
2634
|
+
};
|
|
2635
|
+
var run = async (stream, cb, onError) => {
|
|
2636
|
+
try {
|
|
2637
|
+
await cb(stream);
|
|
2638
|
+
} catch (e) {
|
|
2639
|
+
if (e instanceof Error && onError) {
|
|
2640
|
+
await onError(e, stream);
|
|
2641
|
+
await stream.writeSSE({
|
|
2642
|
+
event: "error",
|
|
2643
|
+
data: e.message
|
|
2644
|
+
});
|
|
2645
|
+
} else {
|
|
2646
|
+
console.error(e);
|
|
2647
|
+
}
|
|
2648
|
+
} finally {
|
|
2649
|
+
stream.close();
|
|
2650
|
+
}
|
|
2651
|
+
};
|
|
2652
|
+
var contextStash = /* @__PURE__ */ new WeakMap;
|
|
2653
|
+
var streamSSE = (c, cb, onError) => {
|
|
2654
|
+
const { readable, writable } = new TransformStream;
|
|
2655
|
+
const stream = new SSEStreamingApi(writable, readable);
|
|
2656
|
+
if (isOldBunVersion()) {
|
|
2657
|
+
c.req.raw.signal.addEventListener("abort", () => {
|
|
2658
|
+
if (!stream.closed) {
|
|
2659
|
+
stream.abort();
|
|
2660
|
+
}
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
contextStash.set(stream.responseReadable, c);
|
|
2664
|
+
c.header("Transfer-Encoding", "chunked");
|
|
2665
|
+
c.header("Content-Type", "text/event-stream");
|
|
2666
|
+
c.header("Cache-Control", "no-cache");
|
|
2667
|
+
c.header("Connection", "keep-alive");
|
|
2668
|
+
run(stream, cb, onError);
|
|
2669
|
+
return c.newResponse(stream.responseReadable);
|
|
2670
|
+
};
|
|
2671
|
+
|
|
2672
|
+
// src/server/routes/events.ts
|
|
2673
|
+
function eventsRoutes(db) {
|
|
2674
|
+
const app = new Hono2;
|
|
2675
|
+
app.post("/", async (c) => {
|
|
2676
|
+
const parsed = await readEventIngestBody(db, c);
|
|
2677
|
+
if (!parsed.ok)
|
|
2678
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
2679
|
+
try {
|
|
2680
|
+
if (!parsed.batch && parsed.events.length === 1) {
|
|
2681
|
+
const [event] = parsed.events;
|
|
2682
|
+
if (!event)
|
|
2683
|
+
return c.json({ error: "body must contain at least one event" }, 422);
|
|
2684
|
+
const result = ingestUniversalEvent(db, event);
|
|
2685
|
+
if (parsed.authorization.kind === "browser-token")
|
|
2686
|
+
touchBrowserIngestToken(db, parsed.authorization.token.id);
|
|
2687
|
+
return c.json(result.event, result.inserted ? 201 : 200);
|
|
2688
|
+
}
|
|
2689
|
+
const results = parsed.events.map((event) => ingestUniversalEvent(db, event));
|
|
2690
|
+
if (parsed.authorization.kind === "browser-token")
|
|
2691
|
+
touchBrowserIngestToken(db, parsed.authorization.token.id);
|
|
2692
|
+
return c.json({
|
|
2693
|
+
inserted: results.filter((result) => result.inserted).length,
|
|
2694
|
+
events: results.map((result) => result.event)
|
|
2695
|
+
}, 201);
|
|
2696
|
+
} catch (error) {
|
|
2697
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 422);
|
|
2698
|
+
}
|
|
2699
|
+
});
|
|
2700
|
+
app.get("/", (c) => {
|
|
2701
|
+
return c.json(searchEvents(db, queryFromRequest(c.req.query())));
|
|
2702
|
+
});
|
|
2703
|
+
app.get("/export", (c) => {
|
|
2704
|
+
const query = queryFromRequest(c.req.query());
|
|
2705
|
+
const chunks = [];
|
|
2706
|
+
exportEventsToJson(db, query, (line) => chunks.push(line));
|
|
2707
|
+
c.header("Content-Type", "application/json");
|
|
2708
|
+
c.header("Content-Disposition", "attachment; filename=events.json");
|
|
2709
|
+
return c.text(chunks.join(`
|
|
2710
|
+
`));
|
|
2711
|
+
});
|
|
2712
|
+
app.get("/stream", (c) => {
|
|
2713
|
+
const query = c.req.query();
|
|
2714
|
+
const filter = streamFilterFromRequest(query);
|
|
2715
|
+
const includeRaw = query.include_raw === "true";
|
|
2716
|
+
const eventNameMode = query.event_name === "event" ? "event" : "type";
|
|
2717
|
+
const requestedLastId = c.req.header("last-event-id") || query.last_event_id || null;
|
|
2718
|
+
const debugOptions = streamDebugOptionsFromRequest(query);
|
|
2719
|
+
return streamSSE(c, async (stream2) => {
|
|
2720
|
+
const writer = debugOptions.writeDelayMs > 0 ? delayedSseWriter(stream2, debugOptions.writeDelayMs) : stream2;
|
|
2721
|
+
const seen = new Set;
|
|
2722
|
+
let lastId = requestedLastId;
|
|
2723
|
+
let rowidCursor = null;
|
|
2724
|
+
if (lastId) {
|
|
2725
|
+
const anchor = eventCursorById(db, lastId);
|
|
2726
|
+
if (!anchor) {
|
|
2727
|
+
const requestedLastId2 = lastId;
|
|
2728
|
+
const latest = latestEventCursor(db, filter);
|
|
2729
|
+
lastId = latest?.event_id ?? null;
|
|
2730
|
+
rowidCursor = latest?.rowid ?? 0;
|
|
2731
|
+
await writeEventOverflow(writer, {
|
|
2732
|
+
reason: "last_event_id_unknown",
|
|
2733
|
+
dropped: 0,
|
|
2734
|
+
last_event_id: lastId,
|
|
2735
|
+
requested_last_event_id: requestedLastId2
|
|
2736
|
+
});
|
|
2737
|
+
} else {
|
|
2738
|
+
if (!hasBufferedEventCatalogEvent(lastId)) {
|
|
2739
|
+
await writeEventOverflow(writer, {
|
|
2740
|
+
reason: "buffer_miss_sqlite_catchup",
|
|
2741
|
+
dropped: 0,
|
|
2742
|
+
last_event_id: lastId
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
const catchup = await writeCatchupEventsAfterRowid(db, writer, filter, anchor.rowid, seen, includeRaw, eventNameMode);
|
|
2746
|
+
rowidCursor = catchup.last_rowid;
|
|
2747
|
+
if (catchup.last_id) {
|
|
2748
|
+
lastId = catchup.last_id;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
} else {
|
|
2752
|
+
const latest = latestEventCursor(db, filter);
|
|
2753
|
+
lastId = latest?.event_id ?? null;
|
|
2754
|
+
rowidCursor = latest?.rowid ?? 0;
|
|
2755
|
+
}
|
|
2756
|
+
const subscription = subscribeEventCatalogEvents(filter, debugOptions.subscriberQueue ? { maxQueue: debugOptions.subscriberQueue } : undefined);
|
|
2757
|
+
let pendingBusEvent = subscription.next();
|
|
2758
|
+
try {
|
|
2759
|
+
while (true) {
|
|
2760
|
+
const next = await Promise.race([
|
|
2761
|
+
pendingBusEvent.then((result) => ({
|
|
2762
|
+
kind: "bus",
|
|
2763
|
+
result
|
|
2764
|
+
})),
|
|
2765
|
+
sleep(500).then(() => ({ kind: "tick" }))
|
|
2766
|
+
]);
|
|
2767
|
+
if (next.kind === "tick") {
|
|
2768
|
+
const catchup2 = await writeCatchupEventsAfterRowid(db, writer, filter, rowidCursor ?? 0, seen, includeRaw, eventNameMode);
|
|
2769
|
+
rowidCursor = catchup2.last_rowid;
|
|
2770
|
+
if (catchup2.last_id)
|
|
2771
|
+
lastId = catchup2.last_id;
|
|
2772
|
+
continue;
|
|
2773
|
+
}
|
|
2774
|
+
if (next.result.done)
|
|
2775
|
+
break;
|
|
2776
|
+
const event = next.result.value;
|
|
2777
|
+
pendingBusEvent = subscription.next();
|
|
2778
|
+
if (event.kind === "overflow") {
|
|
2779
|
+
await writeEventOverflow(writer, event);
|
|
2780
|
+
const catchup2 = await writeCatchupEventsAfterRowid(db, writer, filter, rowidCursor ?? 0, seen, includeRaw, eventNameMode);
|
|
2781
|
+
rowidCursor = catchup2.last_rowid;
|
|
2782
|
+
if (catchup2.last_id)
|
|
2783
|
+
lastId = catchup2.last_id;
|
|
2784
|
+
continue;
|
|
2785
|
+
}
|
|
2786
|
+
const catchup = await writeCatchupEventsAfterRowid(db, writer, filter, rowidCursor ?? 0, seen, includeRaw, eventNameMode);
|
|
2787
|
+
rowidCursor = catchup.last_rowid;
|
|
2788
|
+
if (catchup.last_id)
|
|
2789
|
+
lastId = catchup.last_id;
|
|
2790
|
+
if (seen.has(event.id))
|
|
2791
|
+
continue;
|
|
2792
|
+
await writeEventCatalogEntryById(db, writer, event.id, includeRaw, event.entry, eventNameMode);
|
|
2793
|
+
seen.add(event.id);
|
|
2794
|
+
lastId = event.id;
|
|
2795
|
+
const cursor = eventCursorById(db, event.id);
|
|
2796
|
+
if (cursor)
|
|
2797
|
+
rowidCursor = Math.max(rowidCursor ?? 0, cursor.rowid);
|
|
2798
|
+
trimSeen(seen);
|
|
2799
|
+
}
|
|
2800
|
+
} finally {
|
|
2801
|
+
await subscription.return?.();
|
|
2802
|
+
}
|
|
2803
|
+
});
|
|
2804
|
+
});
|
|
2805
|
+
app.get("/:event_id", (c) => {
|
|
2806
|
+
const includeRaw = c.req.query("include_raw") !== "false";
|
|
2807
|
+
const event = getEvent(db, c.req.param("event_id"), includeRaw);
|
|
2808
|
+
if (!event)
|
|
2809
|
+
return c.json({ error: "Event not found" }, 404);
|
|
2810
|
+
return c.json(event);
|
|
2811
|
+
});
|
|
2812
|
+
return app;
|
|
2813
|
+
}
|
|
2814
|
+
async function readEventIngestBody(db, c) {
|
|
2815
|
+
const authorization = authorizeLogIngest(db, c);
|
|
2816
|
+
if (!authorization) {
|
|
2817
|
+
return { ok: false, status: 401, message: "Unauthorized" };
|
|
2818
|
+
}
|
|
2819
|
+
const request = c.req.raw;
|
|
2820
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
2821
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
2822
|
+
return {
|
|
2823
|
+
ok: false,
|
|
2824
|
+
status: 415,
|
|
2825
|
+
message: "Content-Type must be application/json"
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
const maxPayloadBytes = readPositiveInt("HASNA_LOGS_MAX_PAYLOAD_BYTES", 1048576);
|
|
2829
|
+
const contentLength = Number(c.req.header("content-length") ?? "0");
|
|
2830
|
+
if (Number.isFinite(contentLength) && contentLength > maxPayloadBytes) {
|
|
2831
|
+
return {
|
|
2832
|
+
ok: false,
|
|
2833
|
+
status: 413,
|
|
2834
|
+
message: `Payload exceeds ${maxPayloadBytes} bytes`
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
let raw2 = "";
|
|
2838
|
+
try {
|
|
2839
|
+
raw2 = await request.text();
|
|
2840
|
+
} catch {
|
|
2841
|
+
return { ok: false, status: 400, message: "Unable to read request body" };
|
|
2842
|
+
}
|
|
2843
|
+
if (Buffer.byteLength(raw2, "utf8") > maxPayloadBytes) {
|
|
2844
|
+
return {
|
|
2845
|
+
ok: false,
|
|
2846
|
+
status: 413,
|
|
2847
|
+
message: `Payload exceeds ${maxPayloadBytes} bytes`
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
let body;
|
|
2851
|
+
try {
|
|
2852
|
+
body = JSON.parse(raw2);
|
|
2853
|
+
} catch {
|
|
2854
|
+
return { ok: false, status: 400, message: "Invalid JSON body" };
|
|
2855
|
+
}
|
|
2856
|
+
const maxBatchSize = readPositiveInt("HASNA_LOGS_MAX_EVENT_BATCH_SIZE", readPositiveInt("HASNA_LOGS_MAX_BATCH_SIZE", 1000));
|
|
2857
|
+
const batch = Array.isArray(body) || Boolean(body && typeof body === "object" && !Array.isArray(body) && Array.isArray(body.events));
|
|
2858
|
+
const rawEvents = Array.isArray(body) ? body : eventArrayFromObject(body);
|
|
2859
|
+
if (rawEvents.length > maxBatchSize) {
|
|
2860
|
+
return {
|
|
2861
|
+
ok: false,
|
|
2862
|
+
status: 413,
|
|
2863
|
+
message: `Batch exceeds ${maxBatchSize} events`
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
if (rawEvents.length === 0) {
|
|
2867
|
+
return {
|
|
2868
|
+
ok: false,
|
|
2869
|
+
status: 422,
|
|
2870
|
+
message: "body must contain at least one event"
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
try {
|
|
2874
|
+
const events = rawEvents.map((event, index) => {
|
|
2875
|
+
const validated = validateUniversalEventInput(event, `event[${index}]`);
|
|
2876
|
+
return applyEventIngestAuthorization(validated, authorization, `event[${index}]`);
|
|
2877
|
+
});
|
|
2878
|
+
return { ok: true, events, batch, authorization };
|
|
2879
|
+
} catch (error) {
|
|
2880
|
+
return {
|
|
2881
|
+
ok: false,
|
|
2882
|
+
status: 422,
|
|
2883
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
var BROWSER_EVENT_TYPES = new Set([
|
|
2888
|
+
"log",
|
|
2889
|
+
"exception",
|
|
2890
|
+
"span",
|
|
2891
|
+
"metric",
|
|
2892
|
+
"network",
|
|
2893
|
+
"replay",
|
|
2894
|
+
"session"
|
|
2895
|
+
]);
|
|
2896
|
+
var BROWSER_FORBIDDEN_IDENTITY_FIELDS = [
|
|
2897
|
+
"project_id",
|
|
2898
|
+
"page_id",
|
|
2899
|
+
"machine_id",
|
|
2900
|
+
"repo_id",
|
|
2901
|
+
"process_id",
|
|
2902
|
+
"run_id",
|
|
2903
|
+
"artifact_id"
|
|
2904
|
+
];
|
|
2905
|
+
var BROWSER_FORBIDDEN_IDENTITY_FIELD_SET = new Set(BROWSER_FORBIDDEN_IDENTITY_FIELDS);
|
|
2906
|
+
function applyEventIngestAuthorization(event, authorization, path) {
|
|
2907
|
+
if (authorization.kind !== "browser-token")
|
|
2908
|
+
return event;
|
|
2909
|
+
if (!BROWSER_EVENT_TYPES.has(event.type)) {
|
|
2910
|
+
throw new Error(`${path}.type cannot be ${event.type} when using a browser ingest token`);
|
|
2911
|
+
}
|
|
2912
|
+
if (event.source !== undefined && event.source !== "script" && event.source !== "browser") {
|
|
2913
|
+
throw new Error(`${path}.source must be script or browser when using a browser ingest token`);
|
|
2914
|
+
}
|
|
2915
|
+
for (const field of BROWSER_FORBIDDEN_IDENTITY_FIELDS) {
|
|
2916
|
+
if (event[field] !== undefined && event[field] !== null) {
|
|
2917
|
+
throw new Error(`${path}.${field} cannot be set when using a browser ingest token`);
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
rejectBrowserScopedIdentity(event.attributes, `${path}.attributes`);
|
|
2921
|
+
rejectBrowserScopedIdentity(event.metadata, `${path}.metadata`);
|
|
2922
|
+
const browserMetadata = {
|
|
2923
|
+
browser_token_id: authorization.token.id,
|
|
2924
|
+
browser_token_prefix: authorization.token.token_prefix,
|
|
2925
|
+
ingest_scope: "browser"
|
|
2926
|
+
};
|
|
2927
|
+
const producerId = event.event_id ?? event.id ?? event.source_event_id ?? undefined;
|
|
2928
|
+
const source = event.source ?? "browser";
|
|
2929
|
+
return {
|
|
2930
|
+
...event,
|
|
2931
|
+
event_id: producerId ? browserScopedEventId(authorization.token.project_id, source, producerId) : undefined,
|
|
2932
|
+
source_event_id: event.source_event_id ?? event.event_id ?? event.id ?? undefined,
|
|
2933
|
+
project_id: authorization.token.project_id,
|
|
2934
|
+
source,
|
|
2935
|
+
attributes: {
|
|
2936
|
+
...event.attributes ?? {},
|
|
2937
|
+
...browserMetadata,
|
|
2938
|
+
project_id: authorization.token.project_id
|
|
2939
|
+
},
|
|
2940
|
+
metadata: {
|
|
2941
|
+
...event.metadata ?? {},
|
|
2942
|
+
...browserMetadata
|
|
2943
|
+
}
|
|
2944
|
+
};
|
|
2945
|
+
}
|
|
2946
|
+
function browserScopedEventId(projectId, source, producerId) {
|
|
2947
|
+
const digest = createHash2("sha256").update(projectId).update("\x00").update(source).update("\x00").update(producerId).digest("hex").slice(0, 32);
|
|
2948
|
+
return `evt_browser_${digest}`;
|
|
2949
|
+
}
|
|
2950
|
+
function rejectBrowserScopedIdentity(value, path) {
|
|
2951
|
+
if (!value)
|
|
2952
|
+
return;
|
|
2953
|
+
for (const key of Object.keys(value)) {
|
|
2954
|
+
if (BROWSER_FORBIDDEN_IDENTITY_FIELD_SET.has(key) && value[key] !== undefined && value[key] !== null) {
|
|
2955
|
+
throw new Error(`${path}.${key} cannot be set when using a browser ingest token`);
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
async function writeCatchupEventsAfterRowid(db, stream2, filter, afterRowid, seen, includeRaw, eventNameMode) {
|
|
2960
|
+
let cursor = afterRowid;
|
|
2961
|
+
let lastWritten = null;
|
|
2962
|
+
let total = 0;
|
|
2963
|
+
while (total < 1000) {
|
|
2964
|
+
const rows = queryEventsAfterRowid(db, filter, cursor, 100);
|
|
2965
|
+
if (rows.length === 0)
|
|
2966
|
+
break;
|
|
2967
|
+
for (const row of rows) {
|
|
2968
|
+
cursor = row.rowid;
|
|
2969
|
+
if (seen.has(row.event_id))
|
|
2970
|
+
continue;
|
|
2971
|
+
const written = await writeEventCatalogEntryById(db, stream2, row.event_id, includeRaw, undefined, eventNameMode);
|
|
2972
|
+
if (!written)
|
|
2973
|
+
continue;
|
|
2974
|
+
seen.add(row.event_id);
|
|
2975
|
+
lastWritten = row.event_id;
|
|
2976
|
+
total += 1;
|
|
2977
|
+
trimSeen(seen);
|
|
2978
|
+
}
|
|
2979
|
+
if (rows.length < 100)
|
|
2980
|
+
break;
|
|
2981
|
+
}
|
|
2982
|
+
if (total >= 1000) {
|
|
2983
|
+
await writeEventOverflow(stream2, {
|
|
2984
|
+
reason: "sqlite_catchup_truncated",
|
|
2985
|
+
dropped: 0,
|
|
2986
|
+
last_event_id: lastWritten
|
|
2987
|
+
});
|
|
2988
|
+
}
|
|
2989
|
+
return { last_id: lastWritten, last_rowid: cursor };
|
|
2990
|
+
}
|
|
2991
|
+
function latestEventCursor(db, filter) {
|
|
2992
|
+
const { where, params } = buildEventRecordWhere(filter, null);
|
|
2993
|
+
const row = db.prepare(`SELECT rowid, event_id FROM event_records ${where} ORDER BY rowid DESC LIMIT 1`).get(...params);
|
|
2994
|
+
return row ?? null;
|
|
2995
|
+
}
|
|
2996
|
+
function eventCursorById(db, eventId) {
|
|
2997
|
+
const row = db.prepare("SELECT rowid, event_id FROM event_records WHERE event_id = ?").get(eventId);
|
|
2998
|
+
return row ?? null;
|
|
2999
|
+
}
|
|
3000
|
+
function queryEventsAfterRowid(db, filter, rowid, limit) {
|
|
3001
|
+
const { where, params } = buildEventRecordWhere(filter, rowid);
|
|
3002
|
+
return db.prepare(`SELECT rowid, event_id FROM event_records ${where} ORDER BY rowid ASC LIMIT ?`).all(...params, limit);
|
|
3003
|
+
}
|
|
3004
|
+
function buildEventRecordWhere(filter, afterRowid) {
|
|
3005
|
+
const conditions = [];
|
|
3006
|
+
const params = [];
|
|
3007
|
+
if (afterRowid !== null) {
|
|
3008
|
+
conditions.push("rowid > ?");
|
|
3009
|
+
params.push(afterRowid);
|
|
3010
|
+
}
|
|
3011
|
+
addListCondition(conditions, params, "event_type", filter.event_type);
|
|
3012
|
+
addListCondition(conditions, params, "source", filter.source);
|
|
3013
|
+
addListCondition(conditions, params, "severity", filter.severity);
|
|
3014
|
+
addScalarCondition(conditions, params, "project_id", filter.project_id);
|
|
3015
|
+
addScalarCondition(conditions, params, "page_id", filter.page_id);
|
|
3016
|
+
addScalarCondition(conditions, params, "machine_id", filter.machine_id);
|
|
3017
|
+
addScalarCondition(conditions, params, "repo_id", filter.repo_id);
|
|
3018
|
+
addScalarCondition(conditions, params, "app_id", filter.app_id);
|
|
3019
|
+
addScalarCondition(conditions, params, "process_id", filter.process_id);
|
|
3020
|
+
addScalarCondition(conditions, params, "run_id", filter.run_id);
|
|
3021
|
+
addScalarCondition(conditions, params, "trace_id", filter.trace_id);
|
|
3022
|
+
addScalarCondition(conditions, params, "span_id", filter.span_id);
|
|
3023
|
+
addScalarCondition(conditions, params, "session_id", filter.session_id);
|
|
3024
|
+
addScalarCondition(conditions, params, "release_id", filter.release_id);
|
|
3025
|
+
addScalarCondition(conditions, params, "environment", filter.environment);
|
|
3026
|
+
return {
|
|
3027
|
+
where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
|
|
3028
|
+
params
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
function streamFilterFromRequest(query) {
|
|
3032
|
+
return {
|
|
3033
|
+
event_type: splitCsv(query.type || query.event_type),
|
|
3034
|
+
source: splitCsv(query.source),
|
|
3035
|
+
severity: splitCsv(query.severity || query.level),
|
|
3036
|
+
project_id: query.project_id || undefined,
|
|
3037
|
+
page_id: query.page_id || undefined,
|
|
3038
|
+
machine_id: query.machine_id || undefined,
|
|
3039
|
+
repo_id: query.repo_id || undefined,
|
|
3040
|
+
app_id: query.app_id || undefined,
|
|
3041
|
+
process_id: query.process_id || undefined,
|
|
3042
|
+
run_id: query.run_id || undefined,
|
|
3043
|
+
trace_id: query.trace_id || undefined,
|
|
3044
|
+
span_id: query.span_id || undefined,
|
|
3045
|
+
session_id: query.session_id || undefined,
|
|
3046
|
+
release_id: query.release_id || undefined,
|
|
3047
|
+
environment: query.environment || undefined
|
|
3048
|
+
};
|
|
3049
|
+
}
|
|
3050
|
+
function splitCsv(value) {
|
|
3051
|
+
const values = value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
|
3052
|
+
return values.length > 0 ? values : undefined;
|
|
3053
|
+
}
|
|
3054
|
+
function streamDebugOptionsFromRequest(query) {
|
|
3055
|
+
if (process.env.HASNA_LOGS_STREAM_TEST_HOOKS !== "1") {
|
|
3056
|
+
return { writeDelayMs: 0 };
|
|
3057
|
+
}
|
|
3058
|
+
return {
|
|
3059
|
+
subscriberQueue: boundedIntQuery(query.debug_subscriber_queue, {
|
|
3060
|
+
min: 1,
|
|
3061
|
+
max: 1e4
|
|
3062
|
+
}),
|
|
3063
|
+
writeDelayMs: boundedIntQuery(query.debug_write_delay_ms, { min: 0, max: 5000 }) ?? 0
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
function boundedIntQuery(value, bounds) {
|
|
3067
|
+
if (value === undefined || value.length === 0)
|
|
3068
|
+
return;
|
|
3069
|
+
const parsed = Number.parseInt(value, 10);
|
|
3070
|
+
if (!Number.isFinite(parsed) || parsed < bounds.min)
|
|
3071
|
+
return;
|
|
3072
|
+
return Math.min(parsed, bounds.max);
|
|
3073
|
+
}
|
|
3074
|
+
function delayedSseWriter(stream2, delayMs) {
|
|
3075
|
+
return {
|
|
3076
|
+
async writeSSE(message) {
|
|
3077
|
+
await sleep(delayMs);
|
|
3078
|
+
await stream2.writeSSE(message);
|
|
3079
|
+
}
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
function addScalarCondition(conditions, params, column, value) {
|
|
3083
|
+
if (!value)
|
|
3084
|
+
return;
|
|
3085
|
+
conditions.push(`${column} = ?`);
|
|
3086
|
+
params.push(value);
|
|
3087
|
+
}
|
|
3088
|
+
function addListCondition(conditions, params, column, values) {
|
|
3089
|
+
if (!values || values.length === 0)
|
|
3090
|
+
return;
|
|
3091
|
+
conditions.push(`${column} IN (${values.map(() => "?").join(",")})`);
|
|
3092
|
+
params.push(...values);
|
|
3093
|
+
}
|
|
3094
|
+
async function writeEventCatalogEntry(stream2, entry, eventNameMode) {
|
|
3095
|
+
await stream2.writeSSE({
|
|
3096
|
+
data: JSON.stringify(entry),
|
|
3097
|
+
id: entry.event_id,
|
|
3098
|
+
event: eventNameMode === "event" ? "event" : entry.event_type
|
|
3099
|
+
});
|
|
3100
|
+
}
|
|
3101
|
+
async function writeEventCatalogEntryById(db, stream2, eventId, includeRaw, fallback, eventNameMode = "type") {
|
|
3102
|
+
try {
|
|
3103
|
+
const entry = includeRaw ? getEvent(db, eventId, true) : fallback ?? getEvent(db, eventId, false);
|
|
3104
|
+
if (!entry)
|
|
3105
|
+
return false;
|
|
3106
|
+
await writeEventCatalogEntry(stream2, entry, eventNameMode);
|
|
3107
|
+
return true;
|
|
3108
|
+
} catch (error) {
|
|
3109
|
+
await writeEventOverflow(stream2, {
|
|
3110
|
+
reason: "raw_event_unreadable",
|
|
3111
|
+
dropped: 0,
|
|
3112
|
+
last_event_id: eventId
|
|
3113
|
+
});
|
|
3114
|
+
const entry = fallback ?? getEvent(db, eventId, false);
|
|
3115
|
+
if (!entry)
|
|
3116
|
+
return false;
|
|
3117
|
+
await writeEventCatalogEntry(stream2, entry, eventNameMode);
|
|
3118
|
+
return true;
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
async function writeEventOverflow(stream2, data) {
|
|
3122
|
+
await stream2.writeSSE({
|
|
3123
|
+
event: "overflow",
|
|
3124
|
+
data: JSON.stringify({
|
|
3125
|
+
type: "overflow",
|
|
3126
|
+
reason: data.reason,
|
|
3127
|
+
dropped: data.dropped,
|
|
3128
|
+
last_event_id: "last_event_id" in data ? data.last_event_id : null,
|
|
3129
|
+
requested_last_event_id: "requested_last_event_id" in data ? data.requested_last_event_id : null,
|
|
3130
|
+
created_at: "created_at" in data ? data.created_at : new Date().toISOString()
|
|
3131
|
+
})
|
|
3132
|
+
});
|
|
3133
|
+
}
|
|
3134
|
+
function trimSeen(seen) {
|
|
3135
|
+
while (seen.size > 2000) {
|
|
3136
|
+
const first = seen.values().next().value;
|
|
3137
|
+
if (!first)
|
|
3138
|
+
return;
|
|
3139
|
+
seen.delete(first);
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
function sleep(ms) {
|
|
3143
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3144
|
+
}
|
|
3145
|
+
function eventArrayFromObject(body) {
|
|
3146
|
+
if (body && typeof body === "object" && !Array.isArray(body) && Array.isArray(body.events)) {
|
|
3147
|
+
return body.events;
|
|
3148
|
+
}
|
|
3149
|
+
return [body];
|
|
3150
|
+
}
|
|
3151
|
+
function queryFromRequest(query) {
|
|
3152
|
+
return {
|
|
3153
|
+
event_type: query.type || query.event_type || undefined,
|
|
3154
|
+
source: query.source || undefined,
|
|
3155
|
+
severity: query.severity || query.level || undefined,
|
|
3156
|
+
project_id: query.project_id || undefined,
|
|
3157
|
+
page_id: query.page_id || undefined,
|
|
3158
|
+
machine_id: query.machine_id || undefined,
|
|
3159
|
+
repo_id: query.repo_id || undefined,
|
|
3160
|
+
app_id: query.app_id || undefined,
|
|
3161
|
+
process_id: query.process_id || undefined,
|
|
3162
|
+
run_id: query.run_id || undefined,
|
|
3163
|
+
trace_id: query.trace_id || undefined,
|
|
3164
|
+
span_id: query.span_id || undefined,
|
|
3165
|
+
session_id: query.session_id || undefined,
|
|
3166
|
+
release_id: query.release_id || undefined,
|
|
3167
|
+
environment: query.environment || undefined,
|
|
3168
|
+
since: query.since || undefined,
|
|
3169
|
+
until: query.until || undefined,
|
|
3170
|
+
text: query.text || undefined,
|
|
3171
|
+
limit: query.limit ? Number(query.limit) : 100,
|
|
3172
|
+
offset: query.offset ? Number(query.offset) : 0,
|
|
3173
|
+
include_raw: query.include_raw === "true"
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
// src/server/routes/issues.ts
|
|
3178
|
+
var ISSUE_UPDATE_KEYS = ["status"];
|
|
3179
|
+
var ISSUE_STATUSES = ["open", "resolved", "ignored"];
|
|
3180
|
+
function issuesRoutes(db) {
|
|
3181
|
+
const app = new Hono2;
|
|
3182
|
+
app.get("/", (c) => {
|
|
3183
|
+
const { project_id, status, limit } = c.req.query();
|
|
3184
|
+
return c.json(listIssues(db, project_id || undefined, status || undefined, limit ? Number(limit) : 50));
|
|
3185
|
+
});
|
|
3186
|
+
app.get("/:id", (c) => {
|
|
3187
|
+
const issue = getIssue(db, c.req.param("id"));
|
|
3188
|
+
if (!issue)
|
|
3189
|
+
return c.json({ error: "not found" }, 404);
|
|
3190
|
+
return c.json(issue);
|
|
3191
|
+
});
|
|
3192
|
+
app.get("/:id/logs", (c) => {
|
|
3193
|
+
const issue = getIssue(db, c.req.param("id"));
|
|
3194
|
+
if (!issue)
|
|
3195
|
+
return c.json({ error: "not found" }, 404);
|
|
3196
|
+
const rows = searchLogs(db, {
|
|
3197
|
+
project_id: issue.project_id ?? undefined,
|
|
3198
|
+
level: issue.level,
|
|
3199
|
+
service: issue.service ?? undefined,
|
|
3200
|
+
text: issue.message_template.slice(0, 50),
|
|
3201
|
+
limit: 50
|
|
3202
|
+
});
|
|
3203
|
+
return c.json(rows);
|
|
3204
|
+
});
|
|
3205
|
+
app.put("/:id", async (c) => {
|
|
3206
|
+
const parsed = await readJsonObject(c, { allowedKeys: ISSUE_UPDATE_KEYS });
|
|
3207
|
+
if (!parsed.ok)
|
|
3208
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
3209
|
+
const statusInput = requiredEnum(parsed.value, "status", ISSUE_STATUSES);
|
|
3210
|
+
if (!statusInput.ok)
|
|
3211
|
+
return c.json({ error: statusInput.message }, statusInput.status);
|
|
3212
|
+
const status = statusInput.value;
|
|
3213
|
+
const updated = updateIssueStatus(db, c.req.param("id"), status);
|
|
3214
|
+
if (!updated)
|
|
3215
|
+
return c.json({ error: "not found" }, 404);
|
|
3216
|
+
return c.json(updated);
|
|
3217
|
+
});
|
|
3218
|
+
return app;
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
// src/server/routes/jobs.ts
|
|
3222
|
+
var JOB_CREATE_KEYS = ["project_id", "schedule", "page_id"];
|
|
3223
|
+
var JOB_UPDATE_KEYS = ["enabled", "schedule", "last_run_at"];
|
|
3224
|
+
function jobsRoutes(db) {
|
|
3225
|
+
const app = new Hono2;
|
|
3226
|
+
app.post("/", async (c) => {
|
|
3227
|
+
const parsed = await readJsonObject(c, { allowedKeys: JOB_CREATE_KEYS });
|
|
3228
|
+
if (!parsed.ok)
|
|
3229
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
3230
|
+
const jobInput = validateJobCreate(parsed.value);
|
|
3231
|
+
if (!jobInput.ok)
|
|
3232
|
+
return c.json({ error: jobInput.message }, jobInput.status);
|
|
3233
|
+
const body = jobInput.value;
|
|
3234
|
+
return c.json(createJob(db, body), 201);
|
|
3235
|
+
});
|
|
3236
|
+
app.get("/", (c) => {
|
|
3237
|
+
const { project_id } = c.req.query();
|
|
3238
|
+
return c.json(listJobs(db, project_id || undefined));
|
|
3239
|
+
});
|
|
3240
|
+
app.put("/:id", async (c) => {
|
|
3241
|
+
const parsed = await readJsonObject(c, { allowedKeys: JOB_UPDATE_KEYS });
|
|
3242
|
+
if (!parsed.ok)
|
|
3243
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
3244
|
+
const jobInput = validateJobUpdate(parsed.value);
|
|
3245
|
+
if (!jobInput.ok)
|
|
3246
|
+
return c.json({ error: jobInput.message }, jobInput.status);
|
|
3247
|
+
const body = jobInput.value;
|
|
3248
|
+
const updated = updateJob(db, c.req.param("id"), body);
|
|
3249
|
+
if (!updated)
|
|
3250
|
+
return c.json({ error: "not found" }, 404);
|
|
3251
|
+
return c.json(updated);
|
|
3252
|
+
});
|
|
3253
|
+
app.delete("/:id", (c) => {
|
|
3254
|
+
deleteJob(db, c.req.param("id"));
|
|
3255
|
+
return c.json({ deleted: true });
|
|
3256
|
+
});
|
|
3257
|
+
return app;
|
|
3258
|
+
}
|
|
3259
|
+
function validateJobCreate(body) {
|
|
3260
|
+
const project_id = requiredString(body, "project_id");
|
|
3261
|
+
if (!project_id.ok)
|
|
3262
|
+
return project_id;
|
|
3263
|
+
const schedule = requiredString(body, "schedule");
|
|
3264
|
+
if (!schedule.ok)
|
|
3265
|
+
return schedule;
|
|
3266
|
+
const page_id = optionalString(body, "page_id");
|
|
3267
|
+
if (!page_id.ok)
|
|
3268
|
+
return page_id;
|
|
3269
|
+
return {
|
|
3270
|
+
ok: true,
|
|
3271
|
+
value: {
|
|
3272
|
+
project_id: project_id.value,
|
|
3273
|
+
schedule: schedule.value,
|
|
3274
|
+
page_id: page_id.value
|
|
3275
|
+
}
|
|
3276
|
+
};
|
|
3277
|
+
}
|
|
3278
|
+
function validateJobUpdate(body) {
|
|
3279
|
+
const enabled = optionalNumber(body, "enabled", {
|
|
3280
|
+
integer: true,
|
|
3281
|
+
min: 0,
|
|
3282
|
+
max: 1
|
|
3283
|
+
});
|
|
3284
|
+
if (!enabled.ok)
|
|
3285
|
+
return enabled;
|
|
3286
|
+
const schedule = optionalString(body, "schedule");
|
|
3287
|
+
if (!schedule.ok)
|
|
3288
|
+
return schedule;
|
|
3289
|
+
const last_run_at = optionalString(body, "last_run_at");
|
|
3290
|
+
if (!last_run_at.ok)
|
|
3291
|
+
return last_run_at;
|
|
3292
|
+
return {
|
|
3293
|
+
ok: true,
|
|
3294
|
+
value: {
|
|
3295
|
+
enabled: enabled.value,
|
|
3296
|
+
schedule: schedule.value,
|
|
3297
|
+
last_run_at: last_run_at.value
|
|
3298
|
+
}
|
|
3299
|
+
};
|
|
2068
3300
|
}
|
|
2069
3301
|
|
|
2070
3302
|
// src/server/routes/logs.ts
|
|
3303
|
+
var LOG_LEVELS2 = new Set([
|
|
3304
|
+
"debug",
|
|
3305
|
+
"info",
|
|
3306
|
+
"warn",
|
|
3307
|
+
"error",
|
|
3308
|
+
"fatal"
|
|
3309
|
+
]);
|
|
3310
|
+
var LOG_SOURCES = new Set([
|
|
3311
|
+
"sdk",
|
|
3312
|
+
"script",
|
|
3313
|
+
"scanner",
|
|
3314
|
+
"browser",
|
|
3315
|
+
"node",
|
|
3316
|
+
"bun",
|
|
3317
|
+
"next",
|
|
3318
|
+
"vite",
|
|
3319
|
+
"cli",
|
|
3320
|
+
"build",
|
|
3321
|
+
"test",
|
|
3322
|
+
"mcp",
|
|
3323
|
+
"agent",
|
|
3324
|
+
"otel",
|
|
3325
|
+
"system",
|
|
3326
|
+
"pino",
|
|
3327
|
+
"winston",
|
|
3328
|
+
"structured"
|
|
3329
|
+
]);
|
|
3330
|
+
var PRIVACY_CLASSES = new Set([
|
|
3331
|
+
"public",
|
|
3332
|
+
"internal",
|
|
3333
|
+
"sensitive",
|
|
3334
|
+
"secret",
|
|
3335
|
+
"pii"
|
|
3336
|
+
]);
|
|
3337
|
+
var LOG_ENTRY_KEYS = new Set([
|
|
3338
|
+
"id",
|
|
3339
|
+
"timestamp",
|
|
3340
|
+
"source_event_id",
|
|
3341
|
+
"project_id",
|
|
3342
|
+
"page_id",
|
|
3343
|
+
"level",
|
|
3344
|
+
"source",
|
|
3345
|
+
"service",
|
|
3346
|
+
"message",
|
|
3347
|
+
"privacy",
|
|
3348
|
+
"machine_id",
|
|
3349
|
+
"repo_id",
|
|
3350
|
+
"app_id",
|
|
3351
|
+
"process_id",
|
|
3352
|
+
"run_id",
|
|
3353
|
+
"trace_id",
|
|
3354
|
+
"span_id",
|
|
3355
|
+
"parent_span_id",
|
|
3356
|
+
"session_id",
|
|
3357
|
+
"release_id",
|
|
3358
|
+
"environment",
|
|
3359
|
+
"agent",
|
|
3360
|
+
"url",
|
|
3361
|
+
"stack_trace",
|
|
3362
|
+
"metadata"
|
|
3363
|
+
]);
|
|
2071
3364
|
function logsRoutes(db) {
|
|
2072
3365
|
const app = new Hono2;
|
|
3366
|
+
app.post("/structured", async (c) => {
|
|
3367
|
+
const validation = await validateStructuredIngestRequest(db, c);
|
|
3368
|
+
if (!validation.ok)
|
|
3369
|
+
return c.json({ error: validation.message }, validation.status);
|
|
3370
|
+
const rows = validation.entries.map((entry) => ingestLog(db, entry));
|
|
3371
|
+
return c.json({
|
|
3372
|
+
inserted: rows.length,
|
|
3373
|
+
events: rows.map((row) => ({
|
|
3374
|
+
id: row.id,
|
|
3375
|
+
timestamp: row.timestamp,
|
|
3376
|
+
level: row.level,
|
|
3377
|
+
source: row.source,
|
|
3378
|
+
service: row.service,
|
|
3379
|
+
message: row.message,
|
|
3380
|
+
trace_id: row.trace_id
|
|
3381
|
+
}))
|
|
3382
|
+
}, 201);
|
|
3383
|
+
});
|
|
2073
3384
|
app.post("/", async (c) => {
|
|
2074
|
-
const
|
|
2075
|
-
if (
|
|
2076
|
-
|
|
3385
|
+
const validation = await validateIngestRequest(db, c);
|
|
3386
|
+
if (!validation.ok)
|
|
3387
|
+
return c.json({ error: validation.message }, validation.status);
|
|
3388
|
+
if (validation.batch) {
|
|
3389
|
+
const rows = ingestBatch(db, validation.entries);
|
|
3390
|
+
if (validation.authorization.kind === "browser-token")
|
|
3391
|
+
touchBrowserIngestToken(db, validation.authorization.token.id);
|
|
2077
3392
|
return c.json({ inserted: rows.length }, 201);
|
|
2078
3393
|
}
|
|
2079
|
-
const
|
|
3394
|
+
const [entry] = validation.entries;
|
|
3395
|
+
if (!entry)
|
|
3396
|
+
return c.json({ error: "No log entry provided" }, 422);
|
|
3397
|
+
const row = ingestLog(db, entry);
|
|
3398
|
+
if (validation.authorization.kind === "browser-token")
|
|
3399
|
+
touchBrowserIngestToken(db, validation.authorization.token.id);
|
|
2080
3400
|
return c.json(row, 201);
|
|
2081
3401
|
});
|
|
2082
3402
|
app.get("/", (c) => {
|
|
2083
|
-
const {
|
|
3403
|
+
const {
|
|
3404
|
+
project_id,
|
|
3405
|
+
page_id,
|
|
3406
|
+
level,
|
|
3407
|
+
service,
|
|
3408
|
+
since,
|
|
3409
|
+
until,
|
|
3410
|
+
text,
|
|
3411
|
+
trace_id,
|
|
3412
|
+
limit,
|
|
3413
|
+
offset,
|
|
3414
|
+
fields
|
|
3415
|
+
} = c.req.query();
|
|
2084
3416
|
const rows = searchLogs(db, {
|
|
2085
3417
|
project_id: project_id || undefined,
|
|
2086
3418
|
page_id: page_id || undefined,
|
|
@@ -2127,31 +3459,944 @@ function logsRoutes(db) {
|
|
|
2127
3459
|
since: parseTime(since || "1h"),
|
|
2128
3460
|
limit: limit ? Number(limit) : 20
|
|
2129
3461
|
});
|
|
2130
|
-
return c.json(rows.map((r) => ({
|
|
3462
|
+
return c.json(rows.map((r) => ({
|
|
3463
|
+
id: r.id,
|
|
3464
|
+
timestamp: r.timestamp,
|
|
3465
|
+
level: r.level,
|
|
3466
|
+
message: r.message,
|
|
3467
|
+
service: r.service,
|
|
3468
|
+
age_seconds: Math.floor((Date.now() - new Date(r.timestamp).getTime()) / 1000)
|
|
3469
|
+
})));
|
|
2131
3470
|
});
|
|
2132
3471
|
app.get("/:trace_id/context", (c) => {
|
|
2133
3472
|
const rows = getLogContext(db, c.req.param("trace_id"));
|
|
2134
3473
|
return c.json(rows);
|
|
2135
3474
|
});
|
|
2136
|
-
app.get("/export", (c) => {
|
|
2137
|
-
const { project_id, since, until, level, service, format, limit } = c.req.query();
|
|
2138
|
-
const opts = {
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
3475
|
+
app.get("/export", (c) => {
|
|
3476
|
+
const { project_id, since, until, level, service, format, limit } = c.req.query();
|
|
3477
|
+
const opts = {
|
|
3478
|
+
project_id: project_id || undefined,
|
|
3479
|
+
since: since || undefined,
|
|
3480
|
+
until: until || undefined,
|
|
3481
|
+
level: level || undefined,
|
|
3482
|
+
service: service || undefined,
|
|
3483
|
+
limit: limit ? Number(limit) : undefined
|
|
3484
|
+
};
|
|
3485
|
+
if (format === "csv") {
|
|
3486
|
+
c.header("Content-Type", "text/csv");
|
|
3487
|
+
c.header("Content-Disposition", "attachment; filename=logs.csv");
|
|
3488
|
+
const chunks2 = [];
|
|
3489
|
+
exportToCsv(db, opts, (s) => chunks2.push(s));
|
|
3490
|
+
return c.text(chunks2.join(""));
|
|
3491
|
+
}
|
|
3492
|
+
c.header("Content-Type", "application/json");
|
|
3493
|
+
c.header("Content-Disposition", "attachment; filename=logs.json");
|
|
3494
|
+
const chunks = [];
|
|
3495
|
+
exportToJson(db, opts, (s) => chunks.push(s));
|
|
3496
|
+
return c.text(chunks.join(`
|
|
3497
|
+
`));
|
|
3498
|
+
});
|
|
3499
|
+
return app;
|
|
3500
|
+
}
|
|
3501
|
+
async function validateStructuredIngestRequest(db, c) {
|
|
3502
|
+
const authorization = authorizeLogIngest(db, c);
|
|
3503
|
+
if (!authorization || authorization.kind === "browser-token") {
|
|
3504
|
+
return {
|
|
3505
|
+
ok: false,
|
|
3506
|
+
status: 401,
|
|
3507
|
+
message: "Structured server log ingest requires a trusted API token"
|
|
3508
|
+
};
|
|
3509
|
+
}
|
|
3510
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
3511
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
3512
|
+
return {
|
|
3513
|
+
ok: false,
|
|
3514
|
+
status: 415,
|
|
3515
|
+
message: "Content-Type must be application/json"
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
const maxPayloadBytes = readPositiveInt2("HASNA_LOGS_MAX_PAYLOAD_BYTES", 1048576);
|
|
3519
|
+
const contentLength = Number(c.req.header("content-length") ?? "0");
|
|
3520
|
+
if (Number.isFinite(contentLength) && contentLength > maxPayloadBytes) {
|
|
3521
|
+
return {
|
|
3522
|
+
ok: false,
|
|
3523
|
+
status: 413,
|
|
3524
|
+
message: `Payload exceeds ${maxPayloadBytes} bytes`
|
|
3525
|
+
};
|
|
3526
|
+
}
|
|
3527
|
+
let raw2 = "";
|
|
3528
|
+
try {
|
|
3529
|
+
raw2 = await c.req.text();
|
|
3530
|
+
} catch {
|
|
3531
|
+
return { ok: false, status: 400, message: "Unable to read request body" };
|
|
3532
|
+
}
|
|
3533
|
+
if (Buffer.byteLength(raw2, "utf8") > maxPayloadBytes) {
|
|
3534
|
+
return {
|
|
3535
|
+
ok: false,
|
|
3536
|
+
status: 413,
|
|
3537
|
+
message: `Payload exceeds ${maxPayloadBytes} bytes`
|
|
3538
|
+
};
|
|
3539
|
+
}
|
|
3540
|
+
let body;
|
|
3541
|
+
try {
|
|
3542
|
+
body = JSON.parse(raw2);
|
|
3543
|
+
} catch {
|
|
3544
|
+
return { ok: false, status: 400, message: "Invalid JSON body" };
|
|
3545
|
+
}
|
|
3546
|
+
const maxBatchSize = readPositiveInt2("HASNA_LOGS_MAX_BATCH_SIZE", 1000);
|
|
3547
|
+
const count = structuredPayloadCount(body);
|
|
3548
|
+
if (count > maxBatchSize) {
|
|
3549
|
+
return {
|
|
3550
|
+
ok: false,
|
|
3551
|
+
status: 413,
|
|
3552
|
+
message: `Batch exceeds ${maxBatchSize} entries`
|
|
3553
|
+
};
|
|
3554
|
+
}
|
|
3555
|
+
let result;
|
|
3556
|
+
try {
|
|
3557
|
+
result = structuredLogPayloadToEntries(body, structuredOptionsFromRequest(c));
|
|
3558
|
+
} catch (error) {
|
|
3559
|
+
return {
|
|
3560
|
+
ok: false,
|
|
3561
|
+
status: 422,
|
|
3562
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3563
|
+
};
|
|
3564
|
+
}
|
|
3565
|
+
for (let index = 0;index < result.entries.length; index += 1) {
|
|
3566
|
+
const entry = result.entries[index];
|
|
3567
|
+
if (entry?.source === "browser" || entry?.source === "script") {
|
|
3568
|
+
return {
|
|
3569
|
+
ok: false,
|
|
3570
|
+
status: 422,
|
|
3571
|
+
message: `entry[${index}].source cannot be browser or script for structured server log ingest`
|
|
3572
|
+
};
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
try {
|
|
3576
|
+
validateStructuredLogReferences(db, result.entries);
|
|
3577
|
+
} catch (error) {
|
|
3578
|
+
return {
|
|
3579
|
+
ok: false,
|
|
3580
|
+
status: 422,
|
|
3581
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3582
|
+
};
|
|
3583
|
+
}
|
|
3584
|
+
const maxMessageChars = readPositiveInt2("HASNA_LOGS_MAX_MESSAGE_CHARS", 262144);
|
|
3585
|
+
for (let index = 0;index < result.entries.length; index += 1) {
|
|
3586
|
+
const entry = result.entries[index];
|
|
3587
|
+
if (entry && entry.message.length > maxMessageChars) {
|
|
3588
|
+
return {
|
|
3589
|
+
ok: false,
|
|
3590
|
+
status: 413,
|
|
3591
|
+
message: `entry[${index}].message is too large`
|
|
3592
|
+
};
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
return {
|
|
3596
|
+
ok: true,
|
|
3597
|
+
entries: result.entries,
|
|
3598
|
+
batch: result.batch,
|
|
3599
|
+
authorization
|
|
3600
|
+
};
|
|
3601
|
+
}
|
|
3602
|
+
async function validateIngestRequest(db, c) {
|
|
3603
|
+
const authorization = authorizeLogIngest(db, c);
|
|
3604
|
+
if (!authorization) {
|
|
3605
|
+
return { ok: false, status: 401, message: "Unauthorized" };
|
|
3606
|
+
}
|
|
3607
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
3608
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
3609
|
+
return {
|
|
3610
|
+
ok: false,
|
|
3611
|
+
status: 415,
|
|
3612
|
+
message: "Content-Type must be application/json"
|
|
3613
|
+
};
|
|
3614
|
+
}
|
|
3615
|
+
const maxPayloadBytes = readPositiveInt2("HASNA_LOGS_MAX_PAYLOAD_BYTES", 1048576);
|
|
3616
|
+
const contentLength = Number(c.req.header("content-length") ?? "0");
|
|
3617
|
+
if (Number.isFinite(contentLength) && contentLength > maxPayloadBytes) {
|
|
3618
|
+
return {
|
|
3619
|
+
ok: false,
|
|
3620
|
+
status: 413,
|
|
3621
|
+
message: `Payload exceeds ${maxPayloadBytes} bytes`
|
|
3622
|
+
};
|
|
3623
|
+
}
|
|
3624
|
+
let raw2 = "";
|
|
3625
|
+
try {
|
|
3626
|
+
raw2 = await c.req.text();
|
|
3627
|
+
} catch {
|
|
3628
|
+
return { ok: false, status: 400, message: "Unable to read request body" };
|
|
3629
|
+
}
|
|
3630
|
+
if (Buffer.byteLength(raw2, "utf8") > maxPayloadBytes) {
|
|
3631
|
+
return {
|
|
3632
|
+
ok: false,
|
|
3633
|
+
status: 413,
|
|
3634
|
+
message: `Payload exceeds ${maxPayloadBytes} bytes`
|
|
3635
|
+
};
|
|
3636
|
+
}
|
|
3637
|
+
let body;
|
|
3638
|
+
try {
|
|
3639
|
+
body = JSON.parse(raw2);
|
|
3640
|
+
} catch {
|
|
3641
|
+
return { ok: false, status: 400, message: "Invalid JSON body" };
|
|
3642
|
+
}
|
|
3643
|
+
const maxBatchSize = readPositiveInt2("HASNA_LOGS_MAX_BATCH_SIZE", 1000);
|
|
3644
|
+
if (Array.isArray(body)) {
|
|
3645
|
+
if (body.length > maxBatchSize) {
|
|
3646
|
+
return {
|
|
3647
|
+
ok: false,
|
|
3648
|
+
status: 413,
|
|
3649
|
+
message: `Batch exceeds ${maxBatchSize} entries`
|
|
3650
|
+
};
|
|
3651
|
+
}
|
|
3652
|
+
const entries = [];
|
|
3653
|
+
for (let index = 0;index < body.length; index += 1) {
|
|
3654
|
+
const result2 = validateLogEntry(body[index], `entry[${index}]`);
|
|
3655
|
+
if (!result2.ok)
|
|
3656
|
+
return result2;
|
|
3657
|
+
const authorized2 = applyLogIngestAuthorization(result2.entry, authorization, `entry[${index}]`);
|
|
3658
|
+
if (!authorized2.ok)
|
|
3659
|
+
return authorized2;
|
|
3660
|
+
entries.push(authorized2.entry);
|
|
3661
|
+
}
|
|
3662
|
+
return { ok: true, entries, batch: true, authorization };
|
|
3663
|
+
}
|
|
3664
|
+
const result = validateLogEntry(body, "entry");
|
|
3665
|
+
if (!result.ok)
|
|
3666
|
+
return result;
|
|
3667
|
+
const authorized = applyLogIngestAuthorization(result.entry, authorization, "entry");
|
|
3668
|
+
if (!authorized.ok)
|
|
3669
|
+
return authorized;
|
|
3670
|
+
return { ok: true, entries: [authorized.entry], batch: false, authorization };
|
|
3671
|
+
}
|
|
3672
|
+
function structuredOptionsFromRequest(c) {
|
|
3673
|
+
const query = c.req.query();
|
|
3674
|
+
const format = query.format;
|
|
3675
|
+
if (format !== undefined && format !== "auto" && format !== "pino" && format !== "winston" && format !== "json") {
|
|
3676
|
+
throw new Error("format must be auto, pino, winston, or json");
|
|
3677
|
+
}
|
|
3678
|
+
return {
|
|
3679
|
+
format,
|
|
3680
|
+
source: query.source,
|
|
3681
|
+
service: query.service,
|
|
3682
|
+
project_id: query.project_id,
|
|
3683
|
+
page_id: query.page_id,
|
|
3684
|
+
machine_id: query.machine_id,
|
|
3685
|
+
repo_id: query.repo_id,
|
|
3686
|
+
app_id: query.app_id,
|
|
3687
|
+
process_id: query.process_id,
|
|
3688
|
+
run_id: query.run_id,
|
|
3689
|
+
trace_id: query.trace_id,
|
|
3690
|
+
span_id: query.span_id,
|
|
3691
|
+
parent_span_id: query.parent_span_id,
|
|
3692
|
+
session_id: query.session_id,
|
|
3693
|
+
release_id: query.release_id,
|
|
3694
|
+
environment: query.environment,
|
|
3695
|
+
agent: query.agent,
|
|
3696
|
+
url: query.url
|
|
3697
|
+
};
|
|
3698
|
+
}
|
|
3699
|
+
function structuredPayloadCount(value) {
|
|
3700
|
+
if (Array.isArray(value))
|
|
3701
|
+
return value.length;
|
|
3702
|
+
if (value && typeof value === "object" && !Array.isArray(value) && Array.isArray(value.logs)) {
|
|
3703
|
+
return value.logs.length;
|
|
3704
|
+
}
|
|
3705
|
+
return 1;
|
|
3706
|
+
}
|
|
3707
|
+
function applyLogIngestAuthorization(entry, authorization, path) {
|
|
3708
|
+
if (authorization.kind !== "browser-token")
|
|
3709
|
+
return { ok: true, entry };
|
|
3710
|
+
if (entry.source !== undefined && entry.source !== "script" && entry.source !== "browser") {
|
|
3711
|
+
return {
|
|
3712
|
+
ok: false,
|
|
3713
|
+
status: 422,
|
|
3714
|
+
message: `${path}.source must be script or browser when using a browser ingest token`
|
|
3715
|
+
};
|
|
3716
|
+
}
|
|
3717
|
+
return {
|
|
3718
|
+
ok: true,
|
|
3719
|
+
entry: {
|
|
3720
|
+
...entry,
|
|
3721
|
+
project_id: authorization.token.project_id,
|
|
3722
|
+
source: entry.source ?? "browser"
|
|
3723
|
+
}
|
|
3724
|
+
};
|
|
3725
|
+
}
|
|
3726
|
+
function validateLogEntry(value, path) {
|
|
3727
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
3728
|
+
return { ok: false, status: 422, message: `${path} must be an object` };
|
|
3729
|
+
}
|
|
3730
|
+
const input = value;
|
|
3731
|
+
for (const key of Object.keys(input)) {
|
|
3732
|
+
if (!LOG_ENTRY_KEYS.has(key)) {
|
|
3733
|
+
return {
|
|
3734
|
+
ok: false,
|
|
3735
|
+
status: 422,
|
|
3736
|
+
message: `${path}.${key} is not a supported log field`
|
|
3737
|
+
};
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
if (!LOG_LEVELS2.has(input.level)) {
|
|
3741
|
+
return {
|
|
3742
|
+
ok: false,
|
|
3743
|
+
status: 422,
|
|
3744
|
+
message: `${path}.level must be one of debug, info, warn, error, fatal`
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
if (typeof input.message !== "string" || input.message.length === 0) {
|
|
3748
|
+
return {
|
|
3749
|
+
ok: false,
|
|
3750
|
+
status: 422,
|
|
3751
|
+
message: `${path}.message must be a non-empty string`
|
|
3752
|
+
};
|
|
3753
|
+
}
|
|
3754
|
+
if (input.message.length > readPositiveInt2("HASNA_LOGS_MAX_MESSAGE_CHARS", 262144)) {
|
|
3755
|
+
return { ok: false, status: 413, message: `${path}.message is too large` };
|
|
3756
|
+
}
|
|
3757
|
+
if (input.source !== undefined && !LOG_SOURCES.has(input.source)) {
|
|
3758
|
+
return {
|
|
3759
|
+
ok: false,
|
|
3760
|
+
status: 422,
|
|
3761
|
+
message: `${path}.source is not supported`
|
|
3762
|
+
};
|
|
3763
|
+
}
|
|
3764
|
+
if (input.privacy !== undefined && !PRIVACY_CLASSES.has(input.privacy)) {
|
|
3765
|
+
return {
|
|
3766
|
+
ok: false,
|
|
3767
|
+
status: 422,
|
|
3768
|
+
message: `${path}.privacy is not supported`
|
|
3769
|
+
};
|
|
3770
|
+
}
|
|
3771
|
+
if (input.metadata !== undefined && (!input.metadata || typeof input.metadata !== "object" || Array.isArray(input.metadata))) {
|
|
3772
|
+
return {
|
|
3773
|
+
ok: false,
|
|
3774
|
+
status: 422,
|
|
3775
|
+
message: `${path}.metadata must be an object`
|
|
3776
|
+
};
|
|
3777
|
+
}
|
|
3778
|
+
const entry = {
|
|
3779
|
+
level: input.level,
|
|
3780
|
+
message: input.message
|
|
3781
|
+
};
|
|
3782
|
+
const entryRecord = entry;
|
|
3783
|
+
const optionalStringKeys = [
|
|
3784
|
+
"id",
|
|
3785
|
+
"timestamp",
|
|
3786
|
+
"source_event_id",
|
|
3787
|
+
"project_id",
|
|
3788
|
+
"page_id",
|
|
3789
|
+
"source",
|
|
3790
|
+
"service",
|
|
3791
|
+
"privacy",
|
|
3792
|
+
"machine_id",
|
|
3793
|
+
"repo_id",
|
|
3794
|
+
"app_id",
|
|
3795
|
+
"process_id",
|
|
3796
|
+
"run_id",
|
|
3797
|
+
"trace_id",
|
|
3798
|
+
"span_id",
|
|
3799
|
+
"parent_span_id",
|
|
3800
|
+
"session_id",
|
|
3801
|
+
"release_id",
|
|
3802
|
+
"environment",
|
|
3803
|
+
"agent",
|
|
3804
|
+
"url",
|
|
3805
|
+
"stack_trace"
|
|
3806
|
+
];
|
|
3807
|
+
for (const key of optionalStringKeys) {
|
|
3808
|
+
const copied = copyOptionalString(input, entryRecord, key, path);
|
|
3809
|
+
if (!copied.ok)
|
|
3810
|
+
return copied;
|
|
3811
|
+
}
|
|
3812
|
+
if (input.metadata !== undefined)
|
|
3813
|
+
entry.metadata = input.metadata;
|
|
3814
|
+
return { ok: true, entry };
|
|
3815
|
+
}
|
|
3816
|
+
function copyOptionalString(input, entry, key, path) {
|
|
3817
|
+
const value = input[key];
|
|
3818
|
+
if (value === undefined)
|
|
3819
|
+
return { ok: true };
|
|
3820
|
+
if (typeof value !== "string") {
|
|
3821
|
+
return {
|
|
3822
|
+
ok: false,
|
|
3823
|
+
status: 422,
|
|
3824
|
+
message: `${path}.${key} must be a string`
|
|
3825
|
+
};
|
|
3826
|
+
}
|
|
3827
|
+
entry[key] = value;
|
|
3828
|
+
return { ok: true };
|
|
3829
|
+
}
|
|
3830
|
+
function readPositiveInt2(name, fallback) {
|
|
3831
|
+
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
3832
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
// src/lib/otlp-ingest.ts
|
|
3836
|
+
import { createHash as createHash3 } from "crypto";
|
|
3837
|
+
function ingestOtlpTraces(db, payload) {
|
|
3838
|
+
const events = [];
|
|
3839
|
+
for (const [resourceIndex, resourceSpan] of requiredObjectArray(payload, "resourceSpans", "payload.resourceSpans").entries()) {
|
|
3840
|
+
const resource = attributesFromContainer(resourceSpan.resource);
|
|
3841
|
+
for (const [scopeIndex, scopeSpan] of objectArray(resourceSpan.scopeSpans, "payload.resourceSpans[].scopeSpans").entries()) {
|
|
3842
|
+
const scope = scopeInfo(scopeSpan.scope);
|
|
3843
|
+
const context = { resource, scope, resourceIndex, scopeIndex };
|
|
3844
|
+
for (const span of objectArray(scopeSpan.spans, "payload.resourceSpans[].scopeSpans[].spans")) {
|
|
3845
|
+
events.push(spanToEvent(span, context));
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
}
|
|
3849
|
+
return ingestOtlpEvents(db, "traces", events);
|
|
3850
|
+
}
|
|
3851
|
+
function ingestOtlpLogs(db, payload) {
|
|
3852
|
+
const events = [];
|
|
3853
|
+
for (const [resourceIndex, resourceLog] of requiredObjectArray(payload, "resourceLogs", "payload.resourceLogs").entries()) {
|
|
3854
|
+
const resource = attributesFromContainer(resourceLog.resource);
|
|
3855
|
+
for (const [scopeIndex, scopeLog] of objectArray(resourceLog.scopeLogs, "payload.resourceLogs[].scopeLogs").entries()) {
|
|
3856
|
+
const scope = scopeInfo(scopeLog.scope);
|
|
3857
|
+
const context = { resource, scope, resourceIndex, scopeIndex };
|
|
3858
|
+
for (const [recordIndex, logRecord] of objectArray(scopeLog.logRecords, "payload.resourceLogs[].scopeLogs[].logRecords").entries()) {
|
|
3859
|
+
events.push(logRecordToEvent(logRecord, context, recordIndex));
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
return ingestOtlpEvents(db, "logs", events);
|
|
3864
|
+
}
|
|
3865
|
+
function ingestOtlpMetrics(db, payload) {
|
|
3866
|
+
const events = [];
|
|
3867
|
+
for (const [resourceIndex, resourceMetric] of requiredObjectArray(payload, "resourceMetrics", "payload.resourceMetrics").entries()) {
|
|
3868
|
+
const resource = attributesFromContainer(resourceMetric.resource);
|
|
3869
|
+
for (const [scopeIndex, scopeMetric] of objectArray(resourceMetric.scopeMetrics, "payload.resourceMetrics[].scopeMetrics").entries()) {
|
|
3870
|
+
const scope = scopeInfo(scopeMetric.scope);
|
|
3871
|
+
const context = { resource, scope, resourceIndex, scopeIndex };
|
|
3872
|
+
for (const [metricIndex, metric] of objectArray(scopeMetric.metrics, "payload.resourceMetrics[].scopeMetrics[].metrics").entries()) {
|
|
3873
|
+
events.push(...metricToEvents(metric, context, metricIndex));
|
|
3874
|
+
}
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
return ingestOtlpEvents(db, "metrics", events);
|
|
3878
|
+
}
|
|
3879
|
+
function ingestOtlpEvents(db, signal, inputs) {
|
|
3880
|
+
const events = [];
|
|
3881
|
+
let inserted = 0;
|
|
3882
|
+
for (const input of inputs) {
|
|
3883
|
+
const result = ingestUniversalEvent(db, input);
|
|
3884
|
+
if (result.inserted)
|
|
3885
|
+
inserted += 1;
|
|
3886
|
+
events.push(result.event);
|
|
3887
|
+
}
|
|
3888
|
+
return {
|
|
3889
|
+
signal,
|
|
3890
|
+
accepted: inputs.length,
|
|
3891
|
+
inserted,
|
|
3892
|
+
duplicates: inputs.length - inserted,
|
|
3893
|
+
events
|
|
3894
|
+
};
|
|
3895
|
+
}
|
|
3896
|
+
function spanToEvent(span, context) {
|
|
3897
|
+
const attributes = attributesFromArray(span.attributes);
|
|
3898
|
+
const traceId = optionalString2(span.traceId);
|
|
3899
|
+
const spanId = optionalString2(span.spanId);
|
|
3900
|
+
const parentSpanId = optionalString2(span.parentSpanId);
|
|
3901
|
+
const startTime = unixNanoToIso(span.startTimeUnixNano);
|
|
3902
|
+
const endTime = unixNanoToIso(span.endTimeUnixNano);
|
|
3903
|
+
const spanDurationMs = durationMs(span.startTimeUnixNano, span.endTimeUnixNano);
|
|
3904
|
+
const status = objectValue(span.status);
|
|
3905
|
+
const statusCode = optionalString2(status?.code) ?? stringFromUnknown(status?.code);
|
|
3906
|
+
const isError = statusCode === "2" || statusCode === "STATUS_CODE_ERROR" || statusCode === "ERROR";
|
|
3907
|
+
const name = optionalString2(span.name) ?? "OTLP span";
|
|
3908
|
+
const spanKind = optionalString2(span.kind) ?? stringFromUnknown(span.kind);
|
|
3909
|
+
return {
|
|
3910
|
+
type: "span",
|
|
3911
|
+
source: "otel",
|
|
3912
|
+
source_event_id: traceId && spanId ? `otlp:span:${traceId}:${spanId}` : stableSourceEventId("span", span),
|
|
3913
|
+
event_time: startTime,
|
|
3914
|
+
severity: isError ? "error" : "info",
|
|
3915
|
+
trace_id: traceId,
|
|
3916
|
+
span_id: spanId,
|
|
3917
|
+
parent_span_id: parentSpanId,
|
|
3918
|
+
app_id: resourceString(context.resource, "service.name"),
|
|
3919
|
+
machine_id: resourceString(context.resource, "host.id") ?? resourceString(context.resource, "host.name"),
|
|
3920
|
+
environment: resourceString(context.resource, "deployment.environment"),
|
|
3921
|
+
message: name,
|
|
3922
|
+
attributes: compactObject({
|
|
3923
|
+
category: "otlp_span",
|
|
3924
|
+
signal: "traces",
|
|
3925
|
+
name,
|
|
3926
|
+
operation: attributes["http.route"] ?? attributes["rpc.method"] ?? attributes["db.operation.name"] ?? spanKind,
|
|
3927
|
+
status: isError ? "error" : "ok",
|
|
3928
|
+
started_at: startTime,
|
|
3929
|
+
ended_at: endTime,
|
|
3930
|
+
duration_ms: spanDurationMs,
|
|
3931
|
+
resource: context.resource,
|
|
3932
|
+
scope: context.scope,
|
|
3933
|
+
span_attributes: attributes,
|
|
3934
|
+
otel: compactObject({
|
|
3935
|
+
signal: "traces",
|
|
3936
|
+
span_kind: spanKind,
|
|
3937
|
+
status_code: statusCode,
|
|
3938
|
+
status_message: optionalString2(status?.message),
|
|
3939
|
+
dropped_attributes_count: numberFromUnknown(span.droppedAttributesCount),
|
|
3940
|
+
dropped_events_count: numberFromUnknown(span.droppedEventsCount),
|
|
3941
|
+
dropped_links_count: numberFromUnknown(span.droppedLinksCount),
|
|
3942
|
+
start_time_unix_nano: stringFromUnknown(span.startTimeUnixNano),
|
|
3943
|
+
end_time_unix_nano: stringFromUnknown(span.endTimeUnixNano)
|
|
3944
|
+
})
|
|
3945
|
+
}),
|
|
3946
|
+
body: {
|
|
3947
|
+
span: compactObject({
|
|
3948
|
+
name,
|
|
3949
|
+
kind: spanKind,
|
|
3950
|
+
status,
|
|
3951
|
+
events: spanEvents(span.events),
|
|
3952
|
+
links: spanLinks(span.links)
|
|
3953
|
+
})
|
|
3954
|
+
}
|
|
3955
|
+
};
|
|
3956
|
+
}
|
|
3957
|
+
function logRecordToEvent(logRecord, context, recordIndex) {
|
|
3958
|
+
const attributes = attributesFromArray(logRecord.attributes);
|
|
3959
|
+
const body = otlpAnyValue(logRecord.body);
|
|
3960
|
+
const traceId = optionalString2(logRecord.traceId);
|
|
3961
|
+
const spanId = optionalString2(logRecord.spanId);
|
|
3962
|
+
const severityNumber = numberFromUnknown(logRecord.severityNumber);
|
|
3963
|
+
const severityText = optionalString2(logRecord.severityText);
|
|
3964
|
+
const eventTime = unixNanoToIso(logRecord.timeUnixNano) ?? unixNanoToIso(logRecord.observedTimeUnixNano);
|
|
3965
|
+
const message = typeof body === "string" ? body : severityText ? `OTLP ${severityText} log` : "OTLP log";
|
|
3966
|
+
return {
|
|
3967
|
+
type: "log",
|
|
3968
|
+
source: "otel",
|
|
3969
|
+
source_event_id: stableSourceEventId("log", {
|
|
3970
|
+
traceId,
|
|
3971
|
+
spanId,
|
|
3972
|
+
timeUnixNano: logRecord.timeUnixNano,
|
|
3973
|
+
observedTimeUnixNano: logRecord.observedTimeUnixNano,
|
|
3974
|
+
severityNumber,
|
|
3975
|
+
severityText,
|
|
3976
|
+
resourceIndex: context.resourceIndex,
|
|
3977
|
+
scopeIndex: context.scopeIndex,
|
|
3978
|
+
recordIndex,
|
|
3979
|
+
resource: context.resource,
|
|
3980
|
+
scope: context.scope,
|
|
3981
|
+
body,
|
|
3982
|
+
attributes
|
|
3983
|
+
}),
|
|
3984
|
+
event_time: eventTime,
|
|
3985
|
+
severity: otlpSeverity(severityNumber, severityText),
|
|
3986
|
+
trace_id: traceId,
|
|
3987
|
+
span_id: spanId,
|
|
3988
|
+
app_id: resourceString(context.resource, "service.name"),
|
|
3989
|
+
machine_id: resourceString(context.resource, "host.id") ?? resourceString(context.resource, "host.name"),
|
|
3990
|
+
environment: resourceString(context.resource, "deployment.environment"),
|
|
3991
|
+
message,
|
|
3992
|
+
attributes: compactObject({
|
|
3993
|
+
category: "otlp_log",
|
|
3994
|
+
signal: "logs",
|
|
3995
|
+
resource: context.resource,
|
|
3996
|
+
scope: context.scope,
|
|
3997
|
+
log_attributes: attributes,
|
|
3998
|
+
otel: compactObject({
|
|
3999
|
+
signal: "logs",
|
|
4000
|
+
severity_number: severityNumber,
|
|
4001
|
+
severity_text: severityText,
|
|
4002
|
+
time_unix_nano: stringFromUnknown(logRecord.timeUnixNano),
|
|
4003
|
+
observed_time_unix_nano: stringFromUnknown(logRecord.observedTimeUnixNano),
|
|
4004
|
+
dropped_attributes_count: numberFromUnknown(logRecord.droppedAttributesCount),
|
|
4005
|
+
flags: numberFromUnknown(logRecord.flags)
|
|
4006
|
+
})
|
|
4007
|
+
}),
|
|
4008
|
+
body: {
|
|
4009
|
+
log: compactObject({
|
|
4010
|
+
body,
|
|
4011
|
+
severity_number: severityNumber,
|
|
4012
|
+
severity_text: severityText
|
|
4013
|
+
})
|
|
4014
|
+
}
|
|
4015
|
+
};
|
|
4016
|
+
}
|
|
4017
|
+
function metricToEvents(metric, context, metricIndex) {
|
|
4018
|
+
const name = optionalString2(metric.name) ?? "otel.metric";
|
|
4019
|
+
const description = optionalString2(metric.description);
|
|
4020
|
+
const unit = optionalString2(metric.unit);
|
|
4021
|
+
const points = metricPoints(metric);
|
|
4022
|
+
return points.dataPoints.map((point, index) => {
|
|
4023
|
+
const attributes = attributesFromArray(point.attributes);
|
|
4024
|
+
const eventTime = unixNanoToIso(point.timeUnixNano) ?? unixNanoToIso(point.startTimeUnixNano);
|
|
4025
|
+
return {
|
|
4026
|
+
type: "metric",
|
|
4027
|
+
source: "otel",
|
|
4028
|
+
source_event_id: stableSourceEventId("metric", {
|
|
4029
|
+
name,
|
|
4030
|
+
kind: points.kind,
|
|
4031
|
+
index,
|
|
4032
|
+
startTimeUnixNano: point.startTimeUnixNano,
|
|
4033
|
+
timeUnixNano: point.timeUnixNano,
|
|
4034
|
+
resourceIndex: context.resourceIndex,
|
|
4035
|
+
scopeIndex: context.scopeIndex,
|
|
4036
|
+
metricIndex,
|
|
4037
|
+
pointIndex: index,
|
|
4038
|
+
resource: context.resource,
|
|
4039
|
+
scope: context.scope,
|
|
4040
|
+
attributes,
|
|
4041
|
+
value: metricPointValue(points.kind, point)
|
|
4042
|
+
}),
|
|
4043
|
+
event_time: eventTime,
|
|
4044
|
+
severity: "info",
|
|
4045
|
+
app_id: resourceString(context.resource, "service.name"),
|
|
4046
|
+
machine_id: resourceString(context.resource, "host.id") ?? resourceString(context.resource, "host.name"),
|
|
4047
|
+
environment: resourceString(context.resource, "deployment.environment"),
|
|
4048
|
+
message: name,
|
|
4049
|
+
attributes: compactObject({
|
|
4050
|
+
category: "otlp_metric",
|
|
4051
|
+
signal: "metrics",
|
|
4052
|
+
name,
|
|
4053
|
+
description,
|
|
4054
|
+
unit,
|
|
4055
|
+
resource: context.resource,
|
|
4056
|
+
scope: context.scope,
|
|
4057
|
+
metric_attributes: attributes,
|
|
4058
|
+
otel: compactObject({
|
|
4059
|
+
signal: "metrics",
|
|
4060
|
+
metric_kind: points.kind,
|
|
4061
|
+
aggregation_temporality: points.aggregationTemporality,
|
|
4062
|
+
is_monotonic: points.isMonotonic,
|
|
4063
|
+
start_time_unix_nano: stringFromUnknown(point.startTimeUnixNano),
|
|
4064
|
+
time_unix_nano: stringFromUnknown(point.timeUnixNano),
|
|
4065
|
+
flags: numberFromUnknown(point.flags)
|
|
4066
|
+
})
|
|
4067
|
+
}),
|
|
4068
|
+
body: {
|
|
4069
|
+
metric: compactObject({
|
|
4070
|
+
name,
|
|
4071
|
+
description,
|
|
4072
|
+
unit,
|
|
4073
|
+
kind: points.kind,
|
|
4074
|
+
value: metricPointValue(points.kind, point)
|
|
4075
|
+
})
|
|
4076
|
+
}
|
|
4077
|
+
};
|
|
4078
|
+
});
|
|
4079
|
+
}
|
|
4080
|
+
function metricPoints(metric) {
|
|
4081
|
+
const metricKinds = [
|
|
4082
|
+
"gauge",
|
|
4083
|
+
"sum",
|
|
4084
|
+
"histogram",
|
|
4085
|
+
"exponentialHistogram",
|
|
4086
|
+
"summary"
|
|
4087
|
+
];
|
|
4088
|
+
for (const kind of metricKinds) {
|
|
4089
|
+
if (!(kind in metric))
|
|
4090
|
+
continue;
|
|
4091
|
+
const container = objectValue(metric[kind]);
|
|
4092
|
+
if (!container)
|
|
4093
|
+
throw new Error(`metric.${kind} must be an object`);
|
|
4094
|
+
return {
|
|
4095
|
+
kind,
|
|
4096
|
+
dataPoints: objectArray(container.dataPoints, `metric.${kind}.dataPoints`),
|
|
4097
|
+
aggregationTemporality: stringOrNumber(container.aggregationTemporality),
|
|
4098
|
+
isMonotonic: typeof container.isMonotonic === "boolean" ? container.isMonotonic : undefined
|
|
4099
|
+
};
|
|
4100
|
+
}
|
|
4101
|
+
throw new Error(`metric must contain one of ${metricKinds.join(", ")}`);
|
|
4102
|
+
}
|
|
4103
|
+
function metricPointValue(kind, point) {
|
|
4104
|
+
if (kind === "gauge" || kind === "sum") {
|
|
4105
|
+
return numberOrString(point.asDouble) ?? numberOrString(point.asInt);
|
|
4106
|
+
}
|
|
4107
|
+
if (kind === "histogram") {
|
|
4108
|
+
return compactObject({
|
|
4109
|
+
count: numberOrString(point.count),
|
|
4110
|
+
sum: numberOrString(point.sum),
|
|
4111
|
+
min: numberOrString(point.min),
|
|
4112
|
+
max: numberOrString(point.max),
|
|
4113
|
+
bucket_counts: primitiveArray(point.bucketCounts),
|
|
4114
|
+
explicit_bounds: primitiveArray(point.explicitBounds)
|
|
4115
|
+
});
|
|
4116
|
+
}
|
|
4117
|
+
if (kind === "exponentialHistogram") {
|
|
4118
|
+
return compactObject({
|
|
4119
|
+
count: numberOrString(point.count),
|
|
4120
|
+
sum: numberOrString(point.sum),
|
|
4121
|
+
min: numberOrString(point.min),
|
|
4122
|
+
max: numberOrString(point.max),
|
|
4123
|
+
scale: numberFromUnknown(point.scale),
|
|
4124
|
+
zero_count: numberOrString(point.zeroCount),
|
|
4125
|
+
positive: point.positive,
|
|
4126
|
+
negative: point.negative
|
|
4127
|
+
});
|
|
4128
|
+
}
|
|
4129
|
+
if (kind === "summary") {
|
|
4130
|
+
return compactObject({
|
|
4131
|
+
count: numberOrString(point.count),
|
|
4132
|
+
sum: numberOrString(point.sum),
|
|
4133
|
+
quantile_values: primitiveArray(point.quantileValues)
|
|
4134
|
+
});
|
|
4135
|
+
}
|
|
4136
|
+
return point;
|
|
4137
|
+
}
|
|
4138
|
+
function attributesFromContainer(value) {
|
|
4139
|
+
return attributesFromArray(objectValue(value)?.attributes);
|
|
4140
|
+
}
|
|
4141
|
+
function attributesFromArray(value) {
|
|
4142
|
+
const result = {};
|
|
4143
|
+
for (const entry of objectArray(value, "attributes")) {
|
|
4144
|
+
const key = optionalString2(entry.key);
|
|
4145
|
+
if (!key)
|
|
4146
|
+
continue;
|
|
4147
|
+
result[key] = otlpAnyValue(entry.value);
|
|
4148
|
+
}
|
|
4149
|
+
return result;
|
|
4150
|
+
}
|
|
4151
|
+
function scopeInfo(value) {
|
|
4152
|
+
const scope = objectValue(value);
|
|
4153
|
+
if (!scope)
|
|
4154
|
+
return {};
|
|
4155
|
+
return compactObject({
|
|
4156
|
+
name: optionalString2(scope.name),
|
|
4157
|
+
version: optionalString2(scope.version),
|
|
4158
|
+
attributes: attributesFromArray(scope.attributes),
|
|
4159
|
+
dropped_attributes_count: numberFromUnknown(scope.droppedAttributesCount)
|
|
4160
|
+
});
|
|
4161
|
+
}
|
|
4162
|
+
function otlpAnyValue(value) {
|
|
4163
|
+
const object = objectValue(value);
|
|
4164
|
+
if (!object)
|
|
4165
|
+
return;
|
|
4166
|
+
if ("stringValue" in object)
|
|
4167
|
+
return optionalString2(object.stringValue) ?? stringFromUnknown(object.stringValue);
|
|
4168
|
+
if ("boolValue" in object)
|
|
4169
|
+
return object.boolValue === true;
|
|
4170
|
+
if ("intValue" in object)
|
|
4171
|
+
return numberOrString(object.intValue);
|
|
4172
|
+
if ("doubleValue" in object)
|
|
4173
|
+
return numberOrString(object.doubleValue);
|
|
4174
|
+
if ("bytesValue" in object)
|
|
4175
|
+
return optionalString2(object.bytesValue);
|
|
4176
|
+
if ("arrayValue" in object)
|
|
4177
|
+
return objectArray(objectValue(object.arrayValue)?.values, "arrayValue.values").map(otlpAnyValue);
|
|
4178
|
+
if ("kvlistValue" in object)
|
|
4179
|
+
return attributesFromArray(objectValue(object.kvlistValue)?.values);
|
|
4180
|
+
return;
|
|
4181
|
+
}
|
|
4182
|
+
function spanEvents(value) {
|
|
4183
|
+
return objectArray(value, "span.events").map((event) => compactObject({
|
|
4184
|
+
name: optionalString2(event.name),
|
|
4185
|
+
time_unix_nano: stringFromUnknown(event.timeUnixNano),
|
|
4186
|
+
time: unixNanoToIso(event.timeUnixNano),
|
|
4187
|
+
attributes: attributesFromArray(event.attributes),
|
|
4188
|
+
dropped_attributes_count: numberFromUnknown(event.droppedAttributesCount)
|
|
4189
|
+
}));
|
|
4190
|
+
}
|
|
4191
|
+
function spanLinks(value) {
|
|
4192
|
+
return objectArray(value, "span.links").map((link) => compactObject({
|
|
4193
|
+
trace_id: optionalString2(link.traceId),
|
|
4194
|
+
span_id: optionalString2(link.spanId),
|
|
4195
|
+
trace_state: optionalString2(link.traceState),
|
|
4196
|
+
attributes: attributesFromArray(link.attributes),
|
|
4197
|
+
dropped_attributes_count: numberFromUnknown(link.droppedAttributesCount)
|
|
4198
|
+
}));
|
|
4199
|
+
}
|
|
4200
|
+
function otlpSeverity(severityNumber, severityText) {
|
|
4201
|
+
if (severityNumber !== undefined) {
|
|
4202
|
+
if (severityNumber >= 21)
|
|
4203
|
+
return "fatal";
|
|
4204
|
+
if (severityNumber >= 17)
|
|
4205
|
+
return "error";
|
|
4206
|
+
if (severityNumber >= 13)
|
|
4207
|
+
return "warn";
|
|
4208
|
+
if (severityNumber >= 1 && severityNumber <= 8)
|
|
4209
|
+
return "debug";
|
|
4210
|
+
}
|
|
4211
|
+
const text = severityText?.toLowerCase() ?? "";
|
|
4212
|
+
if (text.includes("fatal") || text.includes("panic"))
|
|
4213
|
+
return "fatal";
|
|
4214
|
+
if (text.includes("error") || text.includes("err"))
|
|
4215
|
+
return "error";
|
|
4216
|
+
if (text.includes("warn"))
|
|
4217
|
+
return "warn";
|
|
4218
|
+
if (text.includes("debug") || text.includes("trace"))
|
|
4219
|
+
return "debug";
|
|
4220
|
+
return "info";
|
|
4221
|
+
}
|
|
4222
|
+
function unixNanoToIso(value) {
|
|
4223
|
+
const ns = bigintFromUnknown(value);
|
|
4224
|
+
if (ns === undefined)
|
|
4225
|
+
return;
|
|
4226
|
+
const ms = ns / 1000000n;
|
|
4227
|
+
if (ms > BigInt(Number.MAX_SAFE_INTEGER) || ms < BigInt(Number.MIN_SAFE_INTEGER))
|
|
4228
|
+
return;
|
|
4229
|
+
const date = new Date(Number(ms));
|
|
4230
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
4231
|
+
}
|
|
4232
|
+
function durationMs(start, end) {
|
|
4233
|
+
const startNs = bigintFromUnknown(start);
|
|
4234
|
+
const endNs = bigintFromUnknown(end);
|
|
4235
|
+
if (startNs === undefined || endNs === undefined || endNs < startNs)
|
|
4236
|
+
return;
|
|
4237
|
+
return Number(endNs - startNs) / 1e6;
|
|
4238
|
+
}
|
|
4239
|
+
function requiredObjectArray(value, key, path) {
|
|
4240
|
+
const object = objectValue(value);
|
|
4241
|
+
if (!object)
|
|
4242
|
+
throw new Error("OTLP payload must be an object");
|
|
4243
|
+
const array = object[key];
|
|
4244
|
+
if (!Array.isArray(array))
|
|
4245
|
+
throw new Error(`${path} must be an array`);
|
|
4246
|
+
return array.map((item, index) => {
|
|
4247
|
+
const itemObject = objectValue(item);
|
|
4248
|
+
if (!itemObject)
|
|
4249
|
+
throw new Error(`${path}[${index}] must be an object`);
|
|
4250
|
+
return itemObject;
|
|
4251
|
+
});
|
|
4252
|
+
}
|
|
4253
|
+
function objectArray(value, path) {
|
|
4254
|
+
if (value === undefined || value === null)
|
|
4255
|
+
return [];
|
|
4256
|
+
if (!Array.isArray(value))
|
|
4257
|
+
throw new Error(`${path} must be an array`);
|
|
4258
|
+
return value.map((item, index) => {
|
|
4259
|
+
const itemObject = objectValue(item);
|
|
4260
|
+
if (!itemObject)
|
|
4261
|
+
throw new Error(`${path}[${index}] must be an object`);
|
|
4262
|
+
return itemObject;
|
|
4263
|
+
});
|
|
4264
|
+
}
|
|
4265
|
+
function objectValue(value) {
|
|
4266
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
4267
|
+
}
|
|
4268
|
+
function optionalString2(value) {
|
|
4269
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
4270
|
+
}
|
|
4271
|
+
function stringFromUnknown(value) {
|
|
4272
|
+
if (value === undefined || value === null || value === "")
|
|
4273
|
+
return;
|
|
4274
|
+
if (typeof value === "string")
|
|
4275
|
+
return value;
|
|
4276
|
+
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
|
|
4277
|
+
return String(value);
|
|
4278
|
+
return;
|
|
4279
|
+
}
|
|
4280
|
+
function resourceString(resource, key) {
|
|
4281
|
+
return optionalString2(resource[key]) ?? stringFromUnknown(resource[key]);
|
|
4282
|
+
}
|
|
4283
|
+
function stringOrNumber(value) {
|
|
4284
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
4285
|
+
return value;
|
|
4286
|
+
return stringFromUnknown(value);
|
|
4287
|
+
}
|
|
4288
|
+
function numberOrString(value) {
|
|
4289
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
4290
|
+
return value;
|
|
4291
|
+
if (typeof value === "string" && value.length > 0) {
|
|
4292
|
+
const parsed = Number(value);
|
|
4293
|
+
return Number.isSafeInteger(parsed) ? parsed : value;
|
|
4294
|
+
}
|
|
4295
|
+
return;
|
|
4296
|
+
}
|
|
4297
|
+
function numberFromUnknown(value) {
|
|
4298
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
4299
|
+
return value;
|
|
4300
|
+
if (typeof value === "string" && value.length > 0) {
|
|
4301
|
+
const parsed = Number(value);
|
|
4302
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
4303
|
+
}
|
|
4304
|
+
return;
|
|
4305
|
+
}
|
|
4306
|
+
function bigintFromUnknown(value) {
|
|
4307
|
+
if (typeof value === "bigint")
|
|
4308
|
+
return value;
|
|
4309
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
4310
|
+
return BigInt(Math.trunc(value));
|
|
4311
|
+
if (typeof value === "string" && /^\d+$/.test(value))
|
|
4312
|
+
return BigInt(value);
|
|
4313
|
+
return;
|
|
4314
|
+
}
|
|
4315
|
+
function primitiveArray(value) {
|
|
4316
|
+
if (!Array.isArray(value))
|
|
4317
|
+
return;
|
|
4318
|
+
return value.filter((item) => item === null || ["string", "number", "boolean"].includes(typeof item));
|
|
4319
|
+
}
|
|
4320
|
+
function compactObject(input) {
|
|
4321
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
4322
|
+
}
|
|
4323
|
+
function stableSourceEventId(kind, value) {
|
|
4324
|
+
return `otlp:${kind}:${createHash3("sha256").update(stableJson(value)).digest("hex").slice(0, 32)}`;
|
|
4325
|
+
}
|
|
4326
|
+
function stableJson(value) {
|
|
4327
|
+
if (value === null || value === undefined)
|
|
4328
|
+
return "null";
|
|
4329
|
+
if (Array.isArray(value))
|
|
4330
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
4331
|
+
if (typeof value === "object") {
|
|
4332
|
+
const object = value;
|
|
4333
|
+
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(",")}}`;
|
|
4334
|
+
}
|
|
4335
|
+
return JSON.stringify(value);
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
// src/server/routes/otel.ts
|
|
4339
|
+
function otelRoutes(db) {
|
|
4340
|
+
const app = new Hono2;
|
|
4341
|
+
app.post("/v1/traces", async (c) => {
|
|
4342
|
+
const authError = requireServerOtlpIngest(db, c);
|
|
4343
|
+
if (authError)
|
|
4344
|
+
return authError;
|
|
4345
|
+
const payload = await readJsonObject(c);
|
|
4346
|
+
if (!payload.ok)
|
|
4347
|
+
return c.json({ error: payload.message }, payload.status);
|
|
4348
|
+
return writeOtlpResponse(c, () => ingestOtlpTraces(db, payload.value));
|
|
4349
|
+
});
|
|
4350
|
+
app.post("/v1/logs", async (c) => {
|
|
4351
|
+
const authError = requireServerOtlpIngest(db, c);
|
|
4352
|
+
if (authError)
|
|
4353
|
+
return authError;
|
|
4354
|
+
const payload = await readJsonObject(c);
|
|
4355
|
+
if (!payload.ok)
|
|
4356
|
+
return c.json({ error: payload.message }, payload.status);
|
|
4357
|
+
return writeOtlpResponse(c, () => ingestOtlpLogs(db, payload.value));
|
|
4358
|
+
});
|
|
4359
|
+
app.post("/v1/metrics", async (c) => {
|
|
4360
|
+
const authError = requireServerOtlpIngest(db, c);
|
|
4361
|
+
if (authError)
|
|
4362
|
+
return authError;
|
|
4363
|
+
const payload = await readJsonObject(c);
|
|
4364
|
+
if (!payload.ok)
|
|
4365
|
+
return c.json({ error: payload.message }, payload.status);
|
|
4366
|
+
return writeOtlpResponse(c, () => ingestOtlpMetrics(db, payload.value));
|
|
2152
4367
|
});
|
|
2153
4368
|
return app;
|
|
2154
4369
|
}
|
|
4370
|
+
function requireServerOtlpIngest(db, c) {
|
|
4371
|
+
const authorization = authorizeLogIngest(db, c);
|
|
4372
|
+
if (!authorization)
|
|
4373
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
4374
|
+
if (authorization.kind === "browser-token") {
|
|
4375
|
+
return c.json({ error: "Browser ingest tokens cannot write OTLP telemetry" }, 403);
|
|
4376
|
+
}
|
|
4377
|
+
return null;
|
|
4378
|
+
}
|
|
4379
|
+
function writeOtlpResponse(c, ingest) {
|
|
4380
|
+
try {
|
|
4381
|
+
const summary = ingest();
|
|
4382
|
+
return c.json({
|
|
4383
|
+
partialSuccess: {},
|
|
4384
|
+
signal: summary.signal,
|
|
4385
|
+
accepted: summary.accepted,
|
|
4386
|
+
inserted: summary.inserted,
|
|
4387
|
+
duplicates: summary.duplicates,
|
|
4388
|
+
events: summary.events.map((event) => ({
|
|
4389
|
+
event_id: event.event_id,
|
|
4390
|
+
event_type: event.event_type,
|
|
4391
|
+
source: event.source,
|
|
4392
|
+
trace_id: event.trace_id,
|
|
4393
|
+
span_id: event.span_id
|
|
4394
|
+
}))
|
|
4395
|
+
}, 200);
|
|
4396
|
+
} catch (error) {
|
|
4397
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 422);
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
2155
4400
|
|
|
2156
4401
|
// src/server/routes/perf.ts
|
|
2157
4402
|
function perfRoutes(db) {
|
|
@@ -2178,13 +4423,17 @@ async function syncGithubRepo(db, project) {
|
|
|
2178
4423
|
if (!project.github_repo)
|
|
2179
4424
|
return project;
|
|
2180
4425
|
const repo = project.github_repo.replace(/^https?:\/\/github\.com\//, "");
|
|
2181
|
-
const headers = {
|
|
4426
|
+
const headers = {
|
|
4427
|
+
Accept: "application/vnd.github.v3+json"
|
|
4428
|
+
};
|
|
2182
4429
|
if (process.env.GITHUB_TOKEN)
|
|
2183
|
-
headers
|
|
4430
|
+
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
2184
4431
|
try {
|
|
2185
4432
|
const [repoRes, commitRes] = await Promise.all([
|
|
2186
4433
|
fetch(`https://api.github.com/repos/${repo}`, { headers }),
|
|
2187
|
-
fetch(`https://api.github.com/repos/${repo}/commits?per_page=1`, {
|
|
4434
|
+
fetch(`https://api.github.com/repos/${repo}/commits?per_page=1`, {
|
|
4435
|
+
headers
|
|
4436
|
+
})
|
|
2188
4437
|
]);
|
|
2189
4438
|
if (!repoRes.ok)
|
|
2190
4439
|
return project;
|
|
@@ -2202,12 +4451,35 @@ async function syncGithubRepo(db, project) {
|
|
|
2202
4451
|
}
|
|
2203
4452
|
|
|
2204
4453
|
// src/server/routes/projects.ts
|
|
4454
|
+
var PROJECT_CREATE_KEYS = [
|
|
4455
|
+
"name",
|
|
4456
|
+
"github_repo",
|
|
4457
|
+
"base_url",
|
|
4458
|
+
"description"
|
|
4459
|
+
];
|
|
4460
|
+
var PAGE_CREATE_KEYS = ["url", "path", "name"];
|
|
4461
|
+
var RETENTION_KEYS = [
|
|
4462
|
+
"max_rows",
|
|
4463
|
+
"debug_ttl_hours",
|
|
4464
|
+
"info_ttl_hours",
|
|
4465
|
+
"warn_ttl_hours",
|
|
4466
|
+
"error_ttl_hours"
|
|
4467
|
+
];
|
|
4468
|
+
var PAGE_AUTH_KEYS = ["type", "credentials"];
|
|
4469
|
+
var PAGE_AUTH_TYPES = ["cookie", "bearer", "basic"];
|
|
4470
|
+
var BROWSER_TOKEN_KEYS = ["name", "allowed_origins"];
|
|
2205
4471
|
function projectsRoutes(db) {
|
|
2206
4472
|
const app = new Hono2;
|
|
2207
4473
|
app.post("/", async (c) => {
|
|
2208
|
-
const
|
|
2209
|
-
|
|
2210
|
-
|
|
4474
|
+
const parsed = await readJsonObject(c, {
|
|
4475
|
+
allowedKeys: PROJECT_CREATE_KEYS
|
|
4476
|
+
});
|
|
4477
|
+
if (!parsed.ok)
|
|
4478
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
4479
|
+
const projectInput = validateProjectCreate(parsed.value);
|
|
4480
|
+
if (!projectInput.ok)
|
|
4481
|
+
return c.json({ error: projectInput.message }, projectInput.status);
|
|
4482
|
+
const body = projectInput.value;
|
|
2211
4483
|
const project = createProject(db, body);
|
|
2212
4484
|
return c.json(project, 201);
|
|
2213
4485
|
});
|
|
@@ -2219,15 +4491,51 @@ function projectsRoutes(db) {
|
|
|
2219
4491
|
return c.json(project);
|
|
2220
4492
|
});
|
|
2221
4493
|
app.post("/:id/pages", async (c) => {
|
|
2222
|
-
const
|
|
2223
|
-
if (!
|
|
2224
|
-
return c.json({ error:
|
|
4494
|
+
const parsed = await readJsonObject(c, { allowedKeys: PAGE_CREATE_KEYS });
|
|
4495
|
+
if (!parsed.ok)
|
|
4496
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
4497
|
+
const pageInput = validatePageCreate(parsed.value);
|
|
4498
|
+
if (!pageInput.ok)
|
|
4499
|
+
return c.json({ error: pageInput.message }, pageInput.status);
|
|
4500
|
+
const body = pageInput.value;
|
|
2225
4501
|
const page = createPage(db, { ...body, project_id: c.req.param("id") });
|
|
2226
4502
|
return c.json(page, 201);
|
|
2227
4503
|
});
|
|
2228
4504
|
app.get("/:id/pages", (c) => c.json(listPages(db, c.req.param("id"))));
|
|
4505
|
+
app.post("/:id/browser-tokens", async (c) => {
|
|
4506
|
+
const project = getProject(db, c.req.param("id"));
|
|
4507
|
+
if (!project)
|
|
4508
|
+
return c.json({ error: "not found" }, 404);
|
|
4509
|
+
const parsed = await readJsonObject(c, { allowedKeys: BROWSER_TOKEN_KEYS });
|
|
4510
|
+
if (!parsed.ok)
|
|
4511
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
4512
|
+
const tokenInput = validateBrowserTokenCreate(parsed.value);
|
|
4513
|
+
if (!tokenInput.ok)
|
|
4514
|
+
return c.json({ error: tokenInput.message }, tokenInput.status);
|
|
4515
|
+
const token = createBrowserIngestToken(db, project.id, tokenInput.value);
|
|
4516
|
+
return c.json(token, 201);
|
|
4517
|
+
});
|
|
4518
|
+
app.get("/:id/browser-tokens", (c) => {
|
|
4519
|
+
const project = getProject(db, c.req.param("id"));
|
|
4520
|
+
if (!project)
|
|
4521
|
+
return c.json({ error: "not found" }, 404);
|
|
4522
|
+
return c.json(listBrowserIngestTokens(db, project.id));
|
|
4523
|
+
});
|
|
4524
|
+
app.delete("/:id/browser-tokens/:token_id", (c) => {
|
|
4525
|
+
const project = getProject(db, c.req.param("id"));
|
|
4526
|
+
if (!project)
|
|
4527
|
+
return c.json({ error: "not found" }, 404);
|
|
4528
|
+
const revoked = revokeBrowserIngestToken(db, project.id, c.req.param("token_id"));
|
|
4529
|
+
return c.json({ revoked });
|
|
4530
|
+
});
|
|
2229
4531
|
app.put("/:id/retention", async (c) => {
|
|
2230
|
-
const
|
|
4532
|
+
const parsed = await readJsonObject(c, { allowedKeys: RETENTION_KEYS });
|
|
4533
|
+
if (!parsed.ok)
|
|
4534
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
4535
|
+
const retentionInput = validateRetention(parsed.value);
|
|
4536
|
+
if (!retentionInput.ok)
|
|
4537
|
+
return c.json({ error: retentionInput.message }, retentionInput.status);
|
|
4538
|
+
const body = retentionInput.value;
|
|
2231
4539
|
setRetentionPolicy(db, c.req.param("id"), body);
|
|
2232
4540
|
return c.json({ updated: true });
|
|
2233
4541
|
});
|
|
@@ -2236,9 +4544,13 @@ function projectsRoutes(db) {
|
|
|
2236
4544
|
return c.json(result);
|
|
2237
4545
|
});
|
|
2238
4546
|
app.post("/:id/pages/:page_id/auth", async (c) => {
|
|
2239
|
-
const
|
|
2240
|
-
if (!
|
|
2241
|
-
return c.json({ error:
|
|
4547
|
+
const parsed = await readJsonObject(c, { allowedKeys: PAGE_AUTH_KEYS });
|
|
4548
|
+
if (!parsed.ok)
|
|
4549
|
+
return c.json({ error: parsed.message }, parsed.status);
|
|
4550
|
+
const authInput = validatePageAuth(parsed.value);
|
|
4551
|
+
if (!authInput.ok)
|
|
4552
|
+
return c.json({ error: authInput.message }, authInput.status);
|
|
4553
|
+
const { type, credentials } = authInput.value;
|
|
2242
4554
|
const result = setPageAuth(db, c.req.param("page_id"), type, credentials);
|
|
2243
4555
|
return c.json({ id: result.id, type: result.type, created_at: result.created_at }, 201);
|
|
2244
4556
|
});
|
|
@@ -2257,211 +4569,409 @@ function projectsRoutes(db) {
|
|
|
2257
4569
|
});
|
|
2258
4570
|
return app;
|
|
2259
4571
|
}
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
await reader.cancel();
|
|
2277
|
-
});
|
|
2278
|
-
this.responseReadable = new ReadableStream({
|
|
2279
|
-
async pull(controller) {
|
|
2280
|
-
const { done, value } = await reader.read();
|
|
2281
|
-
done ? controller.close() : controller.enqueue(value);
|
|
2282
|
-
},
|
|
2283
|
-
cancel: () => {
|
|
2284
|
-
this.abort();
|
|
2285
|
-
}
|
|
2286
|
-
});
|
|
2287
|
-
}
|
|
2288
|
-
async write(input) {
|
|
2289
|
-
try {
|
|
2290
|
-
if (typeof input === "string") {
|
|
2291
|
-
input = this.encoder.encode(input);
|
|
2292
|
-
}
|
|
2293
|
-
await this.writer.write(input);
|
|
2294
|
-
} catch {}
|
|
2295
|
-
return this;
|
|
2296
|
-
}
|
|
2297
|
-
async writeln(input) {
|
|
2298
|
-
await this.write(input + `
|
|
2299
|
-
`);
|
|
2300
|
-
return this;
|
|
2301
|
-
}
|
|
2302
|
-
sleep(ms) {
|
|
2303
|
-
return new Promise((res) => setTimeout(res, ms));
|
|
2304
|
-
}
|
|
2305
|
-
async close() {
|
|
2306
|
-
try {
|
|
2307
|
-
await this.writer.close();
|
|
2308
|
-
} catch {}
|
|
2309
|
-
this.closed = true;
|
|
2310
|
-
}
|
|
2311
|
-
async pipe(body) {
|
|
2312
|
-
this.writer.releaseLock();
|
|
2313
|
-
await body.pipeTo(this.writable, { preventClose: true });
|
|
2314
|
-
this.writer = this.writable.getWriter();
|
|
2315
|
-
}
|
|
2316
|
-
onAbort(listener) {
|
|
2317
|
-
this.abortSubscribers.push(listener);
|
|
4572
|
+
function validateProjectCreate(body) {
|
|
4573
|
+
const name = requiredString(body, "name");
|
|
4574
|
+
if (!name.ok)
|
|
4575
|
+
return name;
|
|
4576
|
+
const github_repo = optionalString(body, "github_repo");
|
|
4577
|
+
if (!github_repo.ok)
|
|
4578
|
+
return github_repo;
|
|
4579
|
+
const base_url = optionalString(body, "base_url");
|
|
4580
|
+
if (!base_url.ok)
|
|
4581
|
+
return base_url;
|
|
4582
|
+
if (base_url.value !== undefined && !isUrl2(base_url.value)) {
|
|
4583
|
+
return {
|
|
4584
|
+
ok: false,
|
|
4585
|
+
status: 422,
|
|
4586
|
+
message: "body.base_url must be a valid URL"
|
|
4587
|
+
};
|
|
2318
4588
|
}
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
4589
|
+
const description = optionalString(body, "description");
|
|
4590
|
+
if (!description.ok)
|
|
4591
|
+
return description;
|
|
4592
|
+
return {
|
|
4593
|
+
ok: true,
|
|
4594
|
+
value: {
|
|
4595
|
+
name: name.value,
|
|
4596
|
+
github_repo: github_repo.value,
|
|
4597
|
+
base_url: base_url.value,
|
|
4598
|
+
description: description.value
|
|
2323
4599
|
}
|
|
4600
|
+
};
|
|
4601
|
+
}
|
|
4602
|
+
function validatePageCreate(body) {
|
|
4603
|
+
const url = requiredString(body, "url");
|
|
4604
|
+
if (!url.ok)
|
|
4605
|
+
return url;
|
|
4606
|
+
if (!isUrl2(url.value))
|
|
4607
|
+
return { ok: false, status: 422, message: "body.url must be a valid URL" };
|
|
4608
|
+
const path = optionalString(body, "path");
|
|
4609
|
+
if (!path.ok)
|
|
4610
|
+
return path;
|
|
4611
|
+
const name = optionalString(body, "name");
|
|
4612
|
+
if (!name.ok)
|
|
4613
|
+
return name;
|
|
4614
|
+
return {
|
|
4615
|
+
ok: true,
|
|
4616
|
+
value: { url: url.value, path: path.value, name: name.value }
|
|
4617
|
+
};
|
|
4618
|
+
}
|
|
4619
|
+
function validateRetention(body) {
|
|
4620
|
+
const value = {};
|
|
4621
|
+
for (const key of RETENTION_KEYS) {
|
|
4622
|
+
const field = optionalNumber(body, key, { integer: true, min: 1 });
|
|
4623
|
+
if (!field.ok)
|
|
4624
|
+
return field;
|
|
4625
|
+
if (field.value !== undefined)
|
|
4626
|
+
value[key] = field.value;
|
|
2324
4627
|
}
|
|
2325
|
-
};
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
4628
|
+
return { ok: true, value };
|
|
4629
|
+
}
|
|
4630
|
+
function validatePageAuth(body) {
|
|
4631
|
+
const type = requiredEnum(body, "type", PAGE_AUTH_TYPES);
|
|
4632
|
+
if (!type.ok)
|
|
4633
|
+
return type;
|
|
4634
|
+
const credentials = requiredString(body, "credentials");
|
|
4635
|
+
if (!credentials.ok)
|
|
4636
|
+
return credentials;
|
|
4637
|
+
return {
|
|
4638
|
+
ok: true,
|
|
4639
|
+
value: { type: type.value, credentials: credentials.value }
|
|
4640
|
+
};
|
|
4641
|
+
}
|
|
4642
|
+
function validateBrowserTokenCreate(body) {
|
|
4643
|
+
const name = optionalString(body, "name");
|
|
4644
|
+
if (!name.ok)
|
|
4645
|
+
return name;
|
|
4646
|
+
const allowedOrigins = optionalStringArray(body, "allowed_origins", {
|
|
4647
|
+
maxItems: 20
|
|
4648
|
+
});
|
|
4649
|
+
if (!allowedOrigins.ok)
|
|
4650
|
+
return allowedOrigins;
|
|
4651
|
+
if (allowedOrigins.value) {
|
|
4652
|
+
const normalizedOrigins = [];
|
|
4653
|
+
for (const origin of allowedOrigins.value) {
|
|
4654
|
+
const normalized = normalizeOrigin2(origin);
|
|
4655
|
+
if (!normalized)
|
|
4656
|
+
return {
|
|
4657
|
+
ok: false,
|
|
4658
|
+
status: 422,
|
|
4659
|
+
message: "body.allowed_origins must contain valid URL origins"
|
|
4660
|
+
};
|
|
4661
|
+
normalizedOrigins.push(normalized);
|
|
2353
4662
|
}
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
`;
|
|
2363
|
-
await this.write(sseData);
|
|
4663
|
+
return {
|
|
4664
|
+
ok: true,
|
|
4665
|
+
value: {
|
|
4666
|
+
name: name.value,
|
|
4667
|
+
allowed_origins: [...new Set(normalizedOrigins)]
|
|
4668
|
+
}
|
|
4669
|
+
};
|
|
2364
4670
|
}
|
|
2365
|
-
};
|
|
2366
|
-
|
|
4671
|
+
return { ok: true, value: { name: name.value } };
|
|
4672
|
+
}
|
|
4673
|
+
function isUrl2(value) {
|
|
2367
4674
|
try {
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
await stream.writeSSE({
|
|
2373
|
-
event: "error",
|
|
2374
|
-
data: e.message
|
|
2375
|
-
});
|
|
2376
|
-
} else {
|
|
2377
|
-
console.error(e);
|
|
2378
|
-
}
|
|
2379
|
-
} finally {
|
|
2380
|
-
stream.close();
|
|
4675
|
+
new URL(value);
|
|
4676
|
+
return true;
|
|
4677
|
+
} catch {
|
|
4678
|
+
return false;
|
|
2381
4679
|
}
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
c.req.raw.signal.addEventListener("abort", () => {
|
|
2389
|
-
if (!stream.closed) {
|
|
2390
|
-
stream.abort();
|
|
2391
|
-
}
|
|
2392
|
-
});
|
|
4680
|
+
}
|
|
4681
|
+
function normalizeOrigin2(value) {
|
|
4682
|
+
try {
|
|
4683
|
+
return new URL(value).origin;
|
|
4684
|
+
} catch {
|
|
4685
|
+
return null;
|
|
2393
4686
|
}
|
|
2394
|
-
|
|
2395
|
-
c.header("Transfer-Encoding", "chunked");
|
|
2396
|
-
c.header("Content-Type", "text/event-stream");
|
|
2397
|
-
c.header("Cache-Control", "no-cache");
|
|
2398
|
-
c.header("Connection", "keep-alive");
|
|
2399
|
-
run(stream, cb, onError);
|
|
2400
|
-
return c.newResponse(stream.responseReadable);
|
|
2401
|
-
};
|
|
4687
|
+
}
|
|
2402
4688
|
|
|
2403
4689
|
// src/server/routes/stream.ts
|
|
2404
4690
|
function streamRoutes(db) {
|
|
2405
4691
|
const app = new Hono2;
|
|
2406
4692
|
app.get("/", (c) => {
|
|
2407
|
-
const { project_id, level, service } = c.req.query();
|
|
4693
|
+
const { project_id, level, service, last_event_id } = c.req.query();
|
|
4694
|
+
const filter = {
|
|
4695
|
+
project_id: project_id || undefined,
|
|
4696
|
+
levels: level ? level.split(",").filter(Boolean) : undefined,
|
|
4697
|
+
service: service || undefined
|
|
4698
|
+
};
|
|
4699
|
+
const requestedLastId = c.req.header("last-event-id") || last_event_id || null;
|
|
2408
4700
|
return streamSSE(c, async (stream2) => {
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
lastId
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
conditions.push("project_id = $project_id");
|
|
2421
|
-
params.$project_id = project_id;
|
|
2422
|
-
}
|
|
2423
|
-
if (level) {
|
|
2424
|
-
conditions.push("level IN (" + level.split(",").map((l, i) => `$l${i}`).join(",") + ")");
|
|
2425
|
-
level.split(",").forEach((l, i) => {
|
|
2426
|
-
params[`$l${i}`] = l;
|
|
4701
|
+
const seen = new Set;
|
|
4702
|
+
let lastId = requestedLastId;
|
|
4703
|
+
if (lastId) {
|
|
4704
|
+
if (!logIdExists(db, lastId)) {
|
|
4705
|
+
const requestedLastId2 = lastId;
|
|
4706
|
+
lastId = latestLogId(db, filter);
|
|
4707
|
+
await writeOverflow(stream2, {
|
|
4708
|
+
reason: "last_event_id_unknown",
|
|
4709
|
+
dropped: 0,
|
|
4710
|
+
last_event_id: lastId,
|
|
4711
|
+
requested_last_event_id: requestedLastId2
|
|
2427
4712
|
});
|
|
4713
|
+
} else {
|
|
4714
|
+
if (!hasBufferedLogEvent(lastId)) {
|
|
4715
|
+
await writeOverflow(stream2, {
|
|
4716
|
+
reason: "buffer_miss_sqlite_catchup",
|
|
4717
|
+
dropped: 0,
|
|
4718
|
+
last_event_id: lastId
|
|
4719
|
+
});
|
|
4720
|
+
}
|
|
4721
|
+
const catchup = await writeCatchupRows(db, stream2, filter, lastId, seen);
|
|
4722
|
+
if (catchup.last_id) {
|
|
4723
|
+
lastId = catchup.last_id;
|
|
4724
|
+
}
|
|
2428
4725
|
}
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
4726
|
+
} else {
|
|
4727
|
+
lastId = latestLogId(db, filter);
|
|
4728
|
+
}
|
|
4729
|
+
const subscription = subscribeLogEvents(filter);
|
|
4730
|
+
let pendingBusEvent = subscription.next();
|
|
4731
|
+
try {
|
|
4732
|
+
while (true) {
|
|
4733
|
+
const next = await Promise.race([
|
|
4734
|
+
pendingBusEvent.then((result) => ({
|
|
4735
|
+
kind: "bus",
|
|
4736
|
+
result
|
|
4737
|
+
})),
|
|
4738
|
+
sleep2(500).then(() => ({ kind: "tick" }))
|
|
4739
|
+
]);
|
|
4740
|
+
if (next.kind === "tick") {
|
|
4741
|
+
if (!lastId) {
|
|
4742
|
+
lastId = latestLogId(db, filter);
|
|
4743
|
+
continue;
|
|
4744
|
+
}
|
|
4745
|
+
const catchup = await writeCatchupRows(db, stream2, filter, lastId, seen);
|
|
4746
|
+
if (catchup.last_id)
|
|
4747
|
+
lastId = catchup.last_id;
|
|
4748
|
+
continue;
|
|
4749
|
+
}
|
|
4750
|
+
if (next.result.done)
|
|
4751
|
+
break;
|
|
4752
|
+
const event = next.result.value;
|
|
4753
|
+
pendingBusEvent = subscription.next();
|
|
4754
|
+
if (event.kind === "overflow") {
|
|
4755
|
+
await writeOverflow(stream2, event);
|
|
4756
|
+
if (lastId) {
|
|
4757
|
+
const catchup = await writeCatchupRows(db, stream2, filter, lastId, seen);
|
|
4758
|
+
if (catchup.last_id)
|
|
4759
|
+
lastId = catchup.last_id;
|
|
4760
|
+
}
|
|
4761
|
+
continue;
|
|
4762
|
+
}
|
|
4763
|
+
if (seen.has(event.id))
|
|
4764
|
+
continue;
|
|
4765
|
+
await writeLogRow(stream2, event.row);
|
|
4766
|
+
seen.add(event.id);
|
|
4767
|
+
lastId = event.id;
|
|
4768
|
+
trimSeen2(seen);
|
|
2438
4769
|
}
|
|
2439
|
-
|
|
4770
|
+
} finally {
|
|
4771
|
+
await subscription.return?.();
|
|
2440
4772
|
}
|
|
2441
4773
|
});
|
|
2442
4774
|
});
|
|
2443
4775
|
return app;
|
|
2444
4776
|
}
|
|
4777
|
+
async function writeCatchupRows(db, stream2, filter, lastId, seen) {
|
|
4778
|
+
const anchor = db.prepare("SELECT rowid FROM logs WHERE id = ?").get(lastId);
|
|
4779
|
+
if (!anchor)
|
|
4780
|
+
return { anchor_found: false, last_id: null };
|
|
4781
|
+
let cursor = anchor.rowid;
|
|
4782
|
+
let lastWritten = null;
|
|
4783
|
+
let total = 0;
|
|
4784
|
+
while (total < 1000) {
|
|
4785
|
+
const rows = queryRowsAfterRowid(db, filter, cursor, 100);
|
|
4786
|
+
if (rows.length === 0)
|
|
4787
|
+
break;
|
|
4788
|
+
for (const row of rows) {
|
|
4789
|
+
cursor = row.rowid;
|
|
4790
|
+
if (seen.has(row.id))
|
|
4791
|
+
continue;
|
|
4792
|
+
await writeLogRow(stream2, row);
|
|
4793
|
+
seen.add(row.id);
|
|
4794
|
+
lastWritten = row.id;
|
|
4795
|
+
total += 1;
|
|
4796
|
+
trimSeen2(seen);
|
|
4797
|
+
}
|
|
4798
|
+
if (rows.length < 100)
|
|
4799
|
+
break;
|
|
4800
|
+
}
|
|
4801
|
+
if (total >= 1000) {
|
|
4802
|
+
await writeOverflow(stream2, {
|
|
4803
|
+
reason: "sqlite_catchup_truncated",
|
|
4804
|
+
dropped: 0,
|
|
4805
|
+
last_event_id: lastWritten ?? lastId
|
|
4806
|
+
});
|
|
4807
|
+
}
|
|
4808
|
+
return { anchor_found: true, last_id: lastWritten };
|
|
4809
|
+
}
|
|
4810
|
+
function latestLogId(db, filter) {
|
|
4811
|
+
const { where, params } = buildWhere(filter, null);
|
|
4812
|
+
const row = db.prepare(`SELECT id FROM logs ${where} ORDER BY rowid DESC LIMIT 1`).get(params);
|
|
4813
|
+
return row?.id ?? null;
|
|
4814
|
+
}
|
|
4815
|
+
function logIdExists(db, id) {
|
|
4816
|
+
const row = db.prepare("SELECT 1 AS found FROM logs WHERE id = ?").get(id);
|
|
4817
|
+
return Boolean(row);
|
|
4818
|
+
}
|
|
4819
|
+
function queryRowsAfterRowid(db, filter, rowid, limit) {
|
|
4820
|
+
const { where, params } = buildWhere(filter, rowid);
|
|
4821
|
+
return db.prepare(`SELECT rowid, * FROM logs ${where} ORDER BY rowid ASC LIMIT $limit`).all({ ...params, $limit: limit });
|
|
4822
|
+
}
|
|
4823
|
+
function buildWhere(filter, afterRowid) {
|
|
4824
|
+
const conditions = [];
|
|
4825
|
+
const params = {};
|
|
4826
|
+
if (afterRowid !== null) {
|
|
4827
|
+
conditions.push("rowid > $rowid");
|
|
4828
|
+
params.$rowid = afterRowid;
|
|
4829
|
+
}
|
|
4830
|
+
if (filter.project_id) {
|
|
4831
|
+
conditions.push("project_id = $project_id");
|
|
4832
|
+
params.$project_id = filter.project_id;
|
|
4833
|
+
}
|
|
4834
|
+
if (filter.levels && filter.levels.length > 0) {
|
|
4835
|
+
const placeholders = filter.levels.map((_, index) => `$level${index}`).join(",");
|
|
4836
|
+
filter.levels.forEach((level, index) => {
|
|
4837
|
+
params[`$level${index}`] = level;
|
|
4838
|
+
});
|
|
4839
|
+
conditions.push(`level IN (${placeholders})`);
|
|
4840
|
+
}
|
|
4841
|
+
if (filter.service) {
|
|
4842
|
+
conditions.push("service = $service");
|
|
4843
|
+
params.$service = filter.service;
|
|
4844
|
+
}
|
|
4845
|
+
return {
|
|
4846
|
+
where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
|
|
4847
|
+
params
|
|
4848
|
+
};
|
|
4849
|
+
}
|
|
4850
|
+
async function writeLogRow(stream2, row) {
|
|
4851
|
+
await stream2.writeSSE({
|
|
4852
|
+
data: JSON.stringify(row),
|
|
4853
|
+
id: row.id,
|
|
4854
|
+
event: row.level
|
|
4855
|
+
});
|
|
4856
|
+
}
|
|
4857
|
+
async function writeOverflow(stream2, data) {
|
|
4858
|
+
await stream2.writeSSE({
|
|
4859
|
+
event: "overflow",
|
|
4860
|
+
data: JSON.stringify({
|
|
4861
|
+
type: "overflow",
|
|
4862
|
+
reason: data.reason,
|
|
4863
|
+
dropped: data.dropped,
|
|
4864
|
+
last_event_id: "last_event_id" in data ? data.last_event_id : null,
|
|
4865
|
+
requested_last_event_id: "requested_last_event_id" in data ? data.requested_last_event_id : null,
|
|
4866
|
+
created_at: "created_at" in data ? data.created_at : new Date().toISOString()
|
|
4867
|
+
})
|
|
4868
|
+
});
|
|
4869
|
+
}
|
|
4870
|
+
function trimSeen2(seen) {
|
|
4871
|
+
while (seen.size > 2000) {
|
|
4872
|
+
const first = seen.values().next().value;
|
|
4873
|
+
if (!first)
|
|
4874
|
+
return;
|
|
4875
|
+
seen.delete(first);
|
|
4876
|
+
}
|
|
4877
|
+
}
|
|
4878
|
+
function sleep2(ms) {
|
|
4879
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4880
|
+
}
|
|
4881
|
+
|
|
4882
|
+
// src/server/routes/test-reports.ts
|
|
4883
|
+
function testReportsRoutes(db) {
|
|
4884
|
+
const app = new Hono2;
|
|
4885
|
+
app.get("/", (c) => {
|
|
4886
|
+
return c.json(searchTestReports(db, queryFromRequest2(c.req.query())));
|
|
4887
|
+
});
|
|
4888
|
+
app.get("/:report_id", (c) => {
|
|
4889
|
+
const report = getTestReport(db, c.req.param("report_id"), c.req.query("include_cases") !== "false");
|
|
4890
|
+
if (!report)
|
|
4891
|
+
return c.json({ error: "Test report not found" }, 404);
|
|
4892
|
+
return c.json(report);
|
|
4893
|
+
});
|
|
4894
|
+
return app;
|
|
4895
|
+
}
|
|
4896
|
+
function queryFromRequest2(query) {
|
|
4897
|
+
return {
|
|
4898
|
+
report_id: query.report_id,
|
|
4899
|
+
event_id: query.event_id,
|
|
4900
|
+
project_id: query.project_id,
|
|
4901
|
+
machine_id: query.machine_id,
|
|
4902
|
+
repo_id: query.repo_id,
|
|
4903
|
+
app_id: query.app_id,
|
|
4904
|
+
process_id: query.process_id,
|
|
4905
|
+
run_id: query.run_id,
|
|
4906
|
+
environment: query.environment,
|
|
4907
|
+
source: query.source,
|
|
4908
|
+
parser: query.parser,
|
|
4909
|
+
parse_status: query.parse_status,
|
|
4910
|
+
path: query.path,
|
|
4911
|
+
case_status: query.case_status,
|
|
4912
|
+
outcome: testReportOutcome(query.outcome),
|
|
4913
|
+
min_failures: query.min_failures ? Number(query.min_failures) : undefined,
|
|
4914
|
+
min_errors: query.min_errors ? Number(query.min_errors) : undefined,
|
|
4915
|
+
min_skipped: query.min_skipped ? Number(query.min_skipped) : undefined,
|
|
4916
|
+
since: query.since,
|
|
4917
|
+
until: query.until,
|
|
4918
|
+
text: query.text,
|
|
4919
|
+
include_cases: query.include_cases === "true" || query.include_cases === "1",
|
|
4920
|
+
limit: query.limit ? Number(query.limit) : undefined,
|
|
4921
|
+
offset: query.offset ? Number(query.offset) : undefined
|
|
4922
|
+
};
|
|
4923
|
+
}
|
|
4924
|
+
function testReportOutcome(value) {
|
|
4925
|
+
if (value === "failed" || value === "error" || value === "nonpassing" || value === "skipped" || value === "passed" || value === "parse_problem") {
|
|
4926
|
+
return value;
|
|
4927
|
+
}
|
|
4928
|
+
return;
|
|
4929
|
+
}
|
|
2445
4930
|
|
|
2446
4931
|
// src/server/index.ts
|
|
2447
4932
|
exitIfMetadataRequest({
|
|
2448
4933
|
name: "logs-serve",
|
|
2449
4934
|
description: "Start the @hasna/logs REST API server.",
|
|
2450
|
-
options: [
|
|
4935
|
+
options: [
|
|
4936
|
+
" -p, --port <n> Port to listen on (default: LOGS_PORT or 3460)",
|
|
4937
|
+
" --token <tok> Require this API token for /api/* requests",
|
|
4938
|
+
" --local-open Explicitly allow trusted local loopback API requests without a token"
|
|
4939
|
+
]
|
|
2451
4940
|
});
|
|
2452
4941
|
var portArg = readOptionValue(["--port", "-p"]);
|
|
4942
|
+
var tokenArg = readOptionValue(["--token"]);
|
|
4943
|
+
if (tokenArg)
|
|
4944
|
+
process.env.HASNA_LOGS_API_TOKEN = tokenArg;
|
|
4945
|
+
if (hasOption(["--local-open"]))
|
|
4946
|
+
process.env.HASNA_LOGS_LOCAL_OPEN = "1";
|
|
2453
4947
|
var PORT = Number(portArg ?? process.env.LOGS_PORT ?? 3460);
|
|
2454
4948
|
var db = getDb();
|
|
2455
4949
|
var app = new Hono2;
|
|
2456
|
-
|
|
4950
|
+
var serverDir = dirname2(fileURLToPath(import.meta.url));
|
|
4951
|
+
var dashboardRoot = resolveDashboardRoot();
|
|
4952
|
+
app.use("*", cors({
|
|
4953
|
+
origin: (origin) => resolveCorsOrigin(origin),
|
|
4954
|
+
allowHeaders: [
|
|
4955
|
+
"Content-Type",
|
|
4956
|
+
"Authorization",
|
|
4957
|
+
"X-Logs-Token",
|
|
4958
|
+
"X-Logs-Browser-Token",
|
|
4959
|
+
"X-Logs-Write-Token"
|
|
4960
|
+
],
|
|
4961
|
+
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
4962
|
+
}));
|
|
2457
4963
|
app.get("/script.js", (c) => {
|
|
2458
4964
|
const host = `${c.req.header("x-forwarded-proto") ?? "http"}://${c.req.header("host") ?? `localhost:${PORT}`}`;
|
|
2459
4965
|
c.header("Content-Type", "application/javascript");
|
|
2460
4966
|
c.header("Cache-Control", "public, max-age=300");
|
|
2461
4967
|
return c.text(getBrowserScript(host));
|
|
2462
4968
|
});
|
|
4969
|
+
app.use("/api/*", requireApiTokenOrBrowserIngest(db));
|
|
2463
4970
|
app.route("/api/logs", logsRoutes(db));
|
|
2464
4971
|
app.route("/api/logs/stream", streamRoutes(db));
|
|
4972
|
+
app.route("/api/events", eventsRoutes(db));
|
|
4973
|
+
app.route("/api/test-reports", testReportsRoutes(db));
|
|
4974
|
+
app.route("/api/otel", otelRoutes(db));
|
|
2465
4975
|
app.route("/api/projects", projectsRoutes(db));
|
|
2466
4976
|
app.route("/api/jobs", jobsRoutes(db));
|
|
2467
4977
|
app.route("/api/alerts", alertsRoutes(db));
|
|
@@ -2469,14 +4979,31 @@ app.route("/api/issues", issuesRoutes(db));
|
|
|
2469
4979
|
app.route("/api/perf", perfRoutes(db));
|
|
2470
4980
|
app.get("/health", (c) => c.json(getHealth(db)));
|
|
2471
4981
|
app.get("/dashboard", (c) => c.redirect("/dashboard/"));
|
|
2472
|
-
app.use("/dashboard/*", serveStatic2({
|
|
2473
|
-
|
|
4982
|
+
app.use("/dashboard/*", serveStatic2({
|
|
4983
|
+
root: dashboardRoot,
|
|
4984
|
+
rewriteRequestPath: (p) => p.replace(/^\/dashboard/, "")
|
|
4985
|
+
}));
|
|
4986
|
+
app.get("/", (c) => c.json({
|
|
4987
|
+
service: "@hasna/logs",
|
|
4988
|
+
port: PORT,
|
|
4989
|
+
status: "ok",
|
|
4990
|
+
dashboard: `http://localhost:${PORT}/dashboard/`
|
|
4991
|
+
}));
|
|
2474
4992
|
startScheduler(db);
|
|
2475
|
-
|
|
4993
|
+
var apiAuthMode = getConfiguredApiToken() ? "token" : isLocalOpenModeEnabled() ? "local-open" : "locked";
|
|
4994
|
+
console.log(`@hasna/logs server running on http://localhost:${PORT} (api auth: ${apiAuthMode})`);
|
|
2476
4995
|
var server_default = {
|
|
2477
4996
|
port: PORT,
|
|
2478
4997
|
fetch: app.fetch
|
|
2479
4998
|
};
|
|
4999
|
+
function resolveDashboardRoot() {
|
|
5000
|
+
const cwdDashboardRoot = resolve(process.cwd(), "dashboard/dist");
|
|
5001
|
+
const candidates = [
|
|
5002
|
+
cwdDashboardRoot,
|
|
5003
|
+
resolve(serverDir, "../../dashboard/dist")
|
|
5004
|
+
];
|
|
5005
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? cwdDashboardRoot;
|
|
5006
|
+
}
|
|
2480
5007
|
export {
|
|
2481
5008
|
server_default as default
|
|
2482
5009
|
};
|