lopata 0.5.1 → 0.6.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.
@@ -683,6 +683,10 @@
683
683
  height: calc(var(--spacing) * 2.5);
684
684
  }
685
685
 
686
+ .h-4 {
687
+ height: calc(var(--spacing) * 4);
688
+ }
689
+
686
690
  .h-5 {
687
691
  height: calc(var(--spacing) * 5);
688
692
  }
@@ -880,6 +884,10 @@
880
884
  min-width: calc(var(--spacing) * 0);
881
885
  }
882
886
 
887
+ .min-w-\[16px\] {
888
+ min-width: 16px;
889
+ }
890
+
883
891
  .min-w-\[20px\] {
884
892
  min-width: 20px;
885
893
  }
@@ -5044,28 +5044,38 @@ function TraceWaterfall({ spans, events, highlightSpanId, onAddAttributeFilter }
5044
5044
  childMap.set(key, []);
5045
5045
  childMap.get(key).push(s3);
5046
5046
  }
5047
+ const processedSpanIdsRef = A2(new Set);
5047
5048
  const spanIdKey = spans.map((s3) => s3.spanId).join(",");
5048
5049
  y2(() => {
5049
5050
  if (spans.length === 0)
5050
5051
  return;
5051
- const autoCollapsed = new Set;
5052
- function walk(parentId, depth) {
5053
- const children = childMap.get(parentId) ?? [];
5054
- for (const child of children) {
5055
- const hasChildren = (childMap.get(child.spanId) ?? []).length > 0;
5056
- if (hasChildren) {
5057
- const spanDur = child.durationMs ?? 0;
5058
- const significantDuration = spanDur > traceDuration * 0.1;
5059
- if (depth >= 2 && !significantDuration) {
5060
- autoCollapsed.add(child.spanId);
5052
+ const previousProcessed = processedSpanIdsRef.current;
5053
+ const currentIds = spans.map((s3) => s3.spanId);
5054
+ const newSpanIds = new Set(currentIds.filter((id) => !previousProcessed.has(id)));
5055
+ processedSpanIdsRef.current = new Set(currentIds);
5056
+ if (newSpanIds.size === 0)
5057
+ return;
5058
+ setCollapsedSpans((prev) => {
5059
+ const next = new Set(prev);
5060
+ function walk(parentId, depth) {
5061
+ const children = childMap.get(parentId) ?? [];
5062
+ for (const child of children) {
5063
+ if (newSpanIds.has(child.spanId)) {
5064
+ const hasChildren = (childMap.get(child.spanId) ?? []).length > 0;
5065
+ if (hasChildren) {
5066
+ const spanDur = child.durationMs ?? 0;
5067
+ const significantDuration = spanDur > traceDuration * 0.1;
5068
+ if (depth >= 2 && !significantDuration) {
5069
+ next.add(child.spanId);
5070
+ }
5071
+ }
5061
5072
  }
5073
+ walk(child.spanId, depth + 1);
5062
5074
  }
5063
- walk(child.spanId, depth + 1);
5064
5075
  }
5065
- }
5066
- walk(null, 0);
5067
- setCollapsedSpans(autoCollapsed);
5068
- setExpandedSpan(null);
5076
+ walk(null, 0);
5077
+ return next;
5078
+ });
5069
5079
  }, [spanIdKey]);
5070
5080
  const toggleCollapse = (spanId) => {
5071
5081
  setCollapsedSpans((prev) => {
@@ -5090,6 +5100,19 @@ function TraceWaterfall({ spans, events, highlightSpanId, onAddAttributeFilter }
5090
5100
  return result;
5091
5101
  }
5092
5102
  const flatSpans = flattenTree(null, 0);
5103
+ const descendantErrorCount = new Map;
5104
+ function countErrors(parentId) {
5105
+ let count = 0;
5106
+ for (const child of childMap.get(parentId) ?? []) {
5107
+ if (child.status === "error")
5108
+ count++;
5109
+ const childErrors = countErrors(child.spanId);
5110
+ count += childErrors;
5111
+ descendantErrorCount.set(child.spanId, childErrors);
5112
+ }
5113
+ return count;
5114
+ }
5115
+ countErrors(null);
5093
5116
  const getParentAttributes = (span) => {
5094
5117
  if (!span.parentSpanId)
5095
5118
  return {};
@@ -5146,8 +5169,13 @@ function TraceWaterfall({ spans, events, highlightSpanId, onAddAttributeFilter }
5146
5169
  class: "inline-block w-4 flex-shrink-0"
5147
5170
  }, undefined, false, undefined, this),
5148
5171
  /* @__PURE__ */ u3("span", {
5149
- class: "truncate",
5172
+ class: `truncate ${span.status === "error" ? "text-red-400" : ""}`,
5150
5173
  children: span.name
5174
+ }, undefined, false, undefined, this),
5175
+ (descendantErrorCount.get(span.spanId) ?? 0) > 0 && /* @__PURE__ */ u3("span", {
5176
+ class: "ml-1 flex-shrink-0 inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full text-[10px] font-medium",
5177
+ style: { background: "var(--color-badge-red-bg)", color: "var(--color-badge-red-text)" },
5178
+ children: descendantErrorCount.get(span.spanId)
5151
5179
  }, undefined, false, undefined, this)
5152
5180
  ]
5153
5181
  }, undefined, true, undefined, this),
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
10
10
 
11
- <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-a68x1m5f.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-rae638a4.js"></script></head>
11
+ <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-3q3dhs4j.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-4y88h3dc.js"></script></head>
12
12
  <body class="h-full bg-surface text-ink" style="font-family: system-ui, -apple-system, sans-serif;">
13
13
  <script>
14
14
  // Apply saved theme before first paint to prevent flash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lopata",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/plugin.ts CHANGED
@@ -1,102 +1,18 @@
1
1
  import { plugin } from 'bun'
2
2
  import type { BrowserBinding } from './bindings/browser'
3
- import { SqliteCacheStorage } from './bindings/cache'
4
- import { FixedLengthStream, IdentityTransformStream } from './bindings/cf-streams'
5
3
  import { ContainerBase, getContainer, getRandom } from './bindings/container'
6
- import { patchGlobalCrypto } from './bindings/crypto-extras'
7
4
  import { DurableObjectBase, WebSocketRequestResponsePair } from './bindings/durable-object'
8
5
  import { EmailMessage } from './bindings/email'
9
- import { HTMLRewriter } from './bindings/html-rewriter'
10
6
  import type { ImageTransformOptions, OutputOptions } from './bindings/images'
11
7
  import { WebSocketPair } from './bindings/websocket-pair'
12
8
  import { NonRetryableError, WorkflowEntrypointBase } from './bindings/workflow'
13
- import { getDatabase } from './db'
14
9
  import { globalEnv } from './env'
15
10
  import { getActiveExecutionContext } from './execution-context'
11
+ import { setupCloudflareGlobals } from './setup-globals'
16
12
  import { getActiveContext } from './tracing/context'
17
- import { instrumentBinding } from './tracing/instrument'
18
- import { addSpanEvent, setSpanAttribute, startSpan } from './tracing/span'
19
-
20
- // Register global `caches` object (CacheStorage) with tracing
21
- const rawCacheStorage = new SqliteCacheStorage(getDatabase())
22
- const cacheMethods = ['match', 'put', 'delete']
23
-
24
- // Instrument the default cache
25
- rawCacheStorage.default = instrumentBinding(rawCacheStorage.default, {
26
- type: 'cache',
27
- name: 'default',
28
- methods: cacheMethods,
29
- }) as typeof rawCacheStorage.default
30
-
31
- // Wrap open() to return instrumented caches
32
- const originalOpen = rawCacheStorage.open.bind(rawCacheStorage)
33
- rawCacheStorage.open = async (cacheName: string) => {
34
- const cache = await originalOpen(cacheName)
35
- return instrumentBinding(cache, {
36
- type: 'cache',
37
- name: cacheName,
38
- methods: cacheMethods,
39
- })
40
- }
41
-
42
- Object.defineProperty(globalThis, 'caches', {
43
- value: rawCacheStorage,
44
- writable: false,
45
- configurable: true,
46
- })
47
-
48
- // Register global `HTMLRewriter` class
49
- Object.defineProperty(globalThis, 'HTMLRewriter', {
50
- value: HTMLRewriter,
51
- writable: false,
52
- configurable: true,
53
- })
54
-
55
- // Register global `WebSocketPair` class
56
- Object.defineProperty(globalThis, 'WebSocketPair', {
57
- value: WebSocketPair,
58
- writable: false,
59
- configurable: true,
60
- })
61
-
62
- // Register global CF stream classes
63
- Object.defineProperty(globalThis, 'IdentityTransformStream', {
64
- value: IdentityTransformStream,
65
- writable: false,
66
- configurable: true,
67
- })
68
-
69
- Object.defineProperty(globalThis, 'FixedLengthStream', {
70
- value: FixedLengthStream,
71
- writable: false,
72
- configurable: true,
73
- })
74
-
75
- // Patch crypto with CF-specific extensions (timingSafeEqual, DigestStream)
76
- patchGlobalCrypto()
77
-
78
- // Set navigator.userAgent to match Cloudflare Workers
79
- Object.defineProperty(globalThis.navigator, 'userAgent', {
80
- value: 'Cloudflare-Workers',
81
- writable: false,
82
- configurable: true,
83
- })
84
-
85
- // Set navigator.language (behind enable_navigator_language compat flag in CF)
86
- if (!globalThis.navigator.language) {
87
- Object.defineProperty(globalThis.navigator, 'language', {
88
- value: 'en',
89
- writable: false,
90
- configurable: true,
91
- })
92
- }
13
+ import { addSpanEvent, persistError, setSpanAttribute, startSpan } from './tracing/span'
93
14
 
94
- // Set performance.timeOrigin to 0 (CF semantics)
95
- Object.defineProperty(globalThis.performance, 'timeOrigin', {
96
- value: 0,
97
- writable: false,
98
- configurable: true,
99
- })
15
+ setupCloudflareGlobals()
100
16
 
101
17
  // Register addEventListener shim for legacy service worker syntax
102
18
  // Workers that use addEventListener("fetch", handler) instead of export default { fetch }
@@ -113,17 +29,6 @@ Object.defineProperty(globalThis, 'addEventListener', {
113
29
  }) /** @internal Get the registered service worker fetch handler */
114
30
  ;(globalThis as any).__lopata_sw_handlers = _serviceWorkerHandlers
115
31
 
116
- // Register scheduler.wait(ms) — await-able setTimeout alternative
117
- Object.defineProperty(globalThis, 'scheduler', {
118
- value: {
119
- wait(ms: number): Promise<void> {
120
- return new Promise((resolve) => setTimeout(resolve, ms))
121
- },
122
- },
123
- writable: false,
124
- configurable: true,
125
- })
126
-
127
32
  // ─── Console instrumentation ─────────────────────────────────────────
128
33
  // Captures console.log/info/warn/error/debug as span events when inside a trace context.
129
34
 
@@ -151,6 +56,10 @@ for (const method of consoleMethods) {
151
56
  if (!ctx) return
152
57
  const message = args.map(formatConsoleArg).join(' ')
153
58
  addSpanEvent(`console.${method}`, method, message)
59
+ if (method === 'error') {
60
+ const errorArg = args.find((a) => a instanceof Error)
61
+ persistError(errorArg ?? new Error(message), 'console.error')
62
+ }
154
63
  }
155
64
  }
156
65
 
@@ -0,0 +1,129 @@
1
+ import { SqliteCacheStorage } from './bindings/cache'
2
+ import { FixedLengthStream, IdentityTransformStream } from './bindings/cf-streams'
3
+ import { patchGlobalCrypto } from './bindings/crypto-extras'
4
+ import { HTMLRewriter } from './bindings/html-rewriter'
5
+ import { WebSocketPair } from './bindings/websocket-pair'
6
+ import { getDatabase } from './db'
7
+ import { instrumentBinding } from './tracing/instrument'
8
+ import { addSpanEvent, setSpanAttribute, startSpan } from './tracing/span'
9
+
10
+ let initialized = false
11
+
12
+ /**
13
+ * Sets up global Cloudflare-compatible APIs:
14
+ * caches, HTMLRewriter, WebSocketPair, IdentityTransformStream, FixedLengthStream,
15
+ * navigator.userAgent, navigator.language, performance.timeOrigin, scheduler.wait(),
16
+ * crypto extensions, and __lopata userland tracing API.
17
+ *
18
+ * Idempotent — safe to call multiple times.
19
+ */
20
+ export function setupCloudflareGlobals() {
21
+ if (initialized) return
22
+ initialized = true // ─── Userland tracing API ────────────────────────────────────────────
23
+ // Exposes a lightweight global that user code can call to create custom
24
+ // spans visible in the Lopata dashboard. In production (without Lopata)
25
+ // the global is simply absent, so the user's thin wrapper becomes a no-op.
26
+ ;(globalThis as any).__lopata = {
27
+ trace<T>(name: string, attrsOrFn: Record<string, unknown> | (() => T | Promise<T>), maybeFn?: () => T | Promise<T>): Promise<T> {
28
+ const fn = typeof attrsOrFn === 'function' ? attrsOrFn : maybeFn!
29
+ const attributes = typeof attrsOrFn === 'function' ? undefined : attrsOrFn
30
+ return startSpan({ name, attributes }, fn)
31
+ },
32
+ setAttribute: setSpanAttribute,
33
+ addEvent(name: string, message?: string, attrs?: Record<string, unknown>): void {
34
+ addSpanEvent(name, 'info', message ?? '', attrs)
35
+ },
36
+ }
37
+
38
+ // Register global `caches` object (CacheStorage) with tracing
39
+ const rawCacheStorage = new SqliteCacheStorage(getDatabase())
40
+ const cacheMethods = ['match', 'put', 'delete']
41
+
42
+ // Instrument the default cache
43
+ rawCacheStorage.default = instrumentBinding(rawCacheStorage.default, {
44
+ type: 'cache',
45
+ name: 'default',
46
+ methods: cacheMethods,
47
+ }) as typeof rawCacheStorage.default
48
+
49
+ // Wrap open() to return instrumented caches
50
+ const originalOpen = rawCacheStorage.open.bind(rawCacheStorage)
51
+ rawCacheStorage.open = async (cacheName: string) => {
52
+ const cache = await originalOpen(cacheName)
53
+ return instrumentBinding(cache, {
54
+ type: 'cache',
55
+ name: cacheName,
56
+ methods: cacheMethods,
57
+ })
58
+ }
59
+
60
+ Object.defineProperty(globalThis, 'caches', {
61
+ value: rawCacheStorage,
62
+ writable: false,
63
+ configurable: true,
64
+ })
65
+
66
+ // Register global `HTMLRewriter` class
67
+ Object.defineProperty(globalThis, 'HTMLRewriter', {
68
+ value: HTMLRewriter,
69
+ writable: false,
70
+ configurable: true,
71
+ })
72
+
73
+ // Register global `WebSocketPair` class
74
+ Object.defineProperty(globalThis, 'WebSocketPair', {
75
+ value: WebSocketPair,
76
+ writable: false,
77
+ configurable: true,
78
+ })
79
+
80
+ // Register global CF stream classes
81
+ Object.defineProperty(globalThis, 'IdentityTransformStream', {
82
+ value: IdentityTransformStream,
83
+ writable: false,
84
+ configurable: true,
85
+ })
86
+
87
+ Object.defineProperty(globalThis, 'FixedLengthStream', {
88
+ value: FixedLengthStream,
89
+ writable: false,
90
+ configurable: true,
91
+ })
92
+
93
+ // Patch crypto with CF-specific extensions (timingSafeEqual, DigestStream)
94
+ patchGlobalCrypto()
95
+
96
+ // Set navigator.userAgent to match Cloudflare Workers
97
+ Object.defineProperty(globalThis.navigator, 'userAgent', {
98
+ value: 'Cloudflare-Workers',
99
+ writable: false,
100
+ configurable: true,
101
+ })
102
+
103
+ // Set navigator.language (behind enable_navigator_language compat flag in CF)
104
+ if (!globalThis.navigator.language) {
105
+ Object.defineProperty(globalThis.navigator, 'language', {
106
+ value: 'en',
107
+ writable: false,
108
+ configurable: true,
109
+ })
110
+ }
111
+
112
+ // Set performance.timeOrigin to 0 (CF semantics)
113
+ Object.defineProperty(globalThis.performance, 'timeOrigin', {
114
+ value: 0,
115
+ writable: false,
116
+ configurable: true,
117
+ })
118
+
119
+ // Register scheduler.wait(ms) — await-able setTimeout alternative
120
+ Object.defineProperty(globalThis, 'scheduler', {
121
+ value: {
122
+ wait(ms: number): Promise<void> {
123
+ return new Promise((resolve) => setTimeout(resolve, ms))
124
+ },
125
+ },
126
+ writable: false,
127
+ configurable: true,
128
+ })
129
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Lopata userland tracing API.
3
+ *
4
+ * Available on `globalThis.__lopata` when running under Lopata dev server.
5
+ * In production (Cloudflare Workers) the global is `undefined` — wrap calls
6
+ * in a thin helper that falls back to a no-op:
7
+ *
8
+ * ```ts
9
+ * // app/lib/trace.ts
10
+ * type TraceFn = <T>(name: string, fn: () => T | Promise<T>) => Promise<T>
11
+ *
12
+ * export const trace: TraceFn = (name, fn) =>
13
+ * globalThis.__lopata?.trace(name, fn) ?? fn()
14
+ *
15
+ * export const setAttribute = (key: string, value: unknown) =>
16
+ * globalThis.__lopata?.setAttribute(key, value)
17
+ *
18
+ * export const addEvent = (name: string, message?: string) =>
19
+ * globalThis.__lopata?.addEvent(name, message)
20
+ * ```
21
+ *
22
+ * Add this file to your tsconfig types to get autocomplete:
23
+ * ```json
24
+ * { "compilerOptions": { "types": ["lopata/src/tracing/global"] } }
25
+ * ```
26
+ */
27
+
28
+ interface LopataTracing {
29
+ /**
30
+ * Create a traced span around `fn`. The span is visible in the Lopata
31
+ * dashboard and becomes a child of the currently active span (if any).
32
+ */
33
+ trace<T>(name: string, fn: () => T | Promise<T>): Promise<T>
34
+ /**
35
+ * Create a traced span with custom attributes.
36
+ */
37
+ trace<T>(name: string, attrs: Record<string, unknown>, fn: () => T | Promise<T>): Promise<T>
38
+
39
+ /**
40
+ * Set an attribute on the currently active span.
41
+ */
42
+ setAttribute(key: string, value: unknown): void
43
+
44
+ /**
45
+ * Add an event (log entry) to the currently active span.
46
+ */
47
+ addEvent(name: string, message?: string, attrs?: Record<string, unknown>): void
48
+ }
49
+
50
+ declare var __lopata: LopataTracing | undefined
@@ -3,13 +3,11 @@ import type { Plugin } from 'vite'
3
3
  let initialized = false
4
4
 
5
5
  /**
6
- * Sets up global Cloudflare-compatible APIs in the Bun process:
7
- * caches, HTMLRewriter, WebSocketPair, IdentityTransformStream, FixedLengthStream,
8
- * navigator.userAgent, scheduler.wait(), crypto extensions.
6
+ * Sets up global Cloudflare-compatible APIs in the Bun process.
9
7
  *
10
8
  * Runs once on configureServer (before middleware), idempotent.
11
- * Imports are lazy because the plugin is externalized by Vite's config bundler —
12
- * dynamic imports run at dev server startup time through Bun's native loader.
9
+ * Uses dynamic import because the plugin file is externalized by Vite's config bundler —
10
+ * the import runs at dev server startup time through Bun's native loader.
13
11
  */
14
12
  export function globalsPlugin(): Plugin {
15
13
  return {
@@ -19,80 +17,8 @@ export function globalsPlugin(): Plugin {
19
17
  if (initialized) return
20
18
  initialized = true
21
19
 
22
- const { SqliteCacheStorage } = await import('../bindings/cache.ts')
23
- const { HTMLRewriter } = await import('../bindings/html-rewriter.ts')
24
- const { WebSocketPair } = await import('../bindings/websocket-pair.ts')
25
- const { IdentityTransformStream, FixedLengthStream } = await import('../bindings/cf-streams.ts')
26
- const { patchGlobalCrypto } = await import('../bindings/crypto-extras.ts')
27
- const { getDatabase } = await import('../db.ts')
28
- const { instrumentBinding } = await import('../tracing/instrument.ts')
29
-
30
- // Global caches (CacheStorage)
31
- const cacheMethods = ['match', 'put', 'delete']
32
- const rawCacheStorage = new SqliteCacheStorage(getDatabase())
33
- rawCacheStorage.default = instrumentBinding(rawCacheStorage.default, {
34
- type: 'cache',
35
- name: 'default',
36
- methods: cacheMethods,
37
- }) as typeof rawCacheStorage.default
38
-
39
- const originalOpen = rawCacheStorage.open.bind(rawCacheStorage)
40
- rawCacheStorage.open = async (cacheName: string) => {
41
- const cache = await originalOpen(cacheName)
42
- return instrumentBinding(cache, {
43
- type: 'cache',
44
- name: cacheName,
45
- methods: cacheMethods,
46
- })
47
- }
48
-
49
- Object.defineProperty(globalThis, 'caches', {
50
- value: rawCacheStorage,
51
- writable: false,
52
- configurable: true,
53
- })
54
-
55
- Object.defineProperty(globalThis, 'HTMLRewriter', {
56
- value: HTMLRewriter,
57
- writable: false,
58
- configurable: true,
59
- })
60
-
61
- Object.defineProperty(globalThis, 'WebSocketPair', {
62
- value: WebSocketPair,
63
- writable: false,
64
- configurable: true,
65
- })
66
-
67
- Object.defineProperty(globalThis, 'IdentityTransformStream', {
68
- value: IdentityTransformStream,
69
- writable: false,
70
- configurable: true,
71
- })
72
-
73
- Object.defineProperty(globalThis, 'FixedLengthStream', {
74
- value: FixedLengthStream,
75
- writable: false,
76
- configurable: true,
77
- })
78
-
79
- patchGlobalCrypto()
80
-
81
- Object.defineProperty(globalThis.navigator, 'userAgent', {
82
- value: 'Cloudflare-Workers',
83
- writable: false,
84
- configurable: true,
85
- })
86
-
87
- Object.defineProperty(globalThis, 'scheduler', {
88
- value: {
89
- wait(ms: number): Promise<void> {
90
- return new Promise((resolve) => setTimeout(resolve, ms))
91
- },
92
- },
93
- writable: false,
94
- configurable: true,
95
- })
20
+ const { setupCloudflareGlobals } = await import('../setup-globals.ts')
21
+ setupCloudflareGlobals()
96
22
  },
97
23
  }
98
24
  }