log-processor.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { Resource } from "@opencode-ai/console-resource"
  2. import type { TraceItem } from "@cloudflare/workers-types"
  3. export default {
  4. async tail(events: TraceItem[]) {
  5. for (const event of events) {
  6. if (!event.event) continue
  7. if (!("request" in event.event)) continue
  8. if (event.event.request.method !== "POST") continue
  9. const url = new URL(event.event.request.url)
  10. if (
  11. url.pathname !== "/zen/v1/chat/completions" &&
  12. url.pathname !== "/zen/v1/messages" &&
  13. url.pathname !== "/zen/v1/responses" &&
  14. !url.pathname.startsWith("/zen/v1/models/") &&
  15. url.pathname !== "/zen/go/v1/chat/completions" &&
  16. url.pathname !== "/zen/go/v1/messages" &&
  17. url.pathname !== "/zen/go/v1/responses" &&
  18. !url.pathname.startsWith("/zen/go/v1/models/")
  19. )
  20. continue
  21. const ip = event.event.request.headers["x-real-ip"]
  22. let data: Record<string, unknown> = {
  23. "cf.continent": event.event.request.cf?.continent,
  24. "cf.country": event.event.request.cf?.country,
  25. "cf.city": event.event.request.cf?.city,
  26. "cf.region": event.event.request.cf?.region,
  27. "cf.latitude": event.event.request.cf?.latitude,
  28. "cf.longitude": event.event.request.cf?.longitude,
  29. "cf.timezone": event.event.request.cf?.timezone,
  30. duration: event.wallTime,
  31. request_length: parseInt(event.event.request.headers["content-length"] ?? "0"),
  32. status: event.event.response?.status ?? 0,
  33. ip,
  34. "ip.prefix": ipPrefix(ip),
  35. }
  36. const time = new Date(event.eventTimestamp ?? Date.now()).toISOString()
  37. const events = [
  38. ...event.logs.flatMap((log) =>
  39. log.message.flatMap((message: string) => {
  40. if (!message.startsWith("_metric:")) return []
  41. const json = JSON.parse(message.slice(8)) as Record<string, unknown>
  42. data = { ...data, ...json }
  43. if ("llm.error.code" in json) {
  44. return [{ time, data: { ...data, event_type: "llm.error" } }]
  45. }
  46. return []
  47. }),
  48. ),
  49. { time, data: { ...data, event_type: "completions" } },
  50. ]
  51. console.log(JSON.stringify(data, null, 2))
  52. const lakeIngest = getLakeIngest()
  53. const [honeycomb, lake] = await Promise.all([
  54. fetch("https://api.honeycomb.io/1/batch/zen", {
  55. method: "POST",
  56. headers: {
  57. "Content-Type": "application/json",
  58. "X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
  59. },
  60. body: JSON.stringify(events),
  61. }),
  62. ...(lakeIngest
  63. ? [
  64. fetch(lakeIngest.url, {
  65. method: "POST",
  66. headers: {
  67. "Content-Type": "application/json",
  68. Authorization: `Bearer ${lakeIngest.secret}`,
  69. },
  70. body: JSON.stringify({ events: events.map((event) => toLakeEvent(event.time, event.data)) }),
  71. }),
  72. ]
  73. : []),
  74. ])
  75. console.log(honeycomb.status)
  76. console.log(await honeycomb.text())
  77. if (lake) {
  78. console.log(lake.status)
  79. console.log(await lake.text())
  80. }
  81. }
  82. },
  83. }
  84. function getLakeIngest(): { url: string; secret: string } | undefined {
  85. try {
  86. return Resource.LakeIngest
  87. } catch {
  88. return undefined
  89. }
  90. }
  91. function toLakeEvent(time: string, data: Record<string, unknown>) {
  92. return {
  93. _datalake_key: "inference.event",
  94. event_timestamp: time,
  95. event_date: time.slice(0, 10),
  96. event_type: string(data, "event_type"),
  97. dataset: "zen",
  98. cf_continent: string(data, "cf.continent"),
  99. cf_country: string(data, "cf.country"),
  100. cf_city: string(data, "cf.city"),
  101. cf_region: string(data, "cf.region"),
  102. cf_latitude: number(data, "cf.latitude"),
  103. cf_longitude: number(data, "cf.longitude"),
  104. cf_timezone: string(data, "cf.timezone"),
  105. duration: number(data, "duration"),
  106. request_length: integer(data, "request_length"),
  107. status: integer(data, "status"),
  108. ip: string(data, "ip"),
  109. ip_prefix: string(data, "ip.prefix"),
  110. is_stream: boolean(data, "is_stream"),
  111. session: string(data, "session"),
  112. request: string(data, "request"),
  113. client: string(data, "client"),
  114. user_agent: string(data, "user_agent"),
  115. model: string(data, "model"),
  116. model_tier: string(data, "model.tier"),
  117. model_variant: string(data, "model.variant"),
  118. source: string(data, "source"),
  119. provider: string(data, "provider"),
  120. provider_model: string(data, "provider.model"),
  121. llm_error_code: integer(data, "llm.error.code"),
  122. llm_error_message: string(data, "llm.error.message"),
  123. error_response: string(data, "error.response"),
  124. error_type: string(data, "error.type"),
  125. error_message: string(data, "error.message"),
  126. error_cause: string(data, "error.cause"),
  127. error_cause2: string(data, "error.cause2"),
  128. api_key: string(data, "api_key"),
  129. workspace: string(data, "workspace"),
  130. user_id: string(data, "user_id"),
  131. is_subscription: boolean(data, "isSubscription"), // removed
  132. subscription: string(data, "subscription"),
  133. response_length: integer(data, "response_length"),
  134. time_to_first_byte: integer(data, "time_to_first_byte"),
  135. timestamp_first_byte: integer(data, "timestamp.first_byte"),
  136. timestamp_last_byte: integer(data, "timestamp.last_byte"),
  137. tokens_input: integer(data, "tokens.input"),
  138. tokens_output: integer(data, "tokens.output"),
  139. tokens_reasoning: integer(data, "tokens.reasoning"),
  140. tokens_cache_read: integer(data, "tokens.cache_read"),
  141. tokens_cache_write_5m: integer(data, "tokens.cache_write_5m"),
  142. tokens_cache_write_1h: integer(data, "tokens.cache_write_1h"),
  143. cost_input_microcents: integer(data, "cost.input.microcents"),
  144. cost_output_microcents: integer(data, "cost.output.microcents"),
  145. cost_cache_read_microcents: integer(data, "cost.cache_read.microcents"),
  146. cost_cache_write_microcents: integer(data, "cost.cache_write.microcents"),
  147. cost_total_microcents: integer(data, "cost.total.microcents"),
  148. }
  149. }
  150. // Returns a stable lookup key for an IP address.
  151. // IPv4: full address as /32 (e.g. "203.0.113.45/32").
  152. // IPv6: the /64 network prefix (e.g. "2001:db8:abcd:1234::/64"). ISPs commonly
  153. // rotate the lower 64 host bits via SLAAC privacy extensions (RFC 8981), so
  154. // grouping by /64 collapses those rotations into one key.
  155. function ipPrefix(ip: string | undefined) {
  156. if (!ip) return undefined
  157. if (ip.includes(".") && !ip.includes(":")) return `${ip}/32`
  158. if (!ip.includes(":")) return undefined
  159. // Expand "::" to its full form, then keep the first 4 hextets.
  160. const [head, tail] = ip.split("::") as [string, string | undefined]
  161. const headParts = head ? head.split(":") : []
  162. const tailParts = tail !== undefined ? tail.split(":") : []
  163. const missing = 8 - headParts.length - tailParts.length
  164. if (missing < 0) return undefined
  165. const full = [...headParts, ...new Array(missing).fill("0"), ...tailParts]
  166. if (full.length !== 8) return undefined
  167. const prefix = full
  168. .slice(0, 4)
  169. .map((part) => part.toLowerCase().replace(/^0+(?=.)/, ""))
  170. .join(":")
  171. return `${prefix}::/64`
  172. }
  173. function string(data: Record<string, unknown>, key: string) {
  174. const value = data[key]
  175. if (typeof value === "string") return value
  176. if (typeof value === "number" || typeof value === "boolean") return String(value)
  177. return undefined
  178. }
  179. function boolean(data: Record<string, unknown>, key: string) {
  180. const value = data[key]
  181. if (typeof value === "boolean") return value
  182. if (typeof value === "string") return value === "true" ? true : value === "false" ? false : undefined
  183. return undefined
  184. }
  185. function integer(data: Record<string, unknown>, key: string) {
  186. const value = number(data, key)
  187. if (value === undefined) return undefined
  188. return Math.round(value)
  189. }
  190. function number(data: Record<string, unknown>, key: string) {
  191. const value = data[key]
  192. if (typeof value === "number") return Number.isFinite(value) ? value : undefined
  193. if (typeof value === "string") {
  194. const parsed = Number(value)
  195. return Number.isFinite(parsed) ? parsed : undefined
  196. }
  197. return undefined
  198. }