@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/daemon.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import { promises as fsp } from "node:fs"
|
|
3
|
+
import * as path from "node:path"
|
|
4
|
+
import { Effect } from "effect"
|
|
5
|
+
import { listAliveEntries, MOTEL_SERVICE_ID, type RegistryEntry, isAlive } from "./registry.js"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dir, "..")
|
|
8
|
+
const DEFAULT_RUNTIME_DIR = path.join(DEFAULT_REPO_ROOT, ".motel-data")
|
|
9
|
+
const DEFAULT_DATABASE_PATH = path.join(DEFAULT_RUNTIME_DIR, "telemetry.sqlite")
|
|
10
|
+
const DEFAULT_HOST = "127.0.0.1"
|
|
11
|
+
const DEFAULT_PORT = 27686
|
|
12
|
+
const START_TIMEOUT_MS = 15_000
|
|
13
|
+
const STOP_TIMEOUT_MS = 10_000
|
|
14
|
+
const LOCK_TIMEOUT_MS = 10_000
|
|
15
|
+
const POLL_INTERVAL_MS = 150
|
|
16
|
+
|
|
17
|
+
type HealthShape = {
|
|
18
|
+
readonly ok: boolean
|
|
19
|
+
readonly service: string
|
|
20
|
+
readonly databasePath: string
|
|
21
|
+
readonly pid: number
|
|
22
|
+
readonly url: string
|
|
23
|
+
readonly workdir: string
|
|
24
|
+
readonly startedAt: string
|
|
25
|
+
readonly version: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type LockShape = {
|
|
29
|
+
readonly pid: number
|
|
30
|
+
readonly createdAt: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type DaemonConfig = {
|
|
34
|
+
readonly repoRoot: string
|
|
35
|
+
readonly runtimeDir: string
|
|
36
|
+
readonly databasePath: string
|
|
37
|
+
readonly logPath: string
|
|
38
|
+
readonly lockPath: string
|
|
39
|
+
readonly host: string
|
|
40
|
+
readonly port: number
|
|
41
|
+
readonly baseUrl: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type DaemonStatus = {
|
|
45
|
+
readonly running: boolean
|
|
46
|
+
readonly managed: boolean
|
|
47
|
+
readonly service: string | null
|
|
48
|
+
readonly pid: number | null
|
|
49
|
+
readonly url: string
|
|
50
|
+
readonly databasePath: string
|
|
51
|
+
readonly workdir: string | null
|
|
52
|
+
readonly startedAt: string | null
|
|
53
|
+
readonly version: string | null
|
|
54
|
+
readonly sameWorkdir: boolean
|
|
55
|
+
readonly reason: string | null
|
|
56
|
+
readonly logPath: string
|
|
57
|
+
readonly lockPath: string
|
|
58
|
+
readonly registryPid: number | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type DaemonManager = {
|
|
62
|
+
readonly applyEnv: Effect.Effect<void>
|
|
63
|
+
readonly getStatus: Effect.Effect<DaemonStatus, DaemonError>
|
|
64
|
+
readonly ensure: Effect.Effect<DaemonStatus, DaemonError>
|
|
65
|
+
readonly stop: Effect.Effect<DaemonStatus, DaemonError>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type DaemonOptions = {
|
|
69
|
+
readonly repoRoot?: string
|
|
70
|
+
readonly runtimeDir?: string
|
|
71
|
+
readonly databasePath?: string
|
|
72
|
+
readonly host?: string
|
|
73
|
+
readonly port?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class DaemonError extends Error {
|
|
77
|
+
readonly _tag = "DaemonError"
|
|
78
|
+
constructor(message: string) {
|
|
79
|
+
super(message)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
84
|
+
|
|
85
|
+
const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
|
|
86
|
+
const repoRoot = path.resolve(options.repoRoot ?? DEFAULT_REPO_ROOT)
|
|
87
|
+
const runtimeDir = path.resolve(options.runtimeDir ?? DEFAULT_RUNTIME_DIR)
|
|
88
|
+
const databasePath = path.resolve(options.databasePath ?? path.join(runtimeDir, "telemetry.sqlite"))
|
|
89
|
+
const host = options.host ?? DEFAULT_HOST
|
|
90
|
+
const port = options.port ?? DEFAULT_PORT
|
|
91
|
+
return {
|
|
92
|
+
repoRoot,
|
|
93
|
+
runtimeDir,
|
|
94
|
+
databasePath,
|
|
95
|
+
logPath: path.join(runtimeDir, "daemon.log"),
|
|
96
|
+
lockPath: path.join(runtimeDir, "daemon.lock"),
|
|
97
|
+
host,
|
|
98
|
+
port,
|
|
99
|
+
baseUrl: `http://${host}:${port}`,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const cwdMatches = (workdir: string) => {
|
|
104
|
+
const cwd = process.cwd()
|
|
105
|
+
const normalizedCwd = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`
|
|
106
|
+
const normalizedWorkdir = workdir.endsWith(path.sep) ? workdir : `${workdir}${path.sep}`
|
|
107
|
+
return normalizedCwd === normalizedWorkdir || normalizedCwd.startsWith(normalizedWorkdir)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pickByCwd = (entries: readonly RegistryEntry[]) => {
|
|
111
|
+
const cwd = process.cwd()
|
|
112
|
+
const withSep = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`
|
|
113
|
+
return entries
|
|
114
|
+
.filter((entry) => {
|
|
115
|
+
const workdir = entry.workdir.endsWith(path.sep) ? entry.workdir : `${entry.workdir}${path.sep}`
|
|
116
|
+
return withSep === workdir || withSep.startsWith(workdir)
|
|
117
|
+
})
|
|
118
|
+
.sort((a, b) => b.workdir.length - a.workdir.length)[0] ?? null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const readRegistryEntry = () => pickByCwd(listAliveEntries())
|
|
122
|
+
|
|
123
|
+
const expectedEnv = (config: DaemonConfig) => ({
|
|
124
|
+
MOTEL_OTEL_BASE_URL: config.baseUrl,
|
|
125
|
+
MOTEL_OTEL_QUERY_URL: config.baseUrl,
|
|
126
|
+
MOTEL_OTEL_HOST: config.host,
|
|
127
|
+
MOTEL_OTEL_PORT: String(config.port),
|
|
128
|
+
MOTEL_OTEL_DB_PATH: config.databasePath,
|
|
129
|
+
MOTEL_OTEL_EXPORTER_URL: `${config.baseUrl}/v1/traces`,
|
|
130
|
+
MOTEL_OTEL_LOGS_EXPORTER_URL: `${config.baseUrl}/v1/logs`,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager => {
|
|
134
|
+
const config = resolveConfig(options)
|
|
135
|
+
const mapError = (error: unknown) => new DaemonError(error instanceof Error ? error.message : String(error))
|
|
136
|
+
|
|
137
|
+
const fetchHealth = async (): Promise<HealthShape | null> => {
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetch(`${config.baseUrl}/api/health`, { signal: AbortSignal.timeout(750) })
|
|
140
|
+
if (!response.ok) return null
|
|
141
|
+
return await response.json() as HealthShape
|
|
142
|
+
} catch {
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const describeManagedMismatch = (health: HealthShape) => {
|
|
148
|
+
if (health.service !== MOTEL_SERVICE_ID) {
|
|
149
|
+
return `Port ${config.port} is in use by ${health.service}, not ${MOTEL_SERVICE_ID}.`
|
|
150
|
+
}
|
|
151
|
+
if (!cwdMatches(health.workdir)) {
|
|
152
|
+
return `Port ${config.port} is serving motel for ${health.workdir}, not ${process.cwd()}.`
|
|
153
|
+
}
|
|
154
|
+
if (health.databasePath !== config.databasePath) {
|
|
155
|
+
return `Port ${config.port} is serving motel with ${health.databasePath}, expected ${config.databasePath}.`
|
|
156
|
+
}
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const readLock = async (): Promise<LockShape | null> => {
|
|
161
|
+
try {
|
|
162
|
+
const raw = await fsp.readFile(config.lockPath, "utf8")
|
|
163
|
+
return JSON.parse(raw) as LockShape
|
|
164
|
+
} catch {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const removeStaleLock = async () => {
|
|
170
|
+
const current = await readLock()
|
|
171
|
+
if (!current) {
|
|
172
|
+
await fsp.rm(config.lockPath, { force: true })
|
|
173
|
+
return true
|
|
174
|
+
}
|
|
175
|
+
if (isAlive(current.pid)) return false
|
|
176
|
+
await fsp.rm(config.lockPath, { force: true })
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const acquireStartupLock = async () => {
|
|
181
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
182
|
+
await fsp.mkdir(config.runtimeDir, { recursive: true })
|
|
183
|
+
|
|
184
|
+
while (Date.now() < deadline) {
|
|
185
|
+
try {
|
|
186
|
+
const handle = await fsp.open(config.lockPath, "wx")
|
|
187
|
+
const contents = JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() } satisfies LockShape)
|
|
188
|
+
await handle.writeFile(contents, "utf8")
|
|
189
|
+
return {
|
|
190
|
+
release: async () => {
|
|
191
|
+
await handle.close().catch(() => undefined)
|
|
192
|
+
await fsp.rm(config.lockPath, { force: true }).catch(() => undefined)
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const errno = error as NodeJS.ErrnoException
|
|
197
|
+
if (errno.code !== "EEXIST") throw error
|
|
198
|
+
if (await removeStaleLock()) continue
|
|
199
|
+
await sleep(POLL_INTERVAL_MS)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
throw new Error(`Timed out waiting for daemon startup lock at ${config.lockPath}`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const openLogFile = async () => {
|
|
207
|
+
await fsp.mkdir(config.runtimeDir, { recursive: true })
|
|
208
|
+
return fs.openSync(config.logPath, "a")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const waitForHealthy = async (pid: number) => {
|
|
212
|
+
const deadline = Date.now() + START_TIMEOUT_MS
|
|
213
|
+
while (Date.now() < deadline) {
|
|
214
|
+
const health = await fetchHealth()
|
|
215
|
+
if (health) {
|
|
216
|
+
const mismatch = describeManagedMismatch(health)
|
|
217
|
+
if (!mismatch) return health
|
|
218
|
+
throw new Error(mismatch)
|
|
219
|
+
}
|
|
220
|
+
if (!isAlive(pid)) {
|
|
221
|
+
throw new Error(`Daemon process ${pid} exited before becoming healthy. See ${config.logPath}.`)
|
|
222
|
+
}
|
|
223
|
+
await sleep(POLL_INTERVAL_MS)
|
|
224
|
+
}
|
|
225
|
+
throw new Error(`Timed out waiting for daemon health at ${config.baseUrl}/api/health. See ${config.logPath}.`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const stopPid = async (pid: number) => {
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, "SIGTERM")
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const errno = error as NodeJS.ErrnoException
|
|
233
|
+
if (errno.code !== "ESRCH") throw error
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS
|
|
237
|
+
while (Date.now() < deadline) {
|
|
238
|
+
if (!isAlive(pid)) return
|
|
239
|
+
const health = await fetchHealth()
|
|
240
|
+
if (!health || health.pid !== pid) return
|
|
241
|
+
await sleep(POLL_INTERVAL_MS)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
throw new Error(`Timed out waiting for daemon ${pid} to stop.`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const getStatus = async (): Promise<DaemonStatus> => {
|
|
248
|
+
const registry = readRegistryEntry()
|
|
249
|
+
const health = await fetchHealth()
|
|
250
|
+
if (!health) {
|
|
251
|
+
return {
|
|
252
|
+
running: false,
|
|
253
|
+
managed: false,
|
|
254
|
+
service: null,
|
|
255
|
+
pid: registry?.pid ?? null,
|
|
256
|
+
url: config.baseUrl,
|
|
257
|
+
databasePath: config.databasePath,
|
|
258
|
+
workdir: registry?.workdir ?? null,
|
|
259
|
+
startedAt: registry?.startedAt ?? null,
|
|
260
|
+
version: registry?.version ?? null,
|
|
261
|
+
sameWorkdir: registry ? cwdMatches(registry.workdir) : false,
|
|
262
|
+
reason: registry ? "Registry entry exists but daemon is not healthy." : null,
|
|
263
|
+
logPath: config.logPath,
|
|
264
|
+
lockPath: config.lockPath,
|
|
265
|
+
registryPid: registry?.pid ?? null,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const mismatch = describeManagedMismatch(health)
|
|
270
|
+
return {
|
|
271
|
+
running: mismatch === null,
|
|
272
|
+
managed: mismatch === null,
|
|
273
|
+
service: health.service,
|
|
274
|
+
pid: health.pid,
|
|
275
|
+
url: health.url,
|
|
276
|
+
databasePath: health.databasePath,
|
|
277
|
+
workdir: health.workdir,
|
|
278
|
+
startedAt: health.startedAt,
|
|
279
|
+
version: health.version,
|
|
280
|
+
sameWorkdir: cwdMatches(health.workdir),
|
|
281
|
+
reason: mismatch,
|
|
282
|
+
logPath: config.logPath,
|
|
283
|
+
lockPath: config.lockPath,
|
|
284
|
+
registryPid: registry?.pid ?? null,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const ensure = async (): Promise<DaemonStatus> => {
|
|
289
|
+
const existing = await getStatus()
|
|
290
|
+
if (existing.managed && existing.running) return existing
|
|
291
|
+
if (existing.service !== null && existing.reason) {
|
|
292
|
+
throw new Error(existing.reason)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const lock = await acquireStartupLock()
|
|
296
|
+
let spawnedPid: number | null = null
|
|
297
|
+
try {
|
|
298
|
+
const rechecked = await getStatus()
|
|
299
|
+
if (rechecked.managed && rechecked.running) return rechecked
|
|
300
|
+
if (rechecked.service !== null && rechecked.reason) {
|
|
301
|
+
throw new Error(rechecked.reason)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const logFd = await openLogFile()
|
|
305
|
+
try {
|
|
306
|
+
const proc = Bun.spawn({
|
|
307
|
+
cmd: [process.execPath, "run", "src/server.ts"],
|
|
308
|
+
cwd: config.repoRoot,
|
|
309
|
+
detached: true,
|
|
310
|
+
env: {
|
|
311
|
+
...process.env,
|
|
312
|
+
...expectedEnv(config),
|
|
313
|
+
},
|
|
314
|
+
stdio: ["ignore", logFd, logFd],
|
|
315
|
+
})
|
|
316
|
+
spawnedPid = proc.pid
|
|
317
|
+
proc.unref()
|
|
318
|
+
} finally {
|
|
319
|
+
fs.closeSync(logFd)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (spawnedPid === null) {
|
|
323
|
+
throw new Error("Daemon failed to spawn.")
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const health = await waitForHealthy(spawnedPid)
|
|
327
|
+
return {
|
|
328
|
+
running: true,
|
|
329
|
+
managed: true,
|
|
330
|
+
service: health.service,
|
|
331
|
+
pid: health.pid,
|
|
332
|
+
url: health.url,
|
|
333
|
+
databasePath: health.databasePath,
|
|
334
|
+
workdir: health.workdir,
|
|
335
|
+
startedAt: health.startedAt,
|
|
336
|
+
version: health.version,
|
|
337
|
+
sameWorkdir: cwdMatches(health.workdir),
|
|
338
|
+
reason: null,
|
|
339
|
+
logPath: config.logPath,
|
|
340
|
+
lockPath: config.lockPath,
|
|
341
|
+
registryPid: health.pid,
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (spawnedPid !== null) {
|
|
345
|
+
await stopPid(spawnedPid).catch(() => undefined)
|
|
346
|
+
}
|
|
347
|
+
throw error
|
|
348
|
+
} finally {
|
|
349
|
+
await lock.release()
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const stop = async (): Promise<DaemonStatus> => {
|
|
354
|
+
const status = await getStatus()
|
|
355
|
+
if (status.pid === null) return status
|
|
356
|
+
if (!status.sameWorkdir) {
|
|
357
|
+
throw new Error(`Refusing to stop motel owned by ${status.workdir}.`)
|
|
358
|
+
}
|
|
359
|
+
if (status.service !== null && status.service !== MOTEL_SERVICE_ID) {
|
|
360
|
+
throw new Error(`Refusing to stop non-motel service ${status.service} on ${status.url}.`)
|
|
361
|
+
}
|
|
362
|
+
await stopPid(status.pid)
|
|
363
|
+
return await getStatus()
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
applyEnv: Effect.sync(() => {
|
|
368
|
+
for (const [key, value] of Object.entries(expectedEnv(config))) {
|
|
369
|
+
process.env[key] = value
|
|
370
|
+
}
|
|
371
|
+
}),
|
|
372
|
+
getStatus: Effect.fn("DaemonManager.getStatus")(() =>
|
|
373
|
+
Effect.tryPromise({
|
|
374
|
+
try: getStatus,
|
|
375
|
+
catch: mapError,
|
|
376
|
+
}),
|
|
377
|
+
)(),
|
|
378
|
+
ensure: Effect.fn("DaemonManager.ensure")(() =>
|
|
379
|
+
Effect.tryPromise({
|
|
380
|
+
try: ensure,
|
|
381
|
+
catch: mapError,
|
|
382
|
+
}),
|
|
383
|
+
)(),
|
|
384
|
+
stop: Effect.fn("DaemonManager.stop")(() =>
|
|
385
|
+
Effect.tryPromise({
|
|
386
|
+
try: stop,
|
|
387
|
+
catch: mapError,
|
|
388
|
+
}),
|
|
389
|
+
)(),
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const defaultManager = createDaemonManager()
|
|
394
|
+
|
|
395
|
+
export const applyManagedDaemonEnv = defaultManager.applyEnv
|
|
396
|
+
export const getManagedDaemonStatus = defaultManager.getStatus
|
|
397
|
+
export const ensureManagedDaemon = defaultManager.ensure
|
|
398
|
+
export const stopManagedDaemon = defaultManager.stop
|
package/src/domain.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Reusable helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const StringRecord = Schema.Record(Schema.String, Schema.String)
|
|
8
|
+
|
|
9
|
+
const DateFromString = Schema.DateFromString
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Core telemetry domain types (Schema is the single source of truth)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export const TraceSpanStatus = Schema.Literals(["ok", "error"])
|
|
16
|
+
export type TraceSpanStatus = typeof TraceSpanStatus.Type
|
|
17
|
+
|
|
18
|
+
export const TraceSpanEvent = Schema.Struct({
|
|
19
|
+
name: Schema.String.pipe(Schema.annotateKey({ description: "Event name" })),
|
|
20
|
+
timestamp: DateFromString.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp" })),
|
|
21
|
+
attributes: StringRecord.pipe(Schema.annotateKey({ description: "Key-value attributes attached to the event" })),
|
|
22
|
+
}).annotate({ identifier: "TraceSpanEvent" })
|
|
23
|
+
|
|
24
|
+
export type TraceSpanEvent = typeof TraceSpanEvent.Type
|
|
25
|
+
|
|
26
|
+
export const TraceSpanItem = Schema.Struct({
|
|
27
|
+
spanId: Schema.String,
|
|
28
|
+
parentSpanId: Schema.NullOr(Schema.String),
|
|
29
|
+
serviceName: Schema.String,
|
|
30
|
+
scopeName: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Instrumentation scope (e.g. module or library name)" })),
|
|
31
|
+
kind: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Span kind: client, server, producer, consumer, or internal" })),
|
|
32
|
+
operationName: Schema.String.pipe(Schema.annotateKey({ description: "The operation this span represents (e.g. HTTP handler, DB query)" })),
|
|
33
|
+
startTime: DateFromString.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp" })),
|
|
34
|
+
isRunning: Schema.Boolean.pipe(Schema.annotateKey({ description: "True when the span has not reported an end timestamp yet" })),
|
|
35
|
+
durationMs: Schema.Number.pipe(Schema.annotateKey({ description: "Wall-clock duration in milliseconds" })),
|
|
36
|
+
status: TraceSpanStatus.pipe(Schema.annotateKey({ description: "ok or error" })),
|
|
37
|
+
depth: Schema.Number.pipe(Schema.annotateKey({ description: "Nesting depth in the span tree (root = 0)" })),
|
|
38
|
+
tags: StringRecord.pipe(Schema.annotateKey({ description: "Span attributes as key-value pairs" })),
|
|
39
|
+
warnings: Schema.Array(Schema.String).pipe(Schema.annotateKey({ description: "Structural warnings (e.g. missing parent span)" })),
|
|
40
|
+
events: Schema.Array(TraceSpanEvent),
|
|
41
|
+
}).annotate({ identifier: "TraceSpan" })
|
|
42
|
+
|
|
43
|
+
export type TraceSpanItem = typeof TraceSpanItem.Type
|
|
44
|
+
|
|
45
|
+
export const TraceItem = Schema.Struct({
|
|
46
|
+
traceId: Schema.String,
|
|
47
|
+
serviceName: Schema.String.pipe(Schema.annotateKey({ description: "Service that owns the root span" })),
|
|
48
|
+
rootOperationName: Schema.String.pipe(Schema.annotateKey({ description: "Operation name of the root span" })),
|
|
49
|
+
startedAt: DateFromString.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp of the earliest span" })),
|
|
50
|
+
isRunning: Schema.Boolean.pipe(Schema.annotateKey({ description: "True when any span in the trace is still open" })),
|
|
51
|
+
durationMs: Schema.Number.pipe(Schema.annotateKey({ description: "End-to-end trace duration in milliseconds" })),
|
|
52
|
+
spanCount: Schema.Number,
|
|
53
|
+
errorCount: Schema.Number.pipe(Schema.annotateKey({ description: "Number of spans with status=error" })),
|
|
54
|
+
warnings: Schema.Array(Schema.String),
|
|
55
|
+
spans: Schema.Array(TraceSpanItem).pipe(Schema.annotateKey({ description: "Spans ordered by parent-child hierarchy, depth-first" })),
|
|
56
|
+
}).annotate({ identifier: "Trace" })
|
|
57
|
+
|
|
58
|
+
export type TraceItem = typeof TraceItem.Type
|
|
59
|
+
|
|
60
|
+
export const TraceSummaryItem = Schema.Struct({
|
|
61
|
+
traceId: Schema.String,
|
|
62
|
+
serviceName: Schema.String,
|
|
63
|
+
rootOperationName: Schema.String,
|
|
64
|
+
startedAt: DateFromString.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp" })),
|
|
65
|
+
isRunning: Schema.Boolean,
|
|
66
|
+
durationMs: Schema.Number,
|
|
67
|
+
spanCount: Schema.Number,
|
|
68
|
+
errorCount: Schema.Number,
|
|
69
|
+
warnings: Schema.Array(Schema.String),
|
|
70
|
+
}).annotate({ identifier: "TraceSummary" })
|
|
71
|
+
|
|
72
|
+
export type TraceSummaryItem = typeof TraceSummaryItem.Type
|
|
73
|
+
|
|
74
|
+
export const SpanItem = Schema.Struct({
|
|
75
|
+
traceId: Schema.String,
|
|
76
|
+
rootOperationName: Schema.String.pipe(Schema.annotateKey({ description: "Operation name of the trace's root span, for context" })),
|
|
77
|
+
parentOperationName: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Parent span operation name, if present" })),
|
|
78
|
+
span: TraceSpanItem,
|
|
79
|
+
}).annotate({ identifier: "SpanWithContext" })
|
|
80
|
+
|
|
81
|
+
export type SpanItem = typeof SpanItem.Type
|
|
82
|
+
|
|
83
|
+
export const LogItem = Schema.Struct({
|
|
84
|
+
id: Schema.String,
|
|
85
|
+
timestamp: DateFromString.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp" })),
|
|
86
|
+
serviceName: Schema.String,
|
|
87
|
+
severityText: Schema.String.pipe(Schema.annotateKey({ description: "Log level: TRACE, DEBUG, INFO, WARN, ERROR, FATAL" })),
|
|
88
|
+
body: Schema.String.pipe(Schema.annotateKey({ description: "Log message body" })),
|
|
89
|
+
traceId: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Associated trace ID, if the log was emitted inside a span" })),
|
|
90
|
+
spanId: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Associated span ID, if the log was emitted inside a span" })),
|
|
91
|
+
scopeName: Schema.NullOr(Schema.String),
|
|
92
|
+
attributes: StringRecord.pipe(Schema.annotateKey({ description: "Merged resource + log attributes as key-value pairs" })),
|
|
93
|
+
}).annotate({ identifier: "Log" })
|
|
94
|
+
|
|
95
|
+
export type LogItem = typeof LogItem.Type
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Shared query result types
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export const FacetItem = Schema.Struct({
|
|
102
|
+
value: Schema.String.pipe(Schema.annotateKey({ description: "Distinct value for the faceted field" })),
|
|
103
|
+
count: Schema.Number.pipe(Schema.annotateKey({ description: "Number of occurrences" })),
|
|
104
|
+
}).annotate({ identifier: "Facet" })
|
|
105
|
+
|
|
106
|
+
export type FacetItem = typeof FacetItem.Type
|
|
107
|
+
|
|
108
|
+
export const StatsItem = Schema.Struct({
|
|
109
|
+
group: Schema.String.pipe(Schema.annotateKey({ description: "Grouping key" })),
|
|
110
|
+
value: Schema.Number.pipe(Schema.annotateKey({ description: "Aggregate value for the chosen metric" })),
|
|
111
|
+
count: Schema.Number.pipe(Schema.annotateKey({ description: "Number of samples in the group" })),
|
|
112
|
+
}).annotate({ identifier: "Stat" })
|
|
113
|
+
|
|
114
|
+
export type StatsItem = typeof StatsItem.Type
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// AI Call types
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** Maps normalized field names to the AI SDK attribute keys in span_attributes */
|
|
121
|
+
export const AI_ATTR_MAP = {
|
|
122
|
+
operationId: "ai.operationId",
|
|
123
|
+
functionId: "ai.telemetry.functionId",
|
|
124
|
+
provider: "ai.model.provider",
|
|
125
|
+
model: "ai.model.id",
|
|
126
|
+
sessionId: "ai.telemetry.metadata.sessionId",
|
|
127
|
+
userId: "ai.telemetry.metadata.userId",
|
|
128
|
+
finishReason: "ai.response.finishReason",
|
|
129
|
+
inputTokens: "ai.usage.inputTokens",
|
|
130
|
+
outputTokens: "ai.usage.outputTokens",
|
|
131
|
+
totalTokens: "ai.usage.totalTokens",
|
|
132
|
+
cachedInputTokens: "ai.usage.cachedInputTokens",
|
|
133
|
+
reasoningTokens: "ai.usage.reasoningTokens",
|
|
134
|
+
msToFirstChunk: "ai.response.msToFirstChunk",
|
|
135
|
+
msToFinish: "ai.response.msToFinish",
|
|
136
|
+
avgOutputTokensPerSecond: "ai.response.avgOutputTokensPerSecond",
|
|
137
|
+
promptMessages: "ai.prompt.messages",
|
|
138
|
+
prompt: "ai.prompt",
|
|
139
|
+
responseText: "ai.response.text",
|
|
140
|
+
tools: "ai.prompt.tools",
|
|
141
|
+
toolChoice: "ai.prompt.toolChoice",
|
|
142
|
+
providerMetadata: "ai.response.providerMetadata",
|
|
143
|
+
responseModel: "ai.response.model",
|
|
144
|
+
responseId: "ai.response.id",
|
|
145
|
+
responseTimestamp: "ai.response.timestamp",
|
|
146
|
+
} as const
|
|
147
|
+
|
|
148
|
+
/** Attribute keys to search across when using the `text` filter */
|
|
149
|
+
export const AI_TEXT_SEARCH_KEYS = [
|
|
150
|
+
"ai.prompt.messages",
|
|
151
|
+
"ai.prompt",
|
|
152
|
+
"ai.response.text",
|
|
153
|
+
"ai.prompt.tools",
|
|
154
|
+
] as const
|
|
155
|
+
|
|
156
|
+
const PREVIEW_LENGTH = 200
|
|
157
|
+
|
|
158
|
+
export const truncatePreview = (value: string | null | undefined): string | null => {
|
|
159
|
+
if (!value) return null
|
|
160
|
+
if (value.length <= PREVIEW_LENGTH) return value
|
|
161
|
+
return value.slice(0, PREVIEW_LENGTH) + "..."
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const AiUsage = Schema.Struct({
|
|
165
|
+
inputTokens: Schema.NullOr(Schema.Number),
|
|
166
|
+
outputTokens: Schema.NullOr(Schema.Number),
|
|
167
|
+
totalTokens: Schema.NullOr(Schema.Number),
|
|
168
|
+
cachedInputTokens: Schema.NullOr(Schema.Number),
|
|
169
|
+
reasoningTokens: Schema.NullOr(Schema.Number),
|
|
170
|
+
}).annotate({ identifier: "AiUsage" })
|
|
171
|
+
|
|
172
|
+
export type AiUsage = typeof AiUsage.Type
|
|
173
|
+
|
|
174
|
+
export const AiCallSummary = Schema.Struct({
|
|
175
|
+
traceId: Schema.String,
|
|
176
|
+
spanId: Schema.String,
|
|
177
|
+
operation: Schema.String.pipe(Schema.annotateKey({ description: "AI operation: streamText, generateText, streamObject, etc." })),
|
|
178
|
+
service: Schema.String,
|
|
179
|
+
functionId: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "ai.telemetry.functionId" })),
|
|
180
|
+
provider: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "ai.model.provider (e.g. openai.responses)" })),
|
|
181
|
+
model: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "ai.model.id (e.g. gpt-5.4)" })),
|
|
182
|
+
status: TraceSpanStatus,
|
|
183
|
+
startedAt: Schema.String.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp" })),
|
|
184
|
+
durationMs: Schema.Number,
|
|
185
|
+
sessionId: Schema.NullOr(Schema.String),
|
|
186
|
+
userId: Schema.NullOr(Schema.String),
|
|
187
|
+
promptPreview: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "First ~200 chars of prompt content" })),
|
|
188
|
+
responsePreview: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "First ~200 chars of response text" })),
|
|
189
|
+
finishReason: Schema.NullOr(Schema.String),
|
|
190
|
+
toolCallCount: Schema.Number.pipe(Schema.annotateKey({ description: "Number of tool call child spans" })),
|
|
191
|
+
usage: Schema.NullOr(AiUsage),
|
|
192
|
+
}).annotate({ identifier: "AiCallSummary" })
|
|
193
|
+
|
|
194
|
+
export type AiCallSummary = typeof AiCallSummary.Type
|
|
195
|
+
|
|
196
|
+
export const AiToolCall = Schema.Struct({
|
|
197
|
+
name: Schema.String,
|
|
198
|
+
spanId: Schema.NullOr(Schema.String),
|
|
199
|
+
status: TraceSpanStatus,
|
|
200
|
+
durationMs: Schema.NullOr(Schema.Number),
|
|
201
|
+
}).annotate({ identifier: "AiToolCall" })
|
|
202
|
+
|
|
203
|
+
export type AiToolCall = typeof AiToolCall.Type
|
|
204
|
+
|
|
205
|
+
export const AiCallDetail = Schema.Struct({
|
|
206
|
+
traceId: Schema.String,
|
|
207
|
+
spanId: Schema.String,
|
|
208
|
+
operation: Schema.String,
|
|
209
|
+
service: Schema.String,
|
|
210
|
+
functionId: Schema.NullOr(Schema.String),
|
|
211
|
+
provider: Schema.NullOr(Schema.String),
|
|
212
|
+
model: Schema.NullOr(Schema.String),
|
|
213
|
+
status: TraceSpanStatus,
|
|
214
|
+
startedAt: Schema.String,
|
|
215
|
+
durationMs: Schema.Number,
|
|
216
|
+
sessionId: Schema.NullOr(Schema.String),
|
|
217
|
+
userId: Schema.NullOr(Schema.String),
|
|
218
|
+
finishReason: Schema.NullOr(Schema.String),
|
|
219
|
+
promptMessages: Schema.NullOr(Schema.Unknown).pipe(Schema.annotateKey({ description: "Full parsed ai.prompt.messages or ai.prompt" })),
|
|
220
|
+
responseText: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Full ai.response.text" })),
|
|
221
|
+
toolCalls: Schema.Array(AiToolCall),
|
|
222
|
+
toolsAvailable: Schema.NullOr(Schema.Unknown).pipe(Schema.annotateKey({ description: "Full ai.prompt.tools" })),
|
|
223
|
+
providerMetadata: Schema.NullOr(Schema.Unknown),
|
|
224
|
+
usage: Schema.NullOr(AiUsage),
|
|
225
|
+
timing: Schema.Struct({
|
|
226
|
+
msToFirstChunk: Schema.NullOr(Schema.Number),
|
|
227
|
+
msToFinish: Schema.NullOr(Schema.Number),
|
|
228
|
+
avgOutputTokensPerSecond: Schema.NullOr(Schema.Number),
|
|
229
|
+
}),
|
|
230
|
+
logs: Schema.Array(Schema.Unknown).pipe(Schema.annotateKey({ description: "Correlated log records" })),
|
|
231
|
+
}).annotate({ identifier: "AiCallDetail" })
|
|
232
|
+
|
|
233
|
+
export type AiCallDetail = typeof AiCallDetail.Type
|