@kitlangton/motel 0.2.4 → 0.2.6

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.
Files changed (66) hide show
  1. package/AGENTS.md +23 -8
  2. package/README.md +13 -2
  3. package/package.json +35 -19
  4. package/skills/motel-debug/SKILL.md +203 -0
  5. package/skills/motel-debug/references/effect.md +38 -0
  6. package/src/App.tsx +12 -5
  7. package/src/StartupGate.tsx +289 -0
  8. package/src/cli.ts +15 -16
  9. package/src/config.ts +7 -1
  10. package/src/daemon.test.ts +332 -51
  11. package/src/daemon.ts +105 -153
  12. package/src/httpApi.ts +1 -0
  13. package/src/httpListPolicy.test.ts +76 -0
  14. package/src/httpListPolicy.ts +129 -0
  15. package/src/index.tsx +9 -2
  16. package/src/localServer.ts +194 -313
  17. package/src/mcp.ts +2 -1
  18. package/src/motel.ts +0 -2
  19. package/src/opentui-jsx.d.ts +11 -0
  20. package/src/otlp.test.ts +65 -0
  21. package/src/otlp.ts +20 -0
  22. package/src/otlpProtobuf.ts +35 -0
  23. package/src/registry.ts +37 -11
  24. package/src/runtime.ts +2 -6
  25. package/src/services/AsyncIngest.ts +22 -8
  26. package/src/services/LogQueryService.ts +13 -27
  27. package/src/services/TelemetryQuery.ts +62 -0
  28. package/src/services/TelemetryStore.ts +546 -231
  29. package/src/services/TraceQueryService.ts +22 -56
  30. package/src/services/ingestRpc.ts +2 -4
  31. package/src/services/queryRpc.ts +15 -0
  32. package/src/services/telemetryQueryWorker.ts +32 -0
  33. package/src/services/telemetryWorker.ts +5 -8
  34. package/src/startupBench.ts +19 -0
  35. package/src/storybook/aiChatStory.tsx +1 -1
  36. package/src/telemetry.test.ts +307 -41
  37. package/src/ui/AiChatView.tsx +1 -1
  38. package/src/ui/AttrFilterModal.tsx +1 -1
  39. package/src/ui/ServiceLogs.tsx +10 -7
  40. package/src/ui/SpanContentView.tsx +24 -21
  41. package/src/ui/TraceDetailsPane.tsx +1 -1
  42. package/src/ui/TraceList.tsx +1 -1
  43. package/src/ui/aiState.ts +10 -22
  44. package/src/ui/app/TraceWorkspace.tsx +2 -1
  45. package/src/ui/app/useAppLayout.ts +1 -1
  46. package/src/ui/app/useTraceScreenData.ts +35 -23
  47. package/src/ui/atoms.ts +1 -1
  48. package/src/ui/cachedLoader.test.ts +23 -0
  49. package/src/ui/cachedLoader.ts +60 -0
  50. package/src/ui/loaders.ts +34 -53
  51. package/src/ui/persistence.ts +3 -3
  52. package/src/ui/primitives.tsx +1 -1
  53. package/src/ui/state.ts +2 -0
  54. package/src/ui/theme.ts +7 -5
  55. package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
  56. package/src/ui/traceSortNav.repro.seed.ts +1 -1
  57. package/src/ui/traceSortNav.repro.test.ts +12 -2
  58. package/src/ui/useAttrFilterPicker.ts +10 -8
  59. package/src/ui/useKeyboardNav.ts +28 -5
  60. package/src/ui/waterfallNav.repro.seed.ts +1 -1
  61. package/src/ui/waterfallNav.repro.test.ts +16 -8
  62. package/web/dist/assets/index-B01z9BaO.css +2 -0
  63. package/web/dist/assets/index-M86tcih5.js +22 -0
  64. package/web/dist/index.html +2 -2
  65. package/web/dist/assets/index-DnyVo03x.js +0 -27
  66. package/web/dist/assets/index-DzuHNBGV.css +0 -2
package/src/mcp.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { BunRuntime, BunStdio } from "@effect/platform-bun"
3
3
  import { Effect, Layer, Logger, Schema } from "effect"
4
+ import { MOTEL_VERSION } from "./registry.js"
4
5
  import { McpServer, Tool, Toolkit } from "effect/unstable/ai"
