1
0

observability.test.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  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 "../../src/observability/logging"
  8. import { resource } from "../../src/observability/otlp"
  9. const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
  10. const opencodeClient = process.env.OPENCODE_CLIENT
  11. afterEach(() => {
  12. if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES
  13. else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes
  14. if (opencodeClient === undefined) delete process.env.OPENCODE_CLIENT
  15. else process.env.OPENCODE_CLIENT = opencodeClient
  16. })
  17. describe("resource", () => {
  18. test("parses and decodes OTEL resource attributes", () => {
  19. process.env.OTEL_RESOURCE_ATTRIBUTES =
  20. "service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
  21. expect(resource().attributes).toMatchObject({
  22. "service.namespace": "anomalyco",
  23. team: "platform,observability",
  24. label: "hello=world",
  25. "key/name": "value here",
  26. })
  27. })
  28. test("drops OTEL resource attributes when any entry is invalid", () => {
  29. process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
  30. expect(resource().attributes["service.namespace"]).toBeUndefined()
  31. expect(resource().attributes["opencode.client"]).toBeDefined()
  32. })
  33. test("keeps built-in attributes when env values conflict", () => {
  34. process.env.OPENCODE_CLIENT = "cli"
  35. process.env.OTEL_RESOURCE_ATTRIBUTES =
  36. "opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
  37. expect(resource().attributes).toMatchObject({
  38. "opencode.client": "cli",
  39. "service.namespace": "anomalyco",
  40. })
  41. expect(resource().attributes["service.instance.id"]).not.toBe("override")
  42. expect(resource().attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
  43. })
  44. })
  45. test("file logger appends concurrent runs with a run on every line", async () => {
  46. const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
  47. await using _ = {
  48. async [Symbol.asyncDispose]() {
  49. await fs.rm(dir, { recursive: true, force: true })
  50. },
  51. }
  52. const file = path.join(dir, "opencode.log")
  53. const write = (runID: string) =>
  54. Effect.forEach(
  55. Array.from({ length: 50 }, (_, index) => index),
  56. (index) => Effect.logInfo(`entry-${index}`),
  57. ).pipe(
  58. Effect.provide(Logger.layer([fileLogger(file, runID)]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
  59. Effect.scoped,
  60. )
  61. await Effect.runPromise(Effect.all([write("run-a"), write("run-b")], { concurrency: "unbounded" }))
  62. const lines = (await Bun.file(file).text()).trim().split("\n")
  63. expect(lines).toHaveLength(100)
  64. expect(lines.filter((line) => line.includes("run=run-a"))).toHaveLength(50)
  65. expect(lines.filter((line) => line.includes("run=run-b"))).toHaveLength(50)
  66. expect(lines.every((line) => line.startsWith("timestamp=") && line.includes(" level=INFO "))).toBe(true)
  67. expect(lines.every((line) => !line.includes(" fiber="))).toBe(true)
  68. expect(lines.every((line) => !line.startsWith("{"))).toBe(true)
  69. })
  70. test("file logger flattens nested objects", async () => {
  71. const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
  72. await using _ = {
  73. async [Symbol.asyncDispose]() {
  74. await fs.rm(dir, { recursive: true, force: true })
  75. },
  76. }
  77. const file = path.join(dir, "opencode.log")
  78. await Effect.logInfo("request complete", {
  79. request: { method: "GET", timing: { duration: 42 } },
  80. tags: ["api", "test"],
  81. }).pipe(
  82. Effect.annotateLogs({ session: { id: "session-1" } }),
  83. Effect.provide(Logger.layer([fileLogger(file, "run-a")]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
  84. Effect.scoped,
  85. Effect.runPromise,
  86. )
  87. const line = (await Bun.file(file).text()).trim()
  88. expect(line).toContain('message="request complete"')
  89. expect(line).toContain("request.method=GET")
  90. expect(line).toContain("request.timing.duration=42")
  91. expect(line).toContain('tags="[\\\"api\\\",\\\"test\\\"]"')
  92. expect(line).toContain("session.id=session-1")
  93. expect(line).not.toContain("request={")
  94. })