@kitlangton/motel 0.2.0 → 0.2.4
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/AGENTS.md +5 -0
- package/package.json +7 -5
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +213 -6
- package/src/daemon.ts +174 -38
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +114 -128
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +68 -0
- package/src/services/TelemetryStore.ts +262 -119
- package/src/services/TraceQueryService.ts +3 -1
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +28 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +11 -28
- package/src/ui/Waterfall.tsx +43 -148
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +288 -124
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +174 -40
- package/src/ui/atoms.ts +131 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +27 -13
- package/src/ui/state.ts +4 -199
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +552 -364
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
package/src/motelClient.ts
CHANGED
|
@@ -13,6 +13,7 @@ export class MotelHttpError extends Error {
|
|
|
13
13
|
|
|
14
14
|
type QueryValue = string | number | boolean | null | undefined
|
|
15
15
|
type Query = Readonly<Record<string, QueryValue>>
|
|
16
|
+
type AttributeFilters = Readonly<Record<string, string>>
|
|
16
17
|
|
|
17
18
|
const appendQuery = (url: URL, query: Query | undefined) => {
|
|
18
19
|
if (!query) return url
|
|
@@ -23,14 +24,20 @@ const appendQuery = (url: URL, query: Query | undefined) => {
|
|
|
23
24
|
return url
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
const appendAttributes = (url: URL,
|
|
27
|
+
const appendAttributes = (url: URL, prefix: "attr" | "attrContains", attributes: AttributeFilters | undefined) => {
|
|
27
28
|
if (!attributes) return url
|
|
28
29
|
for (const [key, value] of Object.entries(attributes)) {
|
|
29
|
-
url.searchParams.set(
|
|
30
|
+
url.searchParams.set(`${prefix}.${key}`, value)
|
|
30
31
|
}
|
|
31
32
|
return url
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
const appendAllAttributes = (
|
|
36
|
+
url: URL,
|
|
37
|
+
attributes: AttributeFilters | undefined,
|
|
38
|
+
attributeContains: AttributeFilters | undefined,
|
|
39
|
+
) => appendAttributes(appendAttributes(url, "attr", attributes), "attrContains", attributeContains)
|
|
40
|
+
|
|
34
41
|
export type SearchTracesInput = {
|
|
35
42
|
readonly service?: string
|
|
36
43
|
readonly operation?: string
|
|
@@ -39,18 +46,33 @@ export type SearchTracesInput = {
|
|
|
39
46
|
readonly lookback?: string
|
|
40
47
|
readonly limit?: number
|
|
41
48
|
readonly cursor?: string
|
|
42
|
-
readonly attributes?:
|
|
49
|
+
readonly attributes?: AttributeFilters
|
|
50
|
+
readonly attributeContains?: AttributeFilters
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type SearchSpansInput = {
|
|
54
|
+
readonly service?: string
|
|
55
|
+
readonly traceId?: string
|
|
56
|
+
readonly operation?: string
|
|
57
|
+
readonly parentOperation?: string
|
|
58
|
+
readonly status?: "ok" | "error"
|
|
59
|
+
readonly lookback?: string
|
|
60
|
+
readonly limit?: number
|
|
61
|
+
readonly attributes?: AttributeFilters
|
|
62
|
+
readonly attributeContains?: AttributeFilters
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
export type SearchLogsInput = {
|
|
46
66
|
readonly service?: string
|
|
67
|
+
readonly severity?: string
|
|
47
68
|
readonly traceId?: string
|
|
48
69
|
readonly spanId?: string
|
|
49
70
|
readonly body?: string
|
|
50
71
|
readonly lookback?: string
|
|
51
72
|
readonly limit?: number
|
|
52
73
|
readonly cursor?: string
|
|
53
|
-
readonly attributes?:
|
|
74
|
+
readonly attributes?: AttributeFilters
|
|
75
|
+
readonly attributeContains?: AttributeFilters
|
|
54
76
|
}
|
|
55
77
|
|
|
56
78
|
export type TraceStatsInput = {
|
|
@@ -62,7 +84,7 @@ export type TraceStatsInput = {
|
|
|
62
84
|
readonly minDurationMs?: number
|
|
63
85
|
readonly lookback?: string
|
|
64
86
|
readonly limit?: number
|
|
65
|
-
readonly attributes?:
|
|
87
|
+
readonly attributes?: AttributeFilters
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
export type LogStatsInput = {
|
|
@@ -73,32 +95,77 @@ export type LogStatsInput = {
|
|
|
73
95
|
readonly body?: string
|
|
74
96
|
readonly lookback?: string
|
|
75
97
|
readonly limit?: number
|
|
76
|
-
readonly attributes?:
|
|
98
|
+
readonly attributes?: AttributeFilters
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
export type FacetsInput = {
|
|
80
102
|
readonly type: "traces" | "logs"
|
|
81
103
|
readonly field: string
|
|
104
|
+
readonly key?: string
|
|
82
105
|
readonly service?: string
|
|
83
106
|
readonly lookback?: string
|
|
84
107
|
readonly limit?: number
|
|
85
108
|
}
|
|
86
109
|
|
|
110
|
+
export type TraceLogOptions = {
|
|
111
|
+
readonly lookback?: string
|
|
112
|
+
readonly limit?: number
|
|
113
|
+
readonly cursor?: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type AiCallSearchInput = {
|
|
117
|
+
readonly service?: string
|
|
118
|
+
readonly traceId?: string
|
|
119
|
+
readonly sessionId?: string
|
|
120
|
+
readonly functionId?: string
|
|
121
|
+
readonly provider?: string
|
|
122
|
+
readonly model?: string
|
|
123
|
+
readonly operation?: string
|
|
124
|
+
readonly status?: "ok" | "error"
|
|
125
|
+
readonly minDurationMs?: number
|
|
126
|
+
readonly text?: string
|
|
127
|
+
readonly lookback?: string
|
|
128
|
+
readonly limit?: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type AiCallStatsInput = {
|
|
132
|
+
readonly groupBy: "provider" | "model" | "functionId" | "sessionId" | "status"
|
|
133
|
+
readonly agg: "count" | "avg_duration" | "p95_duration" | "total_input_tokens" | "total_output_tokens"
|
|
134
|
+
readonly service?: string
|
|
135
|
+
readonly traceId?: string
|
|
136
|
+
readonly sessionId?: string
|
|
137
|
+
readonly functionId?: string
|
|
138
|
+
readonly provider?: string
|
|
139
|
+
readonly model?: string
|
|
140
|
+
readonly operation?: string
|
|
141
|
+
readonly status?: "ok" | "error"
|
|
142
|
+
readonly minDurationMs?: number
|
|
143
|
+
readonly lookback?: string
|
|
144
|
+
readonly limit?: number
|
|
145
|
+
}
|
|
146
|
+
|
|
87
147
|
export class MotelClient extends Context.Service<
|
|
88
148
|
MotelClient,
|
|
89
149
|
{
|
|
90
150
|
readonly searchTraces: (input: SearchTracesInput) => Effect.Effect<unknown, MotelHttpError>
|
|
151
|
+
readonly searchSpans: (input: SearchSpansInput) => Effect.Effect<unknown, MotelHttpError>
|
|
91
152
|
readonly getTrace: (traceId: string) => Effect.Effect<unknown, MotelHttpError>
|
|
92
|
-
readonly
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
) => Effect.Effect<unknown, MotelHttpError>
|
|
153
|
+
readonly getTraceSpans: (traceId: string) => Effect.Effect<unknown, MotelHttpError>
|
|
154
|
+
readonly getTraceLogs: (traceId: string, options: TraceLogOptions) => Effect.Effect<unknown, MotelHttpError>
|
|
155
|
+
readonly getSpan: (spanId: string) => Effect.Effect<unknown, MotelHttpError>
|
|
156
|
+
readonly getSpanLogs: (spanId: string, options: TraceLogOptions) => Effect.Effect<unknown, MotelHttpError>
|
|
96
157
|
readonly searchLogs: (input: SearchLogsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
158
|
+
readonly searchAiCalls: (input: AiCallSearchInput) => Effect.Effect<unknown, MotelHttpError>
|
|
159
|
+
readonly getAiCall: (spanId: string) => Effect.Effect<unknown, MotelHttpError>
|
|
160
|
+
readonly aiCallStats: (input: AiCallStatsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
97
161
|
readonly traceStats: (input: TraceStatsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
98
162
|
readonly logStats: (input: LogStatsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
99
163
|
readonly facets: (input: FacetsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
100
164
|
readonly services: Effect.Effect<unknown, MotelHttpError>
|
|
101
165
|
readonly health: Effect.Effect<unknown, MotelHttpError>
|
|
166
|
+
readonly docs: Effect.Effect<unknown, MotelHttpError>
|
|
167
|
+
readonly getDoc: (name: string) => Effect.Effect<string, MotelHttpError>
|
|
168
|
+
readonly openapi: Effect.Effect<unknown, MotelHttpError>
|
|
102
169
|
}
|
|
103
170
|
>()("motel/MotelClient") {}
|
|
104
171
|
|
|
@@ -107,13 +174,18 @@ export const MotelClientLive = Layer.effect(
|
|
|
107
174
|
Effect.gen(function* () {
|
|
108
175
|
const locator = yield* Locator
|
|
109
176
|
|
|
110
|
-
const get = <A = unknown>(
|
|
177
|
+
const get = <A = unknown>(
|
|
178
|
+
path: string,
|
|
179
|
+
query?: Query,
|
|
180
|
+
attributes?: AttributeFilters,
|
|
181
|
+
attributeContains?: AttributeFilters,
|
|
182
|
+
) =>
|
|
111
183
|
Effect.gen(function* () {
|
|
112
184
|
const { url } = yield* Effect.mapError(
|
|
113
185
|
locator.resolve,
|
|
114
186
|
(err) => new MotelHttpError(0, err.message),
|
|
115
187
|
)
|
|
116
|
-
const target =
|
|
188
|
+
const target = appendAllAttributes(appendQuery(new URL(path, url), query), attributes, attributeContains)
|
|
117
189
|
return yield* Effect.tryPromise({
|
|
118
190
|
try: async () => {
|
|
119
191
|
const res = await fetch(target, { signal: AbortSignal.timeout(5000) })
|
|
@@ -138,10 +210,23 @@ export const MotelClientLive = Layer.effect(
|
|
|
138
210
|
lookback: input.lookback,
|
|
139
211
|
limit: input.limit,
|
|
140
212
|
cursor: input.cursor,
|
|
141
|
-
}, input.attributes),
|
|
213
|
+
}, input.attributes, input.attributeContains),
|
|
214
|
+
|
|
215
|
+
searchSpans: (input) =>
|
|
216
|
+
get("/api/spans/search", {
|
|
217
|
+
service: input.service,
|
|
218
|
+
traceId: input.traceId,
|
|
219
|
+
operation: input.operation,
|
|
220
|
+
parentOperation: input.parentOperation,
|
|
221
|
+
status: input.status,
|
|
222
|
+
lookback: input.lookback,
|
|
223
|
+
limit: input.limit,
|
|
224
|
+
}, input.attributes, input.attributeContains),
|
|
142
225
|
|
|
143
226
|
getTrace: (traceId) => get(`/api/traces/${encodeURIComponent(traceId)}`),
|
|
144
227
|
|
|
228
|
+
getTraceSpans: (traceId) => get(`/api/traces/${encodeURIComponent(traceId)}/spans`),
|
|
229
|
+
|
|
145
230
|
getTraceLogs: (traceId, options) =>
|
|
146
231
|
get(`/api/traces/${encodeURIComponent(traceId)}/logs`, {
|
|
147
232
|
lookback: options.lookback,
|
|
@@ -149,16 +234,61 @@ export const MotelClientLive = Layer.effect(
|
|
|
149
234
|
cursor: options.cursor,
|
|
150
235
|
}),
|
|
151
236
|
|
|
237
|
+
getSpan: (spanId) => get(`/api/spans/${encodeURIComponent(spanId)}`),
|
|
238
|
+
|
|
239
|
+
getSpanLogs: (spanId, options) =>
|
|
240
|
+
get(`/api/spans/${encodeURIComponent(spanId)}/logs`, {
|
|
241
|
+
lookback: options.lookback,
|
|
242
|
+
limit: options.limit,
|
|
243
|
+
cursor: options.cursor,
|
|
244
|
+
}),
|
|
245
|
+
|
|
152
246
|
searchLogs: (input) =>
|
|
153
247
|
get("/api/logs/search", {
|
|
154
248
|
service: input.service,
|
|
249
|
+
severity: input.severity,
|
|
155
250
|
traceId: input.traceId,
|
|
156
251
|
spanId: input.spanId,
|
|
157
252
|
body: input.body,
|
|
158
253
|
lookback: input.lookback,
|
|
159
254
|
limit: input.limit,
|
|
160
255
|
cursor: input.cursor,
|
|
161
|
-
}, input.attributes),
|
|
256
|
+
}, input.attributes, input.attributeContains),
|
|
257
|
+
|
|
258
|
+
searchAiCalls: (input) =>
|
|
259
|
+
get("/api/ai/calls", {
|
|
260
|
+
service: input.service,
|
|
261
|
+
traceId: input.traceId,
|
|
262
|
+
sessionId: input.sessionId,
|
|
263
|
+
functionId: input.functionId,
|
|
264
|
+
provider: input.provider,
|
|
265
|
+
model: input.model,
|
|
266
|
+
operation: input.operation,
|
|
267
|
+
status: input.status,
|
|
268
|
+
minDurationMs: input.minDurationMs,
|
|
269
|
+
text: input.text,
|
|
270
|
+
lookback: input.lookback,
|
|
271
|
+
limit: input.limit,
|
|
272
|
+
}),
|
|
273
|
+
|
|
274
|
+
getAiCall: (spanId) => get(`/api/ai/calls/${encodeURIComponent(spanId)}`),
|
|
275
|
+
|
|
276
|
+
aiCallStats: (input) =>
|
|
277
|
+
get("/api/ai/stats", {
|
|
278
|
+
groupBy: input.groupBy,
|
|
279
|
+
agg: input.agg,
|
|
280
|
+
service: input.service,
|
|
281
|
+
traceId: input.traceId,
|
|
282
|
+
sessionId: input.sessionId,
|
|
283
|
+
functionId: input.functionId,
|
|
284
|
+
provider: input.provider,
|
|
285
|
+
model: input.model,
|
|
286
|
+
operation: input.operation,
|
|
287
|
+
status: input.status,
|
|
288
|
+
minDurationMs: input.minDurationMs,
|
|
289
|
+
lookback: input.lookback,
|
|
290
|
+
limit: input.limit,
|
|
291
|
+
}),
|
|
162
292
|
|
|
163
293
|
traceStats: (input) =>
|
|
164
294
|
get("/api/traces/stats", {
|
|
@@ -188,6 +318,7 @@ export const MotelClientLive = Layer.effect(
|
|
|
188
318
|
get("/api/facets", {
|
|
189
319
|
type: input.type,
|
|
190
320
|
field: input.field,
|
|
321
|
+
key: input.key,
|
|
191
322
|
service: input.service,
|
|
192
323
|
lookback: input.lookback,
|
|
193
324
|
limit: input.limit,
|
|
@@ -196,6 +327,27 @@ export const MotelClientLive = Layer.effect(
|
|
|
196
327
|
services: get("/api/services"),
|
|
197
328
|
|
|
198
329
|
health: get("/api/health"),
|
|
330
|
+
|
|
331
|
+
docs: get("/api/docs"),
|
|
332
|
+
|
|
333
|
+
getDoc: (name) =>
|
|
334
|
+
Effect.gen(function* () {
|
|
335
|
+
const { url } = yield* Effect.mapError(locator.resolve, (err) => new MotelHttpError(0, err.message))
|
|
336
|
+
return yield* Effect.tryPromise({
|
|
337
|
+
try: async () => {
|
|
338
|
+
const res = await fetch(new URL(`/api/docs/${encodeURIComponent(name)}`, url), {
|
|
339
|
+
signal: AbortSignal.timeout(5000),
|
|
340
|
+
})
|
|
341
|
+
const body = await res.text()
|
|
342
|
+
if (!res.ok) throw new MotelHttpError(res.status, body)
|
|
343
|
+
return body
|
|
344
|
+
},
|
|
345
|
+
catch: (err) =>
|
|
346
|
+
err instanceof MotelHttpError ? err : new MotelHttpError(0, (err as Error).message),
|
|
347
|
+
}).pipe(Effect.tapError((err) => (err.status === 0 ? locator.invalidate : Effect.void)))
|
|
348
|
+
}),
|
|
349
|
+
|
|
350
|
+
openapi: get("/openapi.json"),
|
|
199
351
|
}
|
|
200
352
|
}),
|
|
201
353
|
)
|
package/src/registry.ts
CHANGED
|
@@ -16,23 +16,18 @@ export type RegistryEntry = {
|
|
|
16
16
|
readonly workdir: string
|
|
17
17
|
readonly startedAt: string
|
|
18
18
|
readonly version: string
|
|
19
|
+
/**
|
|
20
|
+
* The SQLite database path the daemon is serving. Optional because
|
|
21
|
+
* older daemon builds omit it; consumers should treat a missing
|
|
22
|
+
* value as "unknown" and fall back to whatever validation path
|
|
23
|
+
* they would have used before this field existed (typically an
|
|
24
|
+
* HTTP /api/health probe).
|
|
25
|
+
*/
|
|
26
|
+
readonly databasePath?: string
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
const entryPath = (pid: number) => path.join(registryDir(), `${pid}.json`)
|
|
22
30
|
|
|
23
|
-
let currentEntryPath: string | null = null
|
|
24
|
-
let signalHandlersRegistered = false
|
|
25
|
-
|
|
26
|
-
const cleanup = () => {
|
|
27
|
-
if (!currentEntryPath) return
|
|
28
|
-
try {
|
|
29
|
-
fs.unlinkSync(currentEntryPath)
|
|
30
|
-
} catch {
|
|
31
|
-
// already gone — ignore
|
|
32
|
-
}
|
|
33
|
-
currentEntryPath = null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
31
|
export const isAlive = (pid: number): boolean => {
|
|
37
32
|
try {
|
|
38
33
|
process.kill(pid, 0)
|
|
@@ -72,15 +67,23 @@ export const writeRegistryEntry = (entry: RegistryEntry) => {
|
|
|
72
67
|
fs.mkdirSync(registryDir(), { recursive: true })
|
|
73
68
|
const file = entryPath(entry.pid)
|
|
74
69
|
fs.writeFileSync(file, JSON.stringify(entry, null, 2), "utf8")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove this daemon's registry entry. Intended to be called from a
|
|
74
|
+
* Layer release so the scope-managed server shutdown removes the entry
|
|
75
|
+
* in the same finalizer chain that stops the socket. Historically this
|
|
76
|
+
* was done via ad-hoc process-signal handlers installed here that ran
|
|
77
|
+
* `process.exit(0)` — which races with the Effect runtime's own SIGINT
|
|
78
|
+
* handling and short-circuits the Bun server's graceful stop. The
|
|
79
|
+
* server (via BunRuntime.runMain) now owns signal handling; registry
|
|
80
|
+
* cleanup rides along on scope release.
|
|
81
|
+
*/
|
|
82
|
+
export const removeRegistryEntry = (pid: number) => {
|
|
83
|
+
try {
|
|
84
|
+
fs.unlinkSync(entryPath(pid))
|
|
85
|
+
} catch {
|
|
86
|
+
// Already gone — another cleanup path won the race, or the entry
|
|
87
|
+
// was never written.
|
|
85
88
|
}
|
|
86
89
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
|
|
|
6
6
|
import { Layer, ManagedRuntime } from "effect"
|
|
7
7
|
import { config } from "./config.js"
|
|
8
8
|
import { LogQueryServiceLive } from "./services/LogQueryService.js"
|
|
9
|
-
import { TelemetryStoreLive } from "./services/TelemetryStore.js"
|
|
9
|
+
import { TelemetryStoreLive, TelemetryStoreReadonlyLive } from "./services/TelemetryStore.js"
|
|
10
10
|
import { TraceQueryServiceLive } from "./services/TraceQueryService.js"
|
|
11
11
|
|
|
12
12
|
const telemetryLayer = NodeSdk.layer(() => ({
|
|
@@ -30,9 +30,15 @@ const telemetryLayer = NodeSdk.layer(() => ({
|
|
|
30
30
|
},
|
|
31
31
|
}))
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// TUI-side services are readonly — a daemon/worker writer owns the DB
|
|
34
|
+
// lock while ingests are in flight, and trying to grab the write lock
|
|
35
|
+
// for schema init on startup causes "database is locked" on bun dev.
|
|
36
|
+
const QueryServicesLive = Layer.mergeAll(TraceQueryServiceLive, LogQueryServiceLive).pipe(Layer.provideMerge(TelemetryStoreReadonlyLive))
|
|
34
37
|
|
|
35
38
|
const QueryRuntimeLive = config.otel.enabled ? Layer.mergeAll(QueryServicesLive, telemetryLayer) : QueryServicesLive
|
|
36
39
|
|
|
37
40
|
export const queryRuntime = ManagedRuntime.make(QueryRuntimeLive)
|
|
41
|
+
// `storeRuntime` is the full writer runtime, exposed for the telemetry
|
|
42
|
+
// test suite (and any future tooling that needs the ingest side). The
|
|
43
|
+
// TUI itself only consumes `queryRuntime`, which is readonly.
|
|
38
44
|
export const storeRuntime = ManagedRuntime.make(TelemetryStoreLive)
|
package/src/server.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { BunRuntime } from "@effect/platform-bun"
|
|
2
|
+
import { Layer } from "effect"
|
|
3
|
+
import { ServerLive } from "./localServer.js"
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
// `BunRuntime.runMain` installs signal handlers that interrupt the root
|
|
6
|
+
// fiber on SIGINT/SIGTERM; `Layer.launch` holds the scope open until
|
|
7
|
+
// then. On interruption the scope closes top-down: RegistryLayer's
|
|
8
|
+
// release removes the daemon's registry entry, BunHttpServer's release
|
|
9
|
+
// calls server.stop(), SQLite connections close — all through layer
|
|
10
|
+
// finalizers instead of ad-hoc process.exit handlers.
|
|
11
|
+
Layer.launch(ServerLive).pipe(BunRuntime.runMain)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-thread client for the telemetry worker's ingest RPCs.
|
|
3
|
+
*
|
|
4
|
+
* The HTTP handlers for POST /v1/traces and POST /v1/logs call into
|
|
5
|
+
* this service instead of `TelemetryStore.ingestTraces/Logs`. Each
|
|
6
|
+
* method sends a typed message to the worker, awaits the reply, and
|
|
7
|
+
* returns the worker's result as an Effect. While the worker is
|
|
8
|
+
* serialising a big batch into SQLite, the main thread's event loop
|
|
9
|
+
* is FREE to answer /api/* queries — that's the whole point of the
|
|
10
|
+
* offload. Without this, /api/health and friends queued behind long
|
|
11
|
+
* ingests and reported p95 latencies of 3-5 seconds; after, they
|
|
12
|
+
* stay responsive regardless of ingest load.
|
|
13
|
+
*
|
|
14
|
+
* The worker is spawned as a scope'd resource inside the layer. The
|
|
15
|
+
* protocol pool is sized at 1 because SQLite only supports a single
|
|
16
|
+
* writer at a time anyway — running N concurrent workers would just
|
|
17
|
+
* queue them on SQLite's lock. When the outer scope closes (server
|
|
18
|
+
* shutdown), `BunWorker.layer`'s finalizer sends a close message and
|
|
19
|
+
* terminates the worker if it doesn't exit gracefully in 5s.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as BunWorker from "@effect/platform-bun/BunWorker"
|
|
23
|
+
import { Context, Effect, Layer, Scope } from "effect"
|
|
24
|
+
import * as RpcClient from "effect/unstable/rpc/RpcClient"
|
|
25
|
+
import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"
|
|
26
|
+
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
|
|
27
|
+
import type { WorkerError } from "effect/unstable/workers/WorkerError"
|
|
28
|
+
import { IngestRpcs } from "./ingestRpc.ts"
|
|
29
|
+
|
|
30
|
+
// RpcClient.make always surfaces RpcClientError in addition to the
|
|
31
|
+
// group's declared errors (transport failures, worker crashes, etc.),
|
|
32
|
+
// so the service shape has to mirror that. Without the explicit error
|
|
33
|
+
// type param, TS treats the declared and observed client types as
|
|
34
|
+
// unrelated structural mismatches.
|
|
35
|
+
export class AsyncIngest extends Context.Service<
|
|
36
|
+
AsyncIngest,
|
|
37
|
+
RpcClient.FromGroup<typeof IngestRpcs, RpcClientError | WorkerError>
|
|
38
|
+
>()("@motel/AsyncIngest") {}
|
|
39
|
+
|
|
40
|
+
// Protocol: RpcClient.layerProtocolWorker manages a worker pool and
|
|
41
|
+
// speaks msgpack over structured-clone messages. `size: 1` matches
|
|
42
|
+
// SQLite's single-writer constraint.
|
|
43
|
+
const WorkerProtocol = RpcClient.layerProtocolWorker({ size: 1 }).pipe(
|
|
44
|
+
Layer.provide(RpcSerialization.layerMsgPack),
|
|
45
|
+
Layer.provide(
|
|
46
|
+
BunWorker.layer(() => new Worker(new URL("./telemetryWorker.ts", import.meta.url))),
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
export const AsyncIngestLive = Layer.effect(
|
|
51
|
+
AsyncIngest,
|
|
52
|
+
Effect.gen(function*() {
|
|
53
|
+
const scope = yield* Scope.Scope
|
|
54
|
+
// Keep daemon startup cheap: creating the RPC client here would eagerly
|
|
55
|
+
// spawn the worker and make /api/health wait on the worker's SQLite
|
|
56
|
+
// bootstrap. Cache a lazy initializer instead so the worker only starts
|
|
57
|
+
// on the first ingest request, but is still shared thereafter.
|
|
58
|
+
const getClient = yield* RpcClient.make(IngestRpcs).pipe(
|
|
59
|
+
Effect.provide(WorkerProtocol),
|
|
60
|
+
Effect.cached,
|
|
61
|
+
)
|
|
62
|
+
const withScope = <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, Scope.Scope, scope)
|
|
63
|
+
return {
|
|
64
|
+
ingestTraces: (input, options) => Effect.flatMap(withScope(getClient), (client) => client.ingestTraces(input, options)),
|
|
65
|
+
ingestLogs: (input, options) => Effect.flatMap(withScope(getClient), (client) => client.ingestLogs(input, options)),
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
)
|