@polderlabs/bizar 5.0.2 → 5.1.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.
Files changed (60) hide show
  1. package/bizar-dash/dist/assets/MobileChat-BVojK0n5.js +1 -0
  2. package/bizar-dash/dist/assets/MobileChat-BVojK0n5.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileSettings-C85ApWKZ.js +1 -0
  4. package/bizar-dash/dist/assets/MobileSettings-C85ApWKZ.js.map +1 -0
  5. package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-Clz0NR6Y.js} +20 -25
  6. package/bizar-dash/dist/assets/icons-Clz0NR6Y.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-C1cpttnv.js → main-8feQWXiF.js} +10 -10
  8. package/bizar-dash/dist/assets/main-8feQWXiF.js.map +1 -0
  9. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js +1 -0
  10. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
  11. package/bizar-dash/dist/assets/mobile-D2pc-iNh.js +1 -0
  12. package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile-D2pc-iNh.js.map} +1 -1
  13. package/bizar-dash/dist/assets/mobile-IaZ47uKC.js +2 -0
  14. package/bizar-dash/dist/assets/mobile-IaZ47uKC.js.map +1 -0
  15. package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
  16. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
  17. package/bizar-dash/dist/assets/useSlashCommands-DjEwHl4n.js +2 -0
  18. package/bizar-dash/dist/assets/useSlashCommands-DjEwHl4n.js.map +1 -0
  19. package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
  20. package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
  21. package/bizar-dash/dist/index.html +7 -5
  22. package/bizar-dash/dist/mobile.html +4 -5
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/server/eval.mjs +93 -3
  25. package/bizar-dash/src/server/metrics.mjs +75 -0
  26. package/bizar-dash/src/server/otel.mjs +141 -14
  27. package/bizar-dash/src/server/plugins/registry.mjs +145 -43
  28. package/bizar-dash/src/server/routes/chat.mjs +46 -9
  29. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
  30. package/bizar-dash/src/server/routes/plugins.mjs +123 -16
  31. package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
  32. package/bizar-dash/src/server/server.mjs +44 -15
  33. package/bizar-dash/src/web/MobileApp.tsx +18 -10
  34. package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
  35. package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
  36. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
  37. package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
  38. package/bizar-dash/tests/otel-spans.test.mjs +253 -0
  39. package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
  40. package/package.json +1 -1
  41. package/templates/eval-fixtures/citation.json +16 -0
  42. package/templates/eval-fixtures/code-review.json +16 -0
  43. package/templates/eval-fixtures/concise-output.json +16 -0
  44. package/templates/eval-fixtures/context-window.json +16 -0
  45. package/templates/eval-fixtures/error-recovery.json +16 -0
  46. package/templates/eval-fixtures/json-output.json +20 -0
  47. package/templates/eval-fixtures/multi-language.json +16 -0
  48. package/templates/eval-fixtures/safe-paths.json +16 -0
  49. package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
  50. package/templates/eval-fixtures/unicode-handling.json +16 -0
  51. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
  52. package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
  53. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
  54. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
  55. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
  56. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
  57. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
  58. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
  59. package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
  60. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +0 -1
@@ -29,11 +29,13 @@
29
29
  }
30
30
  })();
31
31
  </script>
32
- <script type="module" crossorigin src="/assets/main-C1cpttnv.js"></script>
33
- <link rel="modulepreload" crossorigin href="/assets/react-vendor-DZRUXSPQ.js">
34
- <link rel="modulepreload" crossorigin href="/assets/icons-Bo0iH9EC.js">
35
- <link rel="modulepreload" crossorigin href="/assets/markdown-DIquRulQ.js">
36
- <link rel="modulepreload" crossorigin href="/assets/mobile-BtxQJftK.js">
32
+ <script type="module" crossorigin src="/assets/main-8feQWXiF.js"></script>
33
+ <link rel="modulepreload" crossorigin href="/assets/react-vendor-Dn4wqh4Z.js">
34
+ <link rel="modulepreload" crossorigin href="/assets/icons-Clz0NR6Y.js">
35
+ <link rel="modulepreload" crossorigin href="/assets/mobile-IaZ47uKC.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-DjEwHl4n.js">
37
39
  <link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
38
40
  <link rel="stylesheet" crossorigin href="/assets/main-DTkNlLrw.css">
39
41
  </head>
@@ -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-DD-FZrTC.js"></script>
16
- <link rel="modulepreload" crossorigin href="/assets/react-vendor-DZRUXSPQ.js">
17
- <link rel="modulepreload" crossorigin href="/assets/icons-Bo0iH9EC.js">
18
- <link rel="modulepreload" crossorigin href="/assets/markdown-DIquRulQ.js">
19
- <link rel="modulepreload" crossorigin href="/assets/mobile-BtxQJftK.js">
15
+ <script type="module" crossorigin src="/assets/mobile-D2pc-iNh.js"></script>
16
+ <link rel="modulepreload" crossorigin href="/assets/react-vendor-Dn4wqh4Z.js">
17
+ <link rel="modulepreload" crossorigin href="/assets/icons-Clz0NR6Y.js">
18
+ <link rel="modulepreload" crossorigin href="/assets/mobile-IaZ47uKC.js">
20
19
  <link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
21
20
  </head>
22
21
  <body>
