@polderlabs/bizar 4.7.2 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bizar-dash/dist/assets/index-DU61awG3.js +9 -0
- package/bizar-dash/dist/assets/index-DU61awG3.js.map +1 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +366 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-DfmIfOUS.css} +1 -1
- package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js → mobile-CL5uUQEC.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-CL5uUQEC.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +338 -0
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
- package/bizar-dash/src/server/memory-store.mjs +121 -0
- package/bizar-dash/src/server/otel.mjs +133 -0
- package/bizar-dash/src/server/routes/chat.mjs +246 -170
- package/bizar-dash/src/server/routes/memory.mjs +46 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
- package/bizar-dash/src/server/server.mjs +40 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
- package/bizar-dash/src/web/lib/search.ts +115 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
- package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
- package/bizar-dash/src/web/styles/memory.css +84 -1
- package/bizar-dash/src/web/styles/settings.css +80 -0
- package/bizar-dash/src/web/views/Memory.tsx +6 -1
- package/bizar-dash/src/web/views/Settings.tsx +96 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
- package/bizar-dash/tests/backup-restore.test.tsx +35 -17
- package/bizar-dash/tests/bundle-analysis.test.mjs +70 -0
- package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
- package/bizar-dash/tests/docker-build.test.mjs +96 -0
- package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
- package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
- package/bizar-dash/tests/memory-graph.test.mjs +95 -0
- package/bizar-dash/tests/otel.test.mjs +188 -0
- package/cli/commands/dash.mjs +6 -0
- package/package.json +7 -1
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
}
|
|
30
30
|
})();
|
|
31
31
|
</script>
|
|
32
|
-
<script type="module" crossorigin src="/assets/main-
|
|
33
|
-
<link rel="modulepreload" crossorigin href="/assets/mobile-
|
|
32
|
+
<script type="module" crossorigin src="/assets/main-DaC1Lc6q.js"></script>
|
|
33
|
+
<link rel="modulepreload" crossorigin href="/assets/mobile-D5WTWvuh.js">
|
|
34
34
|
<link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
|
|
35
|
-
<link rel="stylesheet" crossorigin href="/assets/main-
|
|
35
|
+
<link rel="stylesheet" crossorigin href="/assets/main-DfmIfOUS.css">
|
|
36
36
|
</head>
|
|
37
37
|
<body>
|
|
38
38
|
<div id="root"></div>
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
13
13
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
14
14
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
|
15
|
-
<script type="module" crossorigin src="/assets/mobile-
|
|
16
|
-
<link rel="modulepreload" crossorigin href="/assets/mobile-
|
|
15
|
+
<script type="module" crossorigin src="/assets/mobile-CL5uUQEC.js"></script>
|
|
16
|
+
<link rel="modulepreload" crossorigin href="/assets/mobile-D5WTWvuh.js">
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"4.1.9","results":[[":tests/lib/utils.test.ts",{"duration":
|
|
1
|
+
{"version":"4.1.9","results":[[":tests/lib/utils.test.ts",{"duration":19.332510000000013,"failed":false}],[":tests/components/Button.test.tsx",{"duration":67.29968700000006,"failed":false}],[":tests/components/Card.test.tsx",{"duration":22.37102600000003,"failed":false}],[":tests/hooks/useModal.test.tsx",{"duration":86.52507100000003,"failed":false}],[":tests/components/Toast.test.tsx",{"duration":103.76524499999994,"failed":false}],[":tests/components/Modal.test.tsx",{"duration":107.89120399999996,"failed":false}],[":tests/lib/i18n.test.ts",{"duration":4.197033999999974,"failed":false}],[":tests/components/StatusBadge.test.tsx",{"duration":28.41539800000004,"failed":false}],[":tests/hooks/useToast.test.tsx",{"duration":70.67926699999998,"failed":false}],[":tests/components/Spinner.test.tsx",{"duration":49.17134299999998,"failed":false}],[":tests/backup-restore.test.tsx",{"duration":103.80786699999999,"failed":false}],[":tests/a11y.test.tsx",{"duration":537.859814,"failed":false}],[":tests/lib/search-fuzzy.test.ts",{"duration":6.371206000000029,"failed":false}],[":tests/components/settings-search.test.tsx",{"duration":680.7986129999999,"failed":false}],[":tests/memory-graph-view.test.tsx",{"duration":34.91235599999999,"failed":false}]]}
|
|
@@ -1051,6 +1051,115 @@ function statMaybe(path) {
|
|
|
1051
1051
|
}
|
|
1052
1052
|
}
|
|
1053
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* Get a graph representation of the LightRAG index.
|
|
1056
|
+
*
|
|
1057
|
+
* If `root` is given, fetches entities reachable within `depth` hops
|
|
1058
|
+
* from that entity via LightRAG's /graph endpoint. Otherwise returns
|
|
1059
|
+
* all entities up to `limit`.
|
|
1060
|
+
*
|
|
1061
|
+
* Falls back to reading .bizar/memory/lightrag/kv_store_*.json directly
|
|
1062
|
+
* when the LightRAG server is unavailable.
|
|
1063
|
+
*
|
|
1064
|
+
* @param {{ projectRoot: string, root?: string|null, depth?: number, limit?: number }}
|
|
1065
|
+
* @returns {Promise<{nodes: Array<{id:string,label:string,type:string,size:number,group:string}>, edges: Array<{source:string,target:string,type:string,weight:number}>}>}
|
|
1066
|
+
*/
|
|
1067
|
+
export async function getLightRAGGraph({ projectRoot, root = null, depth = 2, limit = 200 }) {
|
|
1068
|
+
const config = resolveLightRAGConfig(projectRoot);
|
|
1069
|
+
|
|
1070
|
+
// Attempt to query the running server.
|
|
1071
|
+
if (await isRunning(config)) {
|
|
1072
|
+
try {
|
|
1073
|
+
const nodes = [];
|
|
1074
|
+
const edges = [];
|
|
1075
|
+
|
|
1076
|
+
if (root) {
|
|
1077
|
+
// Use LightRAG's /graph endpoint with root entity.
|
|
1078
|
+
const url = `http://${config.host}:${config.port}/graph?root=${encodeURIComponent(root)}&depth=${depth}&limit=${limit}`;
|
|
1079
|
+
const res = await httpGet(url, config.timeoutMs);
|
|
1080
|
+
if (res.status === 200) {
|
|
1081
|
+
let parsed;
|
|
1082
|
+
try { parsed = JSON.parse(res.body); } catch { parsed = {}; }
|
|
1083
|
+
// LightRAG /graph shape varies; normalise to our contract.
|
|
1084
|
+
// LightRAG returns { entities: [...], relations: [...] } or similar.
|
|
1085
|
+
const ents = parsed.entities || parsed.nodes || [];
|
|
1086
|
+
const rels = parsed.relations || parsed.edges || [];
|
|
1087
|
+
for (const e of ents.slice(0, limit)) {
|
|
1088
|
+
const id = String(e.id || e.name || JSON.stringify(e));
|
|
1089
|
+
nodes.push({
|
|
1090
|
+
id,
|
|
1091
|
+
label: String(e.label || e.name || id).slice(0, 60),
|
|
1092
|
+
type: String(e.type || 'entity'),
|
|
1093
|
+
size: Number(e.size || 1),
|
|
1094
|
+
group: String(e.group || e.type || 'default'),
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
for (const r of rels) {
|
|
1098
|
+
edges.push({
|
|
1099
|
+
source: String(r.source || r.from || r.src || ''),
|
|
1100
|
+
target: String(r.target || r.to || r.dst || ''),
|
|
1101
|
+
type: String(r.type || 'related'),
|
|
1102
|
+
weight: Number(r.weight || 1),
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
return { nodes, edges };
|
|
1106
|
+
}
|
|
1107
|
+
} else {
|
|
1108
|
+
// Return all entities from kv_store_full_docs.json.
|
|
1109
|
+
const docsPath = join(config.workingDir, 'kv_store_full_docs.json');
|
|
1110
|
+
if (existsSync(docsPath)) {
|
|
1111
|
+
try {
|
|
1112
|
+
const docs = JSON.parse(readFileSync(docsPath, 'utf8'));
|
|
1113
|
+
const keys = Object.keys(docs || {}).slice(0, limit);
|
|
1114
|
+
// Group by top-level path segment as a proxy for group.
|
|
1115
|
+
for (const k of keys) {
|
|
1116
|
+
const parts = k.replace(/^bizar:\/\//, '').split('/');
|
|
1117
|
+
const group = parts.length > 1 ? parts[0] : 'root';
|
|
1118
|
+
nodes.push({
|
|
1119
|
+
id: k,
|
|
1120
|
+
label: parts[parts.length - 1].replace(/[_-]/g, ' '),
|
|
1121
|
+
type: 'note',
|
|
1122
|
+
size: 1,
|
|
1123
|
+
group,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
return { nodes, edges };
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
console.warn('[lightrag] getLightRAGGraph fallback read failed:', err?.message);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
console.warn('[lightrag] getLightRAGGraph server query failed, falling back:', err?.message);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Fallback: read kv_store files directly.
|
|
1138
|
+
const nodes = [];
|
|
1139
|
+
const edges = [];
|
|
1140
|
+
try {
|
|
1141
|
+
const docsPath = join(config.workingDir, 'kv_store_full_docs.json');
|
|
1142
|
+
if (existsSync(docsPath)) {
|
|
1143
|
+
const docs = JSON.parse(readFileSync(docsPath, 'utf8'));
|
|
1144
|
+
const keys = Object.keys(docs || {}).slice(0, limit);
|
|
1145
|
+
for (const k of keys) {
|
|
1146
|
+
const parts = k.replace(/^bizar:\/\//, '').split('/');
|
|
1147
|
+
const group = parts.length > 1 ? parts[0] : 'root';
|
|
1148
|
+
nodes.push({
|
|
1149
|
+
id: k,
|
|
1150
|
+
label: parts[parts.length - 1].replace(/[_-]/g, ' '),
|
|
1151
|
+
type: 'note',
|
|
1152
|
+
size: 1,
|
|
1153
|
+
group,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
console.warn('[lightrag] getLightRAGGraph fallback read error:', err?.message);
|
|
1159
|
+
}
|
|
1160
|
+
return { nodes, edges };
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1054
1163
|
/**
|
|
1055
1164
|
* Rebuild the graph from scratch. Stops the server, wipes the working dir,
|
|
1056
1165
|
* and re-runs reindexVault. Returns { ok, started, error?, markerPath? }.
|
|
@@ -706,6 +706,127 @@ export {
|
|
|
706
706
|
export const LIGHTRAG_DEFAULT_LLM = 'opencode/gpt-5-nano';
|
|
707
707
|
export const LIGHTRAG_DEFAULT_EMBEDDING = 'opencode/text-embedding-3-small';
|
|
708
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Build a wikilink link graph from all .md notes in the vault.
|
|
711
|
+
*
|
|
712
|
+
* Nodes: one per note (id = relPath, label = title or basename).
|
|
713
|
+
* Edges: one per wikilink [[Target]] found in note bodies.
|
|
714
|
+
*
|
|
715
|
+
* @param {{ projectRoot: string, vaultPath?: string|null, limit?: number }} opts
|
|
716
|
+
* @returns {{ nodes: Array<{id:string,label:string,type:string,size:number,group:string}>, edges: Array<{source:string,target:string,type:string,weight:number}> }}
|
|
717
|
+
*/
|
|
718
|
+
export function getObsidianLinkGraph({ projectRoot, vaultPath = null, limit = 200 }) {
|
|
719
|
+
const { vaultRoot } = resolveVault(projectRoot);
|
|
720
|
+
const root = vaultPath || vaultRoot;
|
|
721
|
+
const nodesMap = new Map(); // id → node
|
|
722
|
+
const edges = [];
|
|
723
|
+
|
|
724
|
+
// Collect all notes.
|
|
725
|
+
const notes = listNotesForGraph(root);
|
|
726
|
+
const sliced = notes.slice(0, limit);
|
|
727
|
+
|
|
728
|
+
for (const note of sliced) {
|
|
729
|
+
if (!nodesMap.has(note.relPath)) {
|
|
730
|
+
nodesMap.set(note.relPath, {
|
|
731
|
+
id: note.relPath,
|
|
732
|
+
label: note.frontmatter?.title || note.relPath.split('/').pop()?.replace(/\.md$/i, '') || note.relPath,
|
|
733
|
+
type: 'note',
|
|
734
|
+
size: 1,
|
|
735
|
+
group: note.relPath.split('/')[0] || 'root',
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Extract wikilinks from body.
|
|
740
|
+
const WIKILINK_RE = /\[\[([^\]\n|]+?)(?:\|[^\]\n]+?)?(?:#[^\]\n]+?)?\]\]/g;
|
|
741
|
+
const body = note.body || '';
|
|
742
|
+
let m;
|
|
743
|
+
WIKILINK_RE.lastIndex = 0;
|
|
744
|
+
while ((m = WIKILINK_RE.exec(body)) !== null) {
|
|
745
|
+
const raw = (m[1] || '').trim();
|
|
746
|
+
if (!raw) continue;
|
|
747
|
+
const target = raw.split('#')[0].trim().split('/').pop() || raw;
|
|
748
|
+
// Resolve to a note id (basename match).
|
|
749
|
+
const targetId = resolveWikilinkTarget(target, notes);
|
|
750
|
+
if (targetId && targetId !== note.relPath) {
|
|
751
|
+
edges.push({
|
|
752
|
+
source: note.relPath,
|
|
753
|
+
target: targetId,
|
|
754
|
+
type: 'links_to',
|
|
755
|
+
weight: 1,
|
|
756
|
+
});
|
|
757
|
+
// Ensure target node exists.
|
|
758
|
+
if (!nodesMap.has(targetId)) {
|
|
759
|
+
nodesMap.set(targetId, {
|
|
760
|
+
id: targetId,
|
|
761
|
+
label: target,
|
|
762
|
+
type: 'note',
|
|
763
|
+
size: 1,
|
|
764
|
+
group: targetId.split('/')[0] || 'root',
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
nodes: [...nodesMap.values()].slice(0, limit),
|
|
773
|
+
edges: edges.slice(0, limit * 3),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* List notes from a specific root path (not the project vault root).
|
|
779
|
+
* Used by getObsidianLinkGraph for scanning arbitrary vaults.
|
|
780
|
+
*/
|
|
781
|
+
function listNotesForGraph(scanRoot) {
|
|
782
|
+
if (!existsSync(scanRoot)) return [];
|
|
783
|
+
const out = [];
|
|
784
|
+
function walk(dir, prefix) {
|
|
785
|
+
let entries;
|
|
786
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
787
|
+
catch { return; }
|
|
788
|
+
for (const e of entries) {
|
|
789
|
+
if (e.name.startsWith('.')) continue;
|
|
790
|
+
const full = join(dir, e.name);
|
|
791
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
792
|
+
if (e.isDirectory()) {
|
|
793
|
+
walk(full, rel);
|
|
794
|
+
} else if (e.name.endsWith('.md')) {
|
|
795
|
+
try {
|
|
796
|
+
const raw = readFileSync(full, 'utf8');
|
|
797
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
798
|
+
out.push({ relPath: rel, frontmatter, body });
|
|
799
|
+
} catch { /* skip */ }
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
walk(scanRoot, '');
|
|
804
|
+
return out;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Resolve a wikilink target (e.g. "My Note" or "subdir/My Note") to a note id.
|
|
809
|
+
* Uses basename matching (Obsidian semantics).
|
|
810
|
+
*/
|
|
811
|
+
function resolveWikilinkTarget(targetName, notes) {
|
|
812
|
+
const normalized = targetName.replace(/\.md$/i, '').toLowerCase();
|
|
813
|
+
// Try exact relPath match first.
|
|
814
|
+
for (const n of notes) {
|
|
815
|
+
const base = n.relPath.replace(/\.md$/i, '').toLowerCase();
|
|
816
|
+
if (base === normalized || base.endsWith('/' + normalized)) {
|
|
817
|
+
return n.relPath;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// Fallback: basename-only match.
|
|
821
|
+
for (const n of notes) {
|
|
822
|
+
const base = n.relPath.split('/').pop()?.replace(/\.md$/i, '').toLowerCase();
|
|
823
|
+
if (base === normalized) {
|
|
824
|
+
return n.relPath;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
|
|
709
830
|
/**
|
|
710
831
|
* Return the effective LightRAG model defaults, applying env-var
|
|
711
832
|
* overrides on top of the built-in opencode-Zen-free defaults.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/otel.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.9.0 — OpenTelemetry distributed tracing for the Bizar dashboard.
|
|
5
|
+
*
|
|
6
|
+
* Initialises the NodeSDK with an OTLP HTTP trace exporter and exposes
|
|
7
|
+
* a single `tracer` instance for ad-hoc spans (chat.send, opencode
|
|
8
|
+
* session creation, etc). Auto-instrumentation is intentionally not
|
|
9
|
+
* enabled — the dashboard already has structured logger + Prometheus
|
|
10
|
+
* for the "what" metrics, so distributed tracing is only interesting
|
|
11
|
+
* for the multi-step flows where request correlation matters
|
|
12
|
+
* (chat SSE pump, opencode session creation).
|
|
13
|
+
*
|
|
14
|
+
* Off by default. Enable with one of:
|
|
15
|
+
* BIZAR_OTEL=1
|
|
16
|
+
* OTEL_ENABLED=1
|
|
17
|
+
* OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318/v1/traces (also opts in)
|
|
18
|
+
*
|
|
19
|
+
* `shutdownOtel()` flushes any pending spans and tears down the SDK.
|
|
20
|
+
* It is idempotent and safe to call multiple times — subsequent calls
|
|
21
|
+
* are a no-op once the SDK has been shut down.
|
|
22
|
+
*
|
|
23
|
+
* The NodeSDK constructor is wrapped in try/catch so that a malformed
|
|
24
|
+
* OTLP endpoint or missing optional binding (e.g. test runs that
|
|
25
|
+
* import this module but never call `initOtel`) does not crash the
|
|
26
|
+
* dashboard.
|
|
27
|
+
*/
|
|
28
|
+
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
29
|
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
30
|
+
import { Resource } from '@opentelemetry/resources';
|
|
31
|
+
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
|
32
|
+
import { trace } from '@opentelemetry/api';
|
|
33
|
+
import { info as logInfo, warn as logWarn } from './logger.mjs';
|
|
34
|
+
|
|
35
|
+
const DEFAULT_OTLP_ENDPOINT = 'http://localhost:4318/v1/traces';
|
|
36
|
+
const SERVICE_NAME = 'bizar-dash';
|
|
37
|
+
const TRACER_NAME = 'bizar-dash';
|
|
38
|
+
const TRACER_VERSION = '0.1.0';
|
|
39
|
+
|
|
40
|
+
let sdk = null;
|
|
41
|
+
let shuttingDown = false;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The shared tracer used by route handlers. Bound to the global
|
|
45
|
+
* tracer provider — when OTEL is not enabled this resolves to a
|
|
46
|
+
* no-op tracer (a `startActiveSpan` call still returns a valid span
|
|
47
|
+
* object whose methods are all no-ops), so route handlers do not
|
|
48
|
+
* need a feature flag of their own.
|
|
49
|
+
*/
|
|
50
|
+
export const tracer = trace.getTracer(TRACER_NAME, TRACER_VERSION);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Initialise the NodeSDK with an OTLP HTTP trace exporter. Safe to
|
|
54
|
+
* call multiple times — the second call returns the cached SDK.
|
|
55
|
+
*
|
|
56
|
+
* `npm_package_version` is set by `npm run` contexts; in any other
|
|
57
|
+
* context we fall back to '0.0.0'. The version is informational
|
|
58
|
+
* (the OTLP collector shows it in the resource panel), so failing to
|
|
59
|
+
* read it is never a startup blocker.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} [opts]
|
|
62
|
+
* @param {string} [opts.serviceName] Override SERVICE_NAME (default 'bizar-dash')
|
|
63
|
+
* @param {string} [opts.endpoint] OTLP HTTP traces endpoint
|
|
64
|
+
* @returns {object|null} The NodeSDK instance, or null on failure
|
|
65
|
+
*/
|
|
66
|
+
export function initOtel({
|
|
67
|
+
serviceName = SERVICE_NAME,
|
|
68
|
+
endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || DEFAULT_OTLP_ENDPOINT,
|
|
69
|
+
} = {}) {
|
|
70
|
+
if (sdk) return sdk;
|
|
71
|
+
if (shuttingDown) return null;
|
|
72
|
+
try {
|
|
73
|
+
sdk = new NodeSDK({
|
|
74
|
+
resource: new Resource({
|
|
75
|
+
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
|
|
76
|
+
[SemanticResourceAttributes.SERVICE_VERSION]:
|
|
77
|
+
process.env.npm_package_version || '0.0.0',
|
|
78
|
+
}),
|
|
79
|
+
traceExporter: new OTLPTraceExporter({ url: endpoint }),
|
|
80
|
+
});
|
|
81
|
+
sdk.start();
|
|
82
|
+
logInfo('OpenTelemetry initialised', {
|
|
83
|
+
module: 'otel',
|
|
84
|
+
serviceName,
|
|
85
|
+
endpoint,
|
|
86
|
+
});
|
|
87
|
+
return sdk;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
logWarn('OpenTelemetry init failed; continuing without tracing', {
|
|
90
|
+
module: 'otel',
|
|
91
|
+
err: err?.message || String(err),
|
|
92
|
+
});
|
|
93
|
+
sdk = null;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Flush pending spans and shut down the SDK. Idempotent.
|
|
100
|
+
*
|
|
101
|
+
* Designed to be wired into the dashboard's SIGTERM/SIGINT handler
|
|
102
|
+
* so spans in flight at shutdown are not silently dropped. Tests
|
|
103
|
+
* call this between scenarios to keep registry state clean.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Promise<void>}
|
|
106
|
+
*/
|
|
107
|
+
export async function shutdownOtel() {
|
|
108
|
+
if (!sdk) return;
|
|
109
|
+
if (shuttingDown) return;
|
|
110
|
+
shuttingDown = true;
|
|
111
|
+
try {
|
|
112
|
+
await sdk.shutdown();
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logWarn('OpenTelemetry shutdown failed', {
|
|
115
|
+
module: 'otel',
|
|
116
|
+
err: err?.message || String(err),
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
sdk = null;
|
|
120
|
+
shuttingDown = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Test-only: return whether the SDK is currently initialised. Lets
|
|
126
|
+
* tests assert that initOtel really did instantiate the SDK without
|
|
127
|
+
* having to introspect private state from outside.
|
|
128
|
+
*
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
export function isOtelEnabled() {
|
|
132
|
+
return sdk !== null;
|
|
133
|
+
}
|