@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.
@@ -1,32 +1,31 @@
1
1
  import { BunRuntime } from "@effect/platform-bun"
2
- import * as BunWorkerRunner from "@effect/platform-bun/BunWorkerRunner"
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 { QueryError, QueryRpcs } from "./queryRpc.js"
4
+ import { QueryRequest, type QueryReply } from "./queryRpc.js"
8
5
 
9
- type QueryMethod = keyof TelemetryStoreReader
6
+ const send = (reply: QueryReply) => postMessage(reply)
10
7
 
11
- const QueryHandlers = QueryRpcs.toLayer(Effect.gen(function*() {
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
- return {
14
- query: ({ method, args }) => {
15
- const member = Reflect.get(store, method as QueryMethod) as unknown
16
- const result = typeof member === "function" ? Reflect.apply(member, store, args) : member
17
- return (result as Effect.Effect<unknown, Error>).pipe(
18
- Effect.mapError((error) => new QueryError({ message: String(error) })),
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
- const WorkerLive = RpcServer.layer(QueryRpcs).pipe(
25
- Layer.provide(QueryHandlers),
26
- Layer.provide(TelemetryStoreQueryWorkerLive),
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)
@@ -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("idx_logs_severity_nocase_time (severity_text=?"))).toBe(true)
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
- void ensureTraceAttributeKeys(selectedTraceService)
185
- .then((entry) => Promise.allSettled(
186
- entry.data
187
- .slice(0, 6)
188
- .map((row) => ensureTraceAttributeValues(selectedTraceService, row.value)),
189
- ))
190
- .catch(() => {})
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 (cacheEpochRef.current === epoch && hydrateSelection && selectedTraceRef.current === traceId) {
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 (cacheEpochRef.current === epoch && hydrateSelection && selectedTraceRef.current === traceId) {
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
+ })
@@ -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 every cached value and aborts dedup tracking for in-flight loads. */
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
- queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listServices))
19
+ query(["services"], Effect.flatMap(TelemetryStoreReadonly, (service) => service.listServices))
9
20
 
10
21
  export const loadRecentTraceSummaries = (serviceName: string) =>
11
- queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceSummaries(serviceName)))
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
- queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.searchTraceSummaries({
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
- queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getTrace(traceId)))
106
+ query(["trace", traceId], Effect.flatMap(TelemetryStoreReadonly, (service) => service.getTrace(traceId)))
96
107
 
97
108
  export const loadTraceLogs = (traceId: string) =>
98
- queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceLogs(traceId)))
109
+ query(["trace-logs", traceId], Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceLogs(traceId)))
99
110
 
100
111
  export const loadServiceLogs = (serviceName: string) =>
101
- queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listRecentLogs(serviceName)))
112
+ query(["service-logs", serviceName], Effect.flatMap(TelemetryStoreReadonly, (service) => service.listRecentLogs(serviceName)))