@@ -1 +1 @@
1
- {"version":"4.1.9","results":[[":tests/lib/utils.test.ts",{"duration":24.401755999999978,"failed":false}],[":tests/components/Button.test.tsx",{"duration":94.65935400000001,"failed":false}],[":tests/components/Card.test.tsx",{"duration":33.34148499999992,"failed":false}],[":tests/hooks/useModal.test.tsx",{"duration":122.76268099999993,"failed":false}],[":tests/components/Toast.test.tsx",{"duration":154.32516699999996,"failed":false}],[":tests/components/Modal.test.tsx",{"duration":154.48290899999995,"failed":false}],[":tests/lib/i18n.test.ts",{"duration":2.5646790000000124,"failed":false}],[":tests/components/StatusBadge.test.tsx",{"duration":35.74290199999996,"failed":false}],[":tests/hooks/useToast.test.tsx",{"duration":102.97662300000002,"failed":false}],[":tests/components/Spinner.test.tsx",{"duration":61.05561799999998,"failed":false}],[":tests/backup-restore.test.tsx",{"duration":172.59814500000005,"failed":false}],[":tests/a11y.test.tsx",{"duration":604.525834,"failed":false}],[":tests/lib/search-fuzzy.test.ts",{"duration":5.145660000000021,"failed":false}],[":tests/components/settings-search.test.tsx",{"duration":706.8917600000001,"failed":false}],[":tests/memory-graph-view.test.tsx",{"duration":47.21873400000004,"failed":false}],[":tests/components/workspace-selector.test.tsx",{"duration":117.41044699999998,"failed":false}],[":tests/components/screenshot-ocr.test.tsx",{"duration":54.35712199999989,"failed":false}],[":tests/voice-recorder.test.tsx",{"duration":120.104243,"failed":false}],[":tests/autosave.test.tsx",{"duration":246.61124700000005,"failed":false}],[":tests/settings-layout.test.tsx",{"duration":193.1172640000001,"failed":false}],[":tests/settings-nav.test.tsx",{"duration":243.567592,"failed":false}],[":tests/components/doctor-panel.test.tsx",{"duration":76.46435400000007,"failed":false}],[":tests/chat-composer.test.tsx",{"duration":167.8578,"failed":false}],[":tests/settings-mode-wiring.test.tsx",{"duration":187.096763,"failed":false}],[":tests/minimax-bar.test.ts",{"duration":0,"failed":true}],[":tests/minimax-bar.test.tsx",{"duration":21.815285000000017,"failed":false}]]}
1
+ {"version":"4.1.9","results":[[":tests/lib/utils.test.ts",{"duration":28.89169000000004,"failed":false}],[":tests/components/Button.test.tsx",{"duration":117.69011,"failed":false}],[":tests/components/Card.test.tsx",{"duration":30.92253400000004,"failed":false}],[":tests/hooks/useModal.test.tsx",{"duration":119.64900699999998,"failed":false}],[":tests/components/Toast.test.tsx",{"duration":159.04681100000005,"failed":false}],[":tests/components/Modal.test.tsx",{"duration":150.9410469999999,"failed":false}],[":tests/lib/i18n.test.ts",{"duration":3.2969249999999874,"failed":false}],[":tests/components/StatusBadge.test.tsx",{"duration":41.157249999999976,"failed":false}],[":tests/hooks/useToast.test.tsx",{"duration":104.85745599999996,"failed":false}],[":tests/components/Spinner.test.tsx",{"duration":70.16560200000004,"failed":false}],[":tests/backup-restore.test.tsx",{"duration":179.13565300000005,"failed":false}],[":tests/a11y.test.tsx",{"duration":715.4879470000001,"failed":false}],[":tests/lib/search-fuzzy.test.ts",{"duration":7.532295999999974,"failed":false}],[":tests/components/settings-search.test.tsx",{"duration":712.275263,"failed":false}],[":tests/memory-graph-view.test.tsx",{"duration":59.40099399999997,"failed":false}],[":tests/components/workspace-selector.test.tsx",{"duration":113.71114899999998,"failed":false}],[":tests/components/screenshot-ocr.test.tsx",{"duration":51.13657699999999,"failed":false}],[":tests/voice-recorder.test.tsx",{"duration":128.61647900000003,"failed":false}],[":tests/autosave.test.tsx",{"duration":251.98326499999996,"failed":false}],[":tests/settings-layout.test.tsx",{"duration":201.76037699999995,"failed":false}],[":tests/settings-nav.test.tsx",{"duration":228.42296299999998,"failed":false}],[":tests/components/doctor-panel.test.tsx",{"duration":67.53857800000003,"failed":false}],[":tests/chat-composer.test.tsx",{"duration":189.9785320000001,"failed":false}],[":tests/settings-mode-wiring.test.tsx",{"duration":205.661208,"failed":false}],[":tests/minimax-bar.test.ts",{"duration":0,"failed":true}],[":tests/minimax-bar.test.tsx",{"duration":30.342043999999987,"failed":false}]]}
@@ -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
- errors.push(`expected object, got ${typeof parsed}`);
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
- * v4.9.0 — OpenTelemetry distributed tracing for the Bizar dashboard.
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
- * a single `tracer` instance for ad-hoc spans (chat.send, opencode
8
- * session creation, etc). Auto-instrumentation is intentionally not
9
- * enabledthe 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).
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
+ }