@kitlangton/motel 0.1.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 +142 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +92 -0
- package/src/App.tsx +217 -0
- package/src/cli.ts +258 -0
- package/src/config.ts +39 -0
- package/src/daemon.test.ts +59 -0
- package/src/daemon.ts +398 -0
- package/src/domain.ts +233 -0
- package/src/httpApi.ts +384 -0
- package/src/index.tsx +18 -0
- package/src/instructions.ts +72 -0
- package/src/localServer.ts +699 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +260 -0
- package/src/motel.ts +86 -0
- package/src/motelClient.ts +201 -0
- package/src/otlp.ts +142 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +86 -0
- package/src/runtime.ts +38 -0
- package/src/server.ts +10 -0
- package/src/services/LogQueryService.ts +43 -0
- package/src/services/TelemetryStore.ts +1821 -0
- package/src/services/TraceQueryService.ts +71 -0
- package/src/telemetry.test.ts +726 -0
- package/src/ui/ServiceLogs.tsx +112 -0
- package/src/ui/SpanDetail.tsx +134 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +91 -0
- package/src/ui/TraceDetailsPane.tsx +169 -0
- package/src/ui/TraceList.tsx +128 -0
- package/src/ui/Waterfall.tsx +412 -0
- package/src/ui/app/TraceListPane.tsx +34 -0
- package/src/ui/app/TraceWorkspace.tsx +254 -0
- package/src/ui/app/useAppLayout.ts +79 -0
- package/src/ui/app/useTraceScreenData.ts +411 -0
- package/src/ui/format.ts +119 -0
- package/src/ui/primitives.tsx +170 -0
- package/src/ui/state.ts +137 -0
- package/src/ui/theme.ts +153 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +220 -0
- package/src/ui/useKeyboardNav.ts +532 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +263 -0
- package/src/ui/waterfallNav.test.ts +422 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-BEKIiisE.js +27 -0
- package/web/dist/assets/index-DzuHNBGV.css +2 -0
- package/web/dist/index.html +13 -0
package/src/otlp.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
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")
|
|
@@ -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,86 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as os from "node:os"
|
|
3
|
+
import * as path from "node:path"
|
|
4
|
+
|
|
5
|
+
export const MOTEL_VERSION = "0.1.0"
|
|
6
|
+
export const MOTEL_SERVICE_ID = "motel-local-server"
|
|
7
|
+
|
|
8
|
+
const stateHome = () =>
|
|
9
|
+
process.env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state")
|
|
10
|
+
|
|
11
|
+
export const registryDir = () => path.join(stateHome(), "motel", "instances")
|
|
12
|
+
|
|
13
|
+
export type RegistryEntry = {
|
|
14
|
+
readonly pid: number
|
|
15
|
+
readonly url: string
|
|
16
|
+
readonly workdir: string
|
|
17
|
+
readonly startedAt: string
|
|
18
|
+
readonly version: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const entryPath = (pid: number) => path.join(registryDir(), `${pid}.json`)
|
|
22
|
+
|
|
23
|
+
let currentEntryPath: string | null = null
|
|
24
|
+
let signalHandlersRegistered = false
|
|
25
|
+
|
|
26
|
+
const cleanup = () => {
|
|
27
|
+
if (!currentEntryPath) return
|
|
28
|
+
try {
|
|
29
|
+
fs.unlinkSync(currentEntryPath)
|
|
30
|
+
} catch {
|
|
31
|
+
// already gone — ignore
|
|
32
|
+
}
|
|
33
|
+
currentEntryPath = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const isAlive = (pid: number): boolean => {
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0)
|
|
39
|
+
return true
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return (err as NodeJS.ErrnoException).code === "EPERM"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const listAliveEntries = (): RegistryEntry[] => {
|
|
46
|
+
const dir = registryDir()
|
|
47
|
+
let files: string[]
|
|
48
|
+
try {
|
|
49
|
+
files = fs.readdirSync(dir)
|
|
50
|
+
} catch {
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
const alive: RegistryEntry[] = []
|
|
54
|
+
for (const f of files) {
|
|
55
|
+
if (!f.endsWith(".json")) continue
|
|
56
|
+
const full = path.join(dir, f)
|
|
57
|
+
try {
|
|
58
|
+
const entry = JSON.parse(fs.readFileSync(full, "utf8")) as RegistryEntry
|
|
59
|
+
if (isAlive(entry.pid)) {
|
|
60
|
+
alive.push(entry)
|
|
61
|
+
} else {
|
|
62
|
+
try { fs.unlinkSync(full) } catch {}
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
try { fs.unlinkSync(full) } catch {}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return alive
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const writeRegistryEntry = (entry: RegistryEntry) => {
|
|
72
|
+
fs.mkdirSync(registryDir(), { recursive: true })
|
|
73
|
+
const file = entryPath(entry.pid)
|
|
74
|
+
fs.writeFileSync(file, JSON.stringify(entry, null, 2), "utf8")
|
|
75
|
+
currentEntryPath = file
|
|
76
|
+
if (!signalHandlersRegistered) {
|
|
77
|
+
signalHandlersRegistered = true
|
|
78
|
+
process.on("exit", cleanup)
|
|
79
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
80
|
+
process.on(sig, () => {
|
|
81
|
+
cleanup()
|
|
82
|
+
process.exit(0)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
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 { LogQueryServiceLive } from "./services/LogQueryService.js"
|
|
9
|
+
import { TelemetryStoreLive } from "./services/TelemetryStore.js"
|
|
10
|
+
import { TraceQueryServiceLive } from "./services/TraceQueryService.js"
|
|
11
|
+
|
|
12
|
+
const telemetryLayer = NodeSdk.layer(() => ({
|
|
13
|
+
spanProcessor: new SimpleSpanProcessor(
|
|
14
|
+
new OTLPTraceExporter({
|
|
15
|
+
url: config.otel.exporterUrl,
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
logRecordProcessor: new SimpleLogRecordProcessor(
|
|
19
|
+
new OTLPLogExporter({
|
|
20
|
+
url: config.otel.logsExporterUrl,
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
loggerMergeWithExisting: false,
|
|
24
|
+
resource: {
|
|
25
|
+
serviceName: config.otel.serviceName,
|
|
26
|
+
attributes: {
|
|
27
|
+
"deployment.environment.name": "local",
|
|
28
|
+
"service.instance.id": "motel.local",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
const QueryServicesLive = Layer.mergeAll(TraceQueryServiceLive, LogQueryServiceLive).pipe(Layer.provideMerge(TelemetryStoreLive))
|
|
34
|
+
|
|
35
|
+
const QueryRuntimeLive = config.otel.enabled ? Layer.mergeAll(QueryServicesLive, telemetryLayer) : QueryServicesLive
|
|
36
|
+
|
|
37
|
+
export const queryRuntime = ManagedRuntime.make(QueryRuntimeLive)
|
|
38
|
+
export const storeRuntime = ManagedRuntime.make(TelemetryStoreLive)
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { config } from "./config.js"
|
|
2
|
+
import { startLocalServer } from "./localServer.js"
|
|
3
|
+
|
|
4
|
+
await startLocalServer()
|
|
5
|
+
|
|
6
|
+
console.log(`motel local telemetry server listening on ${config.otel.queryUrl}`)
|
|
7
|
+
|
|
8
|
+
await new Promise(() => {
|
|
9
|
+
// keep process alive
|
|
10
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Effect, Layer, Context } from "effect"
|
|
2
|
+
import type { LogItem } from "../domain.js"
|
|
3
|
+
import { TelemetryStore } from "./TelemetryStore.js"
|
|
4
|
+
|
|
5
|
+
export class LogQueryService extends Context.Service<
|
|
6
|
+
LogQueryService,
|
|
7
|
+
{
|
|
8
|
+
readonly listRecentLogs: (serviceName: string) => Effect.Effect<readonly LogItem[], Error>
|
|
9
|
+
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>
|
|
12
|
+
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
|
+
}
|
|
14
|
+
>()("motel/LogQueryService") {}
|
|
15
|
+
|
|
16
|
+
export const LogQueryServiceLive = Layer.effect(
|
|
17
|
+
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
|
+
}),
|
|
43
|
+
)
|