5
6
  import { TraceSpanStatus } from "./domain.js"
6
7
  import { MotelClient, MotelClientLive } from "./motelClient.js"
@@ -422,7 +423,7 @@ const ServerLayer = McpServer.toolkit(MotelToolkit).pipe(
422
423
  Layer.provide(
423
424
  McpServer.layerStdio({
424
425
  name: "motel",
425
- version: "0.1.0",
426
+ version: MOTEL_VERSION,
426
427
  }),
427
428
  ),
428
429
  Layer.provide(BunStdio.layer),
package/src/motel.ts CHANGED
@@ -12,7 +12,6 @@ case undefined:
12
12
  case "tui":
13
13
  case "ui": {
14
14
  await run(applyManagedDaemonEnv)
15
- await run(ensureManagedDaemon)
16
15
  await import("./index.js")
17
16
  break
18
17
  }
@@ -42,7 +41,6 @@ case "restart": {
42
41
  // and want the TUI to reconnect to the new binary in one command.
43
42
  await run(stopManagedDaemon)
44
43
  await run(applyManagedDaemonEnv)
45
- await run(ensureManagedDaemon)
46
44
  await import("./index.js")
47
45
  break
48
46
  }
@@ -0,0 +1,11 @@
1
+ import type { Key } from "react"
2
+
3
+ // TypeScript 6 does not preserve React's inherited `key` attribute through
4
+ // OpenTUI's JSX runtime declarations for custom components.
5
+ declare module "@opentui/react/jsx-runtime" {
6
+ namespace JSX {
7
+ interface IntrinsicAttributes {
8
+ readonly key?: Key | null | undefined
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,65 @@
1
+ import rootModule from "@opentelemetry/otlp-transformer/build/esm/generated/root.js"
2
+ import { describe, expect, test } from "bun:test"
3
+ import { normalizeOtlpBinaryId } from "./otlp.ts"
4
+ import { decodeProtobufLogs, decodeProtobufTraces } from "./otlpProtobuf.ts"
5
+
6
+ describe("normalizeOtlpBinaryId", () => {
7
+ test("normalizes hex and canonical base64 IDs", () => {
8
+ expect(normalizeOtlpBinaryId("0123456789ABCDEF", 8)).toBe("0123456789abcdef")
9
+ expect(normalizeOtlpBinaryId(Buffer.from("0123456789abcdef", "hex").toString("base64"), 8)).toBe("0123456789abcdef")
10
+ expect(normalizeOtlpBinaryId(Buffer.from("0123456789abcdef0123456789abcdef", "hex").toString("base64"), 16)).toBe("0123456789abcdef0123456789abcdef")
11
+ })
12
+
13
+ test("preserves non-standard human-readable IDs", () => {
14
+ expect(normalizeOtlpBinaryId("ai-stream-1", 8)).toBe("ai-stream-1")
15
+ expect(normalizeOtlpBinaryId("ai-stream-2", 8)).toBe("ai-stream-2")
16
+ expect(normalizeOtlpBinaryId("trace-ai", 16)).toBe("trace-ai")
17
+ })
18
+
19
+ test("returns null for absent IDs", () => {
20
+ expect(normalizeOtlpBinaryId(null, 16)).toBeNull()
21
+ expect(normalizeOtlpBinaryId(undefined, 8)).toBeNull()
22
+ expect(normalizeOtlpBinaryId("", 16)).toBeNull()
23
+ })
24
+ })
25
+
26
+ const root = rootModule as unknown as {
27
+ readonly opentelemetry: {
28
+ readonly proto: {
29
+ readonly collector: {
30
+ readonly trace: { readonly v1: { readonly ExportTraceServiceRequest: { encode: (message: unknown) => { finish: () => Uint8Array } } } }
31
+ readonly logs: { readonly v1: { readonly ExportLogsServiceRequest: { encode: (message: unknown) => { finish: () => Uint8Array } } } }
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ describe("protobuf OTLP decoders", () => {
38
+ test("decodes traces into the JSON ingest shape", () => {
39
+ const encoded = root.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest.encode({
40
+ resourceSpans: [{
41
+ resource: { attributes: [{ key: "service.name", value: { stringValue: "proto-svc" } }] },
42
+ scopeSpans: [{ spans: [{
43
+ traceId: Buffer.from("0123456789abcdef0123456789abcdef", "hex"),
44
+ spanId: Buffer.from("0123456789abcdef", "hex"),
45
+ name: "op",
46
+ startTimeUnixNano: 1,
47
+ endTimeUnixNano: 2,
48
+ }] }],
49
+ }],
50
+ }).finish()
51
+ const span = decodeProtobufTraces(encoded).resourceSpans?.[0]?.scopeSpans?.[0]?.spans?.[0]
52
+ expect(span?.name).toBe("op")
53
+ expect(normalizeOtlpBinaryId(span?.traceId, 16)).toBe("0123456789abcdef0123456789abcdef")
54
+ expect(normalizeOtlpBinaryId(span?.spanId, 8)).toBe("0123456789abcdef")
55
+ })
56
+
57
+ test("decodes logs into the JSON ingest shape", () => {
58
+ const encoded = root.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest.encode({
59
+ resourceLogs: [{ scopeLogs: [{ logRecords: [{ timeUnixNano: 5, severityText: "INFO", body: { stringValue: "hello" } }] }] }],
60
+ }).finish()
61
+ const record = decodeProtobufLogs(encoded).resourceLogs?.[0]?.scopeLogs?.[0]?.logRecords?.[0]
62
+ expect(record?.severityText).toBe("INFO")
63
+ expect(record?.body?.stringValue).toBe("hello")
64
+ })
65
+ })
package/src/otlp.ts CHANGED
@@ -140,3 +140,23 @@ export const spanKindLabel = (kind: number | undefined): string | null => {
140
140
  }
141
141
 
142
142
  export const spanStatusLabel = (code: number | undefined): "ok" | "error" => (code === 2 ? "error" : "ok")
143
+
144
+ /** Normalize OTLP hex or canonical base64 binary IDs to lowercase hex. */
145
+ export const normalizeOtlpBinaryId = (
146
+ value: string | null | undefined,
147
+ expectedBytes: 8 | 16,
148
+ ): string | null => {
149
+ if (!value) return null
150
+ const expectedHexLength = expectedBytes * 2
151
+ if (value.length === expectedHexLength && /^[0-9a-f]+$/.test(value)) return value
152
+ if (value.length === expectedHexLength && /^[0-9a-fA-F]+$/.test(value)) return value.toLowerCase()
153
+ try {
154
+ const bytes = Buffer.from(value, "base64")
155
+ if (bytes.length === expectedBytes && bytes.toString("base64") === value) {
156
+ return bytes.toString("hex")
157
+ }
158
+ } catch {
159
+ // Preserve non-standard human-readable IDs used by local fixtures and custom exporters.
160
+ }
161
+ return value
162
+ }
@@ -0,0 +1,35 @@
1
+ import rootModule from "@opentelemetry/otlp-transformer/build/esm/generated/root.js"
2
+ import type { OtlpLogExportRequest, OtlpTraceExportRequest } from "./otlp.js"
3
+
4
+ interface ProtobufType {
5
+ readonly decode: (bytes: Uint8Array) => unknown
6
+ readonly toObject: (message: unknown, options: Record<string, unknown>) => unknown
7
+ }
8
+
9
+ const root = rootModule as unknown as {
10
+ readonly opentelemetry: {
11
+ readonly proto: {
12
+ readonly collector: {
13
+ readonly trace: { readonly v1: { readonly ExportTraceServiceRequest: ProtobufType } }
14
+ readonly logs: { readonly v1: { readonly ExportLogsServiceRequest: ProtobufType } }
15
+ }
16
+ }
17
+ }
18
+ }
19
+
20
+ const ExportTraceServiceRequest = root.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest
21
+ const ExportLogsServiceRequest = root.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest
22
+ const decodeOptions = {
23
+ bytes: String,
24
+ longs: String,
25
+ defaults: false,
26
+ enums: Number,
27
+ arrays: true,
28
+ objects: true,
29
+ }
30
+
31
+ export const decodeProtobufTraces = (bytes: Uint8Array): OtlpTraceExportRequest =>
32
+ ExportTraceServiceRequest.toObject(ExportTraceServiceRequest.decode(bytes), decodeOptions) as OtlpTraceExportRequest
33
+
34
+ export const decodeProtobufLogs = (bytes: Uint8Array): OtlpLogExportRequest =>
35
+ ExportLogsServiceRequest.toObject(ExportLogsServiceRequest.decode(bytes), decodeOptions) as OtlpLogExportRequest
package/src/registry.ts CHANGED
@@ -1,14 +1,23 @@
1
1
  import * as fs from "node:fs"
2
2
  import * as os from "node:os"
3
3
  import * as path from "node:path"
4
+ import packageJson from "../package.json" with { type: "json" }
4
5
 
5
- export const MOTEL_VERSION = "0.1.0"
6
+ export const MOTEL_VERSION = packageJson.version
6
7
  export const MOTEL_SERVICE_ID = "motel-local-server"
7
8
 
8
9
  const stateHome = () =>
9
10
  process.env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state")
10
11
 
11
- export const registryDir = () => path.join(stateHome(), "motel", "instances")
12
+ /**
13
+ * The shared, machine-global motel state directory. Holds the SQLite
14
+ * database, daemon log, daemon lock, and the per-pid instance registry.
15
+ * One motel daemon serves every project on this machine — there is no
16
+ * per-cwd state.
17
+ */
18
+ export const motelStateDir = () => process.env.MOTEL_RUNTIME_DIR?.trim() || path.join(stateHome(), "motel")
19
+
20
+ export const registryDir = (runtimeDir = motelStateDir()) => path.join(runtimeDir, "instances")
12
21
 
13
22
  export type RegistryEntry = {
14
23
  readonly pid: number
@@ -16,6 +25,8 @@ export type RegistryEntry = {
16
25
  readonly workdir: string
17
26
  readonly startedAt: string
18
27
  readonly version: string
28
+ readonly instanceId?: string
29
+ readonly processIdentity?: string
19
30
  /**
20
31
  * The SQLite database path the daemon is serving. Optional because
21
32
  * older daemon builds omit it; consumers should treat a missing
@@ -26,7 +37,7 @@ export type RegistryEntry = {
26
37
  readonly databasePath?: string
27
38
  }
28
39
 
29
- const entryPath = (pid: number) => path.join(registryDir(), `${pid}.json`)
40
+ const entryPath = (pid: number, runtimeDir = motelStateDir()) => path.join(registryDir(runtimeDir), `${pid}.json`)
30
41
 
31
42
  export const isAlive = (pid: number): boolean => {
32
43
  try {
@@ -37,8 +48,23 @@ export const isAlive = (pid: number): boolean => {
37
48
  }
38
49
  }
39
50
 
40
- export const listAliveEntries = (): RegistryEntry[] => {
41
- const dir = registryDir()
51
+ export const processIdentity = (pid: number): string | null => {
52
+ try {
53
+ const result = Bun.spawnSync({ cmd: ["ps", "-p", String(pid), "-o", "lstart="] })
54
+ if (result.exitCode !== 0) return null
55
+ const identity = result.stdout.toString().trim()
56
+ return identity.length > 0 ? identity : null
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ export const isManagedDaemonProcess = (entry: RegistryEntry): boolean => {
63
+ return Boolean(entry.instanceId && entry.processIdentity && processIdentity(entry.pid) === entry.processIdentity)
64
+ }
65
+
66
+ export const listAliveEntries = (runtimeDir = motelStateDir()): RegistryEntry[] => {
67
+ const dir = registryDir(runtimeDir)
42
68
  let files: string[]
43
69
  try {
44
70
  files = fs.readdirSync(dir)
@@ -51,7 +77,7 @@ export const listAliveEntries = (): RegistryEntry[] => {
51
77
  const full = path.join(dir, f)
52
78
  try {
53
79
  const entry = JSON.parse(fs.readFileSync(full, "utf8")) as RegistryEntry
54
- if (isAlive(entry.pid)) {
80
+ if (entry.instanceId && entry.processIdentity ? isManagedDaemonProcess(entry) : isAlive(entry.pid)) {
55
81
  alive.push(entry)
56
82
  } else {
57
83
  try { fs.unlinkSync(full) } catch {}
@@ -63,9 +89,9 @@ export const listAliveEntries = (): RegistryEntry[] => {
63
89
  return alive
64
90
  }
65
91
 
66
- export const writeRegistryEntry = (entry: RegistryEntry) => {
67
- fs.mkdirSync(registryDir(), { recursive: true })
68
- const file = entryPath(entry.pid)
92
+ export const writeRegistryEntry = (entry: RegistryEntry, runtimeDir = motelStateDir()) => {
93
+ fs.mkdirSync(registryDir(runtimeDir), { recursive: true })
94
+ const file = entryPath(entry.pid, runtimeDir)
69
95
  fs.writeFileSync(file, JSON.stringify(entry, null, 2), "utf8")
70
96
  }
71
97
 
@@ -79,9 +105,9 @@ export const writeRegistryEntry = (entry: RegistryEntry) => {
79
105
  * server (via BunRuntime.runMain) now owns signal handling; registry
80
106
  * cleanup rides along on scope release.
81
107
  */
82
- export const removeRegistryEntry = (pid: number) => {
108
+ export const removeRegistryEntry = (pid: number, runtimeDir = motelStateDir()) => {
83
109
  try {
84
- fs.unlinkSync(entryPath(pid))
110
+ fs.unlinkSync(entryPath(pid, runtimeDir))
85
111
  } catch {
86
112
  // Already gone — another cleanup path won the race, or the entry
87
113
  // was never written.
package/src/runtime.ts CHANGED
@@ -5,9 +5,7 @@ import { SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs"
5
5
  import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
6
6
  import { Layer, ManagedRuntime } from "effect"
7
7
  import { config } from "./config.js"
8
- import { LogQueryServiceLive } from "./services/LogQueryService.js"
9
8
  import { TelemetryStoreLive, TelemetryStoreReadonlyLive } from "./services/TelemetryStore.js"
10
- import { TraceQueryServiceLive } from "./services/TraceQueryService.js"
11
9
 
12
10
  const telemetryLayer = NodeSdk.layer(() => ({
13
11
  spanProcessor: new SimpleSpanProcessor(
@@ -30,12 +28,10 @@ const telemetryLayer = NodeSdk.layer(() => ({
30
28
  },
31
29
  }))
32
30
 
33
- // TUI-side services are readonly — a daemon/worker writer owns the DB
31
+ // TUI-side runtime is readonly — a daemon/worker writer owns the DB
34
32
  // lock while ingests are in flight, and trying to grab the write lock
35
33
  // for schema init on startup causes "database is locked" on bun dev.
36
- const QueryServicesLive = Layer.mergeAll(TraceQueryServiceLive, LogQueryServiceLive).pipe(Layer.provideMerge(TelemetryStoreReadonlyLive))
37
-
38
- const QueryRuntimeLive = config.otel.enabled ? Layer.mergeAll(QueryServicesLive, telemetryLayer) : QueryServicesLive
34
+ const QueryRuntimeLive = config.otel.enabled ? Layer.mergeAll(TelemetryStoreReadonlyLive, telemetryLayer) : TelemetryStoreReadonlyLive
39
35
 
40
36
  export const queryRuntime = ManagedRuntime.make(QueryRuntimeLive)
41
37
  // `storeRuntime` is the full writer runtime, exposed for the telemetry
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import * as BunWorker from "@effect/platform-bun/BunWorker"
23
- import { Context, Effect, Layer, Scope } from "effect"
23
+ import { Context, Duration, Effect, Exit, Layer, Scope } from "effect"
24
24
  import * as RpcClient from "effect/unstable/rpc/RpcClient"
25
25
  import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"
26
26
  import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
@@ -55,14 +55,28 @@ export const AsyncIngestLive = Layer.effect(
55
55
  // spawn the worker and make /api/health wait on the worker's SQLite
56
56
  // bootstrap. Cache a lazy initializer instead so the worker only starts
57
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)
58
+ const [getClient, invalidateClient] = yield* Effect.cachedInvalidateWithTTL(Effect.gen(function*() {
59
+ const clientScope = yield* Scope.fork(scope, "sequential")
60
+ const protocolContext = yield* Layer.buildWithScope(WorkerProtocol, clientScope)
61
+ const client = yield* RpcClient.make(IngestRpcs).pipe(
62
+ Effect.provide(protocolContext),
63
+ Effect.provideService(Scope.Scope, clientScope),
64
+ )
65
+ return { client, clientScope }
66
+ }), Duration.infinity)
67
+ // Start the sole writer/maintenance worker immediately, but do not make
68
+ // HTTP health wait for SQLite bootstrap. Managed readiness still verifies
69
+ // the worker through explicit ingest probes.
70
+ yield* Effect.forkScoped(getClient.pipe(Effect.ignore))
63
71
  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)),
72
+ ingestTraces: (input, options) =>
73
+ Effect.flatMap(getClient, ({ client, clientScope }) => client.ingestTraces(input, options).pipe(
74
+ Effect.onError(() => Effect.andThen(Scope.close(clientScope, Exit.void), invalidateClient)),
75
+ )),
76
+ ingestLogs: (input, options) =>
77
+ Effect.flatMap(getClient, ({ client, clientScope }) => client.ingestLogs(input, options).pipe(
78
+ Effect.onError(() => Effect.andThen(Scope.close(clientScope, Exit.void), invalidateClient)),
79
+ )),
66
80
  }
67
81
  }),
68
82
  )
@@ -2,42 +2,28 @@ import { Effect, Layer, Context } from "effect"
2
2
  import type { LogItem } from "../domain.js"
3
3
  import { TelemetryStore } from "./TelemetryStore.js"
4
4
 
5
+ /**
6
+ * Compatibility adapter for consumers importing the historical log query module.
7
+ * New internal callers should use TelemetryStoreReadonly directly.
8
+ */
5
9
  export class LogQueryService extends Context.Service<
6
10
  LogQueryService,
7
11
  {
8
12
  readonly listRecentLogs: (serviceName: string) => Effect.Effect<readonly LogItem[], Error>
9
13
  readonly listTraceLogs: (traceId: string) => Effect.Effect<readonly LogItem[], Error>
10
- readonly searchLogs: (input: { readonly serviceName?: string; readonly traceId?: string; readonly spanId?: string; readonly body?: string; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly LogItem[], Error>
11
- readonly logStats: (input: { readonly groupBy: string; readonly agg: "count"; readonly serviceName?: string | null; readonly traceId?: string | null; readonly spanId?: string | null; readonly body?: string | null; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
14
+ readonly searchLogs: (input: { readonly serviceName?: string | null; readonly severity?: string | null; readonly traceId?: string | null; readonly spanId?: string | null; readonly body?: string | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorTimestampMs?: number; readonly cursorId?: string; readonly attributeFilters?: Readonly<Record<string, string>>; readonly attributeContainsFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly LogItem[], Error>
15
+ readonly logStats: (input: { readonly groupBy: string; readonly agg: "count"; readonly serviceName?: string | null; readonly traceId?: string | null; readonly spanId?: string | null; readonly body?: string | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly attributeContainsFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
12
16
  readonly listFacets: (input: { readonly type: "traces" | "logs"; readonly field: string; readonly serviceName?: string | null; readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly { readonly value: string; readonly count: number }[], Error>
13
17
  }
14
18
  >()("motel/LogQueryService") {}
15
19
 
16
20
  export const LogQueryServiceLive = Layer.effect(
17
21
  LogQueryService,
18
- Effect.gen(function* () {
19
- const store = yield* TelemetryStore
20
-
21
- const listRecentLogs = Effect.fn("motel/LogQueryService.listRecentLogs")(function* (serviceName: string) {
22
- yield* Effect.annotateCurrentSpan("log.service_name", serviceName)
23
- const logs = yield* store.listRecentLogs(serviceName)
24
- yield* Effect.annotateCurrentSpan("log.result_count", logs.length)
25
- return logs
26
- })
27
-
28
- const listTraceLogs = Effect.fn("motel/LogQueryService.listTraceLogs")(function* (traceId: string) {
29
- yield* Effect.annotateCurrentSpan("log.trace_id", traceId)
30
- const logs = yield* store.listTraceLogs(traceId)
31
- yield* Effect.annotateCurrentSpan("log.result_count", logs.length)
32
- return logs
33
- })
34
-
35
- return LogQueryService.of({
36
- listRecentLogs,
37
- listTraceLogs,
38
- searchLogs: store.searchLogs,
39
- logStats: store.logStats,
40
- listFacets: store.listFacets,
41
- })
42
- }),
22
+ Effect.map(TelemetryStore, (store) => LogQueryService.of({
23
+ listRecentLogs: store.listRecentLogs,
24
+ listTraceLogs: store.listTraceLogs,
25
+ searchLogs: store.searchLogs,
26
+ logStats: store.logStats,
27
+ listFacets: store.listFacets,
28
+ })),
43
29
  )
@@ -0,0 +1,62 @@
1
+ import * as BunWorker from "@effect/platform-bun/BunWorker"
2
+ import { Duration, Effect, Exit, Layer, Scope } from "effect"
3
+ import * as RpcClient from "effect/unstable/rpc/RpcClient"
4
+ import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
5
+ import type { WorkerError } from "effect/unstable/workers/WorkerError"
6
+ import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"
7
+ import { TelemetryStoreReadonly, type TelemetryStoreReader } from "./TelemetryStore.js"
8
+ import { QueryRpcs } from "./queryRpc.js"
9
+
10
+ type QueryMethod = keyof TelemetryStoreReader
11
+ type QueryClient = RpcClient.FromGroup<typeof QueryRpcs, RpcClientError | WorkerError>
12
+ type QueryClientEntry = { readonly client: QueryClient; readonly clientScope: Scope.Scope }
13
+
14
+ const WorkerProtocol = RpcClient.layerProtocolWorker({ size: 1 }).pipe(
15
+ Layer.provide(RpcSerialization.layerMsgPack),
16
+ Layer.provide(BunWorker.layer(() => new Worker(new URL("./telemetryQueryWorker.ts", import.meta.url)))),
17
+ )
18
+
19
+ const query = <A>(getClient: Effect.Effect<QueryClientEntry, unknown>, invalidateClient: Effect.Effect<void>, method: QueryMethod, args: readonly unknown[] = []) =>
20
+ Effect.flatMap(getClient, ({ client, clientScope }) => client.query({ method, args }).pipe(
21
+ Effect.onError(() => Effect.andThen(Scope.close(clientScope, Exit.void), invalidateClient)),
22
+ )).pipe(
23
+ Effect.map((result) => result as A),
24
+ Effect.mapError((error) => error instanceof Error ? error : new Error(String(error))),
25
+ )
26
+
27
+ export const TelemetryQueryLive = Layer.effect(
28
+ TelemetryStoreReadonly,
29
+ Effect.gen(function*() {
30
+ const scope = yield* Scope.Scope
31
+ const [getClient, invalidateClient] = yield* Effect.cachedInvalidateWithTTL(Effect.gen(function*() {
32
+ const clientScope = yield* Scope.fork(scope, "sequential")
33
+ const protocolContext = yield* Layer.buildWithScope(WorkerProtocol, clientScope)
34
+ const client = yield* RpcClient.make(QueryRpcs).pipe(
35
+ Effect.provide(protocolContext),
36
+ Effect.provideService(Scope.Scope, clientScope),
37
+ )
38
+ return { client, clientScope }
39
+ }), Duration.infinity)
40
+ const run = <A>(method: QueryMethod, args: readonly unknown[] = []) => query<A>(getClient, invalidateClient, method, args)
41
+ return TelemetryStoreReadonly.of({
42
+ listServices: run("listServices"),
43
+ listRecentTraces: (serviceName, options) => run("listRecentTraces", [serviceName, options]),
44
+ listTraceSummaries: (serviceName, options) => run("listTraceSummaries", [serviceName, options]),
45
+ searchTraces: (input) => run("searchTraces", [input]),
46
+ searchTraceSummaries: (input) => run("searchTraceSummaries", [input]),
47
+ traceStats: (input) => run("traceStats", [input]),
48
+ getTrace: (traceId) => run("getTrace", [traceId]),
49
+ getSpan: (spanId) => run("getSpan", [spanId]),
50
+ listTraceSpans: (traceId) => run("listTraceSpans", [traceId]),
51
+ searchSpans: (input) => run("searchSpans", [input]),
52
+ searchLogs: (input) => run("searchLogs", [input]),
53
+ logStats: (input) => run("logStats", [input]),
54
+ listFacets: (input) => run("listFacets", [input]),
55
+ listRecentLogs: (serviceName) => run("listRecentLogs", [serviceName]),
56
+ listTraceLogs: (traceId) => run("listTraceLogs", [traceId]),
57
+ searchAiCalls: (input) => run("searchAiCalls", [input]),
58
+ getAiCall: (spanId) => run("getAiCall", [spanId]),
59
+ aiCallStats: (input) => run("aiCallStats", [input]),
60
+ })
61
+ }),
62
+ )