@polderlabs/bizar 5.0.2 → 5.2.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/MobileChat-TCputYzr.js +1 -0
- package/bizar-dash/dist/assets/MobileChat-TCputYzr.js.map +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js.map +1 -0
- package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-DRDXfbBP.js} +151 -136
- package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
- package/bizar-dash/dist/assets/markdown-C6mXtQxD.js +1 -0
- package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
- package/bizar-dash/dist/assets/mobile--17fkfrl.js +1 -0
- package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile--17fkfrl.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-j3rOZK6v.js +2 -0
- package/bizar-dash/dist/assets/mobile-j3rOZK6v.js.map +1 -0
- package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
- package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
- package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js +2 -0
- package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js.map +1 -0
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
- package/bizar-dash/dist/index.html +8 -6
- package/bizar-dash/dist/mobile.html +4 -5
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/api.mjs +4 -0
- package/bizar-dash/src/server/eval.mjs +93 -3
- package/bizar-dash/src/server/metrics.mjs +75 -0
- package/bizar-dash/src/server/otel.mjs +141 -14
- package/bizar-dash/src/server/plugins/registry.mjs +145 -43
- package/bizar-dash/src/server/routes/chat.mjs +46 -9
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
- package/bizar-dash/src/server/routes/plugins.mjs +131 -16
- package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
- package/bizar-dash/src/server/routes/voice.mjs +42 -6
- package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
- package/bizar-dash/src/server/server.mjs +57 -15
- package/bizar-dash/src/server/voice-store.mjs +27 -0
- package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
- package/bizar-dash/src/web/App.tsx +9 -0
- package/bizar-dash/src/web/MobileApp.tsx +18 -10
- package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
- package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
- package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
- package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
- package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
- package/bizar-dash/src/web/components/Toggle.tsx +31 -0
- package/bizar-dash/src/web/components/Topbar.tsx +6 -0
- package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
- package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
- package/bizar-dash/src/web/styles/main.css +545 -0
- package/bizar-dash/src/web/views/Eval.tsx +172 -0
- package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
- package/bizar-dash/src/web/views/Plugins.tsx +128 -0
- package/bizar-dash/src/web/views/Settings.tsx +2 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
- package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
- package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
- package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
- package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
- package/bizar-dash/tests/otel-spans.test.mjs +253 -0
- package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
- package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
- package/cli/bin.mjs +14 -0
- package/cli/commands/dash.mjs +19 -1
- package/cli/commands/service.mjs +7 -0
- package/cli/commands/tailscale.mjs +251 -0
- package/package.json +1 -1
- package/templates/eval-fixtures/citation.json +16 -0
- package/templates/eval-fixtures/code-review.json +16 -0
- package/templates/eval-fixtures/concise-output.json +16 -0
- package/templates/eval-fixtures/context-window.json +16 -0
- package/templates/eval-fixtures/error-recovery.json +16 -0
- package/templates/eval-fixtures/json-output.json +20 -0
- package/templates/eval-fixtures/multi-language.json +16 -0
- package/templates/eval-fixtures/safe-paths.json +16 -0
- package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
- package/templates/eval-fixtures/unicode-handling.json +16 -0
- package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
- package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
- package/bizar-dash/dist/assets/main-C1cpttnv.js +0 -19
- package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +0 -1
|
@@ -29,13 +29,15 @@
|
|
|
29
29
|
}
|
|
30
30
|
})();
|
|
31
31
|
</script>
|
|
32
|
-
<script type="module" crossorigin src="/assets/main-
|
|
33
|
-
<link rel="modulepreload" crossorigin href="/assets/react-vendor-
|
|
34
|
-
<link rel="modulepreload" crossorigin href="/assets/icons-
|
|
35
|
-
<link rel="modulepreload" crossorigin href="/assets/
|
|
36
|
-
<link rel="modulepreload" crossorigin href="/assets/
|
|
32
|
+
<script type="module" crossorigin src="/assets/main-D5Ditnrd.js"></script>
|
|
33
|
+
<link rel="modulepreload" crossorigin href="/assets/react-vendor-Dn4wqh4Z.js">
|
|
34
|
+
<link rel="modulepreload" crossorigin href="/assets/icons-DRDXfbBP.js">
|
|
35
|
+
<link rel="modulepreload" crossorigin href="/assets/mobile-j3rOZK6v.js">
|
|
36
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-CeHGtduv.js">
|
|
37
|
+
<link rel="modulepreload" crossorigin href="/assets/markdown-C6mXtQxD.js">
|
|
38
|
+
<link rel="modulepreload" crossorigin href="/assets/useSlashCommands-BG-DhEck.js">
|
|
37
39
|
<link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
|
|
38
|
-
<link rel="stylesheet" crossorigin href="/assets/main-
|
|
40
|
+
<link rel="stylesheet" crossorigin href="/assets/main-xFpWMd32.css">
|
|
39
41
|
</head>
|
|
40
42
|
<body>
|
|
41
43
|
<div id="root"></div>
|
|
@@ -12,11 +12,10 @@
|
|
|
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/react-vendor-
|
|
17
|
-
<link rel="modulepreload" crossorigin href="/assets/icons-
|
|
18
|
-
<link rel="modulepreload" crossorigin href="/assets/
|
|
19
|
-
<link rel="modulepreload" crossorigin href="/assets/mobile-BtxQJftK.js">
|
|
15
|
+
<script type="module" crossorigin src="/assets/mobile--17fkfrl.js"></script>
|
|
16
|
+
<link rel="modulepreload" crossorigin href="/assets/react-vendor-Dn4wqh4Z.js">
|
|
17
|
+
<link rel="modulepreload" crossorigin href="/assets/icons-DRDXfbBP.js">
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/assets/mobile-j3rOZK6v.js">
|
|
20
19
|
<link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
|
|
21
20
|
</head>
|
|
22
21
|
<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":16.303165999999976,"failed":false}],[":tests/components/Button.test.tsx",{"duration":106.694435,"failed":false}],[":tests/components/Card.test.tsx",{"duration":19.81299100000001,"failed":false}],[":tests/hooks/useModal.test.tsx",{"duration":159.28117799999995,"failed":false}],[":tests/components/Toast.test.tsx",{"duration":196.709385,"failed":false}],[":tests/components/Modal.test.tsx",{"duration":196.62032399999998,"failed":false}],[":tests/lib/i18n.test.ts",{"duration":3.903647000000035,"failed":false}],[":tests/components/StatusBadge.test.tsx",{"duration":49.069886,"failed":false}],[":tests/hooks/useToast.test.tsx",{"duration":120.39812900000004,"failed":false}],[":tests/components/Spinner.test.tsx",{"duration":77.51955700000008,"failed":false}],[":tests/backup-restore.test.tsx",{"duration":205.62362600000006,"failed":false}],[":tests/a11y.test.tsx",{"duration":754.295168,"failed":false}],[":tests/lib/search-fuzzy.test.ts",{"duration":5.431500000000028,"failed":false}],[":tests/components/settings-search.test.tsx",{"duration":723.2007870000001,"failed":false}],[":tests/memory-graph-view.test.tsx",{"duration":47.14713799999993,"failed":false}],[":tests/components/workspace-selector.test.tsx",{"duration":152.31454200000007,"failed":false}],[":tests/components/screenshot-ocr.test.tsx",{"duration":54.37460299999998,"failed":false}],[":tests/voice-recorder.test.tsx",{"duration":138.534312,"failed":false}],[":tests/autosave.test.tsx",{"duration":268.9614950000001,"failed":false}],[":tests/settings-layout.test.tsx",{"duration":248.58253200000001,"failed":false}],[":tests/settings-nav.test.tsx",{"duration":311.78257699999995,"failed":false}],[":tests/components/doctor-panel.test.tsx",{"duration":82.92706099999998,"failed":false}],[":tests/chat-composer.test.tsx",{"duration":207.18291899999997,"failed":false}],[":tests/settings-mode-wiring.test.tsx",{"duration":221.4198530000001,"failed":false}],[":tests/minimax-bar.test.ts",{"duration":0,"failed":true}],[":tests/minimax-bar.test.tsx",{"duration":36.76302400000009,"failed":false}],[":tests/components/plugin-permissions.test.tsx",{"duration":104.179755,"failed":false}],[":tests/eval-web-ui.test.tsx",{"duration":152.385127,"failed":false}]]}
|
|
@@ -181,6 +181,10 @@ export async function createApiRouter({
|
|
|
181
181
|
// v5.0.0 — Screenshot OCR routes. Accepts images, extracts text.
|
|
182
182
|
const { createOcrRouter } = await import('./routes/ocr.mjs');
|
|
183
183
|
router.use(await createOcrRouter({ projectRoot }));
|
|
184
|
+
// v5.2 — Tailscale auth key integration. Registered before misc.mjs so
|
|
185
|
+
// /tailscale/* routes here take precedence over any overlapping misc routes.
|
|
186
|
+
const { createTailscaleRouter } = await import('./routes/tailscale.mjs');
|
|
187
|
+
router.use(createTailscaleRouter({}));
|
|
184
188
|
router.use(createMiscRouter({ state, broadcast }));
|
|
185
189
|
|
|
186
190
|
// /api/auth/* must be reachable WITHOUT the bearer token so a fresh
|
|
@@ -34,6 +34,8 @@ import { join, extname } from 'node:path';
|
|
|
34
34
|
* @property {object} [jsonSchema]
|
|
35
35
|
* @property {number} [maxTokens]
|
|
36
36
|
* @property {number} [maxLatencyMs]
|
|
37
|
+
* @property {number} [toolCallsMin]
|
|
38
|
+
* @property {string[]} [toolSequence]
|
|
37
39
|
*/
|
|
38
40
|
|
|
39
41
|
/**
|
|
@@ -133,7 +135,25 @@ function checkJsonSchema(content, schema) {
|
|
|
133
135
|
// Basic schema validation — check required fields exist
|
|
134
136
|
const errors = [];
|
|
135
137
|
if (schema.type === 'object' && typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
136
|
-
|
|
138
|
+
if (schema.type !== 'array') {
|
|
139
|
+
errors.push(`expected ${schema.type}, got ${typeof parsed}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (schema.type === 'array') {
|
|
143
|
+
if (!Array.isArray(parsed)) {
|
|
144
|
+
errors.push(`expected array, got ${typeof parsed}`);
|
|
145
|
+
} else {
|
|
146
|
+
if (schema.minItems != null && parsed.length < schema.minItems) {
|
|
147
|
+
errors.push(`array length ${parsed.length} is less than minItems ${schema.minItems}`);
|
|
148
|
+
}
|
|
149
|
+
if (schema.items?.type) {
|
|
150
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
151
|
+
if (typeof parsed[i] !== schema.items.type) {
|
|
152
|
+
errors.push(`item at index ${i} expected type ${schema.items.type}, got ${typeof parsed[i]}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
137
157
|
}
|
|
138
158
|
if (schema.properties) {
|
|
139
159
|
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
@@ -182,18 +202,58 @@ function checkMaxLatency(latencyMs, maxLatencyMs) {
|
|
|
182
202
|
};
|
|
183
203
|
}
|
|
184
204
|
|
|
205
|
+
/**
|
|
206
|
+
* @param {string[]} toolCalls
|
|
207
|
+
* @param {number} minCalls
|
|
208
|
+
* @returns {CheckResult}
|
|
209
|
+
*/
|
|
210
|
+
function checkToolCallsMin(toolCalls, minCalls) {
|
|
211
|
+
if (minCalls == null) return { kind: 'toolCallsMin', ok: true };
|
|
212
|
+
const ok = toolCalls.length >= minCalls;
|
|
213
|
+
return {
|
|
214
|
+
kind: 'toolCallsMin',
|
|
215
|
+
ok,
|
|
216
|
+
message: ok ? undefined : `tool calls ${toolCalls.length} is less than min ${minCalls}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {string[]} toolCalls
|
|
222
|
+
* @param {string[]} sequence
|
|
223
|
+
* @returns {CheckResult}
|
|
224
|
+
*/
|
|
225
|
+
function checkToolSequence(toolCalls, sequence) {
|
|
226
|
+
if (!sequence || !sequence.length) return { kind: 'toolSequence', ok: true };
|
|
227
|
+
const seqStr = sequence.join(',');
|
|
228
|
+
const callStr = toolCalls.join(',');
|
|
229
|
+
// Find the sequence as a subsequence
|
|
230
|
+
let seqIdx = 0;
|
|
231
|
+
for (const call of toolCalls) {
|
|
232
|
+
if (call === sequence[seqIdx]) {
|
|
233
|
+
seqIdx++;
|
|
234
|
+
if (seqIdx === sequence.length) break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const ok = seqIdx === sequence.length;
|
|
238
|
+
return {
|
|
239
|
+
kind: 'toolSequence',
|
|
240
|
+
ok,
|
|
241
|
+
message: ok ? undefined : `tool sequence [${seqStr}] not found in [${callStr}]`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
185
245
|
// ── Core runner ───────────────────────────────────────────────────────────────
|
|
186
246
|
|
|
187
247
|
/**
|
|
188
248
|
* Run a single fixture against an LLM.
|
|
189
249
|
*
|
|
190
250
|
* @param {Fixture} fixture
|
|
191
|
-
* @param {{ llmCall: (prompt: string, opts: {agent: string}) => Promise<{content: string, usage: object}>, timeoutMs?: number }} opts
|
|
251
|
+
* @param {{ llmCall: (prompt: string, opts: {agent: string}) => Promise<{content: string, usage: object}>, timeoutMs?: number, toolCalls?: string[] }} opts
|
|
192
252
|
* @returns {Promise<EvalResult>}
|
|
193
253
|
*/
|
|
194
254
|
export async function runFixture(
|
|
195
255
|
fixture,
|
|
196
|
-
{ llmCall, timeoutMs = 60000 } = {},
|
|
256
|
+
{ llmCall, timeoutMs = 60000, toolCalls = [] } = {},
|
|
197
257
|
) {
|
|
198
258
|
if (!llmCall) throw new Error('llmCall is required');
|
|
199
259
|
const start = Date.now();
|
|
@@ -212,6 +272,10 @@ export async function runFixture(
|
|
|
212
272
|
clearTimeout(timer);
|
|
213
273
|
content = result.content || '';
|
|
214
274
|
usage = result.usage || { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
275
|
+
// Support tool calls returned from llmCall
|
|
276
|
+
if (result.toolCalls?.length) {
|
|
277
|
+
toolCalls = result.toolCalls;
|
|
278
|
+
}
|
|
215
279
|
} catch (err) {
|
|
216
280
|
if (err.message === 'timeout') {
|
|
217
281
|
timeoutHit = true;
|
|
@@ -255,6 +319,12 @@ export async function runFixture(
|
|
|
255
319
|
if (expected.maxLatencyMs != null) {
|
|
256
320
|
checks.push(checkMaxLatency(latencyMs, expected.maxLatencyMs));
|
|
257
321
|
}
|
|
322
|
+
if (expected.toolCallsMin != null) {
|
|
323
|
+
checks.push(checkToolCallsMin(toolCalls, expected.toolCallsMin));
|
|
324
|
+
}
|
|
325
|
+
if (expected.toolSequence?.length) {
|
|
326
|
+
checks.push(checkToolSequence(toolCalls, expected.toolSequence));
|
|
327
|
+
}
|
|
258
328
|
|
|
259
329
|
const ok = checks.every((c) => c.ok);
|
|
260
330
|
|
|
@@ -268,6 +338,26 @@ export async function runFixture(
|
|
|
268
338
|
};
|
|
269
339
|
}
|
|
270
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Run a single fixture by ID from a known fixture map (useful for testing).
|
|
343
|
+
*
|
|
344
|
+
* @param {string} fixtureId
|
|
345
|
+
* @param {Fixture[]} fixtures
|
|
346
|
+
* @param {{ llmCall: Function, timeoutMs?: number, toolCalls?: string[] }} opts
|
|
347
|
+
* @returns {Promise<EvalResult>}
|
|
348
|
+
*/
|
|
349
|
+
export async function runFixtureById(
|
|
350
|
+
fixtureId,
|
|
351
|
+
fixtures,
|
|
352
|
+
{ llmCall, timeoutMs = 60000, toolCalls = [] } = {},
|
|
353
|
+
) {
|
|
354
|
+
const fixture = fixtures.find((f) => f.id === fixtureId);
|
|
355
|
+
if (!fixture) {
|
|
356
|
+
throw new Error(`Fixture not found: ${fixtureId}`);
|
|
357
|
+
}
|
|
358
|
+
return runFixture(fixture, { llmCall, timeoutMs, toolCalls });
|
|
359
|
+
}
|
|
360
|
+
|
|
271
361
|
/**
|
|
272
362
|
* Load all JSON fixtures from a directory.
|
|
273
363
|
*
|
|
@@ -190,4 +190,79 @@ export function reset() {
|
|
|
190
190
|
counters.clear();
|
|
191
191
|
gauges.clear();
|
|
192
192
|
histograms.clear();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── v5.1.0 ────────────────────────────────────────────────────────────────
|
|
196
|
+
// Trace → metrics correlation helpers.
|
|
197
|
+
//
|
|
198
|
+
// The OTEL exporter ships spans to a collector; the Prometheus
|
|
199
|
+
// exporter ships counters from this in-process registry. To answer
|
|
200
|
+
// "how many `chat.send` spans landed in the last hour?" without
|
|
201
|
+
// leaving the dashboard, route handlers call `recordTrace(name,
|
|
202
|
+
// attributes)` from inside their `withSpan` wrapper. The Map keeps a
|
|
203
|
+
// bounded per-(name, attribute-set) count so a single high-cardinality
|
|
204
|
+
// label (e.g. `chat.session_id`) does not blow up memory.
|
|
205
|
+
//
|
|
206
|
+
// `traceCountByName` is intentionally exported read-only — tests
|
|
207
|
+
// import it to assert the helper was called with the right key, but
|
|
208
|
+
// production code should always go through `recordTrace` to keep
|
|
209
|
+
// the key derivation in one place.
|
|
210
|
+
//
|
|
211
|
+
//─── v5.1.0 ends ───────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Bounded counter of recorded trace samples, keyed by
|
|
215
|
+
* `${name}:${JSON.stringify(attributes)}`. Routes populate this
|
|
216
|
+
* from inside their `withSpan` callback so the dashboard can answer
|
|
217
|
+
* "how often did span X fire with attribute set Y" without leaving
|
|
218
|
+
* the process.
|
|
219
|
+
*
|
|
220
|
+
* The Map is intentionally an exported `let`-equivalent: tests
|
|
221
|
+
* reset it via {@link resetTraceCounts}; production code never
|
|
222
|
+
* mutates the Map directly.
|
|
223
|
+
*
|
|
224
|
+
* @type {Map<string, number>}
|
|
225
|
+
*/
|
|
226
|
+
export const traceCountByName = new Map();
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Increment the trace counter for `(name, attributes)`.
|
|
230
|
+
*
|
|
231
|
+
* The composite key is `${name}:${jsonString}` so two spans named
|
|
232
|
+
* `chat.send` with different `chat.session_id` attributes are
|
|
233
|
+
* tracked separately. Undefined/null/empty-string attribute values
|
|
234
|
+
* are dropped from the key for stability (avoids the empty-string vs
|
|
235
|
+
* undefined distinction polluting counts). Keys are alphabetised
|
|
236
|
+
* before serialisation so `{a:1,b:2}` and `{b:2,a:1}` collide on
|
|
237
|
+
* the same bucket — `JSON.stringify` alone would not give that
|
|
238
|
+
* guarantee because it preserves insertion order.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} name
|
|
241
|
+
* @param {Record<string, string|number|boolean|undefined|null>} [attributes]
|
|
242
|
+
* @returns {number} the new total for that key
|
|
243
|
+
*/
|
|
244
|
+
export function recordTrace(name, attributes = {}) {
|
|
245
|
+
if (!name) return 0;
|
|
246
|
+
const keys = [];
|
|
247
|
+
if (attributes && typeof attributes === 'object') {
|
|
248
|
+
for (const k of Object.keys(attributes)) {
|
|
249
|
+
const v = attributes[k];
|
|
250
|
+
if (v === undefined || v === null || v === '') continue;
|
|
251
|
+
keys.push(k);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
keys.sort();
|
|
255
|
+
const cleanAttrs = {};
|
|
256
|
+
for (const k of keys) cleanAttrs[k] = attributes[k];
|
|
257
|
+
const key = `${name}:${JSON.stringify(cleanAttrs)}`;
|
|
258
|
+
const next = (traceCountByName.get(key) || 0) + 1;
|
|
259
|
+
traceCountByName.set(key, next);
|
|
260
|
+
return next;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Clear every recorded-trace counter. Intended for tests.
|
|
265
|
+
*/
|
|
266
|
+
export function resetTraceCounts() {
|
|
267
|
+
traceCountByName.clear();
|
|
193
268
|
}
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/server/otel.mjs
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* v5.1.0 — Expanded OpenTelemetry tracing surface for the Bizar dashboard.
|
|
5
5
|
*
|
|
6
|
-
* Initialises the NodeSDK with an OTLP HTTP trace exporter and exposes
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* Initialises the NodeSDK with an OTLP HTTP trace exporter and exposes:
|
|
7
|
+
* - `tracer` — the shared tracer instance
|
|
8
|
+
* - `initOtel()` / `shutdownOtel()` — SDK lifecycle (off by default)
|
|
9
|
+
* - `withSpan(name, fn, attrs)` — ergonomic wrapper that records
|
|
10
|
+
* OK on success, ERROR+exception
|
|
11
|
+
* on throw, and ends the span in
|
|
12
|
+
* every code path
|
|
13
|
+
* - `setCommonAttributes(span, …)` — fast-path for the recurring
|
|
14
|
+
* user/workspace/ip/ua attributes
|
|
15
|
+
* that 95% of route spans set
|
|
16
|
+
*
|
|
17
|
+
* Auto-instrumentation is intentionally NOT enabled. The dashboard
|
|
18
|
+
* already has structured logger + Prometheus for the "what" metrics, so
|
|
19
|
+
* distributed tracing is only interesting for the multi-step flows
|
|
20
|
+
* where request correlation matters (chat SSE pump, opencode session
|
|
21
|
+
* creation, plugin install, workspace invites, etc).
|
|
13
22
|
*
|
|
14
23
|
* Off by default. Enable with one of:
|
|
15
24
|
* BIZAR_OTEL=1
|
|
@@ -29,11 +38,13 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
|
29
38
|
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
30
39
|
import { Resource } from '@opentelemetry/resources';
|
|
31
40
|
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
|
32
|
-
import { trace } from '@opentelemetry/api';
|
|
41
|
+
import { SpanStatusCode, trace } from '@opentelemetry/api';
|
|
42
|
+
import os from 'node:os';
|
|
33
43
|
import { info as logInfo, warn as logWarn } from './logger.mjs';
|
|
34
44
|
|
|
35
45
|
const DEFAULT_OTLP_ENDPOINT = 'http://localhost:4318/v1/traces';
|
|
36
46
|
const SERVICE_NAME = 'bizar-dash';
|
|
47
|
+
const SERVICE_NAMESPACE = 'bizar';
|
|
37
48
|
const TRACER_NAME = 'bizar-dash';
|
|
38
49
|
const TRACER_VERSION = '0.1.0';
|
|
39
50
|
|
|
@@ -49,6 +60,29 @@ let shuttingDown = false;
|
|
|
49
60
|
*/
|
|
50
61
|
export const tracer = trace.getTracer(TRACER_NAME, TRACER_VERSION);
|
|
51
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Build the OTel resource attributes for this dashboard process.
|
|
65
|
+
*
|
|
66
|
+
* Used both at SDK init time (so a collector sees deployment
|
|
67
|
+
* context) and exposed via `getResourceAttributes()` for any
|
|
68
|
+
* consumer that needs the same metadata on a per-span basis.
|
|
69
|
+
*
|
|
70
|
+
* @returns {Record<string, string|number>}
|
|
71
|
+
*/
|
|
72
|
+
export function getResourceAttributes() {
|
|
73
|
+
return {
|
|
74
|
+
[SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME,
|
|
75
|
+
[SemanticResourceAttributes.SERVICE_VERSION]:
|
|
76
|
+
process.env.npm_package_version || '0.0.0',
|
|
77
|
+
[SemanticResourceAttributes.SERVICE_NAMESPACE]: SERVICE_NAMESPACE,
|
|
78
|
+
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]:
|
|
79
|
+
process.env.NODE_ENV || 'development',
|
|
80
|
+
[SemanticResourceAttributes.PROCESS_PID]: process.pid,
|
|
81
|
+
[SemanticResourceAttributes.HOST_NAME]: os.hostname(),
|
|
82
|
+
[SemanticResourceAttributes.OS_VERSION]: `${os.platform()} ${os.release()}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
/**
|
|
53
87
|
* Initialise the NodeSDK with an OTLP HTTP trace exporter. Safe to
|
|
54
88
|
* call multiple times — the second call returns the cached SDK.
|
|
@@ -70,12 +104,10 @@ export function initOtel({
|
|
|
70
104
|
if (sdk) return sdk;
|
|
71
105
|
if (shuttingDown) return null;
|
|
72
106
|
try {
|
|
107
|
+
const attrs = getResourceAttributes();
|
|
108
|
+
if (serviceName !== SERVICE_NAME) attrs[SemanticResourceAttributes.SERVICE_NAME] = serviceName;
|
|
73
109
|
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
|
-
}),
|
|
110
|
+
resource: new Resource(attrs),
|
|
79
111
|
traceExporter: new OTLPTraceExporter({ url: endpoint }),
|
|
80
112
|
});
|
|
81
113
|
sdk.start();
|
|
@@ -83,6 +115,8 @@ export function initOtel({
|
|
|
83
115
|
module: 'otel',
|
|
84
116
|
serviceName,
|
|
85
117
|
endpoint,
|
|
118
|
+
namespace: SERVICE_NAMESPACE,
|
|
119
|
+
deployment: process.env.NODE_ENV || 'development',
|
|
86
120
|
});
|
|
87
121
|
return sdk;
|
|
88
122
|
} catch (err) {
|
|
@@ -131,3 +165,96 @@ export async function shutdownOtel() {
|
|
|
131
165
|
export function isOtelEnabled() {
|
|
132
166
|
return sdk !== null;
|
|
133
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run `fn` inside an active span on the shared tracer.
|
|
171
|
+
*
|
|
172
|
+
* The helper:
|
|
173
|
+
* - opens a span with the given name and pre-bound attributes;
|
|
174
|
+
* - awaits the user callback (which receives the span as its
|
|
175
|
+
* last argument, after `(req, res)` in route handlers, or as
|
|
176
|
+
* the only argument in free-standing helpers);
|
|
177
|
+
* - sets the span status to OK on success;
|
|
178
|
+
* - on throw: records the exception and sets ERROR status before
|
|
179
|
+
* rethrowing so the original error propagates to `wrap()` /
|
|
180
|
+
* `next(err)` exactly as if the helper were not used;
|
|
181
|
+
* - ends the span in a finally block so a thrown error from the
|
|
182
|
+
* callback cannot leak the span.
|
|
183
|
+
*
|
|
184
|
+
* `withSpan` works under both the live SDK and the no-op tracer
|
|
185
|
+
* shipped when OTEL is disabled — every span method is a no-op then
|
|
186
|
+
* and the callback's return value is what callers see.
|
|
187
|
+
*
|
|
188
|
+
* @template T
|
|
189
|
+
* @param {string} name
|
|
190
|
+
* @param {(span: import('@opentelemetry/api').Span, ...args: any[]) => Promise<T>|T} fn
|
|
191
|
+
* @param {Record<string, string|number|boolean>} [attributes]
|
|
192
|
+
* @returns {(...args: any[]) => Promise<T>}
|
|
193
|
+
*/
|
|
194
|
+
export function withSpan(name, fn, attributes = {}) {
|
|
195
|
+
return (...args) => tracer.startActiveSpan(name, { attributes }, async (span) => {
|
|
196
|
+
try {
|
|
197
|
+
const result = await fn(span, ...args);
|
|
198
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
199
|
+
return result;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
try {
|
|
202
|
+
span.recordException(err);
|
|
203
|
+
} catch {
|
|
204
|
+
/* no-op tracer swallows everything */
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
span.setStatus({
|
|
208
|
+
code: SpanStatusCode.ERROR,
|
|
209
|
+
message: err?.message || String(err),
|
|
210
|
+
});
|
|
211
|
+
} catch {
|
|
212
|
+
/* ignore */
|
|
213
|
+
}
|
|
214
|
+
throw err;
|
|
215
|
+
} finally {
|
|
216
|
+
try {
|
|
217
|
+
span.end();
|
|
218
|
+
} catch {
|
|
219
|
+
/* never re-throw inside OTel helpers */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Apply the common request-scoped attributes most route spans want:
|
|
227
|
+
* user/workspace identity, plus the OTel HTTP semantic conventions
|
|
228
|
+
* for the client IP and user-agent string.
|
|
229
|
+
*
|
|
230
|
+
* Each attribute is set independently and only if a value was
|
|
231
|
+
* provided; passing `undefined`/`null`/empty string skips the
|
|
232
|
+
* attribute so spans for public endpoints don't get an empty
|
|
233
|
+
* `bizar.user.id = ""` cluttering the trace UI.
|
|
234
|
+
*
|
|
235
|
+
* Attribute naming rationale:
|
|
236
|
+
* - `bizar.user.id` — namespaced custom key (avoids colliding
|
|
237
|
+
* with any future OTel `enduser.id`)
|
|
238
|
+
* - `bizar.workspace.id` — namespaced custom key
|
|
239
|
+
* - `http.client_ip` — OTel HTTP semantic convention
|
|
240
|
+
* - `http.user_agent` — OTel HTTP semantic convention (the
|
|
241
|
+
* spec later renamed this to
|
|
242
|
+
* `user_agent.original`; the dashboard
|
|
243
|
+
* keeps `http.user_agent` because the
|
|
244
|
+
* v4.x dashboards in production still
|
|
245
|
+
* search by that key)
|
|
246
|
+
*
|
|
247
|
+
* @param {import('@opentelemetry/api').Span} span
|
|
248
|
+
* @param {object} opts
|
|
249
|
+
* @param {string} [opts.userId]
|
|
250
|
+
* @param {string} [opts.workspaceId]
|
|
251
|
+
* @param {string} [opts.ip]
|
|
252
|
+
* @param {string} [opts.userAgent]
|
|
253
|
+
*/
|
|
254
|
+
export function setCommonAttributes(span, { userId, workspaceId, ip, userAgent } = {}) {
|
|
255
|
+
if (!span) return;
|
|
256
|
+
if (userId) span.setAttribute('bizar.user.id', userId);
|
|
257
|
+
if (workspaceId) span.setAttribute('bizar.workspace.id', workspaceId);
|
|
258
|
+
if (ip) span.setAttribute('http.client_ip', ip);
|
|
259
|
+
if (userAgent) span.setAttribute('http.user_agent', userAgent);
|
|
260
|
+
}
|