@aryasaatvik/motel 0.3.5 → 0.4.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/AGENTS.md +4 -1
- package/README.md +51 -0
- package/package.json +1 -1
- package/src/config.ts +3 -0
- package/src/daemon.ts +69 -35
- package/src/httpApi.ts +28 -9
- package/src/ingestReadiness.test.ts +160 -0
- package/src/ingestReadiness.ts +100 -0
- package/src/localServer.ts +29 -14
- package/src/queryHttp.test.ts +34 -0
- package/src/runtime.ts +4 -2
- package/src/services/AsyncIngest.ts +36 -16
- package/src/services/TelemetryQuery.ts +18 -31
- package/src/services/TelemetryStore.ts +160 -259
- package/src/services/ingestRpc.ts +2 -2
- package/src/services/queryRpc.ts +12 -12
- package/src/services/queryScheduler.test.ts +102 -0
- package/src/services/queryScheduler.ts +157 -0
- package/src/services/retention.test.ts +170 -0
- package/src/services/retention.ts +158 -0
- package/src/services/telemetryQueryProcess.ts +10 -0
- package/src/services/telemetryQueryWorker.ts +25 -26
- package/src/services/telemetryWorker.ts +9 -1
- package/src/telemetry.test.ts +34 -1
- package/src/ui/app/useTraceScreenData.ts +11 -17
- package/src/ui/cachedLoader.test.ts +20 -0
- package/src/ui/cachedLoader.ts +1 -2
- package/src/ui/loaders.ts +17 -6
- package/web/dist/assets/index-CN7ClqOl.js +21 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-5mTXbtNw.js +0 -21
|
@@ -1,32 +1,31 @@
|
|
|
1
1
|
import { BunRuntime } from "@effect/platform-bun"
|
|
2
|
-
import
|
|
3
|
-
import { Effect, Layer } from "effect"
|
|
4
|
-
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
|
|
5
|
-
import * as RpcServer from "effect/unstable/rpc/RpcServer"
|
|
2
|
+
import { Effect, Schema } from "effect"
|
|
6
3
|
import { TelemetryStoreQueryWorkerLive, TelemetryStoreReadonly, type TelemetryStoreReader } from "./TelemetryStore.js"
|
|
7
|
-
import {
|
|
4
|
+
import { QueryRequest, type QueryReply } from "./queryRpc.js"
|
|
8
5
|
|
|
9
|
-
|
|
6
|
+
const send = (reply: QueryReply) => postMessage(reply)
|
|
10
7
|
|
|
11
|
-
|
|
8
|
+
// This thread owns only a readonly SQLite connection. Its enclosing process is
|
|
9
|
+
// retired on query deadlines; the ingestion worker is never part of that process.
|
|
10
|
+
Effect.scoped(Effect.gen(function*() {
|
|
12
11
|
const store = yield* TelemetryStoreReadonly
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
let busy = false
|
|
13
|
+
const receive = ({ data }: MessageEvent<unknown>) => {
|
|
14
|
+
const request = Schema.decodeUnknownSync(QueryRequest)(data)
|
|
15
|
+
if (!Object.hasOwn(store, request.method) || busy) {
|
|
16
|
+
send({ _tag: "error", id: request.id, message: "Invalid or concurrent query request" })
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
busy = true
|
|
20
|
+
const member = store[request.method as keyof TelemetryStoreReader]
|
|
21
|
+
const result = typeof member === "function" ? Reflect.apply(member, store, request.args) : member
|
|
22
|
+
void Effect.runPromise(result as Effect.Effect<unknown, Error>).then(
|
|
23
|
+
(value) => { busy = false; send({ _tag: "result", id: request.id, value }) },
|
|
24
|
+
(error) => { busy = false; send({ _tag: "error", id: request.id, message: String(error) }) },
|
|
25
|
+
)
|
|
21
26
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Layer.provide(RpcServer.layerProtocolWorkerRunner),
|
|
28
|
-
Layer.provide(RpcSerialization.layerMsgPack),
|
|
29
|
-
Layer.provide(BunWorkerRunner.layer),
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
Layer.launch(WorkerLive).pipe(BunRuntime.runMain)
|
|
27
|
+
addEventListener("message", receive)
|
|
28
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => removeEventListener("message", receive)))
|
|
29
|
+
send({ _tag: "ready" })
|
|
30
|
+
return yield* Effect.never
|
|
31
|
+
})).pipe(Effect.provide(TelemetryStoreQueryWorkerLive), BunRuntime.runMain)
|
|
@@ -20,9 +20,14 @@ import { Effect, Layer } from "effect"
|
|
|
20
20
|
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
|
|
21
21
|
import * as RpcServer from "effect/unstable/rpc/RpcServer"
|
|
22
22
|
import type { OtlpLogExportRequest, OtlpTraceExportRequest } from "../otlp.ts"
|
|
23
|
+
import { WriterDiagnostics, type WriterEvent } from "../ingestReadiness.ts"
|
|
23
24
|
import { IngestError, IngestRpcs } from "./ingestRpc.ts"
|
|
24
25
|
import { TelemetryStore, TelemetryStoreWorkerLive } from "./TelemetryStore.ts"
|
|
25
26
|
|
|
27
|
+
const channelName = process.env.MOTEL_INGEST_DIAGNOSTICS_CHANNEL
|
|
28
|
+
const channel = channelName ? new BroadcastChannel(channelName) : undefined
|
|
29
|
+
const publish = (event: WriterEvent) => channel?.postMessage(event)
|
|
30
|
+
|
|
26
31
|
// Wire the two RPC methods to the existing TelemetryStore service.
|
|
27
32
|
// The store's ingest methods already carry their own Effect.fn spans,
|
|
28
33
|
// so the worker-side traces show up correctly attributed — the RPC
|
|
@@ -32,13 +37,16 @@ import { TelemetryStore, TelemetryStoreWorkerLive } from "./TelemetryStore.ts"
|
|
|
32
37
|
const IngestHandlers = IngestRpcs.toLayer(
|
|
33
38
|
Effect.gen(function*() {
|
|
34
39
|
const store = yield* TelemetryStore
|
|
40
|
+
publish({ _tag: "ready" })
|
|
35
41
|
return {
|
|
36
42
|
ingestTraces: ({ payload }) =>
|
|
37
43
|
store.ingestTraces(payload as OtlpTraceExportRequest).pipe(
|
|
44
|
+
Effect.tap((result) => Effect.sync(() => publish({ _tag: "commit", records: result.insertedSpans, at: Date.now() }))),
|
|
38
45
|
Effect.mapError((cause) => new IngestError({ message: String(cause) })),
|
|
39
46
|
),
|
|
40
47
|
ingestLogs: ({ payload }) =>
|
|
41
48
|
store.ingestLogs(payload as OtlpLogExportRequest).pipe(
|
|
49
|
+
Effect.tap((result) => Effect.sync(() => publish({ _tag: "commit", records: result.insertedLogs, at: Date.now() }))),
|
|
42
50
|
Effect.mapError((cause) => new IngestError({ message: String(cause) })),
|
|
43
51
|
),
|
|
44
52
|
}
|
|
@@ -56,4 +64,4 @@ const WorkerLive = RpcServer.layer(IngestRpcs).pipe(
|
|
|
56
64
|
// BunRuntime.runMain installs signal handlers so the scope closes
|
|
57
65
|
// cleanly on termination; the BunHttpServer layer pattern from the
|
|
58
66
|
// main server carries over here.
|
|
59
|
-
Layer.launch(WorkerLive).pipe(BunRuntime.runMain)
|
|
67
|
+
Layer.launch(WorkerLive).pipe(Effect.provideService(WriterDiagnostics, publish), Effect.ensuring(Effect.sync(() => channel?.close())), BunRuntime.runMain)
|
package/src/telemetry.test.ts
CHANGED
|
@@ -326,6 +326,39 @@ describe("motel telemetry store", () => {
|
|
|
326
326
|
}
|
|
327
327
|
})
|
|
328
328
|
|
|
329
|
+
it("evicting traces stay hidden across public queries and reject late arrivals until cleanup completes", async () => {
|
|
330
|
+
const now = String(BigInt(Date.now()) * 1_000_000n)
|
|
331
|
+
const traceId = "eviction-visibility"
|
|
332
|
+
const trace = { resourceSpans: [{ resource: { attributes: [{ key: "service.name", value: { stringValue: "eviction-only" } }] }, scopeSpans: [{ spans: [{ traceId, spanId: "eviction-span", name: "ai.generateText", startTimeUnixNano: now, endTimeUnixNano: now }] }] }] }
|
|
333
|
+
const log = { resourceLogs: [{ resource: { attributes: [{ key: "service.name", value: { stringValue: "eviction-only" } }] }, scopeLogs: [{ logRecords: [{ traceId, spanId: "eviction-span", timeUnixNano: now, body: { stringValue: "hidden log" } }] }] }] }
|
|
334
|
+
await storeRuntime.runPromise(Effect.gen(function*() {
|
|
335
|
+
const store = yield* TelemetryStore
|
|
336
|
+
yield* store.ingestTraces(trace)
|
|
337
|
+
yield* store.ingestLogs(log)
|
|
338
|
+
const probe = new Database(dbPath)
|
|
339
|
+
try {
|
|
340
|
+
probe.query("INSERT INTO retention_traces VALUES (?)").run(traceId)
|
|
341
|
+
expect(yield* store.ingestTraces(trace)).toEqual({ insertedSpans: 0 })
|
|
342
|
+
expect(yield* store.ingestLogs(log)).toEqual({ insertedLogs: 0 })
|
|
343
|
+
expect(yield* store.searchSpans({ traceId })).toEqual([])
|
|
344
|
+
expect(yield* store.searchLogs({ traceId })).toEqual([])
|
|
345
|
+
expect(yield* store.searchTraces({ serviceName: "eviction-only" })).toEqual([])
|
|
346
|
+
expect(yield* store.searchAiCalls({ service: "eviction-only" })).toEqual([])
|
|
347
|
+
expect(yield* store.listServices).not.toContain("eviction-only")
|
|
348
|
+
expect(yield* store.getSpan("eviction-span")).toBeNull()
|
|
349
|
+
expect(yield* store.listFacets({ type: "traces", field: "service" })).not.toContainEqual({ value: "eviction-only", count: 1 })
|
|
350
|
+
yield* store.runRetentionNow
|
|
351
|
+
yield* store.runRetentionNow
|
|
352
|
+
expect(probe.query("SELECT 1 FROM retention_traces WHERE trace_id = ?").get(traceId)).toBeNull()
|
|
353
|
+
// Once deletion is complete, a later export can start a fresh trace as before.
|
|
354
|
+
expect(yield* store.ingestTraces(trace)).toEqual({ insertedSpans: 1 })
|
|
355
|
+
probe.query("INSERT INTO retention_traces VALUES (?)").run(traceId)
|
|
356
|
+
yield* store.runRetentionNow
|
|
357
|
+
yield* store.runRetentionNow
|
|
358
|
+
} finally { probe.close() }
|
|
359
|
+
}))
|
|
360
|
+
})
|
|
361
|
+
|
|
329
362
|
it("retention cleans orphan log indexes even when no logs are deleted", async () => {
|
|
330
363
|
const probe = new Database(dbPath)
|
|
331
364
|
try {
|
|
@@ -722,7 +755,7 @@ describe("motel telemetry store", () => {
|
|
|
722
755
|
WHERE severity_text = ? COLLATE NOCASE AND timestamp_ms >= ?
|
|
723
756
|
ORDER BY timestamp_ms DESC, id DESC LIMIT ?
|
|
724
757
|
`).all("INFO", 0, 80)
|
|
725
|
-
expect(plan.some((row) => row.detail.includes("
|
|
758
|
+
expect(plan.some((row) => row.detail.includes("idx_logs_severity_nocase_cursor (severity_text=?"))).toBe(true)
|
|
726
759
|
} finally {
|
|
727
760
|
probe.close()
|
|
728
761
|
}
|
|
@@ -146,7 +146,6 @@ export const useTraceScreenData = () => {
|
|
|
146
146
|
}, [parsedFilter.aiText])
|
|
147
147
|
|
|
148
148
|
const selectedTraceRef = useRef<string | null>(null)
|
|
149
|
-
const cacheEpochRef = useRef(0)
|
|
150
149
|
const traceDetailCacheRef = useRef(new Map<string, { data: TraceItem | null; fetchedAt: Date }>())
|
|
151
150
|
const traceLogCacheRef = useRef(new Map<string, { data: readonly LogItem[]; fetchedAt: Date }>())
|
|
152
151
|
const serviceLogCacheRef = useRef(new Map<string, { data: readonly LogItem[]; fetchedAt: Date }>())
|
|
@@ -164,12 +163,9 @@ export const useTraceScreenData = () => {
|
|
|
164
163
|
}, [autoRefresh, setRefreshNonce])
|
|
165
164
|
|
|
166
165
|
useEffect(() => {
|
|
167
|
-
cacheEpochRef.current += 1
|
|
168
166
|
traceDetailCacheRef.current.clear()
|
|
169
167
|
traceLogCacheRef.current.clear()
|
|
170
168
|
serviceLogCacheRef.current.clear()
|
|
171
|
-
traceDetailInflightRef.current.clear()
|
|
172
|
-
traceLogInflightRef.current.clear()
|
|
173
169
|
invalidateAiCallDetailCache()
|
|
174
170
|
}, [refreshNonce])
|
|
175
171
|
|
|
@@ -181,13 +177,15 @@ export const useTraceScreenData = () => {
|
|
|
181
177
|
// actually opens the picker.
|
|
182
178
|
useEffect(() => {
|
|
183
179
|
if (!selectedTraceService) return
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
180
|
+
let cancelled = false
|
|
181
|
+
void (async () => {
|
|
182
|
+
const entry = await ensureTraceAttributeKeys(selectedTraceService)
|
|
183
|
+
for (const row of entry.data.slice(0, 2)) {
|
|
184
|
+
if (cancelled) return
|
|
185
|
+
await ensureTraceAttributeValues(selectedTraceService, row.value)
|
|
186
|
+
}
|
|
187
|
+
})().catch(() => {})
|
|
188
|
+
return () => { cancelled = true }
|
|
191
189
|
}, [selectedTraceService])
|
|
192
190
|
|
|
193
191
|
useEffect(() => {
|
|
@@ -278,10 +276,8 @@ export const useTraceScreenData = () => {
|
|
|
278
276
|
return existing
|
|
279
277
|
}
|
|
280
278
|
|
|
281
|
-
const epoch = cacheEpochRef.current
|
|
282
279
|
const request = loadTraceDetail(traceId)
|
|
283
280
|
.then((trace) => {
|
|
284
|
-
if (cacheEpochRef.current !== epoch) return { error: null }
|
|
285
281
|
const fetchedAt = new Date()
|
|
286
282
|
traceDetailCacheRef.current.set(traceId, { data: trace, fetchedAt })
|
|
287
283
|
if (hydrateSelection && selectedTraceRef.current === traceId) {
|
|
@@ -291,7 +287,7 @@ export const useTraceScreenData = () => {
|
|
|
291
287
|
})
|
|
292
288
|
.catch((error) => {
|
|
293
289
|
const message = error instanceof Error ? error.message : String(error)
|
|
294
|
-
if (
|
|
290
|
+
if (hydrateSelection && selectedTraceRef.current === traceId) {
|
|
295
291
|
setTraceDetailState({ status: "error", traceId, data: null, error: message, fetchedAt: null })
|
|
296
292
|
}
|
|
297
293
|
return { error: message }
|
|
@@ -331,10 +327,8 @@ export const useTraceScreenData = () => {
|
|
|
331
327
|
return existing
|
|
332
328
|
}
|
|
333
329
|
|
|
334
|
-
const epoch = cacheEpochRef.current
|
|
335
330
|
const request = loadTraceLogs(traceId)
|
|
336
331
|
.then((logs) => {
|
|
337
|
-
if (cacheEpochRef.current !== epoch) return { error: null }
|
|
338
332
|
const fetchedAt = new Date()
|
|
339
333
|
traceLogCacheRef.current.set(traceId, { data: logs, fetchedAt })
|
|
340
334
|
if (hydrateSelection && selectedTraceRef.current === traceId) {
|
|
@@ -344,7 +338,7 @@ export const useTraceScreenData = () => {
|
|
|
344
338
|
})
|
|
345
339
|
.catch((error) => {
|
|
346
340
|
const message = error instanceof Error ? error.message : String(error)
|
|
347
|
-
if (
|
|
341
|
+
if (hydrateSelection && selectedTraceRef.current === traceId) {
|
|
348
342
|
setLogState({ status: "error", traceId, data: [], error: message, fetchedAt: null })
|
|
349
343
|
}
|
|
350
344
|
return { error: message }
|
|
@@ -21,3 +21,23 @@ describe("makeCachedLoader", () => {
|
|
|
21
21
|
expect(loader.get("key")).toBe(2)
|
|
22
22
|
})
|
|
23
23
|
})
|
|
24
|
+
|
|
25
|
+
it("refresh and invalidation coalesce an in-flight request without stale cleanup deleting its successor", async () => {
|
|
26
|
+
let resolve: (value: number) => void = () => {}
|
|
27
|
+
let loads = 0
|
|
28
|
+
const loader = makeCachedLoader<string, number>({ load: () => { loads++; return new Promise((done) => { resolve = done }) } })
|
|
29
|
+
const first = loader.ensure("key")
|
|
30
|
+
loader.invalidate()
|
|
31
|
+
expect(loader.refresh("key")).toBe(first)
|
|
32
|
+
expect(loader.ensure("key")).toBe(first)
|
|
33
|
+
expect(loads).toBe(1)
|
|
34
|
+
resolve(1)
|
|
35
|
+
expect(await first).toBe(1)
|
|
36
|
+
expect(loader.get("key")).toBe(1)
|
|
37
|
+
const second = loader.refresh("key")
|
|
38
|
+
expect(loads).toBe(2)
|
|
39
|
+
expect(loader.refresh("key")).toBe(second)
|
|
40
|
+
resolve(2)
|
|
41
|
+
await second
|
|
42
|
+
expect(loader.get("key")).toBe(2)
|
|
43
|
+
})
|
package/src/ui/cachedLoader.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface CachedLoader<K, V> {
|
|
|
12
12
|
readonly ensure: (key: K) => Promise<V>
|
|
13
13
|
/** Loads the latest value while deduplicating any load already running for this key. */
|
|
14
14
|
readonly refresh: (key: K) => Promise<V>
|
|
15
|
-
/** Drops
|
|
15
|
+
/** Drops cached values while preserving in-flight deduplication; refresh joins the current load. */
|
|
16
16
|
readonly invalidate: () => void
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -53,7 +53,6 @@ export const makeCachedLoader = <K, V>(opts: CachedLoaderOptions<K, V>): CachedL
|
|
|
53
53
|
|
|
54
54
|
const invalidate = () => {
|
|
55
55
|
cache.clear()
|
|
56
|
-
inflight.clear()
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
return { get, ensure, refresh, invalidate }
|
package/src/ui/loaders.ts
CHANGED
|
@@ -4,11 +4,22 @@ import { queryRuntime } from "../runtime.ts"
|
|
|
4
4
|
import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
|
|
5
5
|
import { makeCachedLoader } from "./cachedLoader.ts"
|
|
6
6
|
|
|
7
|
+
// Refreshes join matching in-flight work; settled results are not retained here.
|
|
8
|
+
const pendingLoads = new Map<string, Promise<unknown>>()
|
|
9
|
+
const query = <A>(key: readonly unknown[], effect: Effect.Effect<A, Error, TelemetryStoreReadonly>): Promise<A> => {
|
|
10
|
+
const hash = JSON.stringify(key)
|
|
11
|
+
const existing = pendingLoads.get(hash)
|
|
12
|
+
if (existing) return existing as Promise<A>
|
|
13
|
+
const request = queryRuntime.runPromise(effect).finally(() => pendingLoads.delete(hash))
|
|
14
|
+
pendingLoads.set(hash, request)
|
|
15
|
+
return request
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
export const loadTraceServices = () =>
|
|
8
|
-
|
|
19
|
+
query(["services"], Effect.flatMap(TelemetryStoreReadonly, (service) => service.listServices))
|
|
9
20
|
|
|
10
21
|
export const loadRecentTraceSummaries = (serviceName: string) =>
|
|
11
|
-
|
|
22
|
+
query(["summaries", serviceName], Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceSummaries(serviceName)))
|
|
12
23
|
|
|
13
24
|
/**
|
|
14
25
|
* Server-side trace summary search. Accepts any combination of:
|
|
@@ -29,7 +40,7 @@ export const loadFilteredTraceSummaries = (
|
|
|
29
40
|
readonly aiText?: string | null
|
|
30
41
|
},
|
|
31
42
|
) =>
|
|
32
|
-
|
|
43
|
+
query(["summaries", serviceName, options], Effect.flatMap(TelemetryStoreReadonly, (service) => service.searchTraceSummaries({
|
|
33
44
|
serviceName,
|
|
34
45
|
attributeFilters: options.attributeFilters,
|
|
35
46
|
aiText: options.aiText ?? null,
|
|
@@ -92,10 +103,10 @@ export const invalidateFacetCaches = () => {
|
|
|
92
103
|
}
|
|
93
104
|
|
|
94
105
|
export const loadTraceDetail = (traceId: string) =>
|
|
95
|
-
|
|
106
|
+
query(["trace", traceId], Effect.flatMap(TelemetryStoreReadonly, (service) => service.getTrace(traceId)))
|
|
96
107
|
|
|
97
108
|
export const loadTraceLogs = (traceId: string) =>
|
|
98
|
-
|
|
109
|
+
query(["trace-logs", traceId], Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceLogs(traceId)))
|
|
99
110
|
|
|
100
111
|
export const loadServiceLogs = (serviceName: string) =>
|
|
101
|
-
|
|
112
|
+
query(["service-logs", serviceName], Effect.flatMap(TelemetryStoreReadonly, (service) => service.listRecentLogs(serviceName)))
|