@livestore/utils 0.4.0-dev.19 → 0.4.0-dev.20
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/dist/.tsbuildinfo.json +1 -1
- package/dist/effect/Debug.d.ts +38 -0
- package/dist/effect/Debug.d.ts.map +1 -0
- package/dist/effect/Debug.js +287 -0
- package/dist/effect/Debug.js.map +1 -0
- package/dist/effect/mod.d.ts +1 -0
- package/dist/effect/mod.d.ts.map +1 -1
- package/dist/effect/mod.js +1 -0
- package/dist/effect/mod.js.map +1 -1
- package/dist/mod.d.ts +0 -1
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +0 -1
- package/dist/mod.js.map +1 -1
- package/package.json +1 -1
- package/src/effect/Debug.ts +375 -0
- package/src/effect/mod.ts +1 -0
- package/src/mod.ts +0 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import * as C from '@effect/experimental/DevTools/Client'
|
|
2
|
+
import type * as Domain from '@effect/experimental/DevTools/Domain'
|
|
3
|
+
import { Duration, Schedule } from 'effect'
|
|
4
|
+
import * as Console from 'effect/Console'
|
|
5
|
+
import * as Effect from 'effect/Effect'
|
|
6
|
+
import { pipe } from 'effect/Function'
|
|
7
|
+
import * as Graph from 'effect/Graph'
|
|
8
|
+
import * as Layer from 'effect/Layer'
|
|
9
|
+
import * as Option from 'effect/Option'
|
|
10
|
+
|
|
11
|
+
interface SpanNodeInfo {
|
|
12
|
+
readonly span: Domain.ParentSpan
|
|
13
|
+
readonly events: readonly Domain.SpanEvent[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SpanNodeGraph = Graph.Graph<SpanNodeInfo, any>
|
|
17
|
+
|
|
18
|
+
class DebugInfo extends Effect.Service<DebugInfo>()('@mattiamanzati/debug/DebugInfo', {
|
|
19
|
+
effect: Effect.sync(() => ({
|
|
20
|
+
mutableGraph: Graph.beginMutation(Graph.directed<SpanNodeInfo, any>()),
|
|
21
|
+
nodeIdBySpanId: new Map<string, number>(),
|
|
22
|
+
})),
|
|
23
|
+
}) {}
|
|
24
|
+
|
|
25
|
+
const layerClientInMemoryGraph = Layer.effect(
|
|
26
|
+
C.Client,
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const { mutableGraph, nodeIdBySpanId } = yield* DebugInfo
|
|
29
|
+
|
|
30
|
+
function ensureNode(traceId: string, spanId: string) {
|
|
31
|
+
const existingNodeId = nodeIdBySpanId.get(spanId)
|
|
32
|
+
if (existingNodeId !== undefined) return existingNodeId
|
|
33
|
+
const nodeId = Graph.addNode(mutableGraph, {
|
|
34
|
+
span: { _tag: 'ExternalSpan', spanId, traceId, sampled: false },
|
|
35
|
+
events: [],
|
|
36
|
+
})
|
|
37
|
+
nodeIdBySpanId.set(spanId, nodeId)
|
|
38
|
+
return nodeId
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function upgradeInfo(prev: Domain.ParentSpan, next: Domain.ParentSpan): [Domain.ParentSpan, boolean] {
|
|
42
|
+
if (prev._tag === 'ExternalSpan' && next._tag === 'Span') return [next, true]
|
|
43
|
+
if (prev._tag === 'Span' && next._tag === 'ExternalSpan') return [prev, false]
|
|
44
|
+
if (prev._tag === 'Span' && prev.status._tag === 'Ended') return [prev, false]
|
|
45
|
+
return [next, false]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function addNode(span: Domain.ParentSpan) {
|
|
49
|
+
const nodeId = ensureNode(span.traceId, span.spanId)
|
|
50
|
+
Graph.updateNode(mutableGraph, nodeId, (previousInfo) => {
|
|
51
|
+
const [latestInfo, upgraded] = upgradeInfo(previousInfo.span, span)
|
|
52
|
+
if (upgraded && latestInfo._tag === 'Span' && Option.isSome(latestInfo.parent)) {
|
|
53
|
+
const parentNodeId = addNode(latestInfo.parent.value)
|
|
54
|
+
Graph.addEdge(mutableGraph, parentNodeId, nodeId, undefined)
|
|
55
|
+
}
|
|
56
|
+
return { ...previousInfo, span: latestInfo }
|
|
57
|
+
})
|
|
58
|
+
return nodeId
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function addEvent(event: Domain.SpanEvent) {
|
|
62
|
+
const nodeId = ensureNode(event.traceId, event.spanId)
|
|
63
|
+
Graph.updateNode(mutableGraph, nodeId, (previousInfo) => ({
|
|
64
|
+
...previousInfo,
|
|
65
|
+
events: [...previousInfo.events, event],
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return C.Client.of({
|
|
70
|
+
unsafeAddSpan: (span) => {
|
|
71
|
+
switch (span._tag) {
|
|
72
|
+
case 'SpanEvent':
|
|
73
|
+
return addEvent(span)
|
|
74
|
+
case 'Span':
|
|
75
|
+
return addNode(span)
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
export const layerDebug = pipe(
|
|
83
|
+
C.makeTracer,
|
|
84
|
+
Effect.map(Layer.setTracer),
|
|
85
|
+
Layer.unwrapEffect,
|
|
86
|
+
Layer.provide(layerClientInMemoryGraph),
|
|
87
|
+
Layer.provideMerge(DebugInfo.Default),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
function formatDuration(startTime: bigint, endTime: bigint | undefined): string {
|
|
91
|
+
if (endTime === undefined) return 'running'
|
|
92
|
+
const durationMs = Number(endTime - startTime) / 1000000 // Convert nanoseconds to milliseconds
|
|
93
|
+
if (durationMs < 1000) return `${durationMs.toFixed(0)}ms`
|
|
94
|
+
if (durationMs < 60000) return `${(durationMs / 1000).toFixed(2)}s`
|
|
95
|
+
return `${(durationMs / 60000).toFixed(2)}m`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getSpanName(span: Domain.ParentSpan): string {
|
|
99
|
+
if (span._tag === 'ExternalSpan') return `[external] ${span.spanId}`
|
|
100
|
+
return span.name
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getSpanStatus(span: Domain.ParentSpan): string {
|
|
104
|
+
if (span._tag === 'ExternalSpan') return '?'
|
|
105
|
+
if (span.status._tag === 'Ended') return '✓'
|
|
106
|
+
return '●'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getSpanDuration(span: Domain.ParentSpan): string {
|
|
110
|
+
if (span._tag === 'ExternalSpan') return ''
|
|
111
|
+
const endTime = span.status._tag === 'Ended' ? span.status.endTime : undefined
|
|
112
|
+
return formatDuration(span.status.startTime, endTime)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Filters a graph by keeping only nodes that match the predicate or have descendants that match.
|
|
117
|
+
* This ensures parent nodes are included even if they don't match, as long as they have matching descendants.
|
|
118
|
+
*
|
|
119
|
+
* @param graph - The graph to filter
|
|
120
|
+
* @param predicate - A function that tests whether a node should be included
|
|
121
|
+
* @returns A new filtered graph containing only matching nodes and their ancestors
|
|
122
|
+
*/
|
|
123
|
+
function filterGraphWithAncestors<N, E>(
|
|
124
|
+
graph: Graph.Graph<N, E>,
|
|
125
|
+
predicate: (nodeData: N, nodeId: number) => boolean,
|
|
126
|
+
): Graph.Graph<N, E> {
|
|
127
|
+
// Find all root nodes (nodes with no incoming edges)
|
|
128
|
+
const rootNodes = Array.from(Graph.indices(Graph.externals(graph, { direction: 'incoming' })))
|
|
129
|
+
|
|
130
|
+
const shouldInclude = new Map<number, boolean>()
|
|
131
|
+
|
|
132
|
+
// Use postorder DFS to evaluate children before parents
|
|
133
|
+
for (const rootId of rootNodes) {
|
|
134
|
+
for (const nodeId of Graph.indices(Graph.dfsPostOrder(graph, { startNodes: [rootId], direction: 'outgoing' }))) {
|
|
135
|
+
const node = Graph.getNode(graph, nodeId)
|
|
136
|
+
if (Option.isNone(node)) continue
|
|
137
|
+
|
|
138
|
+
const matchesPredicate = predicate(node.value, nodeId)
|
|
139
|
+
|
|
140
|
+
// Check if any children should be included
|
|
141
|
+
const children = Graph.neighborsDirected(graph, nodeId, 'outgoing')
|
|
142
|
+
const hasMatchingChildren = children.some((childId) => shouldInclude.get(childId) === true)
|
|
143
|
+
|
|
144
|
+
const include = matchesPredicate || hasMatchingChildren
|
|
145
|
+
shouldInclude.set(nodeId, include)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Create a filtered copy of the graph
|
|
150
|
+
return Graph.mutate(graph, (mutable) => {
|
|
151
|
+
for (const [nodeId] of mutable.nodes) {
|
|
152
|
+
if (shouldInclude.get(nodeId) === true) continue
|
|
153
|
+
Graph.removeNode(mutable, nodeId)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderNode(graph: SpanNodeGraph, nodeId: number, prefix = '', isLast = true): string[] {
|
|
159
|
+
const node = Graph.getNode(graph, nodeId)
|
|
160
|
+
if (Option.isNone(node)) return []
|
|
161
|
+
|
|
162
|
+
const info = node.value
|
|
163
|
+
const status = getSpanStatus(info.span)
|
|
164
|
+
const name = getSpanName(info.span)
|
|
165
|
+
const duration = getSpanDuration(info.span)
|
|
166
|
+
const durationStr = duration ? ` ${duration}` : ''
|
|
167
|
+
|
|
168
|
+
const connector = isLast ? '└─ ' : '├─ '
|
|
169
|
+
const lines: string[] = [`${prefix}${connector}${status} ${name}${durationStr}`]
|
|
170
|
+
|
|
171
|
+
// Get children
|
|
172
|
+
const children = Graph.neighborsDirected(graph, nodeId, 'outgoing')
|
|
173
|
+
const childCount = children.length
|
|
174
|
+
|
|
175
|
+
children.forEach((childId: number, index: number) => {
|
|
176
|
+
const isLastChild = index === childCount - 1
|
|
177
|
+
const childPrefix = prefix + (isLast ? ' ' : '│ ')
|
|
178
|
+
const childLines = renderNode(graph, childId, childPrefix, isLastChild)
|
|
179
|
+
lines.push(...childLines)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
return lines
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface ChromeTraceEvent {
|
|
186
|
+
name: string
|
|
187
|
+
cat: string
|
|
188
|
+
ph: string // Phase: 'B' for begin, 'E' for end
|
|
189
|
+
ts: number // Timestamp in microseconds
|
|
190
|
+
pid: number // Process ID
|
|
191
|
+
tid: number // Thread ID
|
|
192
|
+
args?: Record<string, unknown>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Converts a span node to Chrome Trace Events (Begin and End)
|
|
197
|
+
*/
|
|
198
|
+
function spanToTraceEvents(span: Domain.ParentSpan, _nodeId: number, processId = 1): ChromeTraceEvent[] {
|
|
199
|
+
const events: ChromeTraceEvent[] = []
|
|
200
|
+
|
|
201
|
+
if (span._tag === 'ExternalSpan') {
|
|
202
|
+
// External spans don't have timing info, skip them
|
|
203
|
+
return events
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const name = span.name
|
|
207
|
+
const threadId = 1 // Could use spanId hash or other identifier
|
|
208
|
+
|
|
209
|
+
// Begin event
|
|
210
|
+
const startTimeUs = Number(span.status.startTime) / 1000 // Convert nanoseconds to microseconds
|
|
211
|
+
events.push({
|
|
212
|
+
name,
|
|
213
|
+
cat: 'span',
|
|
214
|
+
ph: 'B',
|
|
215
|
+
ts: startTimeUs,
|
|
216
|
+
pid: processId,
|
|
217
|
+
tid: threadId,
|
|
218
|
+
args: {
|
|
219
|
+
spanId: span.spanId,
|
|
220
|
+
traceId: span.traceId,
|
|
221
|
+
attributes: Object.fromEntries(span.attributes),
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// End event (only if span has ended)
|
|
226
|
+
if (span.status._tag === 'Ended') {
|
|
227
|
+
const endTimeUs = Number(span.status.endTime) / 1000 // Convert nanoseconds to microseconds
|
|
228
|
+
events.push({
|
|
229
|
+
name,
|
|
230
|
+
cat: 'span',
|
|
231
|
+
ph: 'E',
|
|
232
|
+
ts: endTimeUs,
|
|
233
|
+
pid: processId,
|
|
234
|
+
tid: threadId,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return events
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Traverses the graph and collects all trace events
|
|
243
|
+
*/
|
|
244
|
+
function collectTraceEvents(graph: SpanNodeGraph, rootNodes: number[]): ChromeTraceEvent[] {
|
|
245
|
+
const events: ChromeTraceEvent[] = []
|
|
246
|
+
|
|
247
|
+
// Use DFS to traverse all nodes
|
|
248
|
+
for (const rootId of rootNodes) {
|
|
249
|
+
for (const nodeId of Graph.indices(Graph.dfs(graph, { startNodes: [rootId], direction: 'outgoing' }))) {
|
|
250
|
+
const node = Graph.getNode(graph, nodeId)
|
|
251
|
+
if (Option.isNone(node)) continue
|
|
252
|
+
|
|
253
|
+
const nodeEvents = spanToTraceEvents(node.value.span, nodeId)
|
|
254
|
+
events.push(...nodeEvents)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Sort events by timestamp
|
|
259
|
+
events.sort((a, b) => a.ts - b.ts)
|
|
260
|
+
|
|
261
|
+
return events
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface LogTreeOptions {
|
|
265
|
+
readonly regex?: RegExp
|
|
266
|
+
readonly title?: string
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export const logTree = (options: LogTreeOptions = {}) =>
|
|
270
|
+
Effect.gen(function* () {
|
|
271
|
+
const maybeInfo = yield* Effect.serviceOption(DebugInfo)
|
|
272
|
+
if (Option.isNone(maybeInfo))
|
|
273
|
+
return yield* Console.log(
|
|
274
|
+
'(no debug info provided! To show the tree, provide the layerDebug layer in the root of your program)',
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const { mutableGraph } = maybeInfo.value
|
|
278
|
+
const graph = Graph.endMutation(mutableGraph)
|
|
279
|
+
|
|
280
|
+
// Find root nodes (nodes with no incoming edges) using externals
|
|
281
|
+
const rootNodes = Array.from(Graph.indices(Graph.externals(graph, { direction: 'incoming' })))
|
|
282
|
+
|
|
283
|
+
if (rootNodes.length === 0) {
|
|
284
|
+
return yield* Console.log('(empty trace)')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Apply filter to create a filtered copy of the graph
|
|
288
|
+
const filteredGraph = options.regex
|
|
289
|
+
? filterGraphWithAncestors(graph, (nodeData, _nodeId) => {
|
|
290
|
+
const name = getSpanName(nodeData.span)
|
|
291
|
+
return options.regex!.test(name)
|
|
292
|
+
})
|
|
293
|
+
: graph
|
|
294
|
+
|
|
295
|
+
// Find root nodes in the filtered graph
|
|
296
|
+
const filteredRootNodes = Array.from(Graph.indices(Graph.externals(filteredGraph, { direction: 'incoming' })))
|
|
297
|
+
|
|
298
|
+
if (filteredRootNodes.length === 0) {
|
|
299
|
+
return yield* Console.log(options.title ? `${options.title}\n(no matches)` : '(no matches)')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const lines: string[] = []
|
|
303
|
+
|
|
304
|
+
// Add title if provided
|
|
305
|
+
if (options.title) {
|
|
306
|
+
lines.push(options.title)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Render root nodes using the same logic as regular children
|
|
310
|
+
const rootCount = filteredRootNodes.length
|
|
311
|
+
filteredRootNodes.forEach((rootId: number, index: number) => {
|
|
312
|
+
const isLastRoot = index === rootCount - 1
|
|
313
|
+
const rootLines = renderNode(filteredGraph, rootId, '', isLastRoot)
|
|
314
|
+
lines.push(...rootLines)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
return yield* Console.log(lines.join('\n'))
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
export interface LogPerformanceTraceOptions {
|
|
321
|
+
readonly regex?: RegExp
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Logs the span tree in Chrome Performance Trace Event Format.
|
|
326
|
+
* The output can be loaded in Chrome DevTools Performance tab or chrome://tracing
|
|
327
|
+
*
|
|
328
|
+
* @see https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview
|
|
329
|
+
*/
|
|
330
|
+
export const logPerformanceTrace = (options: LogPerformanceTraceOptions = {}) =>
|
|
331
|
+
Effect.gen(function* () {
|
|
332
|
+
const maybeInfo = yield* Effect.serviceOption(DebugInfo)
|
|
333
|
+
if (Option.isNone(maybeInfo)) {
|
|
334
|
+
return yield* Console.log(
|
|
335
|
+
'(no debug info provided! To show the trace, provide the layerDebug layer in the root of your program)',
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const { mutableGraph } = maybeInfo.value
|
|
340
|
+
const graph = Graph.endMutation(mutableGraph)
|
|
341
|
+
|
|
342
|
+
// Find root nodes (nodes with no incoming edges) using externals
|
|
343
|
+
const rootNodes = Array.from(Graph.indices(Graph.externals(graph, { direction: 'incoming' })))
|
|
344
|
+
|
|
345
|
+
if (rootNodes.length === 0) {
|
|
346
|
+
return yield* Console.log('[]')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Apply filter if provided
|
|
350
|
+
const filteredGraph = options.regex
|
|
351
|
+
? filterGraphWithAncestors(graph, (nodeData, _nodeId) => {
|
|
352
|
+
const name = getSpanName(nodeData.span)
|
|
353
|
+
return options.regex!.test(name)
|
|
354
|
+
})
|
|
355
|
+
: graph
|
|
356
|
+
|
|
357
|
+
// Find root nodes in the filtered graph
|
|
358
|
+
const filteredRootNodes = Array.from(Graph.indices(Graph.externals(filteredGraph, { direction: 'incoming' })))
|
|
359
|
+
|
|
360
|
+
// Collect all trace events
|
|
361
|
+
const events = collectTraceEvents(filteredGraph, filteredRootNodes)
|
|
362
|
+
|
|
363
|
+
// Output as compact JSON array
|
|
364
|
+
const json = JSON.stringify(events)
|
|
365
|
+
return yield* Console.log(json)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
export const logScopeState = ({ label }: { label: string }) =>
|
|
369
|
+
Effect.gen(function* () {
|
|
370
|
+
const scope = yield* Effect.scope
|
|
371
|
+
yield* Effect.log(`scope.state[${label}]`, (scope as any).state._tag).pipe(
|
|
372
|
+
Effect.repeat({ schedule: Schedule.fixed(Duration.millis(100)) }),
|
|
373
|
+
Effect.forkScoped,
|
|
374
|
+
)
|
|
375
|
+
})
|
package/src/effect/mod.ts
CHANGED
|
@@ -141,6 +141,7 @@ export { TreeFormatter } from 'effect/ParseResult'
|
|
|
141
141
|
export type { Serializable, SerializableWithResult } from 'effect/Schema'
|
|
142
142
|
export * as SchemaAST from 'effect/SchemaAST'
|
|
143
143
|
export * as BucketQueue from './BucketQueue.ts'
|
|
144
|
+
export * as Debug from './Debug.ts'
|
|
144
145
|
export * as Effect from './Effect.ts'
|
|
145
146
|
export * from './Error.ts'
|
|
146
147
|
export * as Logger from './Logger.ts'
|
package/src/mod.ts
CHANGED