observability.test.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import { NodeFileSystem } from "@effect/platform-node"
  3. import { Effect, Layer, Logger } from "effect"
  4. import fs from "fs/promises"
  5. import os from "os"
  6. import path from "path"
  7. import { fileLogger } from "@opencode-ai/util/observability/logging"
  8. import { resource } from "@opencode-ai/util/observability/otlp"
  9. const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
  10. afterEach(() => {
  11. if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES
  12. else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes
  13. })
  14. describe("resource", () => {
  15. test("parses and decodes OTEL resource attributes", () => {
  16. process.env.OTEL_RESOURCE_ATTRIBUTES =
  17. "service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
  18. expect(resource().attributes).toMatchObject({
  19. "service.namespace": "anomalyco",
  20. team: "platform,observability",
  21. label: "hello=world",
  22. "key/name": "value here",
  23. })
  24. })
  25. test("drops OTEL resource attributes when any entry is invalid", () => {
  26. process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
  27. expect(resource().attributes["service.namespace"]).toBeUndefined()
  28. expect(resource().attributes["opencode.client"]).toBeDefined()
  29. })
  30. test("keeps built-in attributes when env values conflict", () => {
  31. process.env.OTEL_RESOURCE_ATTRIBUTES =
  32. "opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
  33. const app = { client: "cli", version: "1.2.3", channel: "beta" }
  34. expect(resource(app).attributes).toMatchObject({
  35. "opencode.client": "cli",
  36. "service.namespace": "anomalyco",
  37. })
  38. expect(resource(app).attributes["service.instance.id"]).not.toBe("override")
  39. expect(resource(app).attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
  40. })
  41. })
  42. test("falls back to local logging when OTLP initialization fails", async () => {
  43. const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-observability-test-"))
  44. await using _ = {
  45. async [Symbol.asyncDispose]() {
  46. await fs.rm(dir, { recursive: true, force: true })
  47. },
  48. }
  49. const child = Bun.spawn(
  50. [
  51. process.execPath,
  52. "--eval",
  53. `
  54. import { Effect } from "effect"
  55. import { Observability } from "@opencode-ai/util/observability"
  56. await Effect.void.pipe(Effect.provide(Observability.layer()), Effect.scoped, Effect.runPromise)
  57. `,
  58. ],
  59. {
  60. cwd: path.join(import.meta.dir, "../.."),
  61. env: {
  62. ...process.env,
  63. OTEL_EXPORTER_OTLP_ENDPOINT: "://invalid",
  64. XDG_CACHE_HOME: path.join(dir, "cache"),
  65. XDG_CONFIG_HOME: path.join(dir, "config"),
  66. XDG_DATA_HOME: path.join(dir, "data"),
  67. XDG_STATE_HOME: path.join(dir, "state"),
  68. },
  69. stdout: "ignore",
  70. stderr: "pipe",
  71. },
  72. )
  73. const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
  74. expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" })
  75. })
  76. test("file logger appends concurrent runs with a run on every line", async () => {
  77. const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
  78. await using _ = {
  79. async [Symbol.asyncDispose]() {
  80. await fs.rm(dir, { recursive: true, force: true })
  81. },
  82. }
  83. const file = path.join(dir, "opencode.log")
  84. const write = (runID: string) =>
  85. Effect.forEach(
  86. Array.from({ length: 50 }, (_, index) => index),
  87. (index) => Effect.logInfo(`entry-${index}`),
  88. ).pipe(
  89. Effect.provide(Logger.layer([fileLogger(file, runID)]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
  90. Effect.scoped,
  91. )
  92. await Effect.runPromise(Effect.all([write("run-a"), write("run-b")], { concurrency: "unbounded" }))
  93. const lines = (await Bun.file(file).text()).trim().split("\n")
  94. expect(lines).toHaveLength(100)
  95. expect(lines.filter((line) => line.includes("run=run-a"))).toHaveLength(50)
  96. expect(lines.filter((line) => line.includes("run=run-b"))).toHaveLength(50)
  97. expect(lines.every((line) => line.startsWith("timestamp=") && line.includes(" level=INFO "))).toBe(true)
  98. expect(lines.every((line) => !line.includes(" fiber="))).toBe(true)
  99. expect(lines.every((line) => !line.startsWith("{"))).toBe(true)
  100. })
  101. test("file logger flattens nested objects", async () => {
  102. const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
  103. await using _ = {
  104. async [Symbol.asyncDispose]() {
  105. await fs.rm(dir, { recursive: true, force: true })
  106. },
  107. }
  108. const file = path.join(dir, "opencode.log")
  109. await Effect.logInfo("request complete", {
  110. request: { method: "GET", timing: { duration: 42 } },
  111. tags: ["api", "test"],
  112. }).pipe(
  113. Effect.annotateLogs({ session: { id: "session-1" } }),
  114. Effect.provide(Logger.layer([fileLogger(file, "run-a")]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
  115. Effect.scoped,
  116. Effect.runPromise,
  117. )
  118. const line = (await Bun.file(file).text()).trim()
  119. expect(line).toContain('message="request complete"')
  120. expect(line).toContain("request.method=GET")
  121. expect(line).toContain("request.timing.duration=42")
  122. expect(line).toContain('tags="[\\\"api\\\",\\\"test\\\"]"')
  123. expect(line).toContain("session.id=session-1")
  124. expect(line).not.toContain("request={")
  125. })