@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.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- 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
|
+
)
|
package/src/registry.ts
ADDED
|
@@ -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
|
+
)
|