observability.ts 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. import { Effect, Layer, Logger } from "effect"
  2. import { FetchHttpClient } from "effect/unstable/http"
  3. import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
  4. import * as EffectLogger from "./logger"
  5. import { Flag } from "../flag/flag"
  6. import { InstallationChannel, InstallationVersion } from "../installation/version"
  7. import { ensureProcessMetadata } from "../util/opencode-process"
  8. const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
  9. export const enabled = !!base
  10. const processID = crypto.randomUUID()
  11. const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
  12. ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
  13. (acc, x) => {
  14. const [key, ...value] = x.split("=")
  15. acc[key] = value.join("=")
  16. return acc
  17. },
  18. {} as Record<string, string>,
  19. )
  20. : undefined
  21. export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
  22. const processMetadata = ensureProcessMetadata("main")
  23. const attributes: Record<string, string> = (() => {
  24. const value = process.env.OTEL_RESOURCE_ATTRIBUTES
  25. if (!value) return {}
  26. try {
  27. return Object.fromEntries(
  28. value.split(",").map((entry) => {
  29. const index = entry.indexOf("=")
  30. if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
  31. return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
  32. }),
  33. )
  34. } catch {
  35. return {}
  36. }
  37. })()
  38. return {
  39. serviceName: "opencode",
  40. serviceVersion: InstallationVersion,
  41. attributes: {
  42. ...attributes,
  43. "deployment.environment.name": InstallationChannel,
  44. "opencode.client": Flag.OPENCODE_CLIENT,
  45. "opencode.process_role": processMetadata.processRole,
  46. "opencode.run_id": processMetadata.runID,
  47. "service.instance.id": processID,
  48. },
  49. }
  50. }
  51. function logs() {
  52. return Logger.layer(
  53. [
  54. EffectLogger.logger,
  55. OtlpLogger.make({
  56. url: `${base}/v1/logs`,
  57. resource: resource(),
  58. headers,
  59. }),
  60. ],
  61. { mergeWithExisting: false },
  62. ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
  63. }
  64. const traces = async () => {
  65. const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
  66. const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
  67. const SdkBase = await import("@opentelemetry/sdk-trace-base")
  68. // @effect/opentelemetry creates a NodeTracerProvider but never calls
  69. // register(), so the global @opentelemetry/api context manager stays
  70. // as the no-op default. Non-Effect code (like the AI SDK) that calls
  71. // tracer.startActiveSpan() relies on context.active() to find the
  72. // parent span - without a real context manager every span starts a
  73. // new trace. Registering AsyncLocalStorageContextManager fixes this.
  74. const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
  75. const { context } = await import("@opentelemetry/api")
  76. const mgr = new AsyncLocalStorageContextManager()
  77. mgr.enable()
  78. context.setGlobalContextManager(mgr)
  79. return NodeSdk.layer(() => ({
  80. resource: resource(),
  81. spanProcessor: new SdkBase.BatchSpanProcessor(
  82. new OTLP.OTLPTraceExporter({
  83. url: `${base}/v1/traces`,
  84. headers,
  85. }),
  86. ),
  87. }))
  88. }
  89. export const layer = !base
  90. ? EffectLogger.layer
  91. : Layer.unwrap(
  92. Effect.gen(function* () {
  93. const trace = yield* Effect.promise(traces)
  94. return Layer.mergeAll(trace, logs())
  95. }),
  96. )
  97. export const Observability = { enabled, layer }