Skip to content
255 linesCodeBlameRaw
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 */
23import { TELEMETRY_TAG, type Attributes, type TelemetryLog, type TelemetryRequest } from "@g1t/contracts/telemetry";
24
25import { emptyBatch, type Batch } from "./batch.ts";
26import { sizeOf } from "./quota.ts";
27import { clip, hostPath, randomId, route, severityNumber, severityOf, text } from "./shape.ts";
28
29/** The first argument of the dispatcher's line for each request. */
30export const REQUEST_MARK = "g1t-request";
31/** The header the dispatcher hands each app with the request's id. */
32export 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 */
39export 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. */
47export 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. */
50export 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. */
53function 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
68function 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
81function 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
86function 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. */
99export 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. */
105function 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. */
111export 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. */
238export 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}