@kitlangton/motel 0.2.6 → 0.2.8
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 +1 -1
- package/package.json +1 -1
- package/src/config.ts +4 -1
- package/src/services/TelemetryStore.ts +6 -5
- package/src/storybook/fixtures/index.ts +2 -2
- package/src/telemetry.test.ts +75 -0
- package/src/ui/atoms.ts +2 -2
- package/src/ui/serviceSelection.test.ts +50 -0
package/AGENTS.md
CHANGED
|
@@ -132,7 +132,7 @@ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugi
|
|
|
132
132
|
|
|
133
133
|
## Env Vars
|
|
134
134
|
- `MOTEL_OTEL_ENABLED`: defaults to `false` (set to `true` to emit self-traces for debugging motel itself)
|
|
135
|
-
- `MOTEL_OTEL_SERVICE_NAME`: defaults to `motel-otel-tui
|
|
135
|
+
- `MOTEL_OTEL_SERVICE_NAME`: defaults to `motel-otel-tui`; a nonblank explicit value also selects the initial TUI service, overriding the remembered selection (unset or blank values keep the remembered selection)
|
|
136
136
|
- `MOTEL_OTEL_BASE_URL`: defaults to `http://127.0.0.1:27686`
|
|
137
137
|
- `MOTEL_OTEL_HOST`: defaults to `127.0.0.1`
|
|
138
138
|
- `MOTEL_OTEL_PORT`: defaults to `27686`
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -22,10 +22,13 @@ const parsedBaseUrl = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`)
|
|
|
22
22
|
export const resolveOtelUrl = (path: string) => new URL(path.startsWith("/") ? path.slice(1) : path, parsedBaseUrl).toString()
|
|
23
23
|
const serverPort = parsePositiveInt(process.env.MOTEL_OTEL_PORT, Number.parseInt(parsedBaseUrl.port || "80", 10))
|
|
24
24
|
|
|
25
|
+
// Preserve whether a service was requested so the TUI can override remembered state.
|
|
26
|
+
export const explicitServiceName = process.env.MOTEL_OTEL_SERVICE_NAME?.trim() || null
|
|
27
|
+
|
|
25
28
|
export const config = {
|
|
26
29
|
otel: {
|
|
27
30
|
enabled: parseBoolean(process.env.MOTEL_OTEL_ENABLED, false),
|
|
28
|
-
serviceName:
|
|
31
|
+
serviceName: explicitServiceName ?? "motel-otel-tui",
|
|
29
32
|
baseUrl,
|
|
30
33
|
host: process.env.MOTEL_OTEL_HOST?.trim() || parsedBaseUrl.hostname,
|
|
31
34
|
port: serverPort,
|
|
@@ -614,6 +614,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
614
614
|
CREATE INDEX IF NOT EXISTS idx_logs_trace_time ON logs(trace_id, timestamp_ms DESC);
|
|
615
615
|
CREATE INDEX IF NOT EXISTS idx_logs_span_time ON logs(span_id, timestamp_ms DESC);
|
|
616
616
|
CREATE INDEX IF NOT EXISTS idx_logs_severity_time ON logs(severity_text, timestamp_ms DESC);
|
|
617
|
+
CREATE INDEX IF NOT EXISTS idx_logs_severity_nocase_time ON logs(severity_text COLLATE NOCASE, timestamp_ms DESC);
|
|
617
618
|
|
|
618
619
|
CREATE TABLE IF NOT EXISTS trace_summaries (
|
|
619
620
|
trace_id TEXT PRIMARY KEY,
|
|
@@ -1673,7 +1674,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
1673
1674
|
params.push(input.serviceName)
|
|
1674
1675
|
}
|
|
1675
1676
|
if (input.severity) {
|
|
1676
|
-
clauses.push(`severity_text =
|
|
1677
|
+
clauses.push(`severity_text = ? COLLATE NOCASE`)
|
|
1677
1678
|
params.push(input.severity.toUpperCase())
|
|
1678
1679
|
}
|
|
1679
1680
|
if (input.traceId) {
|
|
@@ -1886,7 +1887,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
1886
1887
|
const group = input.groupBy === "service"
|
|
1887
1888
|
? log.serviceName
|
|
1888
1889
|
: input.groupBy === "severity"
|
|
1889
|
-
? log.severityText
|
|
1890
|
+
? log.severityText.toUpperCase()
|
|
1890
1891
|
: input.groupBy === "scope"
|
|
1891
1892
|
? log.scopeName ?? "unknown"
|
|
1892
1893
|
: isAttrGroupBy
|
|
@@ -1932,7 +1933,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
1932
1933
|
const groupExpr = input.groupBy === "service"
|
|
1933
1934
|
? "service_name"
|
|
1934
1935
|
: input.groupBy === "severity"
|
|
1935
|
-
? "severity_text"
|
|
1936
|
+
? "UPPER(severity_text)"
|
|
1936
1937
|
: input.groupBy === "scope"
|
|
1937
1938
|
? "COALESCE(scope_name, 'unknown')"
|
|
1938
1939
|
: "'unknown'"
|
|
@@ -1976,11 +1977,11 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
1976
1977
|
}
|
|
1977
1978
|
if (input.field === "severity") {
|
|
1978
1979
|
const rows = db.query(`
|
|
1979
|
-
SELECT severity_text AS value, COUNT(*) AS count
|
|
1980
|
+
SELECT UPPER(severity_text) AS value, COUNT(*) AS count
|
|
1980
1981
|
FROM logs
|
|
1981
1982
|
WHERE timestamp_ms >= ?
|
|
1982
1983
|
${input.serviceName ? "AND service_name = ?" : ""}
|
|
1983
|
-
GROUP BY
|
|
1984
|
+
GROUP BY value
|
|
1984
1985
|
ORDER BY count DESC, value ASC
|
|
1985
1986
|
LIMIT ?
|
|
1986
1987
|
`).all(...(input.serviceName ? [cutoff, input.serviceName, limit] : [cutoff, limit])) as Array<{ value: string; count: number }>
|
|
@@ -27,7 +27,7 @@ export const makeSpan = (overrides: Partial<TraceSpanItem> = {}): TraceSpanItem
|
|
|
27
27
|
depth: 0,
|
|
28
28
|
tags: {
|
|
29
29
|
"ai.operationId": "ai.streamText",
|
|
30
|
-
"ai.model.id": "
|
|
30
|
+
"ai.model.id": "example-chat-model",
|
|
31
31
|
"ai.model.provider": "anthropic",
|
|
32
32
|
"ai.prompt.messages": "[]",
|
|
33
33
|
},
|
|
@@ -43,7 +43,7 @@ export const makeDetail = (overrides: Partial<AiCallDetail> = {}): AiCallDetail
|
|
|
43
43
|
service: "storybook",
|
|
44
44
|
functionId: "story.demo",
|
|
45
45
|
provider: "anthropic",
|
|
46
|
-
model: "
|
|
46
|
+
model: "example-chat-model",
|
|
47
47
|
status: "ok",
|
|
48
48
|
startedAt: new Date().toISOString(),
|
|
49
49
|
durationMs: 2400,
|
package/src/telemetry.test.ts
CHANGED
|
@@ -668,6 +668,81 @@ describe("motel telemetry store", () => {
|
|
|
668
668
|
expect(result[0]?.severityText).toBe("ERROR")
|
|
669
669
|
})
|
|
670
670
|
|
|
671
|
+
describe("mixed-case stored log severities", () => {
|
|
672
|
+
const serviceName = "severity-case-test"
|
|
673
|
+
const severities = ["Info", "INFO", "info", "Error"]
|
|
674
|
+
|
|
675
|
+
beforeAll(async () => {
|
|
676
|
+
await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) =>
|
|
677
|
+
store.ingestLogs({
|
|
678
|
+
resourceLogs: [{
|
|
679
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: serviceName } }] },
|
|
680
|
+
scopeLogs: [{ logRecords: severities.map((severityText, index) => ({
|
|
681
|
+
timeUnixNano: String(BigInt(Date.now()) * 1_000_000n),
|
|
682
|
+
severityText,
|
|
683
|
+
body: { stringValue: `severity-case-${index}` },
|
|
684
|
+
attributes: [{ key: "batch", value: { stringValue: index < 2 ? "included" : "excluded" } }],
|
|
685
|
+
})) }],
|
|
686
|
+
}],
|
|
687
|
+
}),
|
|
688
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")))
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
for (const severity of ["INFO", "Info", "info"]) {
|
|
692
|
+
it(`filters ${severity} without changing returned severity text`, async () => {
|
|
693
|
+
const logs = await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) =>
|
|
694
|
+
store.searchLogs({ serviceName, severity }),
|
|
695
|
+
))
|
|
696
|
+
expect(logs.map((log) => log.severityText).sort()).toEqual(["INFO", "Info", "info"])
|
|
697
|
+
})
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
it("groups severity variants in SQL stats", async () => {
|
|
701
|
+
const stats = await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) =>
|
|
702
|
+
store.logStats({ serviceName, groupBy: "severity", agg: "count" }),
|
|
703
|
+
))
|
|
704
|
+
expect(stats).toEqual([
|
|
705
|
+
{ group: "INFO", value: 3, count: 3 },
|
|
706
|
+
{ group: "ERROR", value: 1, count: 1 },
|
|
707
|
+
])
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
it("groups severity variants in attribute-filtered stats", async () => {
|
|
711
|
+
const stats = await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) =>
|
|
712
|
+
store.logStats({ serviceName, groupBy: "severity", agg: "count", attributeFilters: { batch: "included" } }),
|
|
713
|
+
))
|
|
714
|
+
expect(stats).toEqual([{ group: "INFO", value: 2, count: 2 }])
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it("indexes case-insensitive severity searches", () => {
|
|
718
|
+
const probe = new Database(dbPath, { readonly: true })
|
|
719
|
+
try {
|
|
720
|
+
const plan = probe.query<{ detail: string }, [string, number, number]>(`
|
|
721
|
+
EXPLAIN QUERY PLAN SELECT * FROM logs
|
|
722
|
+
WHERE severity_text = ? COLLATE NOCASE AND timestamp_ms >= ?
|
|
723
|
+
ORDER BY timestamp_ms DESC, id DESC LIMIT ?
|
|
724
|
+
`).all("INFO", 0, 80)
|
|
725
|
+
expect(plan.some((row) => row.detail.includes("idx_logs_severity_nocase_time (severity_text=?"))).toBe(true)
|
|
726
|
+
} finally {
|
|
727
|
+
probe.close()
|
|
728
|
+
}
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it("groups severity facets without changing persisted text", async () => {
|
|
732
|
+
const facets = await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) =>
|
|
733
|
+
store.listFacets({ serviceName, type: "logs", field: "severity" }),
|
|
734
|
+
))
|
|
735
|
+
expect(facets).toEqual([{ value: "INFO", count: 3 }, { value: "ERROR", count: 1 }])
|
|
736
|
+
const probe = new Database(dbPath, { readonly: true })
|
|
737
|
+
try {
|
|
738
|
+
expect(probe.query("SELECT severity_text FROM logs WHERE service_name = ? ORDER BY id").all(serviceName))
|
|
739
|
+
.toEqual(severities.map((severity_text) => ({ severity_text })))
|
|
740
|
+
} finally {
|
|
741
|
+
probe.close()
|
|
742
|
+
}
|
|
743
|
+
})
|
|
744
|
+
})
|
|
745
|
+
|
|
671
746
|
it("searches log body case-insensitively", async () => {
|
|
672
747
|
const result = await storeRuntime.runPromise(
|
|
673
748
|
Effect.flatMap(TelemetryStore, (store) =>
|
package/src/ui/atoms.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
2
|
-
import { config } from "../config.ts"
|
|
2
|
+
import { config, explicitServiceName } from "../config.ts"
|
|
3
3
|
import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
4
4
|
import type { ThemeName } from "./theme.ts"
|
|
5
5
|
import { readLastService, readLastTheme } from "./persistence.ts"
|
|
@@ -77,7 +77,7 @@ export const logStateAtom = Atom.make(initialLogState).pipe(Atom.keepAlive)
|
|
|
77
77
|
export const serviceLogStateAtom = Atom.make(initialServiceLogState).pipe(Atom.keepAlive)
|
|
78
78
|
export const selectedServiceLogIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
79
79
|
export const selectedTraceIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
80
|
-
export const selectedTraceServiceAtom = Atom.make<string | null>(readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
|
|
80
|
+
export const selectedTraceServiceAtom = Atom.make<string | null>(explicitServiceName ?? readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
|
|
81
81
|
export const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
82
82
|
export const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
83
83
|
export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.keepAlive)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
|
|
6
|
+
describe("initial TUI service selection", () => {
|
|
7
|
+
for (const remembered of [undefined, "svc-b", " \n"]) {
|
|
8
|
+
for (const explicit of [undefined, "", " \t ", "svc-a", " svc-a ", "motel-otel-tui"]) {
|
|
9
|
+
test(`env=${JSON.stringify(explicit)}, remembered=${JSON.stringify(remembered)}`, () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "motel-service-selection-"))
|
|
11
|
+
try {
|
|
12
|
+
if (remembered !== undefined) writeFileSync(join(dir, "last-service.txt"), remembered)
|
|
13
|
+
const env = { ...process.env }
|
|
14
|
+
for (const key of Object.keys(env)) {
|
|
15
|
+
if (key.startsWith("MOTEL_")) delete env[key]
|
|
16
|
+
}
|
|
17
|
+
env.MOTEL_RUNTIME_DIR = dir
|
|
18
|
+
env.MOTEL_OTEL_DB_PATH = join(dir, "telemetry.sqlite")
|
|
19
|
+
if (explicit !== undefined) env.MOTEL_OTEL_SERVICE_NAME = explicit
|
|
20
|
+
|
|
21
|
+
// Fresh processes exercise import-time config and persistence without module-cache leakage.
|
|
22
|
+
const result = Bun.spawnSync([process.execPath, "--no-env-file", "--eval", `
|
|
23
|
+
import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
|
|
24
|
+
import { config } from "./src/config.ts"
|
|
25
|
+
import { selectedTraceServiceAtom } from "./src/ui/atoms.ts"
|
|
26
|
+
const registry = AtomRegistry.make()
|
|
27
|
+
const initial = registry.get(selectedTraceServiceAtom)
|
|
28
|
+
registry.set(selectedTraceServiceAtom, "switched-service")
|
|
29
|
+
console.log(JSON.stringify({
|
|
30
|
+
initial,
|
|
31
|
+
serviceName: config.otel.serviceName,
|
|
32
|
+
switched: registry.get(selectedTraceServiceAtom),
|
|
33
|
+
}))
|
|
34
|
+
registry.dispose()
|
|
35
|
+
`], { cwd: join(import.meta.dir, "../.."), env, timeout: 5_000 })
|
|
36
|
+
|
|
37
|
+
expect(result.exitCode).toBe(0)
|
|
38
|
+
expect(result.stderr.toString()).toBe("")
|
|
39
|
+
expect(JSON.parse(result.stdout.toString())).toEqual({
|
|
40
|
+
initial: explicit?.trim() || remembered?.trim() || "motel-otel-tui",
|
|
41
|
+
serviceName: explicit?.trim() || "motel-otel-tui",
|
|
42
|
+
switched: "switched-service",
|
|
43
|
+
})
|
|
44
|
+
} finally {
|
|
45
|
+
rmSync(dir, { recursive: true, force: true })
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
})
|