@aryasaatvik/motel 0.3.3 → 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 +5 -5
- package/package.json +7 -7
- 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/web/dist/assets/index-5mTXbtNw.js +21 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-DesEgJu7.js +0 -21
package/AGENTS.md
CHANGED
|
@@ -33,13 +33,13 @@
|
|
|
33
33
|
|
|
34
34
|
## Release Strategy
|
|
35
35
|
- npm package: `@aryasaatvik/motel`
|
|
36
|
-
- Releases are run
|
|
36
|
+
- Releases are run by the default `dev` branch GitHub Action with Tegami and npm trusted publishing; local sessions only prepare or verify releases.
|
|
37
37
|
- Tags are versioned as `vX.Y.Z` (`git tag --sort=-version:refname` shows `v0.2.4`, `v0.2.3`, ...)
|
|
38
38
|
- Add user-facing release notes under `.tegami/` and commit them with the implementation they describe.
|
|
39
39
|
- `bun run version:packages` consumes pending entries, updates the package version and changelog, writes the publish lock, and opens or updates `tegami/version-packages` against `dev`.
|
|
40
|
-
- After the version pull request is merged,
|
|
41
|
-
- The
|
|
42
|
-
- See `docs/release.md` for the
|
|
40
|
+
- After the version pull request is merged, the `publish.yml` workflow runs the release checks, then `tegami ci` publishes through npm OIDC, pushes the matching `v<version>` tag, and creates the GitHub Release.
|
|
41
|
+
- The npm trusted publisher is `aryasaatvik/motel` + `publish.yml` with no environment; no `NPM_TOKEN` or npm stage publishing is used.
|
|
42
|
+
- See `docs/release.md` for the workflow and verification procedure.
|
|
43
43
|
- Publishing does not install or restart the machine-global Motel service; that cutover is a separate operation.
|
|
44
44
|
|
|
45
45
|
## Effect LSP
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aryasaatvik/motel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -94,14 +94,14 @@
|
|
|
94
94
|
"@effect/language-service": "0.87.1",
|
|
95
95
|
"@types/bun": "^1.3.14",
|
|
96
96
|
"@types/react": "^19.2.17",
|
|
97
|
-
"tegami": "1.
|
|
97
|
+
"tegami": "1.4.0",
|
|
98
98
|
"typescript": "^6.0.3"
|
|
99
99
|
},
|
|
100
100
|
"dependencies": {
|
|
101
|
-
"@effect/atom-react": "4.0.0-rc.
|
|
102
|
-
"@effect/opentelemetry": "4.0.0-rc.
|
|
103
|
-
"@effect/platform-bun": "4.0.0-rc.
|
|
104
|
-
"@effect/platform-node-shared": "4.0.0-rc.
|
|
101
|
+
"@effect/atom-react": "4.0.0-rc.112",
|
|
102
|
+
"@effect/opentelemetry": "4.0.0-rc.112",
|
|
103
|
+
"@effect/platform-bun": "4.0.0-rc.112",
|
|
104
|
+
"@effect/platform-node-shared": "4.0.0-rc.112",
|
|
105
105
|
"@opentelemetry/api": "^1.9.0",
|
|
106
106
|
"@opentelemetry/exporter-logs-otlp-http": "^0.219.0",
|
|
107
107
|
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"@opentelemetry/sdk-trace-node": "^2.8.0",
|
|
113
113
|
"@opentui/core": "0.4.2",
|
|
114
114
|
"@opentui/react": "0.4.2",
|
|
115
|
-
"effect": "4.0.0-rc.
|
|
115
|
+
"effect": "4.0.0-rc.112",
|
|
116
116
|
"protobufjs": "7.6.4",
|
|
117
117
|
"react": "^19.2.7",
|
|
118
118
|
"scheduler": "^0.27.0"
|
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
|
+
})
|