@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 CHANGED
@@ -33,13 +33,13 @@
33
33
 
34
34
  ## Release Strategy
35
35
  - npm package: `@aryasaatvik/motel`
36
- - Releases are run from an attended local session with Tegami; GitHub Actions only validates pull requests.
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, run `bun run release` from a clean, current `dev` branch with npm and GitHub CLI authentication.
41
- - The release command runs typechecks, Effect diagnostics, tests, and package validation before Tegami publishes npm, pushes the matching `v<version>` tag, and creates the GitHub Release.
42
- - See `docs/release.md` for the attended release and verification procedure.
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",
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.2.6",
97
+ "tegami": "1.4.0",
98
98
  "typescript": "^6.0.3"
99
99
  },
100
100
  "dependencies": {
101
- "@effect/atom-react": "4.0.0-rc.110",
102
- "@effect/opentelemetry": "4.0.0-rc.110",
103
- "@effect/platform-bun": "4.0.0-rc.110",
104
- "@effect/platform-node-shared": "4.0.0-rc.110",
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.110",
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: process.env.MOTEL_OTEL_SERVICE_NAME?.trim() || "motel-otel-tui",
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 severity_text
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": "claude-opus-4-7",
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: "claude-opus-4-7",
46
+ model: "example-chat-model",
47
47
  status: "ok",
48
48
  startedAt: new Date().toISOString(),
49
49
  durationMs: 2400,
@@ -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
+ })