@aryasaatvik/motel 0.3.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.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
package/src/otlp.ts ADDED
@@ -0,0 +1,162 @@
1
+ export interface OtlpAnyValue {
2
+ readonly stringValue?: string
3
+ readonly boolValue?: boolean
4
+ readonly intValue?: string | number
5
+ readonly doubleValue?: number
6
+ readonly bytesValue?: string
7
+ readonly arrayValue?: {
8
+ readonly values?: readonly OtlpAnyValue[]
9
+ }
10
+ readonly kvlistValue?: {
11
+ readonly values?: readonly OtlpKeyValue[]
12
+ }
13
+ }
14
+
15
+ export interface OtlpKeyValue {
16
+ readonly key: string
17
+ readonly value?: OtlpAnyValue
18
+ }
19
+
20
+ export interface OtlpSpanEvent {
21
+ readonly timeUnixNano?: string
22
+ readonly name?: string
23
+ readonly attributes?: readonly OtlpKeyValue[]
24
+ }
25
+
26
+ export interface OtlpSpan {
27
+ readonly traceId: string
28
+ readonly spanId: string
29
+ readonly parentSpanId?: string
30
+ readonly name?: string
31
+ readonly kind?: number
32
+ readonly startTimeUnixNano?: string
33
+ readonly endTimeUnixNano?: string
34
+ readonly attributes?: readonly OtlpKeyValue[]
35
+ readonly status?: {
36
+ readonly code?: number
37
+ readonly message?: string
38
+ }
39
+ readonly events?: readonly OtlpSpanEvent[]
40
+ }
41
+
42
+ export interface OtlpScopeSpans {
43
+ readonly scope?: {
44
+ readonly name?: string
45
+ }
46
+ readonly spans?: readonly OtlpSpan[]
47
+ }
48
+
49
+ export interface OtlpResourceSpans {
50
+ readonly resource?: {
51
+ readonly attributes?: readonly OtlpKeyValue[]
52
+ }
53
+ readonly scopeSpans?: readonly OtlpScopeSpans[]
54
+ }
55
+
56
+ export interface OtlpTraceExportRequest {
57
+ readonly resourceSpans?: readonly OtlpResourceSpans[]
58
+ }
59
+
60
+ export interface OtlpLogRecord {
61
+ readonly timeUnixNano?: string
62
+ readonly observedTimeUnixNano?: string
63
+ readonly severityText?: string
64
+ readonly body?: OtlpAnyValue
65
+ readonly attributes?: readonly OtlpKeyValue[]
66
+ readonly traceId?: string
67
+ readonly spanId?: string
68
+ }
69
+
70
+ export interface OtlpScopeLogs {
71
+ readonly scope?: {
72
+ readonly name?: string
73
+ }
74
+ readonly logRecords?: readonly OtlpLogRecord[]
75
+ }
76
+
77
+ export interface OtlpResourceLogs {
78
+ readonly resource?: {
79
+ readonly attributes?: readonly OtlpKeyValue[]
80
+ }
81
+ readonly scopeLogs?: readonly OtlpScopeLogs[]
82
+ }
83
+
84
+ export interface OtlpLogExportRequest {
85
+ readonly resourceLogs?: readonly OtlpResourceLogs[]
86
+ }
87
+
88
+ export const parseAnyValue = (value: OtlpAnyValue | undefined): unknown => {
89
+ if (!value) return null
90
+ if (value.stringValue !== undefined) return value.stringValue
91
+ if (value.boolValue !== undefined) return value.boolValue
92
+ if (value.intValue !== undefined) return Number(value.intValue)
93
+ if (value.doubleValue !== undefined) return value.doubleValue
94
+ if (value.bytesValue !== undefined) return value.bytesValue
95
+ if (value.arrayValue?.values) return value.arrayValue.values.map(parseAnyValue)
96
+ if (value.kvlistValue?.values) {
97
+ return Object.fromEntries(value.kvlistValue.values.map((entry) => [entry.key, parseAnyValue(entry.value)]))
98
+ }
99
+ return null
100
+ }
101
+
102
+ export const stringifyValue = (value: unknown): string => {
103
+ if (value === null || value === undefined) return ""
104
+ if (typeof value === "string") return value
105
+ if (typeof value === "number" || typeof value === "boolean") return String(value)
106
+ if (Array.isArray(value)) {
107
+ return value.map((entry) => stringifyValue(entry)).filter((entry) => entry.length > 0).join(" ")
108
+ }
109
+ return JSON.stringify(value)
110
+ }
111
+
112
+ export const attributeMap = (attributes: readonly OtlpKeyValue[] | undefined): Record<string, string> =>
113
+ Object.fromEntries((attributes ?? []).map((attribute) => [attribute.key, stringifyValue(parseAnyValue(attribute.value))]))
114
+
115
+ export const nanosToMilliseconds = (value: string | undefined): number => {
116
+ if (!value) return 0
117
+ try {
118
+ return Number(BigInt(value) / 1_000_000n)
119
+ } catch {
120
+ const parsed = Number.parseInt(value, 10)
121
+ return Number.isFinite(parsed) ? Math.floor(parsed / 1_000_000) : 0
122
+ }
123
+ }
124
+
125
+ export const spanKindLabel = (kind: number | undefined): string | null => {
126
+ switch (kind) {
127
+ case 1:
128
+ return "internal"
129
+ case 2:
130
+ return "server"
131
+ case 3:
132
+ return "client"
133
+ case 4:
134
+ return "producer"
135
+ case 5:
136
+ return "consumer"
137
+ default:
138
+ return null
139
+ }
140
+ }
141
+
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
@@ -0,0 +1,39 @@
1
+ export const ATTRIBUTE_FILTER_PREFIX = "attr."
2
+ export const ATTRIBUTE_CONTAINS_PREFIX = "attrContains."
3
+
4
+ export const isAttributeFilterToken = (value: string) => value.startsWith(ATTRIBUTE_FILTER_PREFIX) && value.includes("=")
5
+ export const isAttributeContainsToken = (value: string) => value.startsWith(ATTRIBUTE_CONTAINS_PREFIX) && value.includes("=")
6
+
7
+ export const attributeFiltersFromEntries = (entries: Iterable<readonly [string, string]>) =>
8
+ Object.fromEntries(
9
+ [...entries]
10
+ .filter(([key]) => key.startsWith(ATTRIBUTE_FILTER_PREFIX) && !key.startsWith(ATTRIBUTE_CONTAINS_PREFIX))
11
+ .map(([key, value]) => [key.slice(ATTRIBUTE_FILTER_PREFIX.length), value]),
12
+ )
13
+
14
+ export const attributeContainsFiltersFromEntries = (entries: Iterable<readonly [string, string]>) =>
15
+ Object.fromEntries(
16
+ [...entries]
17
+ .filter(([key]) => key.startsWith(ATTRIBUTE_CONTAINS_PREFIX))
18
+ .map(([key, value]) => [key.slice(ATTRIBUTE_CONTAINS_PREFIX.length), value]),
19
+ )
20
+
21
+ export const attributeFiltersFromArgs = (values: readonly string[]) =>
22
+ Object.fromEntries(
23
+ values
24
+ .filter((v) => isAttributeFilterToken(v) && !isAttributeContainsToken(v))
25
+ .map((value) => {
26
+ const index = value.indexOf("=")
27
+ return [value.slice(ATTRIBUTE_FILTER_PREFIX.length, index), value.slice(index + 1)]
28
+ }),
29
+ )
30
+
31
+ export const attributeContainsFiltersFromArgs = (values: readonly string[]) =>
32
+ Object.fromEntries(
33
+ values
34
+ .filter(isAttributeContainsToken)
35
+ .map((value) => {
36
+ const index = value.indexOf("=")
37
+ return [value.slice(ATTRIBUTE_CONTAINS_PREFIX.length, index), value.slice(index + 1)]
38
+ }),
39
+ )
@@ -0,0 +1,115 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import packageJson from "../package.json" with { type: "json" }
5
+
6
+ export const MOTEL_VERSION = packageJson.version
7
+ export const MOTEL_SERVICE_ID = "motel-local-server"
8
+
9
+ const stateHome = () =>
10
+ process.env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state")
11
+
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")
21
+
22
+ export type RegistryEntry = {
23
+ readonly pid: number
24
+ readonly url: string
25
+ readonly workdir: string
26
+ readonly startedAt: string
27
+ readonly version: string
28
+ readonly instanceId?: string
29
+ readonly processIdentity?: string
30
+ /**
31
+ * The SQLite database path the daemon is serving. Optional because
32
+ * older daemon builds omit it; consumers should treat a missing
33
+ * value as "unknown" and fall back to whatever validation path
34
+ * they would have used before this field existed (typically an
35
+ * HTTP /api/health probe).
36
+ */
37
+ readonly databasePath?: string
38
+ }
39
+
40
+ const entryPath = (pid: number, runtimeDir = motelStateDir()) => path.join(registryDir(runtimeDir), `${pid}.json`)
41
+
42
+ export const isAlive = (pid: number): boolean => {
43
+ try {
44
+ process.kill(pid, 0)
45
+ return true
46
+ } catch (err) {
47
+ return (err as NodeJS.ErrnoException).code === "EPERM"
48
+ }
49
+ }
50
+
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)
68
+ let files: string[]
69
+ try {
70
+ files = fs.readdirSync(dir)
71
+ } catch {
72
+ return []
73
+ }
74
+ const alive: RegistryEntry[] = []
75
+ for (const f of files) {
76
+ if (!f.endsWith(".json")) continue
77
+ const full = path.join(dir, f)
78
+ try {
79
+ const entry = JSON.parse(fs.readFileSync(full, "utf8")) as RegistryEntry
80
+ if (entry.instanceId && entry.processIdentity ? isManagedDaemonProcess(entry) : isAlive(entry.pid)) {
81
+ alive.push(entry)
82
+ } else {
83
+ try { fs.unlinkSync(full) } catch {}
84
+ }
85
+ } catch {
86
+ try { fs.unlinkSync(full) } catch {}
87
+ }
88
+ }
89
+ return alive
90
+ }
91
+
92
+ export const writeRegistryEntry = (entry: RegistryEntry, runtimeDir = motelStateDir()) => {
93
+ fs.mkdirSync(registryDir(runtimeDir), { recursive: true })
94
+ const file = entryPath(entry.pid, runtimeDir)
95
+ fs.writeFileSync(file, JSON.stringify(entry, null, 2), "utf8")
96
+ }
97
+
98
+ /**
99
+ * Remove this daemon's registry entry. Intended to be called from a
100
+ * Layer release so the scope-managed server shutdown removes the entry
101
+ * in the same finalizer chain that stops the socket. Historically this
102
+ * was done via ad-hoc process-signal handlers installed here that ran
103
+ * `process.exit(0)` — which races with the Effect runtime's own SIGINT
104
+ * handling and short-circuits the Bun server's graceful stop. The
105
+ * server (via BunRuntime.runMain) now owns signal handling; registry
106
+ * cleanup rides along on scope release.
107
+ */
108
+ export const removeRegistryEntry = (pid: number, runtimeDir = motelStateDir()) => {
109
+ try {
110
+ fs.unlinkSync(entryPath(pid, runtimeDir))
111
+ } catch {
112
+ // Already gone — another cleanup path won the race, or the entry
113
+ // was never written.
114
+ }
115
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,40 @@
1
+ import * as NodeSdk from "@effect/opentelemetry/NodeSdk"
2
+ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"
3
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
4
+ import { SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs"
5
+ import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
6
+ import { Layer, ManagedRuntime } from "effect"
7
+ import { config } from "./config.js"
8
+ import { TelemetryStoreLive, TelemetryStoreReadonlyLive } from "./services/TelemetryStore.js"
9
+
10
+ const telemetryLayer = NodeSdk.layer(() => ({
11
+ spanProcessor: new SimpleSpanProcessor(
12
+ new OTLPTraceExporter({
13
+ url: config.otel.exporterUrl,
14
+ }),
15
+ ),
16
+ logRecordProcessor: new SimpleLogRecordProcessor(
17
+ new OTLPLogExporter({
18
+ url: config.otel.logsExporterUrl,
19
+ }),
20
+ ),
21
+ loggerMergeWithExisting: false,
22
+ resource: {
23
+ serviceName: config.otel.serviceName,
24
+ attributes: {
25
+ "deployment.environment.name": "local",
26
+ "service.instance.id": "motel.local",
27
+ },
28
+ },
29
+ }))
30
+
31
+ // TUI-side runtime is readonly — a daemon/worker writer owns the DB
32
+ // lock while ingests are in flight, and trying to grab the write lock
33
+ // for schema init on startup causes "database is locked" on bun dev.
34
+ const QueryRuntimeLive = config.otel.enabled ? Layer.mergeAll(TelemetryStoreReadonlyLive, telemetryLayer) : TelemetryStoreReadonlyLive
35
+
36
+ export const queryRuntime = ManagedRuntime.make(QueryRuntimeLive)
37
+ // `storeRuntime` is the full writer runtime, exposed for the telemetry
38
+ // test suite (and any future tooling that needs the ingest side). The
39
+ // TUI itself only consumes `queryRuntime`, which is readonly.
40
+ export const storeRuntime = ManagedRuntime.make(TelemetryStoreLive)
package/src/server.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { BunRuntime } from "@effect/platform-bun"
2
+ import { Layer } from "effect"
3
+ import { ServerLive } from "./localServer.js"
4
+
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,82 @@
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, Duration, Effect, Exit, 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, 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))
71
+ return {
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
+ )),
80
+ }
81
+ }),
82
+ )
@@ -0,0 +1,29 @@
1
+ import { Effect, Layer, Context } from "effect"
2
+ import type { LogItem } from "../domain.js"
3
+ import { TelemetryStore } from "./TelemetryStore.js"
4
+
5
+ /**
6
+ * Compatibility adapter for consumers importing the historical log query module.
7
+ * New internal callers should use TelemetryStoreReadonly directly.
8
+ */
9
+ export class LogQueryService extends Context.Service<
10
+ LogQueryService,
11
+ {
12
+ readonly listRecentLogs: (serviceName: string) => Effect.Effect<readonly LogItem[], Error>
13
+ readonly listTraceLogs: (traceId: string) => Effect.Effect<readonly LogItem[], 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>
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>
17
+ }
18
+ >()("motel/LogQueryService") {}
19
+
20
+ export const LogQueryServiceLive = Layer.effect(
21
+ LogQueryService,
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
+ })),
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
+ )