From c3d26c4912c1664d2371d5fc194cd667e8654f8a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 5 Jul 2026 15:53:50 -0400 Subject: [PATCH] feat(core): improve runtime observability --- packages/cli/src/index.ts | 14 ++-- packages/core/src/event-logger.ts | 24 +++++++ .../core/src/filesystem/location-watcher.ts | 65 ++++++++++--------- packages/core/src/snapshot.ts | 2 +- packages/core/test/event-logger.test.ts | 45 +++++++++++++ packages/server/src/routes.ts | 2 + 6 files changed, 112 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/event-logger.ts create mode 100644 packages/core/test/event-logger.test.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 64116b9b763..5514c3816e8 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,10 +1,10 @@ #!/usr/bin/env bun -import { NodeFileSystem, NodeRuntime, NodeServices } from "@effect/platform-node" -import { Effect, Layer, Logger, References } from "effect" +import { NodeRuntime, NodeServices } from "@effect/platform-node" +import { Effect } from "effect" import { Commands } from "./commands/commands" import { Runtime } from "./framework/runtime" -import { Logging } from "@opencode-ai/core/observability/logging" +import { Observability } from "@opencode-ai/core/observability" import { Updater } from "./services/updater" import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -12,12 +12,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { AppProcess } from "@opencode-ai/core/process" -const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe( - Layer.provide(NodeFileSystem.layer), - Layer.orDie, - Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), -) - const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), api: () => import("./commands/handlers/api"), @@ -53,7 +47,7 @@ Effect.logInfo("cli starting", { Effect.annotateLogs({ role: "cli" }), Effect.provide(Updater.layer), Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), - Effect.provide(LoggingLayer), + Effect.provide(Observability.layer), Effect.provide(NodeServices.layer), Effect.scoped, Effect.tap(() => Effect.sync(() => process.exit(0))), diff --git a/packages/core/src/event-logger.ts b/packages/core/src/event-logger.ts new file mode 100644 index 00000000000..f07c96320ee --- /dev/null +++ b/packages/core/src/event-logger.ts @@ -0,0 +1,24 @@ +export * as EventLogger from "./event-logger" + +import { Effect, Layer } from "effect" +import { makeGlobalNode } from "./effect/app-node" +import { EventV2 } from "./event" + +const Types = new Set([ + "agent.updated", + "catalog.updated", + "command.updated", + "config.updated", +]) + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const events = yield* EventV2.Service + const unsubscribe = yield* events.listen((event) => + Types.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void, + ) + yield* Effect.addFinalizer(() => unsubscribe) + }), +) + +export const node = makeGlobalNode({ name: "event-logger", layer, deps: [EventV2.node] }) diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts index 1566d3d8ff6..613b43b5f87 100644 --- a/packages/core/src/filesystem/location-watcher.ts +++ b/packages/core/src/filesystem/location-watcher.ts @@ -34,46 +34,53 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const git = yield* Git.Service const configService = yield* Config.Service - const config = (yield* configService.entries()) - .filter((entry): entry is Config.Document => entry.type === "document") - .flatMap((item) => item.info.watcher?.ignore ?? []) const publish = (update: { type: "create" | "update" | "delete"; path: string }) => events.publish(FileSystem.Event.Changed, { file: update.path, event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink", }) - if (path.resolve(location.directory) !== path.resolve(os.homedir())) { - yield* watcher - .subscribe({ - path: location.directory, - type: "directory", - ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)], - }) - .pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true })) - } else { - yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory }) - } + yield* Effect.gen(function* () { + const config = (yield* configService.entries()) + .filter((entry): entry is Config.Document => entry.type === "document") + .flatMap((item) => item.info.watcher?.ignore ?? []) + const home = path.resolve(location.directory) === path.resolve(os.homedir()) - if (location.vcs?.type === "git") { - const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory - const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined - if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { - const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( - (entry) => (entry.name === "HEAD" ? [] : [entry.name]), - ) + if (!home) { yield* watcher - .subscribe({ path: vcs, type: "directory", ignore }) - .pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true })) + .subscribe({ + path: location.directory, + type: "directory", + ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)], + }) + .pipe(Stream.runForEach(publish), Effect.forkScoped) } - } + if (home) { + yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory }) + } + + if (location.vcs?.type === "git") { + const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory + const vcs = resolved + ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) + : undefined + if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { + const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( + (entry) => (entry.name === "HEAD" ? [] : [entry.name]), + ) + yield* watcher + .subscribe({ path: vcs, type: "directory", ignore }) + .pipe(Stream.runForEach(publish), Effect.forkScoped) + } + } + }).pipe( + Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }), + Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })), + Effect.forkScoped, + ) return Service.of({}) - }).pipe( - Effect.catchCause((cause) => - Effect.logError("failed to init location watcher service", { cause }).pipe(Effect.as(Service.of({}))), - ), - ), + }), ) export const node = makeLocationNode({ diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 79a2b6cda8b..9843e692262 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -224,7 +224,7 @@ const layer = Layer.effect( }) return Service.of({ capture, files, diff, preview, restore, checkout }) - }), + }).pipe(Effect.withSpan("Snapshot.boot")), ) export const node = makeLocationNode({ diff --git a/packages/core/test/event-logger.test.ts b/packages/core/test/event-logger.test.ts new file mode 100644 index 00000000000..ef48d7abbd9 --- /dev/null +++ b/packages/core/test/event-logger.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer, Logger } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { EventLogger } from "@opencode-ai/core/event-logger" +import { Agent } from "@opencode-ai/schema/agent" +import { Catalog } from "@opencode-ai/schema/catalog" +import { Command } from "@opencode-ai/schema/command" +import { Config } from "@opencode-ai/schema/config" +import { McpEvent } from "@opencode-ai/schema/mcp-event" + +const UnlistedUpdated = EventV2.ephemeral({ type: "test.updated", schema: {} }) + +describe("EventLogger", () => { + test("logs explicitly listed updated events", async () => { + const output = new Array>() + const logger = Logger.map(Logger.formatStructured, (entry) => { + output.push(entry) + }) + + await Effect.gen(function* () { + const events = yield* EventV2.Service + yield* events.publish(Agent.Event.Updated, {}) + yield* events.publish(Catalog.Event.Updated, {}) + yield* events.publish(Command.Event.Updated, {}) + yield* events.publish(Config.Event.Updated, {}) + yield* events.publish(McpEvent.StatusChanged, { server: "example" }) + yield* events.publish(UnlistedUpdated, {}) + }).pipe( + Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, EventLogger.node]))), + Effect.provide(Logger.layer([logger])), + Effect.scoped, + Effect.runPromise, + ) + + expect(output.map((entry) => entry.message)).toEqual([ + ["event", { event: expect.objectContaining({ type: "agent.updated" }) }], + ["event", { event: expect.objectContaining({ type: "catalog.updated" }) }], + ["event", { event: expect.objectContaining({ type: "command.updated" }) }], + ["event", { event: expect.objectContaining({ type: "config.updated" }) }], + ]) + }) +}) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index c5e43989d02..da63120bd8b 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -3,6 +3,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" +import { EventLogger } from "@opencode-ai/core/event-logger" import { Credential } from "@opencode-ai/core/credential" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" @@ -31,6 +32,7 @@ import { sessionLocationLayer } from "./middleware/session-location" const applicationServices = LayerNode.group([ Database.node, EventV2.node, + EventLogger.node, httpClient, ToolOutputStore.cleanupNode, Job.node,