@kitlangton/motel 0.2.5 → 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.
- package/AGENTS.md +11 -8
- package/README.md +13 -2
- package/package.json +31 -19
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +3 -5
- package/src/StartupGate.tsx +8 -10
- package/src/cli.ts +15 -16
- package/src/config.ts +7 -1
- package/src/daemon.test.ts +332 -51
- package/src/daemon.ts +103 -152
- package/src/httpApi.ts +1 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/localServer.ts +194 -323
- package/src/mcp.ts +2 -1
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +20 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/registry.ts +37 -11
- package/src/runtime.ts +2 -6
- package/src/services/AsyncIngest.ts +20 -8
- package/src/services/LogQueryService.ts +11 -25
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +433 -249
- package/src/services/TraceQueryService.ts +18 -52
- package/src/services/ingestRpc.ts +2 -4
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +5 -8
- package/src/storybook/aiChatStory.tsx +1 -1
- package/src/telemetry.test.ts +307 -41
- package/src/ui/AiChatView.tsx +1 -1
- package/src/ui/AttrFilterModal.tsx +1 -1
- package/src/ui/ServiceLogs.tsx +10 -7
- package/src/ui/SpanContentView.tsx +24 -21
- package/src/ui/TraceDetailsPane.tsx +1 -1
- package/src/ui/TraceList.tsx +1 -1
- package/src/ui/aiState.ts +10 -22
- package/src/ui/app/TraceWorkspace.tsx +2 -1
- package/src/ui/app/useAppLayout.ts +1 -1
- package/src/ui/app/useTraceScreenData.ts +22 -18
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/loaders.ts +34 -53
- package/src/ui/primitives.tsx +1 -1
- package/src/ui/state.ts +2 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
- package/src/ui/traceSortNav.repro.seed.ts +1 -1
- package/src/ui/traceSortNav.repro.test.ts +12 -2
- package/src/ui/useAttrFilterPicker.ts +10 -8
- package/src/ui/useKeyboardNav.ts +3 -6
- package/src/ui/waterfallNav.repro.seed.ts +1 -1
- package/src/ui/waterfallNav.repro.test.ts +16 -8
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-M86tcih5.js +22 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DnyVo03x.js +0 -27
- 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:
|
|
426
|
+
version: MOTEL_VERSION,
|
|
426
427
|
}),
|
|
427
428
|
),
|
|
428
429
|
Layer.provide(BunStdio.layer),
|
|
@@ -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
|
+
}
|
package/src/otlp.test.ts
ADDED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
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
|
|
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
|
|
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,16 +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* Effect.gen(function*() {
|
|
59
|
-
const
|
|
60
|
-
|
|
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(
|
|
61
62
|
Effect.provide(protocolContext),
|
|
62
|
-
Effect.provideService(Scope.Scope,
|
|
63
|
+
Effect.provideService(Scope.Scope, clientScope),
|
|
63
64
|
)
|
|
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))
|
|
65
71
|
return {
|
|
66
|
-
ingestTraces: (input, options) =>
|
|
67
|
-
|
|
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
|
+
)),
|
|
68
80
|
}
|
|
69
81
|
}),
|
|
70
82
|
)
|
|
@@ -2,6 +2,10 @@ 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
|
{
|
|
@@ -15,29 +19,11 @@ export class LogQueryService extends Context.Service<
|
|
|
15
19
|
|
|
16
20
|
export const LogQueryServiceLive = Layer.effect(
|
|
17
21
|
LogQueryService,
|
|
18
|
-
Effect.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
)
|