| 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 | */ |
| 22 | import type { ServiceBinding } from "./clients"; |
| 23 | |
| 24 | /** Where telemetry is sent from outside: OTLP's base, under which SDKs add `/v1/traces` and so on. */ |
| 25 | export const TELEMETRY_OTLP_ENDPOINT = "https://otel.g1t.sh"; |
| 26 | |
| 27 | /** The prefix of a project's ingest token, so a leaked one is recognisable. */ |
| 28 | export 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 | */ |
| 36 | export 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. */ |
| 39 | export type TelemetrySource = "cloudflare" | "otlp"; |
| 40 | |
| 41 | /** Log levels, as OpenTelemetry's severity numbers group them. */ |
| 42 | export type LogSeverity = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; |
| 43 | export 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. */ |
| 46 | export type AttributeValue = string | number | boolean | null | AttributeValue[] | { [key: string]: AttributeValue }; |
| 47 | export 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 | */ |
| 55 | export 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 | */ |
| 70 | export 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 | |
| 94 | export 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 | |
| 108 | export type SpanKind = "unspecified" | "internal" | "server" | "client" | "producer" | "consumer"; |
| 109 | export type SpanStatus = "unset" | "ok" | "error"; |
| 110 | |
| 111 | export 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. */ |
| 128 | export 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 | |
| 139 | export type TraceDetail = { |
| 140 | trace_id: string; |
| 141 | spans: TelemetrySpan[]; |
| 142 | logs: TelemetryLog[]; |
| 143 | }; |
| 144 | |
| 145 | export type MetricKind = "gauge" | "sum" | "histogram" | "summary"; |
| 146 | |
| 147 | export 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. */ |
| 157 | export 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. */ |
| 165 | export type Percentiles = { p50: number | null; p75: number | null; p95: number | null; p99: number | null }; |
| 166 | |
| 167 | /** One step of the overview's charts. */ |
| 168 | export 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. */ |
| 181 | export type TelemetryTotals = { |
| 182 | requests: number; |
| 183 | errors: number; |
| 184 | error_rate: number; |
| 185 | latency: Percentiles; |
| 186 | cpu: Percentiles; |
| 187 | }; |
| 188 | |
| 189 | export 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 | */ |
| 207 | export 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. */ |
| 228 | export 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 | |
| 237 | export 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 | |
| 248 | export 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 | |
| 260 | export 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 | |
| 268 | export 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. */ |
| 271 | export 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 | */ |
| 288 | export 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 | |
| 300 | export 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 | |
| 307 | export const DEFAULT_TELEMETRY_SETTINGS: TelemetrySettings = { retention_days: 7, deployments: true }; |
| 308 | |
| 309 | /** Retention a project can choose, in days. */ |
| 310 | export 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. */ |
| 313 | export 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 | }; |
| 323 | |
| 324 | /** Whom a call is for: the site and the API check access before they ask. */ |
| 325 | type ProjectRef = { project_id: string }; |
| 326 | |
| 327 | export interface TelemetryApi { |
| 328 | overview(args: ProjectRef & TelemetryFilter & { step_s?: number | null }): Promise<TelemetryOverview>; |
| 329 | requests(args: ProjectRef & RequestQuery): Promise<Page<TelemetryRequest>>; |
| 330 | request(args: ProjectRef & { id: string }): Promise<{ request: TelemetryRequest; logs: TelemetryLog[] } | null>; |
| 331 | logs(args: ProjectRef & LogQuery): Promise<Page<TelemetryLog>>; |
| 332 | traces(args: ProjectRef & TraceQuery): Promise<Page<TraceSummary>>; |
| 333 | trace(args: ProjectRef & { trace_id: string }): Promise<TraceDetail | null>; |
| 334 | errors(args: ProjectRef & TelemetryFilter & { state?: ErrorGroup["state"] | null }): Promise<ErrorGroup[]>; |
| 335 | setError(args: ProjectRef & { fingerprint: string; state?: ErrorGroup["state"]; issue_number?: number | null }): Promise<ErrorGroup | null>; |
| 336 | metrics(args: ProjectRef): Promise<MetricInfo[]>; |
| 337 | 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>; |
| 338 | /** What is in the store: services, environments and attribute keys seen, for filters. */ |
| 339 | facets(args: ProjectRef): Promise<{ services: string[]; environments: string[]; attribute_keys: string[] }>; |
| 340 | settings(args: ProjectRef): Promise<TelemetrySettings>; |
| 341 | setSettings(args: ProjectRef & Partial<TelemetrySettings>): Promise<TelemetrySettings>; |
| 342 | tokens(args: ProjectRef): Promise<IngestToken[]>; |
| 343 | createToken(args: ProjectRef & { name: string; created_by: string }): Promise<{ token: IngestToken; secret: string }>; |
| 344 | revokeToken(args: ProjectRef & { id: string }): Promise<boolean>; |
| 345 | /** |
| 346 | * The token g1t's own deployments of the project send with, made the |
| 347 | * first time: the deployments service binds it to each app it uploads. |
| 348 | */ |
| 349 | deploymentToken(args: ProjectRef): Promise<{ secret: string }>; |
| 350 | exports(args: ProjectRef): Promise<TelemetryExport[]>; |
| 351 | putExport(args: ProjectRef & { id?: string | null; name: string; endpoint: string; signals: TelemetryExport["signals"]; headers?: Record<string, string> | null; enabled: boolean }): Promise<TelemetryExport>; |
| 352 | deleteExport(args: ProjectRef & { id: string }): Promise<boolean>; |
| 353 | usage(args: ProjectRef): Promise<TelemetryUsage>; |
| 354 | /** Everything kept for a project, gone: when the project is deleted. */ |
| 355 | purge(args: ProjectRef): Promise<boolean>; |
| 356 | } |
| 357 | |
| 358 | /** |
| 359 | * The site's live tail: `GET /live?project_id=…` on the service, a |
| 360 | * WebSocket the site opens after checking the viewer may read the |
| 361 | * project. Each message is one `LiveItem`. |
| 362 | */ |
| 363 | export type LiveItem = |
| 364 | | { type: "request"; request: TelemetryRequest } |
| 365 | | { type: "log"; log: TelemetryLog } |
| 366 | | { type: "span"; span: TelemetrySpan }; |
| 367 | |
| 368 | async function call<T>(service: ServiceBinding, method: string, args: object): Promise<T> { |
| 369 | const response = await service.fetch(`https://service/rpc/${method}`, { |
| 370 | method: "POST", |
| 371 | headers: { "content-type": "application/json" }, |
| 372 | body: JSON.stringify(args), |
| 373 | }); |
| 374 | if (!response.ok) throw new Error(`telemetry ${method} failed with status ${response.status}: ${await response.text()}`); |
| 375 | return (await response.json()) as T; |
| 376 | } |
| 377 | |
| 378 | export function telemetryClient(service: ServiceBinding): TelemetryApi { |
| 379 | const c = <T>(method: string) => (args: object) => call<T>(service, method, args); |
| 380 | return { |
| 381 | overview: c("overview"), |
| 382 | requests: c("requests"), |
| 383 | request: c("request"), |
| 384 | logs: c("logs"), |
| 385 | traces: c("traces"), |
| 386 | trace: c("trace"), |
| 387 | errors: c("errors"), |
| 388 | setError: c("set_error"), |
| 389 | metrics: c("metrics"), |
| 390 | metric: c("metric"), |
| 391 | facets: c("facets"), |
| 392 | settings: c("settings"), |
| 393 | setSettings: c("set_settings"), |
| 394 | tokens: c("tokens"), |
| 395 | createToken: c("create_token"), |
| 396 | revokeToken: c("revoke_token"), |
| 397 | deploymentToken: c("deployment_token"), |
| 398 | exports: c("exports"), |
| 399 | putExport: c("put_export"), |
| 400 | deleteExport: c("delete_export"), |
| 401 | usage: c("usage"), |
| 402 | purge: c("purge"), |
| 403 | }; |
| 404 | } |