@aryasaatvik/motel 0.3.4 → 0.3.5
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
|
@@ -131,7 +131,7 @@ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugi
|
|
|
131
131
|
|
|
132
132
|
## Env Vars
|
|
133
133
|
- `MOTEL_OTEL_ENABLED`: defaults to `false` (set to `true` to emit self-traces for debugging motel itself)
|
|
134
|
-
- `MOTEL_OTEL_SERVICE_NAME`: defaults to `motel-otel-tui
|
|
134
|
+
- `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)
|
|
135
135
|
- `MOTEL_OTEL_BASE_URL`: defaults to `http://127.0.0.1:27686`
|
|
136
136
|
- `MOTEL_OTEL_HOST`: defaults to `127.0.0.1`
|
|
137
137
|
- `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,
|
|
@@ -616,6 +616,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
616
616
|
CREATE INDEX IF NOT EXISTS idx_logs_trace_time ON logs(trace_id, timestamp_ms DESC);
|
|
617
617
|
CREATE INDEX IF NOT EXISTS idx_logs_span_time ON logs(span_id, timestamp_ms DESC);
|
|
618
618
|
CREATE INDEX IF NOT EXISTS idx_logs_severity_time ON logs(severity_text, timestamp_ms DESC);
|
|
619
|
+
CREATE INDEX IF NOT EXISTS idx_logs_severity_nocase_time ON logs(severity_text COLLATE NOCASE, timestamp_ms DESC);
|
|
619
620
|
|
|
620
621
|
CREATE TABLE IF NOT EXISTS trace_summaries (
|
|
621
622
|
trace_id TEXT PRIMARY KEY,
|
|
@@ -1671,7 +1672,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
1671
1672
|
params.push(input.serviceName)
|
|
1672
1673
|
}
|
|
1673
1674
|
if (input.severity) {
|
|
1674
|
-
clauses.push(`severity_text =
|
|
1675
|
+
clauses.push(`severity_text = ? COLLATE NOCASE`)
|
|
1675
1676
|
params.push(input.severity.toUpperCase())
|
|
1676
1677
|
}
|
|
1677
1678
|
if (input.traceId) {
|
|
@@ -1884,7 +1885,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
1884
1885
|
const group = input.groupBy === "service"
|
|
1885
1886
|
? log.serviceName
|
|
1886
1887
|
: input.groupBy === "severity"
|
|
1887
|
-
? log.severityText
|
|
1888
|
+
? log.severityText.toUpperCase()
|
|
1888
1889
|
: input.groupBy === "scope"
|
|
1889
1890
|
? log.scopeName ?? "unknown"
|
|
1890
1891
|
: isAttrGroupBy
|
|
@@ -1930,7 +1931,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
1930
1931
|
const groupExpr = input.groupBy === "service"
|
|
1931
1932
|
? "service_name"
|
|
1932
1933
|
: input.groupBy === "severity"
|
|
1933
|
-
? "severity_text"
|
|
1934
|
+
? "UPPER(severity_text)"
|
|
1934
1935
|
: input.groupBy === "scope"
|
|
1935
1936
|
? "COALESCE(scope_name, 'unknown')"
|
|
1936
1937
|
: "'unknown'"
|
|
@@ -1974,11 +1975,11 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
1974
1975
|
}
|
|
1975
1976
|
if (input.field === "severity") {
|
|
1976
1977
|
const rows = db.query(`
|
|
1977
|
-
SELECT severity_text AS value, COUNT(*) AS count
|
|
1978
|
+
SELECT UPPER(severity_text) AS value, COUNT(*) AS count
|
|
1978
1979
|
FROM logs
|
|
1979
1980
|
WHERE timestamp_ms >= ?
|
|
1980
1981
|
${input.serviceName ? "AND service_name = ?" : ""}
|
|
1981
|
-
GROUP BY
|
|
1982
|
+
GROUP BY value
|
|
1982
1983
|
ORDER BY count DESC, value ASC
|
|
1983
1984
|
LIMIT ?
|
|
1984
1985
|
`).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
|
+
})
|