Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

437 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: {
199 deployment_id: string;
200 environment: string | null;
201 commit_sha: string | null;
202 first_us: number;
203 requests: number;
204 errors: number;
205 latency: Percentiles;
206 }[];
207 services: { service: string; source: TelemetrySource; requests: number; errors: number; last_us: number }[];
208 regions: { region: string; requests: number }[];
209};
210
211/**
212 * Errors, grouped: the same exception (name, message with its numbers and
213 * ids taken out, and the top frame of its stack) is one group.
214 */
215export type ErrorGroup = {
216 fingerprint: string;
217 name: string;
218 message: string;
219 stack: string | null;
220 service: string;
221 environment: string | null;
222 first_us: number;
223 last_us: number;
224 count: number;
225 /** The deployment it was first seen on: what likely brought it in. */
226 first_deployment_id: string | null;
227 last_request_id: string | null;
228 last_trace_id: string | null;
229 /** resolved, ignored or open. A resolved group that happens again opens again. */
230 state: "open" | "resolved" | "ignored";
231 /** The work item made for it, by number in the workspace, so an agent can take it. */
232 item: number | null;
233 /**
234 * One linked before issues became work items: its old number in the
235 * project's repository, whose address (`/<owner>/<repo>/issues/<n>`)
236 * still leads to the work item. Null once `item` is set.
237 */
238 legacy_number: number | null;
239};
240
241/** What every list query can narrow by. */
242export type TelemetryFilter = {
243 from_us?: number | null;
244 to_us?: number | null;
245 services?: string[] | null;
246 environments?: string[] | null;
247 deployment_id?: string | null;
248 source?: TelemetrySource | null;
249};
250
251export type LogQuery = TelemetryFilter & {
252 /** Words to find in the body (all of them), or a phrase in quotes. */
253 text?: string | null;
254 severities?: LogSeverity[] | null;
255 trace_id?: string | null;
256 request_id?: string | null;
257 /** Where to carry on from: the last page's `next`. */
258 cursor?: string | null;
259 limit?: number | null;
260};
261
262export type RequestQuery = TelemetryFilter & {
263 text?: string | null;
264 /** 2xx, 3xx, 4xx, 5xx, or a status. */
265 status?: string[] | null;
266 /** Only those that failed: a 5xx, or an outcome other than ok. */
267 errors_only?: boolean | null;
268 route?: string | null;
269 min_duration_ms?: number | null;
270 cursor?: string | null;
271 limit?: number | null;
272};
273
274export type TraceQuery = TelemetryFilter & {
275 text?: string | null;
276 errors_only?: boolean | null;
277 min_duration_ms?: number | null;
278 cursor?: string | null;
279 limit?: number | null;
280};
281
282export type Page<T> = { items: T[]; next: string | null };
283
284/** A project's ingest token, as listed: the secret itself is shown once, when made. */
285export type IngestToken = {
286 id: string;
287 name: string;
288 /** The first characters, to tell tokens apart. */
289 hint: string;
290 created_by: string | null;
291 created_us: number;
292 last_used_us: number | null;
293 /** Made by g1t for its deployments; replaced on its own, not by people. */
294 managed: boolean;
295};
296
297/**
298 * Where a project's telemetry is also sent, as it arrives: any OTLP/HTTP
299 * endpoint (a collector, or a vendor that takes OTLP), with the headers it
300 * needs. Header values are secrets: written, never read back.
301 */
302export type TelemetryExport = {
303 id: string;
304 name: string;
305 endpoint: string;
306 /** Which signals go: traces, logs (with requests as log records) and metrics. */
307 signals: ("traces" | "logs" | "metrics")[];
308 header_names: string[];
309 enabled: boolean;
310 last_error: string | null;
311 last_sent_us: number | null;
312};
313
314export type TelemetrySettings = {
315 /** Days requests, logs and spans are kept. Charts are kept longer, as per-minute counts. */
316 retention_days: number;
317 /** Whether apps on g1t.page send. On by default; off keeps the tokens and history. */
318 deployments: boolean;
319};
320
321export const DEFAULT_TELEMETRY_SETTINGS: TelemetrySettings = { retention_days: 7, deployments: true };
322
323/** Retention a project can choose, in days. */
324export const RETENTION_CHOICES = [1, 3, 7, 14, 30] as const;
325
326/** How much a project has stored and sent this month, for the page and for billing. */
327export type TelemetryUsage = {
328 month: string;
329 /** Bytes received, after decoding: what ingest is metered by. */
330 ingested_bytes: number;
331 requests: number;
332 logs: number;
333 spans: number;
334 metric_points: number;
335 stored_bytes: number;
336 /** Bytes received today (UTC): a workspace without the plan sends a thirtieth of its monthly observability a day. */
337 today_bytes?: number;
338};
339
340/** Whom a call is for: the site and the API check access before they ask. */
341type ProjectRef = { project_id: string; /** The project's workspace, by slug: the store remembers it, for a free workspace's cap. */ workspace?: string };
342
343export interface TelemetryApi {
344 overview(args: ProjectRef & TelemetryFilter & { step_s?: number | null }): Promise<TelemetryOverview>;
345 requests(args: ProjectRef & RequestQuery): Promise<Page<TelemetryRequest>>;
346 request(args: ProjectRef & { id: string }): Promise<{ request: TelemetryRequest; logs: TelemetryLog[] } | null>;
347 logs(args: ProjectRef & LogQuery): Promise<Page<TelemetryLog>>;
348 traces(args: ProjectRef & TraceQuery): Promise<Page<TraceSummary>>;
349 trace(args: ProjectRef & { trace_id: string }): Promise<TraceDetail | null>;
350 errors(args: ProjectRef & TelemetryFilter & { state?: ErrorGroup["state"] | null }): Promise<ErrorGroup[]>;
351 setError(args: ProjectRef & { fingerprint: string; state?: ErrorGroup["state"]; item?: number | null }): Promise<ErrorGroup | null>;
352 metrics(args: ProjectRef): Promise<MetricInfo[]>;
353 metric(
354 args: ProjectRef &
355 TelemetryFilter & {
356 name: string;
357 aggregate?: "avg" | "sum" | "min" | "max" | "p50" | "p95" | "p99" | null;
358 group_by?: string | null;
359 step_s?: number | null;
360 },
361 ): Promise<MetricSeries>;
362 /** What is in the store: services, environments and attribute keys seen, for filters. */
363 facets(args: ProjectRef): Promise<{ services: string[]; environments: string[]; attribute_keys: string[] }>;
364 settings(args: ProjectRef): Promise<TelemetrySettings>;
365 setSettings(args: ProjectRef & Partial<TelemetrySettings>): Promise<TelemetrySettings>;
366 tokens(args: ProjectRef): Promise<IngestToken[]>;
367 createToken(args: ProjectRef & { name: string; created_by: string }): Promise<{ token: IngestToken; secret: string }>;
368 revokeToken(args: ProjectRef & { id: string }): Promise<boolean>;
369 /**
370 * The token g1t's own deployments of the project send with, made the
371 * first time: the deployments service binds it to each app it uploads.
372 */
373 deploymentToken(args: ProjectRef): Promise<{ secret: string }>;
374 exports(args: ProjectRef): Promise<TelemetryExport[]>;
375 putExport(
376 args: ProjectRef & {
377 id?: string | null;
378 name: string;
379 endpoint: string;
380 signals: TelemetryExport["signals"];
381 headers?: Record<string, string> | null;
382 enabled: boolean;
383 },
384 ): Promise<TelemetryExport>;
385 deleteExport(args: ProjectRef & { id: string }): Promise<boolean>;
386 usage(args: ProjectRef): Promise<TelemetryUsage>;
387 /** Everything kept for a project, gone: when the project is deleted. */
388 purge(args: ProjectRef): Promise<boolean>;
389}
390
391/**
392 * The site's live tail: `GET /live?project_id=…` on the service, a
393 * WebSocket the site opens after checking the viewer may read the
394 * project. Each message is one `LiveItem`.
395 */
396export type LiveItem = { type: "request"; request: TelemetryRequest } | { type: "log"; log: TelemetryLog } | { type: "span"; span: TelemetrySpan };
397
398async function call<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
399 const response = await service.fetch(`https://service/rpc/${method}`, {
400 method: "POST",
401 headers: { "content-type": "application/json" },
402 body: JSON.stringify(args),
403 });
404 if (!response.ok) throw new Error(`telemetry ${method} failed with status ${response.status}: ${await response.text()}`);
405 return (await response.json()) as T;
406}
407
408export function telemetryClient(service: ServiceBinding): TelemetryApi {
409 const c =
410 <T>(method: string) =>
411 (args: object) =>
412 call<T>(service, method, args);
413 return {
414 overview: c("overview"),
415 requests: c("requests"),
416 request: c("request"),
417 logs: c("logs"),
418 traces: c("traces"),
419 trace: c("trace"),
420 errors: c("errors"),
421 setError: c("set_error"),
422 metrics: c("metrics"),
423 metric: c("metric"),
424 facets: c("facets"),
425 settings: c("settings"),
426 setSettings: c("set_settings"),
427 tokens: c("tokens"),
428 createToken: c("create_token"),
429 revokeToken: c("revoke_token"),
430 deploymentToken: c("deployment_token"),
431 exports: c("exports"),
432 putExport: c("put_export"),
433 deleteExport: c("delete_export"),
434 usage: c("usage"),
435 purge: c("purge"),
436 };
437}