| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142 |
- import { afterEach, describe, expect, test } from "bun:test"
- import { NodeFileSystem } from "@effect/platform-node"
- import { Effect, Layer, Logger } from "effect"
- import fs from "fs/promises"
- import os from "os"
- import path from "path"
- import { fileLogger } from "@opencode-ai/util/observability/logging"
- import { resource } from "@opencode-ai/util/observability/otlp"
- const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
- afterEach(() => {
- if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES
- else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes
- })
- describe("resource", () => {
- test("parses and decodes OTEL resource attributes", () => {
- process.env.OTEL_RESOURCE_ATTRIBUTES =
- "service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
- expect(resource().attributes).toMatchObject({
- "service.namespace": "anomalyco",
- team: "platform,observability",
- label: "hello=world",
- "key/name": "value here",
- })
- })
- test("drops OTEL resource attributes when any entry is invalid", () => {
- process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
- expect(resource().attributes["service.namespace"]).toBeUndefined()
- expect(resource().attributes["opencode.client"]).toBeDefined()
- })
- test("keeps built-in attributes when env values conflict", () => {
- process.env.OTEL_RESOURCE_ATTRIBUTES =
- "opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
- const app = { client: "cli", version: "1.2.3", channel: "beta" }
- expect(resource(app).attributes).toMatchObject({
- "opencode.client": "cli",
- "service.namespace": "anomalyco",
- })
- expect(resource(app).attributes["service.instance.id"]).not.toBe("override")
- expect(resource(app).attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
- })
- })
- test("falls back to local logging when OTLP initialization fails", async () => {
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-observability-test-"))
- await using _ = {
- async [Symbol.asyncDispose]() {
- await fs.rm(dir, { recursive: true, force: true })
- },
- }
- const child = Bun.spawn(
- [
- process.execPath,
- "--eval",
- `
- import { Effect } from "effect"
- import { Observability } from "@opencode-ai/util/observability"
- await Effect.void.pipe(Effect.provide(Observability.layer()), Effect.scoped, Effect.runPromise)
- `,
- ],
- {
- cwd: path.join(import.meta.dir, "../.."),
- env: {
- ...process.env,
- OTEL_EXPORTER_OTLP_ENDPOINT: "://invalid",
- XDG_CACHE_HOME: path.join(dir, "cache"),
- XDG_CONFIG_HOME: path.join(dir, "config"),
- XDG_DATA_HOME: path.join(dir, "data"),
- XDG_STATE_HOME: path.join(dir, "state"),
- },
- stdout: "ignore",
- stderr: "pipe",
- },
- )
- const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
- expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" })
- })
- test("file logger appends concurrent runs with a run on every line", async () => {
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
- await using _ = {
- async [Symbol.asyncDispose]() {
- await fs.rm(dir, { recursive: true, force: true })
- },
- }
- const file = path.join(dir, "opencode.log")
- const write = (runID: string) =>
- Effect.forEach(
- Array.from({ length: 50 }, (_, index) => index),
- (index) => Effect.logInfo(`entry-${index}`),
- ).pipe(
- Effect.provide(Logger.layer([fileLogger(file, runID)]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
- Effect.scoped,
- )
- await Effect.runPromise(Effect.all([write("run-a"), write("run-b")], { concurrency: "unbounded" }))
- const lines = (await Bun.file(file).text()).trim().split("\n")
- expect(lines).toHaveLength(100)
- expect(lines.filter((line) => line.includes("run=run-a"))).toHaveLength(50)
- expect(lines.filter((line) => line.includes("run=run-b"))).toHaveLength(50)
- expect(lines.every((line) => line.startsWith("timestamp=") && line.includes(" level=INFO "))).toBe(true)
- expect(lines.every((line) => !line.includes(" fiber="))).toBe(true)
- expect(lines.every((line) => !line.startsWith("{"))).toBe(true)
- })
- test("file logger flattens nested objects", async () => {
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
- await using _ = {
- async [Symbol.asyncDispose]() {
- await fs.rm(dir, { recursive: true, force: true })
- },
- }
- const file = path.join(dir, "opencode.log")
- await Effect.logInfo("request complete", {
- request: { method: "GET", timing: { duration: 42 } },
- tags: ["api", "test"],
- }).pipe(
- Effect.annotateLogs({ session: { id: "session-1" } }),
- Effect.provide(Logger.layer([fileLogger(file, "run-a")]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
- Effect.scoped,
- Effect.runPromise,
- )
- const line = (await Bun.file(file).text()).trim()
- expect(line).toContain('message="request complete"')
- expect(line).toContain("request.method=GET")
- expect(line).toContain("request.timing.duration=42")
- expect(line).toContain('tags="[\\\"api\\\",\\\"test\\\"]"')
- expect(line).toContain("session.id=session-1")
- expect(line).not.toContain("request={")
- })
|