| 1 | /** |
| 2 | * Tail events from g1t.page, as rows. Two Workers name this service as |
| 3 | * their Tail Worker, and each tells half of a request: |
| 4 | * |
| 5 | * - **The dispatcher** (`services/pages`), in front of every app: it |
| 6 | * writes one `g1t-request` line per request (`REQUEST_MARK`) with the |
| 7 | * request's id, the app's script and the trace it began or continued, |
| 8 | * and its tail event has the status, the time taken, the method, the |
| 9 | * address and where in the world it was served. Every request is here, |
| 10 | * including those a site's files answered without running its code. |
| 11 | * - **The app itself**, when its code ran: the logs it wrote, the |
| 12 | * exceptions it threw, its CPU time and outcome, and its tags |
| 13 | * (`TELEMETRY_TAG`: the project and the deployment). The dispatcher |
| 14 | * handed it the request's id (`x-g1t-request-id`) and a `traceparent`, |
| 15 | * which is how the halves meet, and how spans an OpenTelemetry SDK in |
| 16 | * the app sends join the request's trace. |
| 17 | * |
| 18 | * Halves arrive apart, in either order; the project's store merges them |
| 19 | * by id (./store.ts). The dispatcher's half names the project the |
| 20 | * deployments service noted for the app's hostname; when it had none yet, |
| 21 | * the service looks the script up itself (./index.ts). |
| 22 | */ |
| 23 | import { TELEMETRY_TAG, type Attributes, type TelemetryLog, type TelemetryRequest } from "@g1t/contracts/telemetry"; |
| 24 | |
| 25 | import { emptyBatch, type Batch } from "./batch.ts"; |
| 26 | import { sizeOf } from "./quota.ts"; |
| 27 | import { clip, hostPath, randomId, route, severityNumber, severityOf, text } from "./shape.ts"; |
| 28 | |
| 29 | /** The first argument of the dispatcher's line for each request. */ |
| 30 | export const REQUEST_MARK = "g1t-request"; |
| 31 | /** The header the dispatcher hands each app with the request's id. */ |
| 32 | export const REQUEST_ID_HEADER = "x-g1t-request-id"; |
| 33 | |
| 34 | /** |
| 35 | * What the dispatcher's line says: the request, its app, and what the |
| 36 | * deployments service noted for the app's hostname (`AppTelemetry` there), |
| 37 | * when the dispatcher had it. |
| 38 | */ |
| 39 | export type RequestMark = { |
| 40 | id: string; |
| 41 | script: string; |
| 42 | trace_id: string; |
| 43 | app?: { project: string; deployment: string | null; environment: string | null; service: string; commit: string | null } | null; |
| 44 | }; |
| 45 | |
| 46 | /** A request's half, merged with the other half by id in the store. */ |
| 47 | export type RequestHalf = Partial<TelemetryRequest> & { id: string; half: "edge" | "app" }; |
| 48 | |
| 49 | /** Rows for one project, or for one script whose project is not known yet. */ |
| 50 | export type TailGroup = { project_id: string | null; script: string; batch: Batch; halves: RequestHalf[] }; |
| 51 | |
| 52 | /** What a tail event is about: its project and deployment from its tags. */ |
| 53 | function tagged(tags: readonly string[] | undefined): { project_id: string | null; deployment_id: string | null; environment: string | null; service: string } { |
| 54 | let service = ""; |
| 55 | let project_id: string | null = null; |
| 56 | let deployment_id: string | null = null; |
| 57 | let environment: string | null = null; |
| 58 | for (const tag of tags ?? []) { |
| 59 | if (tag.startsWith(TELEMETRY_TAG.project)) project_id = tag.slice(TELEMETRY_TAG.project.length); |
| 60 | else if (tag.startsWith(TELEMETRY_TAG.deployment)) deployment_id = tag.slice(TELEMETRY_TAG.deployment.length); |
| 61 | else if (tag === "production" || tag === "preview") environment = tag; |
| 62 | // `project:<workspace>/<slug>`: the service is the project's name. |
| 63 | else if (tag.startsWith("project:")) service = tag.slice(tag.indexOf("/") + 1); |
| 64 | } |
| 65 | return { project_id, deployment_id, environment, service }; |
| 66 | } |
| 67 | |
| 68 | function markOf(item: TraceItem): RequestMark | null { |
| 69 | for (const log of item.logs) { |
| 70 | const message = log.message as unknown; |
| 71 | if (!Array.isArray(message) || message[0] !== REQUEST_MARK) continue; |
| 72 | const mark = message[1] as Partial<RequestMark> | undefined; |
| 73 | if (mark && typeof mark.id === "string" && typeof mark.script === "string") { |
| 74 | const app = mark.app && typeof mark.app.project === "string" ? mark.app : null; |
| 75 | return { id: mark.id, script: mark.script, trace_id: typeof mark.trace_id === "string" ? mark.trace_id : "", app }; |
| 76 | } |
| 77 | } |
| 78 | return null; |
| 79 | } |
| 80 | |
| 81 | function fetchInfo(item: TraceItem): TraceItemFetchEventInfo | null { |
| 82 | const event = item.event as TraceItemFetchEventInfo | null; |
| 83 | return event && typeof event === "object" && "request" in event ? event : null; |
| 84 | } |
| 85 | |
| 86 | function triggerOf(item: TraceItem): string { |
| 87 | const event = item.event as Record<string, unknown> | null; |
| 88 | if (!event) return "other"; |
| 89 | if ("request" in event) return "fetch"; |
| 90 | if ("cron" in event) return "scheduled"; |
| 91 | if ("queue" in event) return "queue"; |
| 92 | if ("mailFrom" in event) return "email"; |
| 93 | if ("rpcMethod" in event) return "rpc"; |
| 94 | if ("scheduledTime" in event) return "alarm"; |
| 95 | return "other"; |
| 96 | } |
| 97 | |
| 98 | /** The `traceparent` header's trace id, if it is a valid one. */ |
| 99 | export function traceIdOf(traceparent: string | undefined | null): string | null { |
| 100 | const m = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/.exec((traceparent ?? "").trim().toLowerCase()); |
| 101 | return m && !/^0+$/.test(m[1]) ? m[1] : null; |
| 102 | } |
| 103 | |
| 104 | /** The status code of an event that failed before answering: what the visitor got. */ |
| 105 | function failedStatus(outcome: string): number | null { |
| 106 | if (outcome === "ok" || outcome === "canceled" || outcome === "responseStreamDisconnected") return null; |
| 107 | return outcome === "exceededCpu" || outcome === "exceededMemory" ? 503 : 500; |
| 108 | } |
| 109 | |
| 110 | /** Tail events as rows, grouped by the project (or, for the dispatcher's half, the script) they are of. */ |
| 111 | export function readTail(events: readonly TraceItem[]): TailGroup[] { |
| 112 | const groups = new Map<string, TailGroup>(); |
| 113 | const group = (project_id: string | null, script: string): TailGroup => { |
| 114 | const key = project_id ?? `script:${script}`; |
| 115 | let g = groups.get(key); |
| 116 | if (!g) groups.set(key, (g = { project_id, script, batch: emptyBatch(), halves: [] })); |
| 117 | return g; |
| 118 | }; |
| 119 | |
| 120 | for (const item of events) { |
| 121 | const time = (item.eventTimestamp ?? Date.now()) * 1000; |
| 122 | const info = fetchInfo(item); |
| 123 | const mark = markOf(item); |
| 124 | |
| 125 | if (mark) { |
| 126 | // The dispatcher's half: every request, whatever answered it. |
| 127 | const g = group(mark.app?.project ?? null, mark.script); |
| 128 | const where = info ? hostPath(info.request.url) : null; |
| 129 | const cf = (info?.request.cf ?? {}) as { colo?: string; country?: string }; |
| 130 | const status = info?.response?.status ?? failedStatus(item.outcome); |
| 131 | g.halves.push({ |
| 132 | id: mark.id, |
| 133 | half: "edge", |
| 134 | ...(mark.app |
| 135 | ? { service: mark.app.service, environment: mark.app.environment, deployment_id: mark.app.deployment, commit_sha: mark.app.commit, source: "cloudflare" as const } |
| 136 | : {}), |
| 137 | time_us: time, |
| 138 | duration_us: Math.round(item.wallTime * 1000), |
| 139 | trigger: "fetch", |
| 140 | method: info?.request.method ?? null, |
| 141 | url: where?.url ?? null, |
| 142 | route: where ? route(where.path) : null, |
| 143 | status, |
| 144 | region: cf.colo ?? null, |
| 145 | country: cf.country ?? null, |
| 146 | trace_id: mark.trace_id || null, |
| 147 | }); |
| 148 | g.batch.bytes += sizeOf(g.halves[g.halves.length - 1]); |
| 149 | continue; |
| 150 | } |
| 151 | |
| 152 | const tags = tagged(item.scriptTags); |
| 153 | if (!tags.project_id) continue; |
| 154 | const g = group(tags.project_id, item.scriptName ?? ""); |
| 155 | const headers = info?.request.headers ?? {}; |
| 156 | const id = headers[REQUEST_ID_HEADER] || randomId(); |
| 157 | const trace_id = traceIdOf(headers.traceparent); |
| 158 | const origin = { |
| 159 | service: tags.service, |
| 160 | environment: tags.environment, |
| 161 | deployment_id: tags.deployment_id, |
| 162 | commit_sha: null, |
| 163 | source: "cloudflare" as const, |
| 164 | }; |
| 165 | |
| 166 | const logs: TelemetryLog[] = item.logs.map((log) => { |
| 167 | const number = severityNumber(log.level); |
| 168 | return { |
| 169 | ...origin, |
| 170 | id: randomId(), |
| 171 | time_us: log.timestamp * 1000, |
| 172 | severity: severityOf(number), |
| 173 | severity_number: number, |
| 174 | body: clip(text(log.message)), |
| 175 | trace_id, |
| 176 | span_id: null, |
| 177 | request_id: id, |
| 178 | attributes: (log.level === "log" ? {} : { "log.level": log.level }) as Attributes, |
| 179 | }; |
| 180 | }); |
| 181 | for (const e of item.exceptions) { |
| 182 | logs.push({ |
| 183 | ...origin, |
| 184 | id: randomId(), |
| 185 | time_us: e.timestamp * 1000, |
| 186 | severity: "error", |
| 187 | severity_number: 17, |
| 188 | body: clip(`${e.name}: ${e.message}${e.stack ? `\n${e.stack}` : ""}`), |
| 189 | trace_id, |
| 190 | span_id: null, |
| 191 | request_id: id, |
| 192 | attributes: { "exception.type": e.name, "exception.message": clip(e.message, 4096) }, |
| 193 | }); |
| 194 | g.batch.errors.push({ ...origin, time_us: e.timestamp * 1000, name: e.name, message: e.message, stack: e.stack ?? null, request_id: id, trace_id }); |
| 195 | } |
| 196 | if (item.truncated) { |
| 197 | logs.push({ |
| 198 | ...origin, |
| 199 | id: randomId(), |
| 200 | time_us: time, |
| 201 | severity: "warn", |
| 202 | severity_number: 13, |
| 203 | body: "Cloudflare cut this request's logs short: it wrote more than a tail event holds.", |
| 204 | trace_id, |
| 205 | span_id: null, |
| 206 | request_id: id, |
| 207 | attributes: {}, |
| 208 | }); |
| 209 | } |
| 210 | g.batch.logs.push(...logs); |
| 211 | |
| 212 | const where = info ? hostPath(info.request.url) : null; |
| 213 | const status = info?.response?.status ?? failedStatus(item.outcome); |
| 214 | const first = item.exceptions[0]; |
| 215 | g.halves.push({ |
| 216 | ...origin, |
| 217 | id, |
| 218 | half: "app", |
| 219 | time_us: time, |
| 220 | duration_us: Math.round(item.wallTime * 1000), |
| 221 | trigger: triggerOf(item), |
| 222 | method: info?.request.method ?? null, |
| 223 | url: where?.url ?? null, |
| 224 | route: where ? route(where.path) : null, |
| 225 | status, |
| 226 | outcome: item.outcome, |
| 227 | cpu_ms: item.cpuTime, |
| 228 | trace_id, |
| 229 | log_count: logs.length, |
| 230 | error: first ? { name: first.name, message: clip(first.message, 2048) } : item.outcome !== "ok" && item.outcome !== "canceled" ? { name: item.outcome, message: outcomeText(item.outcome) } : null, |
| 231 | }); |
| 232 | g.batch.bytes += sizeOf(g.halves[g.halves.length - 1]) + logs.reduce((n, l) => n + sizeOf(l), 0); |
| 233 | } |
| 234 | return [...groups.values()]; |
| 235 | } |
| 236 | |
| 237 | /** What Cloudflare's outcome means, for the request's error line. */ |
| 238 | export function outcomeText(outcome: string): string { |
| 239 | switch (outcome) { |
| 240 | case "exception": |
| 241 | return "The code threw before it answered."; |
| 242 | case "exceededCpu": |
| 243 | return "The request used more CPU time than it is allowed."; |
| 244 | case "exceededMemory": |
| 245 | return "The isolate ran out of memory."; |
| 246 | case "exceededWallTime": |
| 247 | return "The request ran longer than it is allowed."; |
| 248 | case "scriptNotFound": |
| 249 | return "No app was there to answer."; |
| 250 | case "loadShed": |
| 251 | return "Cloudflare shed the request under load."; |
| 252 | default: |
| 253 | return `The request ended with ${outcome}.`; |
| 254 | } |
| 255 | } |