Skip to content
406 linesCodeBlameRaw
1/**
2 * Observability: what a project's running software did, as requests, logs,
3 * traces and metrics. The telemetry service (`services/telemetry`) keeps
4 * one store per project (a Durable Object with its own SQLite), filled from
5 * two doors:
6 *
7 * - **Deployments on g1t.page**, with nothing to set up: every app the
8 * deployments service uploads names the telemetry service as its Tail
9 * Worker, so each request it answers arrives with its logs, exceptions,
10 * status, CPU and wall time. Its tags (`TELEMETRY_TAG`) say which project
11 * and deployment it belongs to.
12 * - **OpenTelemetry (OTLP over HTTP)** from anywhere, in JSON or protobuf:
13 * `https://otel.g1t.sh/v1/{traces,logs,metrics}` with a project's ingest
14 * token. Apps on g1t.page get the endpoint and a token as
15 * `OTEL_EXPORTER_OTLP_*` variables, so an OpenTelemetry SDK in them sends
16 * spans without configuration; software hosted anywhere else sends the
17 * same way.
18 *
19 * Wire shapes are snake_case end to end. Times are microseconds since the
20 * Unix epoch (`*_us`): exact enough for spans, and safe in a JSON number.
21 */
22import type { ServiceBinding } from "./clients";
23
24/** Where telemetry is sent from outside: OTLP's base, under which SDKs add `/v1/traces` and so on. */
25export const TELEMETRY_OTLP_ENDPOINT = "https://otel.g1t.sh";
26
27/** The prefix of a project's ingest token, so a leaked one is recognisable. */
28export const TELEMETRY_TOKEN_PREFIX = "g1to_";
29
30/**
31 * The tags each app on g1t.page carries for its telemetry (script tags in
32 * the dispatch namespace; they arrive with every tail event):
33 * `g1t-p:<project id>` and `g1t-d:<deployment id>`. The environment is
34 * the tag `production` or `preview` the app already has.
35 */
36export const TELEMETRY_TAG = { project: "g1t-p:", deployment: "g1t-d:" } as const;
37
38/** Where a record came from: an app on g1t.page, or OTLP from an SDK or a collector. */
39export type TelemetrySource = "cloudflare" | "otlp";
40
41/** Log levels, as OpenTelemetry's severity numbers group them. */
42export type LogSeverity = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
43export const LOG_SEVERITIES: readonly LogSeverity[] = ["trace", "debug", "info", "warn", "error", "fatal"];
44
45/** An attribute's value, as OpenTelemetry allows: arrays and maps flattened to JSON where they nest. */
46export type AttributeValue = string | number | boolean | null | AttributeValue[] | { [key: string]: AttributeValue };
47export type Attributes = Record<string, AttributeValue>;
48
49/**
50 * What every record is filed under: which software sent it (`service`,
51 * OpenTelemetry's `service.name`; an app on g1t.page is its project's
52 * name), where it ran (`environment`: production, preview, or what
53 * `deployment.environment.name` said) and, on g1t.page, which deployment.
54 */
55export type TelemetryOrigin = {
56 service: string;
57 environment: string | null;
58 deployment_id: string | null;
59 /** The commit it was built from, when known (`vcs.ref.head.revision` or the deployment's). */
60 commit_sha: string | null;
61 source: TelemetrySource;
62};
63
64/**
65 * One request (or other invocation) an app on g1t.page answered: the tail
66 * event, with the logs it wrote under it. Also made from each server span
67 * sent over OTLP that has no parent here, so software hosted elsewhere
68 * shows the same numbers.
69 */
70export type TelemetryRequest = TelemetryOrigin & {
71 id: string;
72 time_us: number;
73 duration_us: number;
74 /** fetch, scheduled, queue, alarm, email, rpc or other. */
75 trigger: string;
76 method: string | null;
77 /** Host and path, without the query. */
78 url: string | null;
79 /** The path with ids taken out (`/users/:id`), what the numbers group by. */
80 route: string | null;
81 status: number | null;
82 /** ok, exception, exceededCpu, canceled and the rest, as Cloudflare says; ok or error over OTLP. */
83 outcome: string;
84 cpu_ms: number | null;
85 /** Where it ran: Cloudflare's colo, or `cloud.region`. */
86 region: string | null;
87 country: string | null;
88 trace_id: string | null;
89 /** How many log lines it wrote; `logs` fills them in on the request's own page. */
90 log_count: number;
91 error: { name: string; message: string } | null;
92};
93
94export type TelemetryLog = TelemetryOrigin & {
95 id: string;
96 time_us: number;
97 severity: LogSeverity;
98 /** OpenTelemetry's 1–24. */
99 severity_number: number;
100 body: string;
101 trace_id: string | null;
102 span_id: string | null;
103 /** The request it was written under, for an app on g1t.page. */
104 request_id: string | null;
105 attributes: Attributes;
106};
107
108export type SpanKind = "unspecified" | "internal" | "server" | "client" | "producer" | "consumer";
109export type SpanStatus = "unset" | "ok" | "error";
110
111export type TelemetrySpan = TelemetryOrigin & {
112 trace_id: string;
113 span_id: string;
114 parent_span_id: string | null;
115 name: string;
116 kind: SpanKind;
117 start_us: number;
118 duration_us: number;
119 status: SpanStatus;
120 status_message: string | null;
121 attributes: Attributes;
122 resource: Attributes;
123 events: { time_us: number; name: string; attributes: Attributes }[];
124 links: { trace_id: string; span_id: string }[];
125};
126
127/** A trace in a list: its root (or earliest) span, and what the rest add up to. */
128export type TraceSummary = TelemetryOrigin & {
129 trace_id: string;
130 name: string;
131 start_us: number;
132 duration_us: number;
133 span_count: number;
134 error_count: number;
135 services: string[];
136 status: number | null;
137};
138
139export type TraceDetail = {
140 trace_id: string;
141 spans: TelemetrySpan[];
142 logs: TelemetryLog[];
143};
144
145export type MetricKind = "gauge" | "sum" | "histogram" | "summary";
146
147export type MetricInfo = {
148 name: string;
149 kind: MetricKind;
150 unit: string;
151 description: string;
152 services: string[];
153 last_us: number;
154};
155
156/** A metric over time: one value per step, by the attribute it was grouped by. */
157export type MetricSeries = {
158 name: string;
159 unit: string;
160 step_s: number;
161 series: { group: string | null; points: { time_us: number; value: number | null }[] }[];
162};
163
164/** The latency (and CPU) percentiles of a set of requests, in milliseconds. */
165export type Percentiles = { p50: number | null; p75: number | null; p95: number | null; p99: number | null };
166
167/** One step of the overview's charts. */
168export type TelemetryPoint = {
169 time_us: number;
170 requests: number;
171 errors: number;
172 status_2xx: number;
173 status_3xx: number;
174 status_4xx: number;
175 status_5xx: number;
176 latency: Percentiles;
177 cpu: Percentiles;
178};
179
180/** Numbers for a period, and the same for the one before it, to compare. */
181export type TelemetryTotals = {
182 requests: number;
183 errors: number;
184 error_rate: number;
185 latency: Percentiles;
186 cpu: Percentiles;
187};
188
189export type TelemetryOverview = {
190 from_us: number;
191 to_us: number;
192 step_s: number;
193 totals: TelemetryTotals;
194 previous: TelemetryTotals;
195 points: TelemetryPoint[];
196 routes: { route: string; method: string | null; requests: number; errors: number; latency: Percentiles }[];
197 /** Each deployment's numbers over the period, newest first: a regression points at the deployment it began with. */
198 deployments: { deployment_id: string; environment: string | null; commit_sha: string | null; first_us: number; requests: number; errors: number; latency: Percentiles }[];
199 services: { service: string; source: TelemetrySource; requests: number; errors: number; last_us: number }[];
200 regions: { region: string; requests: number }[];
201};
202
203/**
204 * Errors, grouped: the same exception (name, message with its numbers and
205 * ids taken out, and the top frame of its stack) is one group.
206 */
207export type ErrorGroup = {
208 fingerprint: string;
209 name: string;
210 message: string;
211 stack: string | null;
212 service: string;
213 environment: string | null;
214 first_us: number;
215 last_us: number;
216 count: number;
217 /** The deployment it was first seen on: what likely brought it in. */
218 first_deployment_id: string | null;
219 last_request_id: string | null;
220 last_trace_id: string | null;
221 /** resolved, ignored or open. A resolved group that happens again opens again. */
222 state: "open" | "resolved" | "ignored";
223 /** The issue made for it, so an agent can take it. */
224 issue_number: number | null;
225};
226
227/** What every list query can narrow by. */
228export type TelemetryFilter = {
229 from_us?: number | null;
230 to_us?: number | null;
231 services?: string[] | null;
232 environments?: string[] | null;
233 deployment_id?: string | null;
234 source?: TelemetrySource | null;
235};
236
237export type LogQuery = TelemetryFilter & {
238 /** Words to find in the body (all of them), or a phrase in quotes. */
239 text?: string | null;
240 severities?: LogSeverity[] | null;
241 trace_id?: string | null;
242 request_id?: string | null;
243 /** Where to carry on from: the last page's `next`. */
244 cursor?: string | null;
245 limit?: number | null;
246};
247
248export type RequestQuery = TelemetryFilter & {
249 text?: string | null;
250 /** 2xx, 3xx, 4xx, 5xx, or a status. */
251 status?: string[] | null;
252 /** Only those that failed: a 5xx, or an outcome other than ok. */
253 errors_only?: boolean | null;
254 route?: string | null;
255 min_duration_ms?: number | null;
256 cursor?: string | null;
257 limit?: number | null;
258};
259
260export type TraceQuery = TelemetryFilter & {
261 text?: string | null;
262 errors_only?: boolean | null;
263 min_duration_ms?: number | null;
264 cursor?: string | null;
265 limit?: number | null;
266};
267
268export type Page<T> = { items: T[]; next: string | null };
269
270/** A project's ingest token, as listed: the secret itself is shown once, when made. */
271export type IngestToken = {
272 id: string;
273 name: string;
274 /** The first characters, to tell tokens apart. */
275 hint: string;
276 created_by: string | null;
277 created_us: number;
278 last_used_us: number | null;
279 /** Made by g1t for its deployments; replaced on its own, not by people. */
280 managed: boolean;
281};
282
283/**
284 * Where a project's telemetry is also sent, as it arrives: any OTLP/HTTP
285 * endpoint (a collector, or a vendor that takes OTLP), with the headers it
286 * needs. Header values are secrets: written, never read back.
287 */
288export type TelemetryExport = {
289 id: string;
290 name: string;
291 endpoint: string;
292 /** Which signals go: traces, logs (with requests as log records) and metrics. */
293 signals: ("traces" | "logs" | "metrics")[];
294 header_names: string[];
295 enabled: boolean;
296 last_error: string | null;
297 last_sent_us: number | null;
298};
299
300export type TelemetrySettings = {
301 /** Days requests, logs and spans are kept. Charts are kept longer, as per-minute counts. */
302 retention_days: number;
303 /** Whether apps on g1t.page send. On by default; off keeps the tokens and history. */
304 deployments: boolean;
305};
306
307export const DEFAULT_TELEMETRY_SETTINGS: TelemetrySettings = { retention_days: 7, deployments: true };
308
309/** Retention a project can choose, in days. */
310export const RETENTION_CHOICES = [1, 3, 7, 14, 30] as const;
311
312/** How much a project has stored and sent this month, for the page and for billing. */
313export type TelemetryUsage = {
314 month: string;
315 /** Bytes received, after decoding: what ingest is metered by. */
316 ingested_bytes: number;
317 requests: number;
318 logs: number;
319 spans: number;
320 metric_points: number;
321 stored_bytes: number;
322 /** Bytes received today (UTC): a workspace without the plan sends a thirtieth of its monthly observability a day. */
323 today_bytes?: number;
324};
325
326/** Whom a call is for: the site and the API check access before they ask. */
327type ProjectRef = { project_id: string; /** The project's workspace, by slug: the store remembers it, for a free workspace's cap. */ workspace?: string };
328
329export interface TelemetryApi {
330 overview(args: ProjectRef & TelemetryFilter & { step_s?: number | null }): Promise<TelemetryOverview>;
331 requests(args: ProjectRef & RequestQuery): Promise<Page<TelemetryRequest>>;
332 request(args: ProjectRef & { id: string }): Promise<{ request: TelemetryRequest; logs: TelemetryLog[] } | null>;
333 logs(args: ProjectRef & LogQuery): Promise<Page<TelemetryLog>>;
334 traces(args: ProjectRef & TraceQuery): Promise<Page<TraceSummary>>;
335 trace(args: ProjectRef & { trace_id: string }): Promise<TraceDetail | null>;
336 errors(args: ProjectRef & TelemetryFilter & { state?: ErrorGroup["state"] | null }): Promise<ErrorGroup[]>;
337 setError(args: ProjectRef & { fingerprint: string; state?: ErrorGroup["state"]; issue_number?: number | null }): Promise<ErrorGroup | null>;
338 metrics(args: ProjectRef): Promise<MetricInfo[]>;
339 metric(args: ProjectRef & TelemetryFilter & { name: string; aggregate?: "avg" | "sum" | "min" | "max" | "p50" | "p95" | "p99" | null; group_by?: string | null; step_s?: number | null }): Promise<MetricSeries>;
340 /** What is in the store: services, environments and attribute keys seen, for filters. */
341 facets(args: ProjectRef): Promise<{ services: string[]; environments: string[]; attribute_keys: string[] }>;
342 settings(args: ProjectRef): Promise<TelemetrySettings>;
343 setSettings(args: ProjectRef & Partial<TelemetrySettings>): Promise<TelemetrySettings>;
344 tokens(args: ProjectRef): Promise<IngestToken[]>;
345 createToken(args: ProjectRef & { name: string; created_by: string }): Promise<{ token: IngestToken; secret: string }>;
346 revokeToken(args: ProjectRef & { id: string }): Promise<boolean>;
347 /**
348 * The token g1t's own deployments of the project send with, made the
349 * first time: the deployments service binds it to each app it uploads.
350 */
351 deploymentToken(args: ProjectRef): Promise<{ secret: string }>;
352 exports(args: ProjectRef): Promise<TelemetryExport[]>;
353 putExport(args: ProjectRef & { id?: string | null; name: string; endpoint: string; signals: TelemetryExport["signals"]; headers?: Record<string, string> | null; enabled: boolean }): Promise<TelemetryExport>;
354 deleteExport(args: ProjectRef & { id: string }): Promise<boolean>;
355 usage(args: ProjectRef): Promise<TelemetryUsage>;
356 /** Everything kept for a project, gone: when the project is deleted. */
357 purge(args: ProjectRef): Promise<boolean>;
358}
359
360/**
361 * The site's live tail: `GET /live?project_id=…` on the service, a
362 * WebSocket the site opens after checking the viewer may read the
363 * project. Each message is one `LiveItem`.
364 */
365export type LiveItem =
366 | { type: "request"; request: TelemetryRequest }
367 | { type: "log"; log: TelemetryLog }
368 | { type: "span"; span: TelemetrySpan };
369
370async function call<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
371 const response = await service.fetch(`https://service/rpc/${method}`, {
372 method: "POST",
373 headers: { "content-type": "application/json" },
374 body: JSON.stringify(args),
375 });
376 if (!response.ok) throw new Error(`telemetry ${method} failed with status ${response.status}: ${await response.text()}`);
377 return (await response.json()) as T;
378}
379
380export function telemetryClient(service: ServiceBinding): TelemetryApi {
381 const c = <T>(method: string) => (args: object) => call<T>(service, method, args);
382 return {
383 overview: c("overview"),
384 requests: c("requests"),
385 request: c("request"),
386 logs: c("logs"),
387 traces: c("traces"),
388 trace: c("trace"),
389 errors: c("errors"),
390 setError: c("set_error"),
391 metrics: c("metrics"),
392 metric: c("metric"),
393 facets: c("facets"),
394 settings: c("settings"),
395 setSettings: c("set_settings"),
396 tokens: c("tokens"),
397 createToken: c("create_token"),
398 revokeToken: c("revoke_token"),
399 deploymentToken: c("deployment_token"),
400 exports: c("exports"),
401 putExport: c("put_export"),
402 deleteExport: c("delete_export"),
403 usage: c("usage"),
404 purge: c("purge"),
405 };
406}