@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.
Files changed (53) hide show
  1. package/AGENTS.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/package.json +92 -0
  5. package/src/App.tsx +217 -0
  6. package/src/cli.ts +258 -0
  7. package/src/config.ts +39 -0
  8. package/src/daemon.test.ts +59 -0
  9. package/src/daemon.ts +398 -0
  10. package/src/domain.ts +233 -0
  11. package/src/httpApi.ts +384 -0
  12. package/src/index.tsx +18 -0
  13. package/src/instructions.ts +72 -0
  14. package/src/localServer.ts +699 -0
  15. package/src/locator.ts +138 -0
  16. package/src/mcp.ts +260 -0
  17. package/src/motel.ts +86 -0
  18. package/src/motelClient.ts +201 -0
  19. package/src/otlp.ts +142 -0
  20. package/src/queryFilters.ts +39 -0
  21. package/src/registry.ts +86 -0
  22. package/src/runtime.ts +38 -0
  23. package/src/server.ts +10 -0
  24. package/src/services/LogQueryService.ts +43 -0
  25. package/src/services/TelemetryStore.ts +1821 -0
  26. package/src/services/TraceQueryService.ts +71 -0
  27. package/src/telemetry.test.ts +726 -0
  28. package/src/ui/ServiceLogs.tsx +112 -0
  29. package/src/ui/SpanDetail.tsx +134 -0
  30. package/src/ui/SpanDetailFull.tsx +224 -0
  31. package/src/ui/SpanDetailPane.tsx +91 -0
  32. package/src/ui/TraceDetailsPane.tsx +169 -0
  33. package/src/ui/TraceList.tsx +128 -0
  34. package/src/ui/Waterfall.tsx +412 -0
  35. package/src/ui/app/TraceListPane.tsx +34 -0
  36. package/src/ui/app/TraceWorkspace.tsx +254 -0
  37. package/src/ui/app/useAppLayout.ts +79 -0
  38. package/src/ui/app/useTraceScreenData.ts +411 -0
  39. package/src/ui/format.ts +119 -0
  40. package/src/ui/primitives.tsx +170 -0
  41. package/src/ui/state.ts +137 -0
  42. package/src/ui/theme.ts +153 -0
  43. package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
  44. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  45. package/src/ui/traceSortNav.repro.test.ts +220 -0
  46. package/src/ui/useKeyboardNav.ts +532 -0
  47. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  48. package/src/ui/waterfallNav.repro.test.ts +263 -0
  49. package/src/ui/waterfallNav.test.ts +422 -0
  50. package/src/ui/waterfallNav.ts +75 -0
  51. package/web/dist/assets/index-BEKIiisE.js +27 -0
  52. package/web/dist/assets/index-DzuHNBGV.css +2 -0
  53. 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
+ )
@@ -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
+ )