Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.
A new service, services/telemetry (g1t-telemetry), keeps each project's telemetry in a Durable Object with its own SQLite: requests, logs (full-text indexed), spans, metric points, errors grouped by fingerprint, per-minute rollups with latency and CPU histograms (kept 90 days), ingest tokens and exports. Two doors fill it. Every app on g1t.page and the dispatcher in front of them name it as their Tail Worker: the dispatcher gives each request an id and a traceparent and writes one line naming the request and the app's project, the app's own event brings its logs, exceptions and CPU time, and the store joins the halves by id, so even a request a site's files answered is counted. And https://otel.g1t.sh/v1/{traces,logs,metrics} takes OTLP over HTTP in JSON or protobuf (a decoder of its own, no dependency), gzipped or not, with a project's g1to_ ingest token; api.g1t.sh/otlp is the same door, and the one self-hosted installations use. Apps on g1t.page are deployed with OTEL_EXPORTER_OTLP_* set to the project's deployments token, so an OpenTelemetry SDK in them sends spans that sit under the request's trace. Cloudflare's own OpenTelemetry export is taken at /cloudflare/v1/* once CLOUDFLARE_EXPORT_KEY is set. Exports send everything on to any OTLP/HTTP endpoint. The project's Observability page replaces its roadmap entry: Overview (numbers against the period before, requests and latency charts, numbers by deployment newest first, routes, open errors, services, regions), Requests, a request's page, Logs with a live tail, Traces and a waterfall, Errors (resolve, ignore, or make an issue with g1t assigned), Metrics, and Settings. It is for people with a role on the project, public or not. The API has every read and change under /workspaces/:workspace/projects/:project/observability, with the observability:read and observability:write scopes, and MCP has an observability tool. The guide is docs.g1t.sh/guides/observability/. Web analytics moves to Insights on the roadmap.
| 15 | 15 | mod checks; | |
| 16 | 16 | mod customize; | |
| 17 | 17 | mod deployments; | |
| 18 | + | mod telemetry; | |
| 18 | 19 | mod deploy_keys; | |
| 19 | 20 | mod limits; | |
| 20 | 21 | mod logs; | |
| ⋯ | |||
| 879 | 880 | } | |
| 880 | 881 | // A deleted comment has nothing to say, as GitHub's says nothing. | |
| 881 | 882 | Outcome::Ok(_) if route.no_content() => Ok(Response::empty()?.with_status(204)), | |
| 882 | − | Outcome::Ok(value) => reply(&value), | |
| 883 | + | Outcome::Ok(value) => Response::from_json(&wire::snake_case_keeping(value, route.op.as_given())), | |
| 883 | 884 | // A token without the scope a call needs is told which one. | |
| 884 | 885 | Outcome::Fail(refused) => match (refused.code, audit::missing_scope(route.op, &viewer, &input)) { | |
| 885 | 886 | (FailureCode::Forbidden, Some(scope)) => Ok(reply(&json!({ | |
| ⋯ | |||
| 976 | 977 | ||
| 977 | 978 | // The API is called from browsers too: the reference's explorer, and apps | |
| 978 | 979 | // built on g1t. It carries no cookies, so any origin may call it. | |
| 980 | + | /// The signal an OTLP path sends (`/otlp/v1/traces`, `logs` or `metrics`), | |
| 981 | + | /// if it is one. | |
| 982 | + | fn otlp_signal(path: &str) -> Option<&'static str> { | |
| 983 | + | let path = path.strip_prefix("/v1").filter(|rest| rest.starts_with('/')).unwrap_or(path); | |
| 984 | + | match path.trim_end_matches('/') { | |
| 985 | + | "/otlp/v1/traces" => Some("traces"), | |
| 986 | + | "/otlp/v1/logs" => Some("logs"), | |
| 987 | + | "/otlp/v1/metrics" => Some("metrics"), | |
| 988 | + | _ => None, | |
| 989 | + | } | |
| 990 | + | } | |
| 991 | + | ||
| 992 | + | /// OpenTelemetry sent to the API's own address: a self-hosted install's | |
| 993 | + | /// only door, and a second one hosted. It goes to the telemetry service as | |
| 994 | + | /// it came, which checks the project's ingest token itself (not a g1t | |
| 995 | + | /// token), and its answer comes back as it is, CORS headers included. | |
| 996 | + | async fn forward_otlp(mut request: Request, env: &Env, signal: &str) -> Result<Response> { | |
| 997 | + | let method = request.method(); | |
| 998 | + | let headers = worker::Headers::new(); | |
| 999 | + | for name in ["authorization", "x-g1t-token", "content-type", "content-encoding"] { | |
| 1000 | + | if let Some(value) = request.headers().get(name)? { | |
| 1001 | + | headers.set(name, &value)?; | |
| 1002 | + | } | |
| 1003 | + | } | |
| 1004 | + | let mut init = worker::RequestInit::new(); | |
| 1005 | + | init.with_method(method.clone()).with_headers(headers); | |
| 1006 | + | if method == Method::Post { | |
| 1007 | + | let body = request.bytes().await?; | |
| 1008 | + | init.with_body(Some(worker::js_sys::Uint8Array::from(body.as_slice()).into())); | |
| 1009 | + | } | |
| 1010 | + | let target = format!("https://telemetry/v1/{signal}"); | |
| 1011 | + | let mut answer = env.service("TELEMETRY")?.fetch_request(Request::new_with_init(&target, &init)?).await?; | |
| 1012 | + | // A fresh response, with the service's headers: a fetched one's cannot | |
| 1013 | + | // be changed, and nothing should be added to them. | |
| 1014 | + | let status = answer.status_code(); | |
| 1015 | + | let headers = worker::Headers::new(); | |
| 1016 | + | for (name, value) in answer.headers().entries() { | |
| 1017 | + | headers.set(&name, &value)?; | |
| 1018 | + | } | |
| 1019 | + | let bytes = answer.bytes().await?; | |
| 1020 | + | let response = if bytes.is_empty() { Response::empty()? } else { Response::from_bytes(bytes)? }; | |
| 1021 | + | Ok(response.with_status(status).with_headers(headers)) | |
| 1022 | + | } | |
| 1023 | + | ||
| 979 | 1024 | #[event(fetch)] | |
| 980 | 1025 | async fn fetch(request: Request, env: Env, _ctx: Context) -> Result<Response> { | |
| 1026 | + | // OTLP passes through untouched, before any g1t token is read. | |
| 1027 | + | if let Some(signal) = otlp_signal(&request.path()) { | |
| 1028 | + | return forward_otlp(request, &env, signal).await; | |
| 1029 | + | } | |
| 981 | 1030 | let mut response = respond(request, &env).await?; | |
| 982 | 1031 | let headers = response.headers_mut(); | |
| 983 | 1032 | headers.set("access-control-allow-origin", "*")?; | |
| 84 | 84 | } | |
| 85 | 85 | }, | |
| 86 | 86 | // A tool per operation, as the server had before its | |
| 87 | − | // resource tools. Still answered, no longer listed. | |
| 88 | − | None => match Op::by_name(name) { | |
| 87 | + | // resource tools. Still answered, no longer listed; never | |
| 88 | + | // one offered over REST alone. | |
| 89 | + | None => match Op::by_name(name).filter(|op| !crate::tools::REST_ONLY.contains(op)) { | |
| 89 | 90 | Some(op) => op, | |
| 90 | 91 | None => return Ok(Some(error(id, -32602, "Unknown tool."))), | |
| 91 | 92 | }, | |
| ⋯ | |||
| 97 | 98 | // In `snake_case`, as the REST API answers; the protocol's | |
| 98 | 99 | // own envelope keeps MCP's spelling. | |
| 99 | 100 | Outcome::Ok(value) => ( | |
| 100 | − | serde_json::to_string_pretty(&g1t_kit::wire::snake_case(value))?, | |
| 101 | + | serde_json::to_string_pretty(&g1t_kit::wire::snake_case_keeping(value, op.as_given()))?, | |
| 101 | 102 | false, | |
| 102 | 103 | ), | |
| 103 | 104 | Outcome::Fail(failure) => (failure.message, true), | |
| 14 | 14 | use crate::deploy_keys::DeployKeysOp; | |
| 15 | 15 | use crate::mirrors::MirrorsOp; | |
| 16 | 16 | use crate::deployments::DeploymentsOp; | |
| 17 | + | use crate::telemetry::TelemetryOp; | |
| 17 | 18 | use crate::packages::PackagesOp; | |
| 18 | 19 | use crate::folios::FoliosOp; | |
| 19 | 20 | use crate::protection::ProtectionOp; | |
| ⋯ | |||
| 484 | 485 | ], | |
| 485 | 486 | ), | |
| 486 | 487 | ( | |
| 488 | + | "Observability", | |
| 489 | + | "What a project's running software did: its requests, logs, traces, grouped errors and metrics, from its apps on g1t.page with nothing to set up and from OpenTelemetry (OTLP over HTTP) sent from anywhere with an ingest token. Reading needs a role of your own on the project's repository; resolving errors, Triage; settings, ingest tokens, exports and usage, Admin.", | |
| 490 | + | &[ | |
| 491 | + | Op::Telemetry(TelemetryOp::GetOverview), | |
| 492 | + | Op::Telemetry(TelemetryOp::ListRequests), | |
| 493 | + | Op::Telemetry(TelemetryOp::GetRequest), | |
| 494 | + | Op::Telemetry(TelemetryOp::ListLogs), | |
| 495 | + | Op::Telemetry(TelemetryOp::ListTraces), | |
| 496 | + | Op::Telemetry(TelemetryOp::GetTrace), | |
| 497 | + | Op::Telemetry(TelemetryOp::ListErrors), | |
| 498 | + | Op::Telemetry(TelemetryOp::UpdateError), | |
| 499 | + | Op::Telemetry(TelemetryOp::ListMetrics), | |
| 500 | + | Op::Telemetry(TelemetryOp::GetMetric), | |
| 501 | + | Op::Telemetry(TelemetryOp::GetSettings), | |
| 502 | + | Op::Telemetry(TelemetryOp::UpdateSettings), | |
| 503 | + | Op::Telemetry(TelemetryOp::ListTokens), | |
| 504 | + | Op::Telemetry(TelemetryOp::CreateToken), | |
| 505 | + | Op::Telemetry(TelemetryOp::DeleteToken), | |
| 506 | + | Op::Telemetry(TelemetryOp::ListExports), | |
| 507 | + | Op::Telemetry(TelemetryOp::CreateExport), | |
| 508 | + | Op::Telemetry(TelemetryOp::UpdateExport), | |
| 509 | + | Op::Telemetry(TelemetryOp::DeleteExport), | |
| 510 | + | Op::Telemetry(TelemetryOp::GetUsage), | |
| 511 | + | ], | |
| 512 | + | ), | |
| 513 | + | ( | |
| 487 | 514 | "Run protection", | |
| 488 | 515 | "What keeps workflow runs safe: environments' protection rules (required reviewers, a wait timer, which branches may deploy) and the reviews of the jobs they hold, approving a pull request's run from outside, what a job's token gets when its workflow writes no `permissions:`, and repository_dispatch, which a job's own token may send.", | |
| 489 | 516 | &[ | |
| ⋯ | |||
| 840 | 867 | Op::Checks(op) => op.title(), | |
| 841 | 868 | Op::About(op) => op.title(), | |
| 842 | 869 | Op::Deployments(op) => op.title(), | |
| 870 | + | Op::Telemetry(op) => op.title(), | |
| 843 | 871 | Op::Protection(op) => op.title(), | |
| 844 | 872 | Op::Tokens(op) => op.title(), | |
| 845 | 873 | Op::Artifacts(op) => op.title(), | |
| 34 | 34 | use crate::deploy_keys::DeployKeysOp; | |
| 35 | 35 | use crate::mirrors::MirrorsOp; | |
| 36 | 36 | use crate::deployments::DeploymentsOp; | |
| 37 | + | use crate::telemetry::TelemetryOp; | |
| 37 | 38 | use crate::packages::PackagesOp; | |
| 38 | 39 | use crate::folios::FoliosOp; | |
| 39 | 40 | use crate::protection::ProtectionOp; | |
| ⋯ | |||
| 69 | 70 | pub projects: Fetcher, | |
| 70 | 71 | /// Deployments wherever they run, and environments. | |
| 71 | 72 | pub deployments: Fetcher, | |
| 73 | + | /// Observability: each project's requests, logs, traces, errors and | |
| 74 | + | /// metrics (services/telemetry). It checks no access; telemetry.rs does. | |
| 75 | + | pub telemetry: Fetcher, | |
| 72 | 76 | /// Packages: their settings, versions, deleting and restoring them. | |
| 73 | 77 | pub packages: Fetcher, | |
| 74 | 78 | /// The artifacts service (services/artifacts): docs, slides, designs and | |
| ⋯ | |||
| 101 | 105 | security: env.service("SECURITY")?, | |
| 102 | 106 | projects: env.service("PROJECTS")?, | |
| 103 | 107 | deployments: env.service("DEPLOYMENTS")?, | |
| 108 | + | telemetry: env.service("TELEMETRY")?, | |
| 104 | 109 | packages: env.service("PACKAGES")?, | |
| 105 | 110 | artifacts: env.service("ARTIFACTS")?, | |
| 106 | 111 | agents: env.service("AGENTS")?, | |
| ⋯ | |||
| 323 | 328 | About(AboutOp), | |
| 324 | 329 | /// Deployments wherever they run, and environments: deployments.rs. | |
| 325 | 330 | Deployments(DeploymentsOp), | |
| 331 | + | /// A project's observability: requests, logs, traces, errors, metrics, | |
| 332 | + | /// and how they are collected: telemetry.rs. | |
| 333 | + | Telemetry(TelemetryOp), | |
| 326 | 334 | /// Environments' protection rules, approving runs, the token's default | |
| 327 | 335 | /// permissions and repository dispatch: protection.rs. | |
| 328 | 336 | Protection(ProtectionOp), | |
| ⋯ | |||
| 716 | 724 | } | |
| 717 | 725 | ||
| 718 | 726 | impl Op { | |
| 719 | − | pub const ALL: [Op; 388] = [ | |
| 727 | + | pub const ALL: [Op; 408] = [ | |
| 720 | 728 | Op::Whoami, | |
| 721 | 729 | Op::GetWorkspace, | |
| 722 | 730 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1003 | 1011 | Op::Deployments(DeploymentsOp::ListEnvironments), | |
| 1004 | 1012 | Op::Deployments(DeploymentsOp::GetEnvironment), | |
| 1005 | 1013 | Op::Deployments(DeploymentsOp::StartDeploymentBuild), | |
| 1014 | + | Op::Telemetry(TelemetryOp::GetOverview), | |
| 1015 | + | Op::Telemetry(TelemetryOp::ListRequests), | |
| 1016 | + | Op::Telemetry(TelemetryOp::GetRequest), | |
| 1017 | + | Op::Telemetry(TelemetryOp::ListLogs), | |
| 1018 | + | Op::Telemetry(TelemetryOp::ListTraces), | |
| 1019 | + | Op::Telemetry(TelemetryOp::GetTrace), | |
| 1020 | + | Op::Telemetry(TelemetryOp::ListErrors), | |
| 1021 | + | Op::Telemetry(TelemetryOp::UpdateError), | |
| 1022 | + | Op::Telemetry(TelemetryOp::ListMetrics), | |
| 1023 | + | Op::Telemetry(TelemetryOp::GetMetric), | |
| 1024 | + | Op::Telemetry(TelemetryOp::GetSettings), | |
| 1025 | + | Op::Telemetry(TelemetryOp::UpdateSettings), | |
| 1026 | + | Op::Telemetry(TelemetryOp::ListTokens), | |
| 1027 | + | Op::Telemetry(TelemetryOp::CreateToken), | |
| 1028 | + | Op::Telemetry(TelemetryOp::DeleteToken), | |
| 1029 | + | Op::Telemetry(TelemetryOp::ListExports), | |
| 1030 | + | Op::Telemetry(TelemetryOp::CreateExport), | |
| 1031 | + | Op::Telemetry(TelemetryOp::UpdateExport), | |
| 1032 | + | Op::Telemetry(TelemetryOp::DeleteExport), | |
| 1033 | + | Op::Telemetry(TelemetryOp::GetUsage), | |
| 1006 | 1034 | Op::Artifacts(ArtifactsOp::ListArtifacts), | |
| 1007 | 1035 | Op::Artifacts(ArtifactsOp::ListRunArtifacts), | |
| 1008 | 1036 | Op::Artifacts(ArtifactsOp::GetArtifact), | |
| ⋯ | |||
| 1318 | 1346 | Op::Checks(op) => op.name(), | |
| 1319 | 1347 | Op::About(op) => op.name(), | |
| 1320 | 1348 | Op::Deployments(op) => op.name(), | |
| 1349 | + | Op::Telemetry(op) => op.name(), | |
| 1321 | 1350 | Op::Protection(op) => op.name(), | |
| 1322 | 1351 | Op::Tokens(op) => op.name(), | |
| 1323 | 1352 | Op::Artifacts(op) => op.name(), | |
| ⋯ | |||
| 1898 | 1927 | Op::Checks(op) => op.description(), | |
| 1899 | 1928 | Op::About(op) => op.description(), | |
| 1900 | 1929 | Op::Deployments(op) => op.description(), | |
| 1930 | + | Op::Telemetry(op) => op.description(), | |
| 1901 | 1931 | Op::Protection(op) => op.description(), | |
| 1902 | 1932 | Op::Tokens(op) => op.description(), | |
| 1903 | 1933 | Op::Artifacts(op) => op.description(), | |
| ⋯ | |||
| 3421 | 3451 | Op::Checks(op) => op.input(), | |
| 3422 | 3452 | Op::About(op) => op.input(), | |
| 3423 | 3453 | Op::Deployments(op) => op.input(), | |
| 3454 | + | Op::Telemetry(op) => op.input(), | |
| 3424 | 3455 | Op::Protection(op) => op.input(), | |
| 3425 | 3456 | Op::Tokens(op) => op.input(), | |
| 3426 | 3457 | Op::Artifacts(op) => op.input(), | |
| ⋯ | |||
| 3433 | 3464 | } | |
| 3434 | 3465 | } | |
| 3435 | 3466 | ||
| 3467 | + | /// Fields of its answer that are keyed by data and so are sent as they | |
| 3468 | + | /// are, beside those every answer keeps (`g1t_kit::wire::USER_KEYED`). | |
| 3469 | + | pub(crate) fn as_given(self) -> &'static [&'static str] { | |
| 3470 | + | match self { | |
| 3471 | + | Op::Telemetry(_) => crate::telemetry::AS_GIVEN, | |
| 3472 | + | _ => &[], | |
| 3473 | + | } | |
| 3474 | + | } | |
| 3475 | + | ||
| 3436 | 3476 | /// Whether REST answers with a redirect to the `url` it returns, as | |
| 3437 | 3477 | /// GitHub's downloads do. | |
| 3438 | 3478 | pub(crate) fn redirects(self) -> bool { | |
| ⋯ | |||
| 3522 | 3562 | if let Op::Customize(_) = self { | |
| 3523 | 3563 | return false; | |
| 3524 | 3564 | } | |
| 3565 | + | // A project's observability names the workspace and the project. | |
| 3566 | + | if let Op::Telemetry(_) = self { | |
| 3567 | + | return false; | |
| 3568 | + | } | |
| 3525 | 3569 | if let Op::About(op) = self { | |
| 3526 | 3570 | return op.needs_repo(); | |
| 3527 | 3571 | } | |
| ⋯ | |||
| 5820 | 5864 | Op::Checks(op) => crate::checks::run(op, services, viewer, input).await, | |
| 5821 | 5865 | Op::About(op) => crate::about::run(op, services, viewer, input).await, | |
| 5822 | 5866 | Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await, | |
| 5867 | + | // A project's observability: telemetry.rs checks who may see | |
| 5868 | + | // and change it, as the site does. | |
| 5869 | + | Op::Telemetry(op) => crate::telemetry::run(op, services, viewer, input).await, | |
| 5823 | 5870 | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 5824 | 5871 | Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await, | |
| 5825 | 5872 | Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await, | |
| 14928 | 14928 | "deleted": true | |
| 14929 | 14929 | }, | |
| 14930 | 14930 | "notes": "Only an artifact in the trash can be deleted for good: `trash_workspace_artifact` first." | |
| 14931 | + | }, | |
| 14932 | + | "get_observability_overview": { | |
| 14933 | + | "params": { | |
| 14934 | + | "workspace": "flagon-io", | |
| 14935 | + | "project": "g1t" | |
| 14936 | + | }, | |
| 14937 | + | "query": { | |
| 14938 | + | "from": "2026-10-12T13:00:00Z", | |
| 14939 | + | "to": "2026-10-12T14:00:00Z", | |
| 14940 | + | "environment": "production" | |
| 14941 | + | }, | |
| 14942 | + | "response": { | |
| 14943 | + | "from_us": 1791810000000000, | |
| 14944 | + | "to_us": 1791813600000000, | |
| 14945 | + | "step_s": 60, | |
| 14946 | + | "totals": { | |
| 14947 | + | "requests": 18204, | |
| 14948 | + | "errors": 41, | |
| 14949 | + | "error_rate": 0.0023, | |
| 14950 | + | "latency": { | |
| 14951 | + | "p50": 38.2, | |
| 14952 | + | "p75": 71.5, | |
| 14953 | + | "p95": 212.0, | |
| 14954 | + | "p99": 640.0 | |
| 14955 | + | }, | |
| 14956 | + | "cpu": { | |
| 14957 | + | "p50": 4.1, | |
| 14958 | + | "p75": 6.8, | |
| 14959 | + | "p95": 14.2, | |
| 14960 | + | "p99": 29.5 | |
| 14961 | + | } | |
| 14962 | + | }, | |
| 14963 | + | "previous": { | |
| 14964 | + | "requests": 17650, | |
| 14965 | + | "errors": 3, | |
| 14966 | + | "error_rate": 0.0002, | |
| 14967 | + | "latency": { | |
| 14968 | + | "p50": 35.9, | |
| 14969 | + | "p75": 66.0, | |
| 14970 | + | "p95": 188.0, | |
| 14971 | + | "p99": 402.0 | |
| 14972 | + | }, | |
| 14973 | + | "cpu": { | |
| 14974 | + | "p50": 3.9, | |
| 14975 | + | "p75": 6.5, | |
| 14976 | + | "p95": 13.0, | |
| 14977 | + | "p99": 22.1 | |
| 14978 | + | } | |
| 14979 | + | }, | |
| 14980 | + | "points": [ | |
| 14981 | + | { | |
| 14982 | + | "time_us": 1791810000000000, | |
| 14983 | + | "requests": 302, | |
| 14984 | + | "errors": 0, | |
| 14985 | + | "status_2xx": 288, | |
| 14986 | + | "status_3xx": 9, | |
| 14987 | + | "status_4xx": 5, | |
| 14988 | + | "status_5xx": 0, | |
| 14989 | + | "latency": { | |
| 14990 | + | "p50": 36.0, | |
| 14991 | + | "p75": 68.0, | |
| 14992 | + | "p95": 190.0, | |
| 14993 | + | "p99": 410.0 | |
| 14994 | + | }, | |
| 14995 | + | "cpu": { | |
| 14996 | + | "p50": 4.0, | |
| 14997 | + | "p75": 6.6, | |
| 14998 | + | "p95": 13.1, | |
| 14999 | + | "p99": 22.0 | |
| 15000 | + | } | |
| 15001 | + | } | |
| 15002 | + | ], | |
| 15003 | + | "routes": [ | |
| 15004 | + | { | |
| 15005 | + | "route": "/:workspace/:repo/pulls/:number", | |
| 15006 | + | "method": "GET", | |
| 15007 | + | "requests": 2210, | |
| 15008 | + | "errors": 37, | |
| 15009 | + | "latency": { | |
| 15010 | + | "p50": 61.0, | |
| 15011 | + | "p75": 120.0, | |
| 15012 | + | "p95": 480.0, | |
| 15013 | + | "p99": 842.0 | |
| 15014 | + | } | |
| 15015 | + | } | |
| 15016 | + | ], | |
| 15017 | + | "deployments": [ | |
| 15018 | + | { | |
| 15019 | + | "deployment_id": "dpl_01kq9a2c4e6g8j0m2p4r6t8v0x", | |
| 15020 | + | "environment": "production", | |
| 15021 | + | "commit_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 15022 | + | "first_us": 1791813041000000, | |
| 15023 | + | "requests": 2904, | |
| 15024 | + | "errors": 39, | |
| 15025 | + | "latency": { | |
| 15026 | + | "p50": 40.1, | |
| 15027 | + | "p75": 77.0, | |
| 15028 | + | "p95": 260.0, | |
| 15029 | + | "p99": 720.0 | |
| 15030 | + | } | |
| 15031 | + | } | |
| 15032 | + | ], | |
| 15033 | + | "services": [ | |
| 15034 | + | { | |
| 15035 | + | "service": "g1t-web", | |
| 15036 | + | "source": "cloudflare", | |
| 15037 | + | "requests": 18204, | |
| 15038 | + | "errors": 41, | |
| 15039 | + | "last_us": 1791813599000000 | |
| 15040 | + | } | |
| 15041 | + | ], | |
| 15042 | + | "regions": [ | |
| 15043 | + | { | |
| 15044 | + | "region": "IAD", | |
| 15045 | + | "requests": 9120 | |
| 15046 | + | }, | |
| 15047 | + | { | |
| 15048 | + | "region": "AMS", | |
| 15049 | + | "requests": 4410 | |
| 15050 | + | } | |
| 15051 | + | ] | |
| 15052 | + | }, | |
| 15053 | + | "notes": "`totals` is the period and `previous` the period of the same length before it, to compare. Latency and CPU are in milliseconds. `deployments` lists each deployment that answered in the period, newest first: when the error rate or latency rose, the deployment it began with is usually the first one with the higher numbers. Points are one per `step_s` seconds, chosen from the period unless `step` says. See [Observability](/guides/observability/)." | |
| 15054 | + | }, | |
| 15055 | + | "list_requests": { | |
| 15056 | + | "params": { | |
| 15057 | + | "workspace": "flagon-io", | |
| 15058 | + | "project": "g1t" | |
| 15059 | + | }, | |
| 15060 | + | "query": { | |
| 15061 | + | "errors_only": "true", | |
| 15062 | + | "limit": "1" | |
| 15063 | + | }, | |
| 15064 | + | "response": { | |
| 15065 | + | "items": [ | |
| 15066 | + | { | |
| 15067 | + | "id": "req_01kq9b7d9f1h3k5m7p9r1t3v5x", | |
| 15068 | + | "time_us": 1791813807418000, | |
| 15069 | + | "duration_us": 842000, | |
| 15070 | + | "trigger": "fetch", | |
| 15071 | + | "method": "GET", | |
| 15072 | + | "url": "g1t.sh/flagon-io/g1t/pulls/412", | |
| 15073 | + | "route": "/:workspace/:repo/pulls/:number", | |
| 15074 | + | "status": 500, | |
| 15075 | + | "outcome": "exception", | |
| 15076 | + | "cpu_ms": 31.4, | |
| 15077 | + | "region": "IAD", | |
| 15078 | + | "country": "US", | |
| 15079 | + | "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", | |
| 15080 | + | "log_count": 2, | |
| 15081 | + | "error": { | |
| 15082 | + | "name": "TypeError", | |
| 15083 | + | "message": "Cannot read properties of undefined (reading 'head')" | |
| 15084 | + | }, | |
| 15085 | + | "service": "g1t-web", | |
| 15086 | + | "environment": "production", | |
| 15087 | + | "deployment_id": "dpl_01kq9a2c4e6g8j0m2p4r6t8v0x", | |
| 15088 | + | "commit_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 15089 | + | "source": "cloudflare" | |
| 15090 | + | } | |
| 15091 | + | ], | |
| 15092 | + | "next": "eyJ0IjoxNzkxODEzODA3NDE4MDAwfQ" | |
| 15093 | + | }, | |
| 15094 | + | "notes": "Apps on g1t.page send their requests with nothing to set up; software hosted elsewhere sends them as OpenTelemetry server spans. `route` is the path with ids taken out, which the overview groups by. Pass `next` back as `cursor` for the next page; it is null on the last." | |
| 15095 | + | }, | |
| 15096 | + | "get_request": { | |
| 15097 | + | "params": { | |
| 15098 | + | "workspace": "flagon-io", | |
| 15099 | + | "project": "g1t", | |
| 15100 | + | "id": "req_01kq9b7d9f1h3k5m7p9r1t3v5x" | |
| 15101 | + | }, | |
| 15102 | + | "response": { | |
| 15103 | + | "request": { | |
| 15104 | + | "id": "req_01kq9b7d9f1h3k5m7p9r1t3v5x", | |
| 15105 | + | "time_us": 1791813807418000, | |
| 15106 | + | "duration_us": 842000, | |
| 15107 | + | "trigger": "fetch", | |
| 15108 | + | "method": "GET", | |
| 15109 | + | "url": "g1t.sh/flagon-io/g1t/pulls/412", | |
| 15110 | + | "route": "/:workspace/:repo/pulls/:number", | |
| 15111 | + | "status": 500, | |
| 15112 | + | "outcome": "exception", | |
| 15113 | + | "cpu_ms": 31.4, | |
| 15114 | + | "region": "IAD", | |
| 15115 | + | "country": "US", | |
| 15116 | + | "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", | |
| 15117 | + | "log_count": 2, | |
| 15118 | + | "error": { | |
| 15119 | + | "name": "TypeError", | |
| 15120 | + | "message": "Cannot read properties of undefined (reading 'head')" | |
| 15121 | + | }, | |
| 15122 | + | "service": "g1t-web", | |
| 15123 | + | "environment": "production", | |
| 15124 | + | "deployment_id": "dpl_01kq9a2c4e6g8j0m2p4r6t8v0x", | |
| 15125 | + | "commit_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 15126 | + | "source": "cloudflare" | |
| 15127 | + | }, | |
| 15128 | + | "logs": [ | |
| 15129 | + | { | |
| 15130 | + | "id": "log_01kq9b7d9f1h3k5m7p9r1t3v5y", | |
| 15131 | + | "time_us": 1791813808251000, | |
| 15132 | + | "severity": "error", | |
| 15133 | + | "severity_number": 17, | |
| 15134 | + | "body": "loading pull request 412 failed: Cannot read properties of undefined (reading 'head')", | |
| 15135 | + | "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", | |
| 15136 | + | "span_id": "00f067aa0ba902b7", | |
| 15137 | + | "request_id": "req_01kq9b7d9f1h3k5m7p9r1t3v5x", | |
| 15138 | + | "attributes": { | |
| 15139 | + | "code.function": "loadPull", | |
| 15140 | + | "http.route": "/:workspace/:repo/pulls/:number" | |
| 15141 | + | }, | |
| 15142 | + | "service": "g1t-web", | |
| 15143 | + | "environment": "production", | |
| 15144 | + | "deployment_id": "dpl_01kq9a2c4e6g8j0m2p4r6t8v0x", | |
| 15145 | + | "commit_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 15146 | + | "source": "cloudflare" | |
| 15147 | + | } | |
| 15148 | + | ] | |
| 15149 | + | }, | |
| 15150 | + | "notes": "`404` when the project has no request with that id, or it is older than the project's retention." | |
| 15151 | + | }, | |
| 15152 | + | "list_logs": { | |
| 15153 | + | "params": { | |
| 15154 | + | "workspace": "flagon-io", | |
| 15155 | + | "project": "g1t" | |
| 15156 | + | }, | |
| 15157 | + | "query": { | |
| 15158 | + | "q": "\"failed\"", | |
| 15159 | + | "level": "error,fatal", | |
| 15160 | + | "limit": "1" | |
| 15161 | + | }, | |
| 15162 | + | "response": { | |
| 15163 | + | "items": [ | |
| 15164 | + | { | |
| 15165 | + | "id": "log_01kq9b7d9f1h3k5m7p9r1t3v5y", | |
| 15166 | + | "time_us": 1791813808251000, | |
| 15167 | + | "severity": "error", | |
| 15168 | + | "severity_number": 17, | |
| 15169 | + | "body": "loading pull request 412 failed: Cannot read properties of undefined (reading 'head')", | |
| 15170 | + | "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", | |
| 15171 | + | "span_id": "00f067aa0ba902b7", | |
| 15172 | + | "request_id": "req_01kq9b7d9f1h3k5m7p9r1t3v5x", | |
| 15173 | + | "attributes": { | |
| 15174 | + | "code.function": "loadPull", | |
| 15175 | + | "http.route": "/:workspace/:repo/pulls/:number" | |
| 15176 | + | }, | |
| 15177 | + | "service": "g1t-web", | |
| 15178 | + | "environment": "production", | |
| 15179 | + | "deployment_id": "dpl_01kq9a2c4e6g8j0m2p4r6t8v0x", | |
| 15180 | + | "commit_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 15181 | + | "source": "cloudflare" | |
| 15182 | + | } | |
| 15183 | + | ], | |
| 15184 | + | "next": null | |
| 15185 | + | }, | |
| 15186 | + | "notes": "`q` finds lines holding all of its words, or a phrase in quotes. `level` takes one or more of `trace`, `debug`, `info`, `warn`, `error` and `fatal`. Follow `trace_id` to [the trace](/reference/api/observability/get-trace/) or `request_id` to [the request](/reference/api/observability/get-request/)." | |
| 15187 | + | }, | |
| 15188 | + | "list_traces": { | |
| 15189 | + | "params": { | |
| 15190 | + | "workspace": "flagon-io", | |
| 15191 | + | "project": "g1t" | |
| 15192 | + | }, | |
| 15193 | + | "query": { | |
| 15194 | + | "errors_only": "true", | |
| 15195 | + | "min_duration_ms": "500" | |
| 15196 | + | }, | |
| 15197 | + | "response": { | |
| 15198 | + | "items": [ | |
| 15199 | + | { | |
| 15200 | + | "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", | |
| 15201 | + | "name": "GET /:workspace/:repo/pulls/:number", | |
| 15202 | + | "start_us": 1791813807418000, | |
| 15203 | + | "duration_us": 842000, | |
| 15204 | + | "span_count": 6, | |
| 15205 | + | "error_count": 1, | |
| 15206 | + | "services": [ | |
| 15207 | + | "g1t-web", | |
| 15208 | + | "g1t-work" | |
| 15209 | + | ], | |
| 15210 | + | "status": 500, | |
| 15211 | + | "service": "g1t-web", | |
| 15212 | + | "environment": "production", | |
| 15213 | + | "deployment_id": "dpl_01kq9a2c4e6g8j0m2p4r6t8v0x", | |
| 15214 | + | "commit_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 15215 | + | "source": "otlp" | |
| 15216 | + | } | |
| 15217 | + | ], | |
| 15218 | + | "next": null | |
| 15219 | + | }, | |
| 15220 | + | "notes": "Each trace is its root span (or its earliest, when the root has not arrived), with what the rest add up to." | |
| 15221 | + | }, | |
| 15222 | + | "get_trace": { | |
| 15223 | + | "params": { | |
| 15224 | + | "workspace": "flagon-io", | |
| 15225 | + | "project": "g1t", | |
| 15226 | + | "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736" | |
| 15227 | + | }, | |
| 15228 | + | "response": { | |
| 15229 | + | "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", | |
| 15230 | + | "spans": [ | |
| 15231 | + | { | |
| 15232 | + | "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", | |
| 15233 | + | "span_id": "00f067aa0ba902b7", | |
| 15234 | + | "parent_span_id": null, | |
| 15235 | + | "name": "GET /:workspace/:repo/pulls/:number", | |
| 15236 | + | "kind": "server", | |
| 15237 | + | "start_us": 1791813807418000, | |
| 15238 | + | "duration_us": 842000, | |
| 15239 | + | "status": "error", | |
| 15240 | + | "status_message": "TypeError", | |
| 15241 | + | "attributes": { | |
| 15242 | + | "http.request.method": "GET", | |
| 15243 | + | "http.response.status_code": 500 | |
| 15244 | + | }, | |
| 15245 | + | "resource": { | |
| 15246 | + | "service.name": "g1t-web", | |
| 15247 | + | "deployment.environment.name": "production" | |
| 15248 | + | }, | |
| 15249 | + | "events": [], | |
| 15250 | + | "links": [], | |
| 15251 | + | "service": "g1t-web", | |
| 15252 | + | "environment": "production", | |
| 15253 | + | "deployment_id": "dpl_01kq9a2c4e6g8j0m2p4r6t8v0x", | |
| 15254 | + | "commit_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 15255 | + | "source": "otlp" | |
| 15256 | + | } | |
| 15257 | + | ], | |
| 15258 | + | "logs": [ | |
| 15259 | + | { | |
| 15260 | + | "id": "log_01kq9b7d9f1h3k5m7p9r1t3v5y", | |
| 15261 | + | "time_us": 1791813808251000, | |
| 15262 | + | "severity": "error", | |
| 15263 | + | "severity_number": 17, | |
| 15264 | + | "body": "loading pull request 412 failed: Cannot read properties of undefined (reading 'head')", | |
| 15265 | + | "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", | |
| 15266 | + | "span_id": "00f067aa0ba902b7", | |
| 15267 | + | "request_id": "req_01kq9b7d9f1h3k5m7p9r1t3v5x", | |
| 15268 | + | "attributes": { | |
| 15269 | + | "code.function": "loadPull", | |
| 15270 | + | "http.route": "/:workspace/:repo/pulls/:number" | |
| 15271 | + | }, | |
| 15272 | + | "service": "g1t-web", | |
| 15273 | + | "environment": "production", | |
| 15274 | + | "deployment_id": "dpl_01kq9a2c4e6g8j0m2p4r6t8v0x", | |
| 15275 | + | "commit_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 15276 | + | "source": "cloudflare" | |
| 15277 | + | } | |
| 15278 | + | ] | |
| 15279 | + | }, | |
| 15280 | + | "notes": "Spans are in the order they started. `404` when the project has no spans of that trace." | |
| 15281 | + | }, | |
| 15282 | + | "list_errors": { | |
| 15283 | + | "params": { | |
| 15284 | + | "workspace": "flagon-io", | |
| 15285 | + | "project": "g1t" | |
| 15286 | + | }, | |
| 15287 | + | "query": { | |
| 15288 | + | "state": "open" | |
| 15289 | + | }, | |
| 15290 | + | "response": [ | |
| 15291 | + | { | |
| 15292 | + | "fingerprint": "9f2c41d07ab3e815", | |
| 15293 | + | "name": "TypeError", | |
| 15294 | + | "message": "Cannot read properties of undefined (reading 'head')", | |
| 15295 | + | "stack": "TypeError: Cannot read properties of undefined (reading 'head')\n at loadPull (app/routes/pulls.ts:88:31)", | |
| 15296 | + | "service": "g1t-web", | |
| 15297 | + | "environment": "production", | |
| 15298 | + | "first_us": 1791813062000000, | |
| 15299 | + | "last_us": 1791813808000000, | |
| 15300 | + | "count": 37, | |
| 15301 | + | "first_deployment_id": "dpl_01kq9a2c4e6g8j0m2p4r6t8v0x", | |
| 15302 | + | "last_request_id": "req_01kq9b7d9f1h3k5m7p9r1t3v5x", | |
| 15303 | + | "last_trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", | |
| 15304 | + | "state": "open", | |
| 15305 | + | "issue_number": null | |
| 15306 | + | } | |
| 15307 | + | ], | |
| 15308 | + | "notes": "The same exception is one group: its name, its message with numbers and ids taken out, and the top frame of its stack. `first_deployment_id` is the deployment it was first seen on, what likely brought it in. At most 200 groups, most recently seen first." | |
| 15309 | + | }, | |
| 15310 | + | "update_error": { | |
| 15311 | + | "params": { | |
| 15312 | + | "workspace": "flagon-io", | |
| 15313 | + | "project": "g1t", | |
| 15314 | + | "fingerprint": "9f2c41d07ab3e815" | |
| 15315 | + | }, | |
| 15316 | + | "request": { | |
| 15317 | + | "state": "resolved", | |
| 15318 | + | "issue_number": 418 | |
| 15319 | + | }, | |
| 15320 | + | "response": { | |
| 15321 | + | "fingerprint": "9f2c41d07ab3e815", | |
| 15322 | + | "name": "TypeError", | |
| 15323 | + | "message": "Cannot read properties of undefined (reading 'head')", | |
| 15324 | + | "stack": "TypeError: Cannot read properties of undefined (reading 'head')\n at loadPull (app/routes/pulls.ts:88:31)", | |
| 15325 | + | "service": "g1t-web", | |
| 15326 | + | "environment": "production", | |
| 15327 | + | "first_us": 1791813062000000, | |
| 15328 | + | "last_us": 1791813808000000, | |
| 15329 | + | "count": 37, | |
| 15330 | + | "first_deployment_id": "dpl_01kq9a2c4e6g8j0m2p4r6t8v0x", | |
| 15331 | + | "last_request_id": "req_01kq9b7d9f1h3k5m7p9r1t3v5x", | |
| 15332 | + | "last_trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", | |
| 15333 | + | "state": "resolved", | |
| 15334 | + | "issue_number": 418 | |
| 15335 | + | }, | |
| 15336 | + | "notes": "A resolved group that happens again opens again. `issue_number` links the issue made for it, so an agent can take it; null unlinks it. Needs the Triage role or higher." | |
| 15337 | + | }, | |
| 15338 | + | "list_metrics": { | |
| 15339 | + | "params": { | |
| 15340 | + | "workspace": "flagon-io", | |
| 15341 | + | "project": "g1t" | |
| 15342 | + | }, | |
| 15343 | + | "response": [ | |
| 15344 | + | { | |
| 15345 | + | "name": "http.server.request.duration", | |
| 15346 | + | "kind": "histogram", | |
| 15347 | + | "unit": "s", | |
| 15348 | + | "description": "Duration of HTTP server requests.", | |
| 15349 | + | "services": [ | |
| 15350 | + | "g1t-api" | |
| 15351 | + | ], | |
| 15352 | + | "last_us": 1791813800000000 | |
| 15353 | + | }, | |
| 15354 | + | { | |
| 15355 | + | "name": "queue.depth", | |
| 15356 | + | "kind": "gauge", | |
| 15357 | + | "unit": "{message}", | |
| 15358 | + | "description": "Messages waiting.", | |
| 15359 | + | "services": [ | |
| 15360 | + | "g1t-work" | |
| 15361 | + | ], | |
| 15362 | + | "last_us": 1791813780000000 | |
| 15363 | + | } | |
| 15364 | + | ] | |
| 15365 | + | }, | |
| 15366 | + | "get_metric": { | |
| 15367 | + | "params": { | |
| 15368 | + | "workspace": "flagon-io", | |
| 15369 | + | "project": "g1t", | |
| 15370 | + | "metric": "http.server.request.duration" | |
| 15371 | + | }, | |
| 15372 | + | "query": { | |
| 15373 | + | "aggregate": "p95", | |
| 15374 | + | "group_by": "http.route", | |
| 15375 | + | "step": "300" | |
| 15376 | + | }, | |
| 15377 | + | "response": { | |
| 15378 | + | "name": "http.server.request.duration", | |
| 15379 | + | "unit": "s", | |
| 15380 | + | "step_s": 300, | |
| 15381 | + | "series": [ | |
| 15382 | + | { | |
| 15383 | + | "group": "/repos/:owner/:name/pulls", | |
| 15384 | + | "points": [ | |
| 15385 | + | { | |
| 15386 | + | "time_us": 1791813300000000, | |
| 15387 | + | "value": 0.212 | |
| 15388 | + | }, | |
| 15389 | + | { | |
| 15390 | + | "time_us": 1791813600000000, | |
| 15391 | + | "value": 0.236 | |
| 15392 | + | } | |
| 15393 | + | ] | |
| 15394 | + | } | |
| 15395 | + | ] | |
| 15396 | + | }, | |
| 15397 | + | "notes": "A metric name with characters outside letters, digits, `.`, `_` and `-` is URL-encoded in the path. Gauges average in each step unless `aggregate` says `min` or `max`; sums add, and a cumulative sum becomes its increase; histograms give their mean, or a percentile from their buckets." | |
| 15398 | + | }, | |
| 15399 | + | "get_observability_settings": { | |
| 15400 | + | "params": { | |
| 15401 | + | "workspace": "flagon-io", | |
| 15402 | + | "project": "g1t" | |
| 15403 | + | }, | |
| 15404 | + | "response": { | |
| 15405 | + | "retention_days": 7, | |
| 15406 | + | "deployments": true | |
| 15407 | + | }, | |
| 15408 | + | "notes": "Requests, logs and spans are kept for `retention_days`; the overview's charts are kept longer, as per-minute counts. Needs the Admin role." | |
| 15409 | + | }, | |
| 15410 | + | "update_observability_settings": { | |
| 15411 | + | "params": { | |
| 15412 | + | "workspace": "flagon-io", | |
| 15413 | + | "project": "g1t" | |
| 15414 | + | }, | |
| 15415 | + | "request": { | |
| 15416 | + | "retention_days": 14 | |
| 15417 | + | }, | |
| 15418 | + | "response": { | |
| 15419 | + | "retention_days": 14, | |
| 15420 | + | "deployments": true | |
| 15421 | + | }, | |
| 15422 | + | "notes": "`retention_days` is 1, 3, 7, 14 or 30. Turning `deployments` off stops the project's apps on g1t.page from sending and keeps its tokens and history. Needs the Admin role." | |
| 15423 | + | }, | |
| 15424 | + | "list_ingest_tokens": { | |
| 15425 | + | "params": { | |
| 15426 | + | "workspace": "flagon-io", | |
| 15427 | + | "project": "g1t" | |
| 15428 | + | }, | |
| 15429 | + | "response": [ | |
| 15430 | + | { | |
| 15431 | + | "id": "a1b2c3d4", | |
| 15432 | + | "name": "Deployments on g1t.page", | |
| 15433 | + | "hint": "g1to_prj_…2c8e", | |
| 15434 | + | "created_by": null, | |
| 15435 | + | "created_us": 1790841600000000, | |
| 15436 | + | "last_used_us": 1791813809000000, | |
| 15437 | + | "managed": true | |
| 15438 | + | }, | |
| 15439 | + | { | |
| 15440 | + | "id": "k3m9q2x7", | |
| 15441 | + | "name": "fly.io collector", | |
| 15442 | + | "hint": "g1to_prj_…9f3a", | |
| 15443 | + | "created_by": "syntaqx", | |
| 15444 | + | "created_us": 1791796364000000, | |
| 15445 | + | "last_used_us": 1791813790000000, | |
| 15446 | + | "managed": false | |
| 15447 | + | } | |
| 15448 | + | ], | |
| 15449 | + | "notes": "Secrets are never returned: `hint` tells tokens apart. The `managed` one is g1t's own, for the project's apps on g1t.page, and is replaced by g1t, not by people. Needs the Admin role." | |
| 15450 | + | }, | |
| 15451 | + | "create_ingest_token": { | |
| 15452 | + | "params": { | |
| 15453 | + | "workspace": "flagon-io", | |
| 15454 | + | "project": "g1t" | |
| 15455 | + | }, | |
| 15456 | + | "request": { | |
| 15457 | + | "name": "fly.io collector" | |
| 15458 | + | }, | |
| 15459 | + | "response": { | |
| 15460 | + | "token": { | |
| 15461 | + | "id": "k3m9q2x7", | |
| 15462 | + | "name": "fly.io collector", | |
| 15463 | + | "hint": "g1to_prj_…9f3a", | |
| 15464 | + | "created_by": "syntaqx", | |
| 15465 | + | "created_us": 1791796364000000, | |
| 15466 | + | "last_used_us": null, | |
| 15467 | + | "managed": false | |
| 15468 | + | }, | |
| 15469 | + | "secret": "g1to_prj_…" | |
| 15470 | + | }, | |
| 15471 | + | "notes": "The secret is returned this once. Send it as `Authorization: Bearer <secret>` (or the `x-g1t-token` header) with OTLP over HTTP, in JSON or protobuf, to `https://otel.g1t.sh/v1/traces`, `/v1/logs` and `/v1/metrics`, or to `/otlp/v1/…` on the API's own address. Needs the Admin role." | |
| 15472 | + | }, | |
| 15473 | + | "delete_ingest_token": { | |
| 15474 | + | "params": { | |
| 15475 | + | "workspace": "flagon-io", | |
| 15476 | + | "project": "g1t", | |
| 15477 | + | "id": "k3m9q2x7" | |
| 15478 | + | }, | |
| 15479 | + | "response": true, | |
| 15480 | + | "notes": "What sends with it is refused from then on. `404` for the managed token, which people cannot delete. Needs the Admin role." | |
| 15481 | + | }, | |
| 15482 | + | "list_telemetry_exports": { | |
| 15483 | + | "params": { | |
| 15484 | + | "workspace": "flagon-io", | |
| 15485 | + | "project": "g1t" | |
| 15486 | + | }, | |
| 15487 | + | "response": [ | |
| 15488 | + | { | |
| 15489 | + | "id": "x7d2k9m4", | |
| 15490 | + | "name": "Honeycomb", | |
| 15491 | + | "endpoint": "https://api.honeycomb.io", | |
| 15492 | + | "signals": [ | |
| 15493 | + | "traces", | |
| 15494 | + | "logs" | |
| 15495 | + | ], | |
| 15496 | + | "header_names": [ | |
| 15497 | + | "x-honeycomb-team" | |
| 15498 | + | ], | |
| 15499 | + | "enabled": true, | |
| 15500 | + | "last_error": null, | |
| 15501 | + | "last_sent_us": 1791813809000000 | |
| 15502 | + | } | |
| 15503 | + | ], | |
| 15504 | + | "notes": "Header values are secrets: written, never read back, so only `header_names` are shown. `last_error` is what the endpoint answered the last time sending failed. Needs the Admin role." | |
| 15505 | + | }, | |
| 15506 | + | "create_telemetry_export": { | |
| 15507 | + | "params": { | |
| 15508 | + | "workspace": "flagon-io", | |
| 15509 | + | "project": "g1t" | |
| 15510 | + | }, | |
| 15511 | + | "request": { | |
| 15512 | + | "name": "Honeycomb", | |
| 15513 | + | "endpoint": "https://api.honeycomb.io", | |
| 15514 | + | "signals": [ | |
| 15515 | + | "traces", | |
| 15516 | + | "logs" | |
| 15517 | + | ], | |
| 15518 | + | "headers": { | |
| 15519 | + | "x-honeycomb-team": "hcaik_…" | |
| 15520 | + | } | |
| 15521 | + | }, | |
| 15522 | + | "response": { | |
| 15523 | + | "id": "x7d2k9m4", | |
| 15524 | + | "name": "Honeycomb", | |
| 15525 | + | "endpoint": "https://api.honeycomb.io", | |
| 15526 | + | "signals": [ | |
| 15527 | + | "traces", | |
| 15528 | + | "logs" | |
| 15529 | + | ], | |
| 15530 | + | "header_names": [ | |
| 15531 | + | "x-honeycomb-team" | |
| 15532 | + | ], | |
| 15533 | + | "enabled": true, | |
| 15534 | + | "last_error": null, | |
| 15535 | + | "last_sent_us": null | |
| 15536 | + | }, | |
| 15537 | + | "notes": "`endpoint` is the OTLP/HTTP base address; `/v1/traces`, `/v1/logs` and `/v1/metrics` are added. Requests go as log records. Telemetry is sent on as it arrives, from then on. Needs the Admin role." | |
| 15538 | + | }, | |
| 15539 | + | "update_telemetry_export": { | |
| 15540 | + | "params": { | |
| 15541 | + | "workspace": "flagon-io", | |
| 15542 | + | "project": "g1t", | |
| 15543 | + | "id": "x7d2k9m4" | |
| 15544 | + | }, | |
| 15545 | + | "request": { | |
| 15546 | + | "enabled": false | |
| 15547 | + | }, | |
| 15548 | + | "response": { | |
| 15549 | + | "id": "x7d2k9m4", | |
| 15550 | + | "name": "Honeycomb", | |
| 15551 | + | "endpoint": "https://api.honeycomb.io", | |
| 15552 | + | "signals": [ | |
| 15553 | + | "traces", | |
| 15554 | + | "logs" | |
| 15555 | + | ], | |
| 15556 | + | "header_names": [ | |
| 15557 | + | "x-honeycomb-team" | |
| 15558 | + | ], | |
| 15559 | + | "enabled": false, | |
| 15560 | + | "last_error": null, | |
| 15561 | + | "last_sent_us": 1791813809000000 | |
| 15562 | + | }, | |
| 15563 | + | "notes": "Only what is given changes. A header given is set, a header given as `\"\"` is removed, and the rest are kept. Needs the Admin role." | |
| 15564 | + | }, | |
| 15565 | + | "delete_telemetry_export": { | |
| 15566 | + | "params": { | |
| 15567 | + | "workspace": "flagon-io", | |
| 15568 | + | "project": "g1t", | |
| 15569 | + | "id": "x7d2k9m4" | |
| 15570 | + | }, | |
| 15571 | + | "response": true, | |
| 15572 | + | "notes": "Needs the Admin role." | |
| 15573 | + | }, | |
| 15574 | + | "get_observability_usage": { | |
| 15575 | + | "params": { | |
| 15576 | + | "workspace": "flagon-io", | |
| 15577 | + | "project": "g1t" | |
| 15578 | + | }, | |
| 15579 | + | "response": { | |
| 15580 | + | "month": "2026-10", | |
| 15581 | + | "ingested_bytes": 4831920112, | |
| 15582 | + | "requests": 1840221, | |
| 15583 | + | "logs": 6204118, | |
| 15584 | + | "spans": 9921540, | |
| 15585 | + | "metric_points": 3120400, | |
| 15586 | + | "stored_bytes": 1210443776 | |
| 15587 | + | }, | |
| 15588 | + | "notes": "This month so far. `ingested_bytes` is what arrived, after decoding: what ingest is metered by. Needs the Admin role." | |
| 14931 | 15589 | } | |
| 14932 | 15590 | } |
| 117 | 117 | Op::Rules(RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset) => return as_is, | |
| 118 | 118 | // Deployments travel in `snake_case` between services too. | |
| 119 | 119 | Op::Deployments(_) => return as_is, | |
| 120 | + | // So does telemetry. | |
| 121 | + | Op::Telemetry(_) => return as_is, | |
| 120 | 122 | // Artifacts are shaped by the API itself, in `snake_case`. | |
| 121 | 123 | Op::Artifacts(_) => return as_is, | |
| 122 | 124 | // So are archive links. |
| 9 | 9 | use crate::deploy_keys::DeployKeysOp; | |
| 10 | 10 | use crate::mirrors::MirrorsOp; | |
| 11 | 11 | use crate::deployments::DeploymentsOp; | |
| 12 | + | use crate::telemetry::TelemetryOp; | |
| 12 | 13 | use crate::packages::PackagesOp; | |
| 13 | 14 | use crate::folios::FoliosOp; | |
| 14 | 15 | use crate::protection::ProtectionOp; | |
| ⋯ | |||
| 158 | 159 | route("GET", "/workspaces/:workspace/projects", Op::ListProjects, &[]), | |
| 159 | 160 | route("GET", "/workspaces/:workspace/projects/:project", Op::GetProject, &[]), | |
| 160 | 161 | route("PATCH", "/workspaces/:workspace/projects/:project", Op::UpdateProject, &[]), | |
| 162 | + | // A project's observability: what its software did, and how it is collected. | |
| 163 | + | route( | |
| 164 | + | "GET", | |
| 165 | + | "/workspaces/:workspace/projects/:project/observability/overview", | |
| 166 | + | Op::Telemetry(TelemetryOp::GetOverview), | |
| 167 | + | &[("from", "from"), ("to", "to"), ("environment", "environment"), ("service", "service"), ("deployment_id", "deployment_id"), ("step", "step")], | |
| 168 | + | ), | |
| 169 | + | route( | |
| 170 | + | "GET", | |
| 171 | + | "/workspaces/:workspace/projects/:project/observability/requests", | |
| 172 | + | Op::Telemetry(TelemetryOp::ListRequests), | |
| 173 | + | &[ | |
| 174 | + | ("from", "from"), | |
| 175 | + | ("to", "to"), | |
| 176 | + | ("q", "q"), | |
| 177 | + | ("status", "status"), | |
| 178 | + | ("errors_only", "errors_only"), | |
| 179 | + | ("route", "route"), | |
| 180 | + | ("min_duration_ms", "min_duration_ms"), | |
| 181 | + | ("environment", "environment"), | |
| 182 | + | ("service", "service"), | |
| 183 | + | ("deployment_id", "deployment_id"), | |
| 184 | + | ("cursor", "cursor"), | |
| 185 | + | ("limit", "limit"), | |
| 186 | + | ], | |
| 187 | + | ), | |
| 188 | + | route("GET", "/workspaces/:workspace/projects/:project/observability/requests/:id", Op::Telemetry(TelemetryOp::GetRequest), &[]), | |
| 189 | + | route( | |
| 190 | + | "GET", | |
| 191 | + | "/workspaces/:workspace/projects/:project/observability/logs", | |
| 192 | + | Op::Telemetry(TelemetryOp::ListLogs), | |
| 193 | + | &[ | |
| 194 | + | ("from", "from"), | |
| 195 | + | ("to", "to"), | |
| 196 | + | ("q", "q"), | |
| 197 | + | ("level", "level"), | |
| 198 | + | ("trace_id", "trace_id"), | |
| 199 | + | ("request_id", "request_id"), | |
| 200 | + | ("environment", "environment"), | |
| 201 | + | ("service", "service"), | |
| 202 | + | ("deployment_id", "deployment_id"), | |
| 203 | + | ("cursor", "cursor"), | |
| 204 | + | ("limit", "limit"), | |
| 205 | + | ], | |
| 206 | + | ), | |
| 207 | + | route( | |
| 208 | + | "GET", | |
| 209 | + | "/workspaces/:workspace/projects/:project/observability/traces", | |
| 210 | + | Op::Telemetry(TelemetryOp::ListTraces), | |
| 211 | + | &[ | |
| 212 | + | ("from", "from"), | |
| 213 | + | ("to", "to"), | |
| 214 | + | ("q", "q"), | |
| 215 | + | ("errors_only", "errors_only"), | |
| 216 | + | ("min_duration_ms", "min_duration_ms"), | |
| 217 | + | ("environment", "environment"), | |
| 218 | + | ("service", "service"), | |
| 219 | + | ("cursor", "cursor"), | |
| 220 | + | ("limit", "limit"), | |
| 221 | + | ], | |
| 222 | + | ), | |
| 223 | + | route("GET", "/workspaces/:workspace/projects/:project/observability/traces/:trace_id", Op::Telemetry(TelemetryOp::GetTrace), &[]), | |
| 224 | + | route( | |
| 225 | + | "GET", | |
| 226 | + | "/workspaces/:workspace/projects/:project/observability/errors", | |
| 227 | + | Op::Telemetry(TelemetryOp::ListErrors), | |
| 228 | + | &[("state", "state"), ("environment", "environment"), ("service", "service"), ("from", "from"), ("to", "to")], | |
| 229 | + | ), | |
| 230 | + | route("PATCH", "/workspaces/:workspace/projects/:project/observability/errors/:fingerprint", Op::Telemetry(TelemetryOp::UpdateError), &[]), | |
| 231 | + | route("GET", "/workspaces/:workspace/projects/:project/observability/metrics", Op::Telemetry(TelemetryOp::ListMetrics), &[]), | |
| 232 | + | route( | |
| 233 | + | "GET", | |
| 234 | + | "/workspaces/:workspace/projects/:project/observability/metrics/:metric", | |
| 235 | + | Op::Telemetry(TelemetryOp::GetMetric), | |
| 236 | + | &[("from", "from"), ("to", "to"), ("aggregate", "aggregate"), ("group_by", "group_by"), ("step", "step"), ("service", "service"), ("environment", "environment")], | |
| 237 | + | ), | |
| 238 | + | route("GET", "/workspaces/:workspace/projects/:project/observability/settings", Op::Telemetry(TelemetryOp::GetSettings), &[]), | |
| 239 | + | route("PATCH", "/workspaces/:workspace/projects/:project/observability/settings", Op::Telemetry(TelemetryOp::UpdateSettings), &[]), | |
| 240 | + | route("GET", "/workspaces/:workspace/projects/:project/observability/tokens", Op::Telemetry(TelemetryOp::ListTokens), &[]), | |
| 241 | + | route("POST", "/workspaces/:workspace/projects/:project/observability/tokens", Op::Telemetry(TelemetryOp::CreateToken), &[]), | |
| 242 | + | route("DELETE", "/workspaces/:workspace/projects/:project/observability/tokens/:id", Op::Telemetry(TelemetryOp::DeleteToken), &[]), | |
| 243 | + | route("GET", "/workspaces/:workspace/projects/:project/observability/exports", Op::Telemetry(TelemetryOp::ListExports), &[]), | |
| 244 | + | route("POST", "/workspaces/:workspace/projects/:project/observability/exports", Op::Telemetry(TelemetryOp::CreateExport), &[]), | |
| 245 | + | route("PATCH", "/workspaces/:workspace/projects/:project/observability/exports/:id", Op::Telemetry(TelemetryOp::UpdateExport), &[]), | |
| 246 | + | route("DELETE", "/workspaces/:workspace/projects/:project/observability/exports/:id", Op::Telemetry(TelemetryOp::DeleteExport), &[]), | |
| 247 | + | route("GET", "/workspaces/:workspace/projects/:project/observability/usage", Op::Telemetry(TelemetryOp::GetUsage), &[]), | |
| 161 | 248 | route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]), | |
| 162 | 249 | route( | |
| 163 | 250 | "GET", | |
| ⋯ | |||
| 1426 | 1513 | } | |
| 1427 | 1514 | ||
| 1428 | 1515 | #[test] | |
| 1516 | + | fn observability_is_addressed_by_workspace_and_project() { | |
| 1517 | + | let query = [("level".to_owned(), "error,warn".to_owned()), ("q".to_owned(), "timeout".to_owned())]; | |
| 1518 | + | let (route, input) = resolve("GET", "/workspaces/acme/projects/web/observability/logs", &query, Value::Null).unwrap(); | |
| 1519 | + | assert_eq!(route.op, Op::Telemetry(TelemetryOp::ListLogs)); | |
| 1520 | + | assert_eq!(input, json!({ "level": "error,warn", "q": "timeout", "workspace": "acme", "project": "web" })); | |
| 1521 | + | let (route, input) = | |
| 1522 | + | resolve("GET", "/workspaces/acme/projects/web/observability/metrics/http.server.request.duration", &[], Value::Null).unwrap(); | |
| 1523 | + | assert_eq!(route.op, Op::Telemetry(TelemetryOp::GetMetric)); | |
| 1524 | + | assert_eq!(input["metric"], json!("http.server.request.duration")); | |
| 1525 | + | let (route, input) = | |
| 1526 | + | resolve("PATCH", "/workspaces/acme/projects/web/observability/errors/f00d", &[], json!({ "state": "resolved" })).unwrap(); | |
| 1527 | + | assert_eq!(route.op, Op::Telemetry(TelemetryOp::UpdateError)); | |
| 1528 | + | assert_eq!(input, json!({ "state": "resolved", "workspace": "acme", "project": "web", "fingerprint": "f00d" })); | |
| 1529 | + | } | |
| 1530 | + | ||
| 1531 | + | #[test] | |
| 1429 | 1532 | fn billing_is_addressed_by_workspace() { | |
| 1430 | 1533 | let query = [("from".to_owned(), "2026-10-01".to_owned()), ("products".to_owned(), "agent,sandboxes".to_owned())]; | |
| 1431 | 1534 | let (route, input) = resolve("GET", "/workspaces/acme/usage", &query, Value::Null).unwrap(); | |
| 1 | + | //! Observability over REST and MCP: what a project's running software did, | |
| 2 | + | //! as requests, logs, traces, errors and metrics, and how it is collected | |
| 3 | + | //! (settings, ingest tokens and exports). | |
| 4 | + | //! | |
| 5 | + | //! The telemetry service keeps each project's store and checks nothing: | |
| 6 | + | //! this resolves the project through the projects service and decides | |
| 7 | + | //! first, as the site does. Reading takes a role of the viewer's own on | |
| 8 | + | //! the project's repository (logs and request addresses can hold private | |
| 9 | + | //! data, so a public repository is not enough); resolving and ignoring | |
| 10 | + | //! errors takes Triage; settings, ingest tokens, exports and usage take | |
| 11 | + | //! Admin (`manage_integrations`). Anyone who may not read is told there is | |
| 12 | + | //! no such project. | |
| 13 | + | ||
| 14 | + | use g1t_contracts::access::{self, Capability, RepoRef}; | |
| 15 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 16 | + | use serde_json::{Map, Value, json}; | |
| 17 | + | use worker::{Headers, Method, Request, RequestInit, Result}; | |
| 18 | + | ||
| 19 | + | use crate::operations::Services; | |
| 20 | + | ||
| 21 | + | /// One operation on a project's observability. | |
| 22 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 23 | + | pub enum TelemetryOp { | |
| 24 | + | GetOverview, | |
| 25 | + | ListRequests, | |
| 26 | + | GetRequest, | |
| 27 | + | ListLogs, | |
| 28 | + | ListTraces, | |
| 29 | + | GetTrace, | |
| 30 | + | ListErrors, | |
| 31 | + | UpdateError, | |
| 32 | + | ListMetrics, | |
| 33 | + | GetMetric, | |
| 34 | + | GetSettings, | |
| 35 | + | UpdateSettings, | |
| 36 | + | ListTokens, | |
| 37 | + | CreateToken, | |
| 38 | + | DeleteToken, | |
| 39 | + | ListExports, | |
| 40 | + | CreateExport, | |
| 41 | + | UpdateExport, | |
| 42 | + | DeleteExport, | |
| 43 | + | GetUsage, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Log levels, as the telemetry service groups them. | |
| 47 | + | const SEVERITIES: [&str; 6] = ["trace", "debug", "info", "warn", "error", "fatal"]; | |
| 48 | + | /// An error group's states. | |
| 49 | + | const ERROR_STATES: [&str; 3] = ["open", "resolved", "ignored"]; | |
| 50 | + | /// What a metric's values in a step may be reduced to. | |
| 51 | + | const AGGREGATES: [&str; 7] = ["avg", "sum", "min", "max", "p50", "p95", "p99"]; | |
| 52 | + | /// The signals an export may send. | |
| 53 | + | const SIGNALS: [&str; 3] = ["traces", "logs", "metrics"]; | |
| 54 | + | /// Retention a project can choose, in days (`RETENTION_CHOICES` in @g1t/contracts). | |
| 55 | + | const RETENTION_DAYS: [u64; 5] = [1, 3, 7, 14, 30]; | |
| 56 | + | ||
| 57 | + | /// Fields keyed by what the software sent (OpenTelemetry attributes and | |
| 58 | + | /// resources), passed through as they are rather than put in `snake_case`. | |
| 59 | + | pub const AS_GIVEN: &[&str] = &["attributes", "resource"]; | |
| 60 | + | ||
| 61 | + | /// What an operation needs of the viewer on the project's repository. | |
| 62 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 63 | + | enum Need { | |
| 64 | + | /// A role of their own: a member, a collaborator, or the workspace's token. | |
| 65 | + | Insider, | |
| 66 | + | Can(Capability), | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | impl TelemetryOp { | |
| 70 | + | /// Every one: `Op::ALL` lists each as `Op::Telemetry(…)`, which a test | |
| 71 | + | /// checks against this. | |
| 72 | + | #[cfg(test)] | |
| 73 | + | pub const ALL: [TelemetryOp; 20] = [ | |
| 74 | + | TelemetryOp::GetOverview, | |
| 75 | + | TelemetryOp::ListRequests, | |
| 76 | + | TelemetryOp::GetRequest, | |
| 77 | + | TelemetryOp::ListLogs, | |
| 78 | + | TelemetryOp::ListTraces, | |
| 79 | + | TelemetryOp::GetTrace, | |
| 80 | + | TelemetryOp::ListErrors, | |
| 81 | + | TelemetryOp::UpdateError, | |
| 82 | + | TelemetryOp::ListMetrics, | |
| 83 | + | TelemetryOp::GetMetric, | |
| 84 | + | TelemetryOp::GetSettings, | |
| 85 | + | TelemetryOp::UpdateSettings, | |
| 86 | + | TelemetryOp::ListTokens, | |
| 87 | + | TelemetryOp::CreateToken, | |
| 88 | + | TelemetryOp::DeleteToken, | |
| 89 | + | TelemetryOp::ListExports, | |
| 90 | + | TelemetryOp::CreateExport, | |
| 91 | + | TelemetryOp::UpdateExport, | |
| 92 | + | TelemetryOp::DeleteExport, | |
| 93 | + | TelemetryOp::GetUsage, | |
| 94 | + | ]; | |
| 95 | + | ||
| 96 | + | pub fn name(self) -> &'static str { | |
| 97 | + | match self { | |
| 98 | + | TelemetryOp::GetOverview => "get_observability_overview", | |
| 99 | + | TelemetryOp::ListRequests => "list_requests", | |
| 100 | + | TelemetryOp::GetRequest => "get_request", | |
| 101 | + | TelemetryOp::ListLogs => "list_logs", | |
| 102 | + | TelemetryOp::ListTraces => "list_traces", | |
| 103 | + | TelemetryOp::GetTrace => "get_trace", | |
| 104 | + | TelemetryOp::ListErrors => "list_errors", | |
| 105 | + | TelemetryOp::UpdateError => "update_error", | |
| 106 | + | TelemetryOp::ListMetrics => "list_metrics", | |
| 107 | + | TelemetryOp::GetMetric => "get_metric", | |
| 108 | + | TelemetryOp::GetSettings => "get_observability_settings", | |
| 109 | + | TelemetryOp::UpdateSettings => "update_observability_settings", | |
| 110 | + | TelemetryOp::ListTokens => "list_ingest_tokens", | |
| 111 | + | TelemetryOp::CreateToken => "create_ingest_token", | |
| 112 | + | TelemetryOp::DeleteToken => "delete_ingest_token", | |
| 113 | + | TelemetryOp::ListExports => "list_telemetry_exports", | |
| 114 | + | TelemetryOp::CreateExport => "create_telemetry_export", | |
| 115 | + | TelemetryOp::UpdateExport => "update_telemetry_export", | |
| 116 | + | TelemetryOp::DeleteExport => "delete_telemetry_export", | |
| 117 | + | TelemetryOp::GetUsage => "get_observability_usage", | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// For the API reference: "List logs". | |
| 122 | + | pub fn title(self) -> &'static str { | |
| 123 | + | match self { | |
| 124 | + | TelemetryOp::GetOverview => "Get a project's observability overview", | |
| 125 | + | TelemetryOp::ListRequests => "List requests", | |
| 126 | + | TelemetryOp::GetRequest => "Get a request", | |
| 127 | + | TelemetryOp::ListLogs => "List logs", | |
| 128 | + | TelemetryOp::ListTraces => "List traces", | |
| 129 | + | TelemetryOp::GetTrace => "Get a trace", | |
| 130 | + | TelemetryOp::ListErrors => "List errors", | |
| 131 | + | TelemetryOp::UpdateError => "Update an error", | |
| 132 | + | TelemetryOp::ListMetrics => "List metrics", | |
| 133 | + | TelemetryOp::GetMetric => "Get a metric", | |
| 134 | + | TelemetryOp::GetSettings => "Get observability settings", | |
| 135 | + | TelemetryOp::UpdateSettings => "Update observability settings", | |
| 136 | + | TelemetryOp::ListTokens => "List ingest tokens", | |
| 137 | + | TelemetryOp::CreateToken => "Create an ingest token", | |
| 138 | + | TelemetryOp::DeleteToken => "Delete an ingest token", | |
| 139 | + | TelemetryOp::ListExports => "List telemetry exports", | |
| 140 | + | TelemetryOp::CreateExport => "Create a telemetry export", | |
| 141 | + | TelemetryOp::UpdateExport => "Update a telemetry export", | |
| 142 | + | TelemetryOp::DeleteExport => "Delete a telemetry export", | |
| 143 | + | TelemetryOp::GetUsage => "Get observability usage", | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | pub fn description(self) -> &'static str { | |
| 148 | + | match self { | |
| 149 | + | TelemetryOp::GetOverview => "A project's numbers over a period (the last 24 hours unless you say): totals (requests, errors, error_rate, and latency and CPU percentiles p50, p75, p95 and p99 in milliseconds) and the same for the period before it, to compare; points, one per step (step_s seconds), with requests, errors, counts by status class and percentiles; routes, by requests; deployments, each one's numbers newest first, so a regression points at the deployment it began with; services and regions. from and to are RFC 3339 times or Unix milliseconds. Narrow it by environment, service or deployment_id. Needs a role on the project's repository.", | |
| 150 | + | TelemetryOp::ListRequests => "The requests (and other invocations) a project's software answered, newest first: each with its method, url, route (ids taken out), status, outcome, duration_us, cpu_ms, region, trace_id, log_count, error, and where it came from (service, environment, deployment_id, commit_sha, source). Apps on g1t.page send them with nothing to set up; software elsewhere, as OpenTelemetry server spans. Filter by words in q, status (2xx, 3xx, 4xx, 5xx or a status, comma-separated), errors_only (a 5xx or an outcome other than ok), route, min_duration_ms, environment, service and deployment_id, between from and to. A page holds limit; pass next back as cursor for the next. Needs a role on the project's repository.", | |
| 151 | + | TelemetryOp::GetRequest => "One request by id, with every log line it wrote, oldest first. Needs a role on the project's repository.", | |
| 152 | + | TelemetryOp::ListLogs => "Search a project's logs, newest first. q finds lines holding all of its words, or a phrase in quotes. level narrows to levels (trace, debug, info, warn, error, fatal, comma-separated); trace_id and request_id to one trace's or one request's lines; environment, service and deployment_id to where they ran; from and to to a period. Each line has its time_us, severity, body, attributes, trace_id, span_id and request_id. A page holds limit; pass next back as cursor for the next. Needs a role on the project's repository.", | |
| 153 | + | TelemetryOp::ListTraces => "A project's traces, newest first: each its root span's name, start_us, duration_us, span_count, error_count, the services it crossed and its HTTP status. Filter by words in q, errors_only, min_duration_ms, environment and service, between from and to. A page holds limit; pass next back as cursor for the next. Needs a role on the project's repository.", | |
| 154 | + | TelemetryOp::GetTrace => "One trace by its id (32 hex characters): every span, with its parent, kind, timing, status, attributes, resource, events and links, and the log lines written under it. Needs a role on the project's repository.", | |
| 155 | + | TelemetryOp::ListErrors => "A project's errors, grouped: the same exception (its name, its message with numbers and ids taken out, and the top frame of its stack) is one group, with its count, first and last time seen, the deployment it was first seen on (what likely brought it in), its last request and trace, its state (open, resolved or ignored) and the issue made for it. Most recently seen first, at most 200. Filter by state, environment and service, between from and to. Needs a role on the project's repository.", | |
| 156 | + | TelemetryOp::UpdateError => "Resolve, ignore or reopen an error group (state), or link it to the issue made for it (issue_number; null unlinks it). A resolved group that happens again opens again. Needs the Triage role or higher.", | |
| 157 | + | TelemetryOp::ListMetrics => "The metrics a project's software has sent: each with its name, kind (gauge, sum, histogram or summary), unit, description, the services that send it and when it last did. Needs a role on the project's repository.", | |
| 158 | + | TelemetryOp::GetMetric => "One metric over time, one value per step: gauges averaged in each step (or min or max), sums added (a cumulative sum becomes its increase), histograms their mean or a percentile (p50, p95, p99) from their buckets. group_by splits it into a series per value of an attribute (or service). The last 24 hours unless from and to say. Needs a role on the project's repository.", | |
| 159 | + | TelemetryOp::GetSettings => "How long a project's requests, logs and spans are kept (retention_days; charts are kept longer, as per-minute counts) and whether its apps on g1t.page send telemetry (deployments). Needs the Admin role.", | |
| 160 | + | TelemetryOp::UpdateSettings => "Change how long a project's telemetry is kept (retention_days: 1, 3, 7, 14 or 30) and whether its apps on g1t.page send it (deployments; off keeps the tokens and history). Only what you give changes. Needs the Admin role.", | |
| 161 | + | TelemetryOp::ListTokens => "A project's ingest tokens, which OpenTelemetry SDKs and collectors send with: each its name, hint (its first characters), who made it, when, and when it was last used. managed marks the one g1t made for its own deployments, which people cannot delete. Secrets are never returned. Needs the Admin role.", | |
| 162 | + | TelemetryOp::CreateToken => "Make an ingest token for a project. Returns token and secret: the secret is shown this once, so keep it. Send it as `Authorization: Bearer <secret>` (or `x-g1t-token`) with OTLP over HTTP to the project's endpoint. Needs the Admin role.", | |
| 163 | + | TelemetryOp::DeleteToken => "Delete an ingest token: what sends with it is refused from then on. The token g1t made for its own deployments cannot be deleted. Needs the Admin role.", | |
| 164 | + | TelemetryOp::ListExports => "Where a project's telemetry is also sent as it arrives: each export's name, OTLP/HTTP endpoint, signals (traces, logs, metrics), header_names (values are never returned), whether it is enabled, and its last error and send. Needs the Admin role.", | |
| 165 | + | TelemetryOp::CreateExport => "Send a project's telemetry on to any OTLP/HTTP endpoint as it arrives: a collector, or a vendor that takes OTLP. signals are traces, logs (requests go as log records) and metrics, all three unless you say; headers are sent with each request, such as an API key, and are written, never read back. Needs the Admin role.", | |
| 166 | + | TelemetryOp::UpdateExport => "Change an export: its name, endpoint, signals and whether it is enabled. Only what you give changes. Headers given are set; a header given as \"\" is removed; those not given are kept. Needs the Admin role.", | |
| 167 | + | TelemetryOp::DeleteExport => "Stop sending a project's telemetry to an export. Needs the Admin role.", | |
| 168 | + | TelemetryOp::GetUsage => "What a project has sent and kept this month: ingested_bytes (what ingest is metered by), requests, logs, spans, metric_points and stored_bytes. Needs the Admin role.", | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// Whether it changes anything. | |
| 173 | + | pub fn writes(self) -> bool { | |
| 174 | + | matches!( | |
| 175 | + | self, | |
| 176 | + | TelemetryOp::UpdateError | |
| 177 | + | | TelemetryOp::UpdateSettings | |
| 178 | + | | TelemetryOp::CreateToken | |
| 179 | + | | TelemetryOp::DeleteToken | |
| 180 | + | | TelemetryOp::CreateExport | |
| 181 | + | | TelemetryOp::UpdateExport | |
| 182 | + | | TelemetryOp::DeleteExport | |
| 183 | + | ) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | fn need(self) -> Need { | |
| 187 | + | match self { | |
| 188 | + | TelemetryOp::UpdateError => Need::Can(Capability::Triage), | |
| 189 | + | TelemetryOp::GetSettings | |
| 190 | + | | TelemetryOp::UpdateSettings | |
| 191 | + | | TelemetryOp::ListTokens | |
| 192 | + | | TelemetryOp::CreateToken | |
| 193 | + | | TelemetryOp::DeleteToken | |
| 194 | + | | TelemetryOp::ListExports | |
| 195 | + | | TelemetryOp::CreateExport | |
| 196 | + | | TelemetryOp::UpdateExport | |
| 197 | + | | TelemetryOp::DeleteExport | |
| 198 | + | | TelemetryOp::GetUsage => Need::Can(Capability::ManageIntegrations), | |
| 199 | + | _ => Need::Insider, | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | pub fn input(self) -> Value { | |
| 204 | + | let text = |what: &str| json!({ "type": "string", "description": what }); | |
| 205 | + | let integer = |what: &str| json!({ "type": "integer", "description": what }); | |
| 206 | + | let mut properties = Map::new(); | |
| 207 | + | properties.insert("workspace".into(), text("The workspace's slug, e.g. \"flagon-io\".")); | |
| 208 | + | properties.insert("project".into(), text("The project's slug, as in g1t.sh/{workspace}/{project}.")); | |
| 209 | + | let mut required = vec!["workspace", "project"]; | |
| 210 | + | let mut add = |name: &str, schema: Value| { | |
| 211 | + | properties.insert(name.to_owned(), schema); | |
| 212 | + | }; | |
| 213 | + | let period = |add: &mut dyn FnMut(&str, Value)| { | |
| 214 | + | add("from", text("The start of the period: an RFC 3339 time or Unix milliseconds. 24 hours before `to` unless you say.")); | |
| 215 | + | add("to", text("The end of the period: an RFC 3339 time or Unix milliseconds. Now unless you say.")); | |
| 216 | + | }; | |
| 217 | + | let place = |add: &mut dyn FnMut(&str, Value)| { | |
| 218 | + | add("environment", text("Only what ran in this environment, such as production or preview; several comma-separated.")); | |
| 219 | + | add("service", text("Only what this service sent (OpenTelemetry's service.name; an app on g1t.page is its project's name); several comma-separated.")); | |
| 220 | + | }; | |
| 221 | + | let page = |add: &mut dyn FnMut(&str, Value)| { | |
| 222 | + | add("cursor", text("Where to carry on from: the last page's `next`.")); | |
| 223 | + | add("limit", integer("How many a page holds.")); | |
| 224 | + | }; | |
| 225 | + | let words = "Words to find, all of them, or a phrase in quotes."; | |
| 226 | + | match self { | |
| 227 | + | TelemetryOp::GetOverview => { | |
| 228 | + | period(&mut add); | |
| 229 | + | place(&mut add); | |
| 230 | + | add("deployment_id", text("Only this deployment's (dpl_…).")); | |
| 231 | + | add("step", integer("Seconds per point of the charts; chosen from the period unless you say.")); | |
| 232 | + | } | |
| 233 | + | TelemetryOp::ListRequests => { | |
| 234 | + | period(&mut add); | |
| 235 | + | add("q", text(words)); | |
| 236 | + | add("status", text("Only these statuses: 2xx, 3xx, 4xx, 5xx or a status such as 404, comma-separated.")); | |
| 237 | + | add("errors_only", json!({ "type": "boolean", "description": "Only those that failed: a 5xx, or an outcome other than ok." })); | |
| 238 | + | add("route", text("Only this route, as the list shows it, such as /users/:id.")); | |
| 239 | + | add("min_duration_ms", integer("Only those that took at least this many milliseconds.")); | |
| 240 | + | place(&mut add); | |
| 241 | + | add("deployment_id", text("Only this deployment's (dpl_…).")); | |
| 242 | + | page(&mut add); | |
| 243 | + | } | |
| 244 | + | TelemetryOp::GetRequest => { | |
| 245 | + | add("id", text("The request's id.")); | |
| 246 | + | required.push("id"); | |
| 247 | + | } | |
| 248 | + | TelemetryOp::ListLogs => { | |
| 249 | + | period(&mut add); | |
| 250 | + | add("q", text(words)); | |
| 251 | + | add("level", json!({ "type": "string", "description": "Only these levels: trace, debug, info, warn, error or fatal, comma-separated." })); | |
| 252 | + | add("trace_id", text("Only the lines written under this trace.")); | |
| 253 | + | add("request_id", text("Only the lines this request wrote.")); | |
| 254 | + | place(&mut add); | |
| 255 | + | add("deployment_id", text("Only this deployment's (dpl_…).")); | |
| 256 | + | page(&mut add); | |
| 257 | + | } | |
| 258 | + | TelemetryOp::ListTraces => { | |
| 259 | + | period(&mut add); | |
| 260 | + | add("q", text(words)); | |
| 261 | + | add("errors_only", json!({ "type": "boolean", "description": "Only traces with a span that failed." })); | |
| 262 | + | add("min_duration_ms", integer("Only traces that took at least this many milliseconds.")); | |
| 263 | + | place(&mut add); | |
| 264 | + | page(&mut add); | |
| 265 | + | } | |
| 266 | + | TelemetryOp::GetTrace => { | |
| 267 | + | add("trace_id", text("The trace's id, 32 hex characters.")); | |
| 268 | + | required.push("trace_id"); | |
| 269 | + | } | |
| 270 | + | TelemetryOp::ListErrors => { | |
| 271 | + | add("state", json!({ "type": "string", "enum": ERROR_STATES, "description": "Only groups in this state." })); | |
| 272 | + | place(&mut add); | |
| 273 | + | period(&mut add); | |
| 274 | + | } | |
| 275 | + | TelemetryOp::UpdateError => { | |
| 276 | + | add("fingerprint", text("The error group's fingerprint, as list_errors gives it.")); | |
| 277 | + | add("state", json!({ "type": "string", "enum": ERROR_STATES, "description": "Its state now: resolved, ignored, or open again." })); | |
| 278 | + | add("issue_number", json!({ "type": ["integer", "null"], "description": "The issue made for it; null unlinks it." })); | |
| 279 | + | required.push("fingerprint"); | |
| 280 | + | } | |
| 281 | + | TelemetryOp::ListMetrics | |
| 282 | + | | TelemetryOp::GetSettings | |
| 283 | + | | TelemetryOp::ListTokens | |
| 284 | + | | TelemetryOp::ListExports | |
| 285 | + | | TelemetryOp::GetUsage => {} | |
| 286 | + | TelemetryOp::GetMetric => { | |
| 287 | + | add("metric", text("The metric's name, such as http.server.request.duration.")); | |
| 288 | + | required.push("metric"); | |
| 289 | + | period(&mut add); | |
| 290 | + | add("aggregate", json!({ "type": "string", "enum": AGGREGATES, "description": "How each step's values are reduced: avg for gauges and sum for sums unless you say; a percentile for histograms." })); | |
| 291 | + | add("group_by", text("An attribute (or service) to split it into a series per value.")); | |
| 292 | + | add("step", integer("Seconds per point; chosen from the period unless you say.")); | |
| 293 | + | place(&mut add); | |
| 294 | + | } | |
| 295 | + | TelemetryOp::UpdateSettings => { | |
| 296 | + | add("retention_days", json!({ "type": "integer", "enum": RETENTION_DAYS, "description": "Days requests, logs and spans are kept." })); | |
| 297 | + | add("deployments", json!({ "type": "boolean", "description": "Whether the project's apps on g1t.page send telemetry." })); | |
| 298 | + | } | |
| 299 | + | TelemetryOp::CreateToken => { | |
| 300 | + | add("name", text("What it is for, such as the machine or collector that sends with it.")); | |
| 301 | + | required.push("name"); | |
| 302 | + | } | |
| 303 | + | TelemetryOp::DeleteToken => { | |
| 304 | + | add("id", text("The token's id.")); | |
| 305 | + | required.push("id"); | |
| 306 | + | } | |
| 307 | + | TelemetryOp::CreateExport | TelemetryOp::UpdateExport => { | |
| 308 | + | if self == TelemetryOp::UpdateExport { | |
| 309 | + | add("id", text("The export's id.")); | |
| 310 | + | required.push("id"); | |
| 311 | + | } else { | |
| 312 | + | required.extend(["name", "endpoint"]); | |
| 313 | + | } | |
| 314 | + | add("name", text("Its name, for people.")); | |
| 315 | + | add("endpoint", text("The OTLP/HTTP base address, such as https://otlp.example.com; /v1/traces and the rest are added.")); | |
| 316 | + | add("signals", json!({ "type": "array", "items": { "type": "string", "enum": SIGNALS }, "description": "What it sends: traces, logs and metrics. All three unless you say." })); | |
| 317 | + | add("headers", json!({ "type": "object", "additionalProperties": { "type": "string" }, "description": "Headers sent with each request, such as an API key. Written, never read back; \"\" removes one." })); | |
| 318 | + | add("enabled", json!({ "type": "boolean", "description": "Whether it sends. True unless you say." })); | |
| 319 | + | } | |
| 320 | + | TelemetryOp::DeleteExport => { | |
| 321 | + | add("id", text("The export's id.")); | |
| 322 | + | required.push("id"); | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | /// A non-empty string field, trimmed. | |
| 330 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 331 | + | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned) | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | /// A number given as a number or as text. | |
| 335 | + | fn number(input: &Value, key: &str) -> std::result::Result<Option<u64>, String> { | |
| 336 | + | match &input[key] { | |
| 337 | + | Value::Null => Ok(None), | |
| 338 | + | Value::Number(number) => number.as_u64().map(Some).ok_or_else(|| format!("{key} must be a whole number.")), | |
| 339 | + | Value::String(digits) if digits.trim().is_empty() => Ok(None), | |
| 340 | + | Value::String(digits) => digits.trim().parse().map(Some).map_err(|_| format!("{key} must be a whole number.")), | |
| 341 | + | _ => Err(format!("{key} must be a whole number.")), | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | /// A flag given as a boolean or as text. | |
| 346 | + | fn flag(input: &Value, key: &str) -> std::result::Result<Option<bool>, String> { | |
| 347 | + | match &input[key] { | |
| 348 | + | Value::Null => Ok(None), | |
| 349 | + | Value::Bool(value) => Ok(Some(*value)), | |
| 350 | + | Value::String(text) => match text.trim() { | |
| 351 | + | "" => Ok(None), | |
| 352 | + | "true" | "1" => Ok(Some(true)), | |
| 353 | + | "false" | "0" => Ok(Some(false)), | |
| 354 | + | _ => Err(format!("{key} is true or false.")), | |
| 355 | + | }, | |
| 356 | + | _ => Err(format!("{key} is true or false.")), | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | /// A list given as an array or as comma-separated text. | |
| 361 | + | fn list(input: &Value, key: &str) -> Vec<String> { | |
| 362 | + | match &input[key] { | |
| 363 | + | Value::Array(items) => items.iter().filter_map(Value::as_str).map(str::trim).filter(|item| !item.is_empty()).map(str::to_owned).collect(), | |
| 364 | + | Value::String(text) => text.split(',').map(str::trim).filter(|item| !item.is_empty()).map(str::to_owned).collect(), | |
| 365 | + | _ => Vec::new(), | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | /// A time as microseconds since the epoch, from Unix milliseconds or an | |
| 370 | + | /// RFC 3339 time, in UTC (`Z`) or with an offset (`+02:00`). | |
| 371 | + | pub(crate) fn micros(given: &Value) -> Option<u64> { | |
| 372 | + | let text = match given { | |
| 373 | + | Value::Number(number) => return number.as_u64().map(|ms| ms * 1000), | |
| 374 | + | Value::String(text) => text.trim(), | |
| 375 | + | _ => return None, | |
| 376 | + | }; | |
| 377 | + | if !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()) { | |
| 378 | + | return text.parse::<u64>().ok().map(|ms| ms * 1000); | |
| 379 | + | } | |
| 380 | + | if let Some(ms) = g1t_contracts::time::parse_rfc3339(text) { | |
| 381 | + | return Some(ms * 1000); | |
| 382 | + | } | |
| 383 | + | // An offset: the time there, less the offset, is UTC. | |
| 384 | + | let at = text.len().checked_sub(6)?; | |
| 385 | + | let (local, offset) = text.split_at(at); | |
| 386 | + | let sign: i64 = match offset.as_bytes().first() { | |
| 387 | + | Some(b'+') => 1, | |
| 388 | + | Some(b'-') => -1, | |
| 389 | + | _ => return None, | |
| 390 | + | }; | |
| 391 | + | let (hours, minutes) = offset[1..].split_once(':')?; | |
| 392 | + | let shift = sign * (hours.parse::<i64>().ok()? * 60 + minutes.parse::<i64>().ok()?) * 60_000; | |
| 393 | + | let ms = g1t_contracts::time::parse_rfc3339(&format!("{local}Z"))? as i64 - shift; | |
| 394 | + | u64::try_from(ms).ok().map(|ms| ms * 1000) | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | /// The period and the place every list narrows by, in the service's names. | |
| 398 | + | fn filter(input: &Value, out: &mut Map<String, Value>) -> std::result::Result<(), String> { | |
| 399 | + | for (key, field) in [("from", "from_us"), ("to", "to_us")] { | |
| 400 | + | let given = &input[key]; | |
| 401 | + | if given.is_null() || given.as_str().is_some_and(|text| text.trim().is_empty()) { | |
| 402 | + | continue; | |
| 403 | + | } | |
| 404 | + | let at = micros(given).ok_or_else(|| format!("{key} must be an RFC 3339 time or Unix milliseconds."))?; | |
| 405 | + | out.insert(field.into(), at.into()); | |
| 406 | + | } | |
| 407 | + | for (key, field) in [("environment", "environments"), ("service", "services")] { | |
| 408 | + | let values = list(input, key); | |
| 409 | + | if !values.is_empty() { | |
| 410 | + | out.insert(field.into(), json!(values)); | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | Ok(()) | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | /// The arguments the telemetry service takes for `op`, from the | |
| 417 | + | /// operation's input, without the project; `Err` says what is missing or | |
| 418 | + | /// wrong. `actor` is who makes a token. | |
| 419 | + | pub(crate) fn args(op: TelemetryOp, input: &Value, actor: Option<&str>) -> std::result::Result<(&'static str, Map<String, Value>), String> { | |
| 420 | + | let mut out = Map::new(); | |
| 421 | + | let copy = |out: &mut Map<String, Value>, keys: &[(&str, &str)]| { | |
| 422 | + | for (key, field) in keys { | |
| 423 | + | if let Some(value) = text(input, key) { | |
| 424 | + | out.insert((*field).to_owned(), value.into()); | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | }; | |
| 428 | + | let page = |out: &mut Map<String, Value>| -> std::result::Result<(), String> { | |
| 429 | + | copy(out, &[("cursor", "cursor")]); | |
| 430 | + | if let Some(limit) = number(input, "limit")? { | |
| 431 | + | out.insert("limit".into(), limit.into()); | |
| 432 | + | } | |
| 433 | + | Ok(()) | |
| 434 | + | }; | |
| 435 | + | let required = |key: &str, what: &str| text(input, key).ok_or_else(|| format!("Give {what}.")); | |
| 436 | + | let method = match op { | |
| 437 | + | TelemetryOp::GetOverview => { | |
| 438 | + | filter(input, &mut out)?; | |
| 439 | + | copy(&mut out, &[("deployment_id", "deployment_id")]); | |
| 440 | + | if let Some(step) = number(input, "step")? { | |
| 441 | + | out.insert("step_s".into(), step.into()); | |
| 442 | + | } | |
| 443 | + | "overview" | |
| 444 | + | } | |
| 445 | + | TelemetryOp::ListRequests => { | |
| 446 | + | filter(input, &mut out)?; | |
| 447 | + | copy(&mut out, &[("q", "text"), ("route", "route"), ("deployment_id", "deployment_id")]); | |
| 448 | + | let status = list(input, "status"); | |
| 449 | + | if !status.is_empty() { | |
| 450 | + | out.insert("status".into(), json!(status)); | |
| 451 | + | } | |
| 452 | + | if let Some(errors) = flag(input, "errors_only")? { | |
| 453 | + | out.insert("errors_only".into(), errors.into()); | |
| 454 | + | } | |
| 455 | + | if let Some(ms) = number(input, "min_duration_ms")? { | |
| 456 | + | out.insert("min_duration_ms".into(), ms.into()); | |
| 457 | + | } | |
| 458 | + | page(&mut out)?; | |
| 459 | + | "requests" | |
| 460 | + | } | |
| 461 | + | TelemetryOp::GetRequest => { | |
| 462 | + | out.insert("id".into(), required("id", "the request's id")?.into()); | |
| 463 | + | "request" | |
| 464 | + | } | |
| 465 | + | TelemetryOp::ListLogs => { | |
| 466 | + | filter(input, &mut out)?; | |
| 467 | + | copy(&mut out, &[("q", "text"), ("trace_id", "trace_id"), ("request_id", "request_id"), ("deployment_id", "deployment_id")]); | |
| 468 | + | let levels: Vec<String> = list(input, "level").into_iter().map(|level| level.to_lowercase()).collect(); | |
| 469 | + | if let Some(unknown) = levels.iter().find(|level| !SEVERITIES.contains(&level.as_str())) { | |
| 470 | + | return Err(format!("{unknown} is not a level: use trace, debug, info, warn, error or fatal.")); | |
| 471 | + | } | |
| 472 | + | if !levels.is_empty() { | |
| 473 | + | out.insert("severities".into(), json!(levels)); | |
| 474 | + | } | |
| 475 | + | page(&mut out)?; | |
| 476 | + | "logs" | |
| 477 | + | } | |
| 478 | + | TelemetryOp::ListTraces => { | |
| 479 | + | filter(input, &mut out)?; | |
| 480 | + | copy(&mut out, &[("q", "text")]); | |
| 481 | + | if let Some(errors) = flag(input, "errors_only")? { | |
| 482 | + | out.insert("errors_only".into(), errors.into()); | |
| 483 | + | } | |
| 484 | + | if let Some(ms) = number(input, "min_duration_ms")? { | |
| 485 | + | out.insert("min_duration_ms".into(), ms.into()); | |
| 486 | + | } | |
| 487 | + | page(&mut out)?; | |
| 488 | + | "traces" | |
| 489 | + | } | |
| 490 | + | TelemetryOp::GetTrace => { | |
| 491 | + | out.insert("trace_id".into(), required("trace_id", "the trace's id")?.to_lowercase().into()); | |
| 492 | + | "trace" | |
| 493 | + | } | |
| 494 | + | TelemetryOp::ListErrors => { | |
| 495 | + | filter(input, &mut out)?; | |
| 496 | + | if let Some(state) = text(input, "state") { | |
| 497 | + | if !ERROR_STATES.contains(&state.as_str()) { | |
| 498 | + | return Err(format!("{state} is not a state: use open, resolved or ignored.")); | |
| 499 | + | } | |
| 500 | + | out.insert("state".into(), state.into()); | |
| 501 | + | } | |
| 502 | + | "errors" | |
| 503 | + | } | |
| 504 | + | TelemetryOp::UpdateError => { | |
| 505 | + | out.insert("fingerprint".into(), required("fingerprint", "the error group's fingerprint")?.into()); | |
| 506 | + | if let Some(state) = text(input, "state") { | |
| 507 | + | if !ERROR_STATES.contains(&state.as_str()) { | |
| 508 | + | return Err(format!("{state} is not a state: use open, resolved or ignored.")); | |
| 509 | + | } | |
| 510 | + | out.insert("state".into(), state.into()); | |
| 511 | + | } | |
| 512 | + | match input.get("issue_number") { | |
| 513 | + | None => {} | |
| 514 | + | Some(Value::Null) => { | |
| 515 | + | out.insert("issue_number".into(), Value::Null); | |
| 516 | + | } | |
| 517 | + | Some(_) => { | |
| 518 | + | let issue = number(input, "issue_number")?.ok_or("issue_number must be a whole number, or null.")?; | |
| 519 | + | out.insert("issue_number".into(), issue.into()); | |
| 520 | + | } | |
| 521 | + | } | |
| 522 | + | if !out.contains_key("state") && !out.contains_key("issue_number") { | |
| 523 | + | return Err("Give a state, or an issue_number.".to_owned()); | |
| 524 | + | } | |
| 525 | + | "set_error" | |
| 526 | + | } | |
| 527 | + | TelemetryOp::ListMetrics => "metrics", | |
| 528 | + | TelemetryOp::GetMetric => { | |
| 529 | + | out.insert("name".into(), required("metric", "the metric's name")?.into()); | |
| 530 | + | filter(input, &mut out)?; | |
| 531 | + | if let Some(aggregate) = text(input, "aggregate") { | |
| 532 | + | if !AGGREGATES.contains(&aggregate.as_str()) { | |
| 533 | + | return Err(format!("{aggregate} is not an aggregate: use avg, sum, min, max, p50, p95 or p99.")); | |
| 534 | + | } | |
| 535 | + | out.insert("aggregate".into(), aggregate.into()); | |
| 536 | + | } | |
| 537 | + | copy(&mut out, &[("group_by", "group_by")]); | |
| 538 | + | if let Some(step) = number(input, "step")? { | |
| 539 | + | out.insert("step_s".into(), step.into()); | |
| 540 | + | } | |
| 541 | + | "metric" | |
| 542 | + | } | |
| 543 | + | TelemetryOp::GetSettings => "settings", | |
| 544 | + | TelemetryOp::UpdateSettings => { | |
| 545 | + | if let Some(days) = number(input, "retention_days")? { | |
| 546 | + | if !RETENTION_DAYS.contains(&days) { | |
| 547 | + | return Err("retention_days is 1, 3, 7, 14 or 30.".to_owned()); | |
| 548 | + | } | |
| 549 | + | out.insert("retention_days".into(), days.into()); | |
| 550 | + | } | |
| 551 | + | if let Some(on) = flag(input, "deployments")? { | |
| 552 | + | out.insert("deployments".into(), on.into()); | |
| 553 | + | } | |
| 554 | + | "set_settings" | |
| 555 | + | } | |
| 556 | + | TelemetryOp::ListTokens => "tokens", | |
| 557 | + | TelemetryOp::CreateToken => { | |
| 558 | + | out.insert("name".into(), required("name", "the token a name")?.into()); | |
| 559 | + | out.insert("created_by".into(), actor.map_or(Value::Null, Value::from)); | |
| 560 | + | "create_token" | |
| 561 | + | } | |
| 562 | + | TelemetryOp::DeleteToken => { | |
| 563 | + | out.insert("id".into(), required("id", "the token's id")?.into()); | |
| 564 | + | "revoke_token" | |
| 565 | + | } | |
| 566 | + | TelemetryOp::ListExports => "exports", | |
| 567 | + | TelemetryOp::CreateExport | TelemetryOp::UpdateExport => { | |
| 568 | + | if op == TelemetryOp::UpdateExport { | |
| 569 | + | out.insert("id".into(), required("id", "the export's id")?.into()); | |
| 570 | + | } else { | |
| 571 | + | required("name", "the export a name")?; | |
| 572 | + | required("endpoint", "the endpoint to send to")?; | |
| 573 | + | } | |
| 574 | + | copy(&mut out, &[("name", "name"), ("endpoint", "endpoint")]); | |
| 575 | + | if !input["signals"].is_null() { | |
| 576 | + | let signals = list(input, "signals"); | |
| 577 | + | if let Some(unknown) = signals.iter().find(|signal| !SIGNALS.contains(&signal.as_str())) { | |
| 578 | + | return Err(format!("{unknown} is not a signal: use traces, logs or metrics.")); | |
| 579 | + | } | |
| 580 | + | out.insert("signals".into(), json!(signals)); | |
| 581 | + | } | |
| 582 | + | match &input["headers"] { | |
| 583 | + | Value::Null => {} | |
| 584 | + | Value::Object(headers) => { | |
| 585 | + | let mut sent = Map::new(); | |
| 586 | + | for (name, value) in headers { | |
| 587 | + | let value = value.as_str().ok_or_else(|| format!("The header {name} must be text."))?; | |
| 588 | + | sent.insert(name.trim().to_owned(), value.into()); | |
| 589 | + | } | |
| 590 | + | out.insert("headers".into(), Value::Object(sent)); | |
| 591 | + | } | |
| 592 | + | _ => return Err("headers must be an object of names and values.".to_owned()), | |
| 593 | + | } | |
| 594 | + | if let Some(enabled) = flag(input, "enabled")? { | |
| 595 | + | out.insert("enabled".into(), enabled.into()); | |
| 596 | + | } | |
| 597 | + | "put_export" | |
| 598 | + | } | |
| 599 | + | TelemetryOp::DeleteExport => { | |
| 600 | + | out.insert("id".into(), required("id", "the export's id")?.into()); | |
| 601 | + | "delete_export" | |
| 602 | + | } | |
| 603 | + | TelemetryOp::GetUsage => "usage", | |
| 604 | + | }; | |
| 605 | + | Ok((method, out)) | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | /// Calls the telemetry service: its answer, or its refusal of a bad | |
| 609 | + | /// argument (`400`, in words) as invalid. | |
| 610 | + | async fn ask(services: &Services, method: &str, args: &Value) -> Result<Outcome<Value>> { | |
| 611 | + | let headers = Headers::new(); | |
| 612 | + | headers.set("content-type", "application/json")?; | |
| 613 | + | let mut init = RequestInit::new(); | |
| 614 | + | init.with_method(Method::Post) | |
| 615 | + | .with_headers(headers) | |
| 616 | + | .with_body(Some(serde_json::to_string(args)?.into())); | |
| 617 | + | let request = Request::new_with_init(&format!("https://service/rpc/{method}"), &init)?; | |
| 618 | + | let mut response = services.telemetry.fetch_request(request).await?; | |
| 619 | + | let text = response.text().await.unwrap_or_default(); | |
| 620 | + | match response.status_code() { | |
| 621 | + | 200 => Ok(Outcome::Ok(g1t_kit::read_answer(method, &text)?)), | |
| 622 | + | 400 => Ok(Outcome::fail(FailureCode::Invalid, text.trim())), | |
| 623 | + | status => Err(worker::Error::RustError(format!("telemetry {method} failed with status {status}: {text}"))), | |
| 624 | + | } | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | /// The repository a project's permission comes from, as the deployments | |
| 628 | + | /// service reads it: a mirrored project has none, and its workspace's base | |
| 629 | + | /// permission decides. | |
| 630 | + | fn repo_of(project: &Value) -> (String, String, bool) { | |
| 631 | + | let source = &project["source"]; | |
| 632 | + | let private = project["private"].as_bool().unwrap_or(true); | |
| 633 | + | if source["kind"] == "hosted" { | |
| 634 | + | ( | |
| 635 | + | source["repoId"].as_str().unwrap_or_default().to_owned(), | |
| 636 | + | source["repo"]["namespace"].as_str().unwrap_or_default().to_owned(), | |
| 637 | + | private, | |
| 638 | + | ) | |
| 639 | + | } else { | |
| 640 | + | (String::new(), project["workspace"].as_str().unwrap_or_default().to_owned(), private) | |
| 641 | + | } | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | /// Whether the viewer may do what `need` says on the project; otherwise | |
| 645 | + | /// the refusal. | |
| 646 | + | fn allowed(viewer: &Viewer, project: &Value, need: Need) -> std::result::Result<(), Outcome<Value>> { | |
| 647 | + | let (id, namespace, private) = repo_of(project); | |
| 648 | + | let repo = RepoRef { id: &id, namespace: &namespace, private }; | |
| 649 | + | let user = viewer.as_ref(); | |
| 650 | + | if !user.is_some_and(|user| access::granted(user, repo).is_some()) { | |
| 651 | + | return Err(Outcome::fail(FailureCode::NotFound, "There is no such project.")); | |
| 652 | + | } | |
| 653 | + | if let Need::Can(capability) = need | |
| 654 | + | && !access::can(user, repo, capability) | |
| 655 | + | { | |
| 656 | + | let name = format!("{}/{}", project["workspace"].as_str().unwrap_or_default(), project["slug"].as_str().unwrap_or_default()); | |
| 657 | + | return Err(Outcome::fail(FailureCode::Forbidden, access::needs(capability, &name))); | |
| 658 | + | } | |
| 659 | + | Ok(()) | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | pub async fn run(op: TelemetryOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 663 | + | let slug = |key: &str| text(input, key).map(|value| value.to_lowercase()); | |
| 664 | + | let (Some(workspace), Some(slug)) = (slug("workspace"), slug("project")) else { | |
| 665 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the workspace's slug and the project's.")); | |
| 666 | + | }; | |
| 667 | + | let Some(user) = viewer else { | |
| 668 | + | let what = if op.writes() { "Changing" } else { "Reading" }; | |
| 669 | + | return Ok(Outcome::fail(FailureCode::Unauthenticated, format!("{what} a project's observability needs a g1t access token."))); | |
| 670 | + | }; | |
| 671 | + | let (method, mut args) = match args(op, input, Some(&user.username)) { | |
| 672 | + | Ok(found) => found, | |
| 673 | + | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 674 | + | }; | |
| 675 | + | let found: Outcome<Value> = | |
| 676 | + | g1t_kit::call(&services.projects, "get", &json!({ "workspace": workspace, "slug": slug, "viewer": viewer })).await?; | |
| 677 | + | let project = match found { | |
| 678 | + | Outcome::Ok(project) => project, | |
| 679 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 680 | + | }; | |
| 681 | + | if let Err(refused) = allowed(viewer, &project, op.need()) { | |
| 682 | + | return Ok(refused); | |
| 683 | + | } | |
| 684 | + | let Some(project_id) = project["id"].as_str() else { | |
| 685 | + | return Ok(Outcome::fail(FailureCode::NotFound, "There is no such project.")); | |
| 686 | + | }; | |
| 687 | + | args.insert("project_id".into(), project_id.into()); | |
| 688 | + | ||
| 689 | + | // A change to an export names only what changes: the rest is as it is. | |
| 690 | + | if op == TelemetryOp::UpdateExport { | |
| 691 | + | let listed = ask(services, "exports", &json!({ "project_id": project_id })).await?; | |
| 692 | + | let Outcome::Ok(Value::Array(exports)) = listed else { | |
| 693 | + | return Ok(listed); | |
| 694 | + | }; | |
| 695 | + | let Some(current) = exports.iter().find(|export| export["id"] == args["id"]) else { | |
| 696 | + | return Ok(Outcome::fail(FailureCode::NotFound, "The project has no export with that id.")); | |
| 697 | + | }; | |
| 698 | + | for key in ["name", "endpoint", "signals", "enabled"] { | |
| 699 | + | if !args.contains_key(key) { | |
| 700 | + | args.insert(key.into(), current[key].clone()); | |
| 701 | + | } | |
| 702 | + | } | |
| 703 | + | } | |
| 704 | + | if op == TelemetryOp::CreateExport { | |
| 705 | + | args.entry("signals").or_insert_with(|| json!(SIGNALS)); | |
| 706 | + | args.entry("enabled").or_insert(Value::Bool(true)); | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | let answered = ask(services, method, &Value::Object(args)).await?; | |
| 710 | + | // Nothing found answers null, and deleting nothing false. | |
| 711 | + | let missing = match op { | |
| 712 | + | TelemetryOp::GetRequest => Some("The project has no request with that id."), | |
| 713 | + | TelemetryOp::GetTrace => Some("The project has no trace with that id."), | |
| 714 | + | TelemetryOp::UpdateError => Some("The project has no error with that fingerprint."), | |
| 715 | + | TelemetryOp::DeleteToken => Some("The project has no ingest token with that id that people can delete."), | |
| 716 | + | TelemetryOp::DeleteExport => Some("The project has no export with that id."), | |
| 717 | + | _ => None, | |
| 718 | + | }; | |
| 719 | + | Ok(match (answered, missing) { | |
| 720 | + | (Outcome::Ok(Value::Null | Value::Bool(false)), Some(message)) => Outcome::fail(FailureCode::NotFound, message), | |
| 721 | + | (answered, _) => answered, | |
| 722 | + | }) | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | #[cfg(test)] | |
| 726 | + | mod tests { | |
| 727 | + | use super::*; | |
| 728 | + | ||
| 729 | + | #[test] | |
| 730 | + | fn a_query_becomes_the_services_arguments() { | |
| 731 | + | let (method, args) = super::args( | |
| 732 | + | TelemetryOp::ListLogs, | |
| 733 | + | &json!({ "workspace": "acme", "project": "web", "q": "timeout", "level": "Error,warn", "environment": "production", "from": "1790918179000", "limit": "50", "ignored": 1 }), | |
| 734 | + | None, | |
| 735 | + | ) | |
| 736 | + | .unwrap(); | |
| 737 | + | assert_eq!(method, "logs"); | |
| 738 | + | assert_eq!(args["text"], json!("timeout")); | |
| 739 | + | assert_eq!(args["severities"], json!(["error", "warn"])); | |
| 740 | + | assert_eq!(args["environments"], json!(["production"])); | |
| 741 | + | assert_eq!(args["from_us"], json!(1_790_918_179_000_000u64)); | |
| 742 | + | assert_eq!(args["limit"], json!(50)); | |
| 743 | + | assert!(!args.contains_key("ignored") && !args.contains_key("workspace")); | |
| 744 | + | assert!(super::args(TelemetryOp::ListLogs, &json!({ "level": "loud" }), None).is_err()); | |
| 745 | + | assert!(super::args(TelemetryOp::ListLogs, &json!({ "from": "yesterday" }), None).is_err()); | |
| 746 | + | ||
| 747 | + | let (method, args) = super::args( | |
| 748 | + | TelemetryOp::ListRequests, | |
| 749 | + | &json!({ "status": "5xx,404", "errors_only": "true", "min_duration_ms": 250, "service": "api" }), | |
| 750 | + | None, | |
| 751 | + | ) | |
| 752 | + | .unwrap(); | |
| 753 | + | assert_eq!(method, "requests"); | |
| 754 | + | assert_eq!((args["status"].clone(), args["errors_only"].clone(), args["min_duration_ms"].clone()), (json!(["5xx", "404"]), json!(true), json!(250))); | |
| 755 | + | assert_eq!(args["services"], json!(["api"])); | |
| 756 | + | ||
| 757 | + | let (method, args) = super::args(TelemetryOp::GetMetric, &json!({ "metric": "http.server.request.duration", "aggregate": "p95", "step": "60" }), None).unwrap(); | |
| 758 | + | assert_eq!((method, args["name"].clone(), args["step_s"].clone()), ("metric", json!("http.server.request.duration"), json!(60))); | |
| 759 | + | assert!(super::args(TelemetryOp::GetMetric, &json!({ "metric": "x", "aggregate": "median" }), None).is_err()); | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | #[test] | |
| 763 | + | fn changes_say_only_what_they_change() { | |
| 764 | + | let (method, args) = super::args(TelemetryOp::UpdateError, &json!({ "fingerprint": "f1", "issue_number": null }), None).unwrap(); | |
| 765 | + | assert_eq!(method, "set_error"); | |
| 766 | + | assert_eq!(args.get("issue_number"), Some(&Value::Null), "null unlinks the issue"); | |
| 767 | + | assert!(!args.contains_key("state")); | |
| 768 | + | assert!(super::args(TelemetryOp::UpdateError, &json!({ "fingerprint": "f1" }), None).is_err()); | |
| 769 | + | assert!(super::args(TelemetryOp::UpdateError, &json!({ "fingerprint": "f1", "state": "fixed" }), None).is_err()); | |
| 770 | + | assert!(super::args(TelemetryOp::UpdateSettings, &json!({ "retention_days": 5 }), None).is_err()); | |
| 771 | + | let (method, args) = super::args(TelemetryOp::CreateToken, &json!({ "name": "laptop" }), Some("ada")).unwrap(); | |
| 772 | + | assert_eq!((method, args["created_by"].clone()), ("create_token", json!("ada"))); | |
| 773 | + | let (method, args) = super::args(TelemetryOp::UpdateExport, &json!({ "id": "e1", "headers": { "x-api-key": "" } }), None).unwrap(); | |
| 774 | + | assert_eq!(method, "put_export"); | |
| 775 | + | assert_eq!(args["headers"], json!({ "x-api-key": "" })); | |
| 776 | + | assert!(!args.contains_key("signals")); | |
| 777 | + | assert!(super::args(TelemetryOp::CreateExport, &json!({ "name": "vendor" }), None).is_err()); | |
| 778 | + | assert!(super::args(TelemetryOp::CreateExport, &json!({ "name": "v", "endpoint": "https://x", "signals": ["spans"] }), None).is_err()); | |
| 779 | + | } | |
| 780 | + | ||
| 781 | + | #[test] | |
| 782 | + | fn times_are_milliseconds_or_rfc_3339() { | |
| 783 | + | assert_eq!(micros(&json!(1_790_918_179_000u64)), Some(1_790_918_179_000_000)); | |
| 784 | + | assert_eq!(micros(&json!("2026-10-02T05:16:19Z")), Some(1_790_918_179_000_000)); | |
| 785 | + | assert_eq!(micros(&json!("2026-10-02T07:16:19+02:00")), Some(1_790_918_179_000_000)); | |
| 786 | + | assert_eq!(micros(&json!("2026-10-02T00:16:19.500-05:00")), Some(1_790_918_179_500_000)); | |
| 787 | + | assert_eq!(micros(&json!("soon")), None); | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | #[test] | |
| 791 | + | fn reading_takes_a_role_of_ones_own() { | |
| 792 | + | use g1t_contracts::User; | |
| 793 | + | let project = json!({ | |
| 794 | + | "id": "prj_1", "workspace": "acme", "slug": "web", "private": false, | |
| 795 | + | "source": { "kind": "hosted", "repoId": "repo_1", "repo": { "namespace": "acme", "name": "web" } }, | |
| 796 | + | }); | |
| 797 | + | let person = |username: &str, membership: Value| -> Viewer { | |
| 798 | + | let mut user = User { username: username.into(), ..User::default() }; | |
| 799 | + | if !membership.is_null() { | |
| 800 | + | user.workspaces = vec![serde_json::from_value(membership).unwrap()]; | |
| 801 | + | } | |
| 802 | + | Some(user) | |
| 803 | + | }; | |
| 804 | + | let refused = |viewer: &Viewer, need: Need| match allowed(viewer, &project, need) { | |
| 805 | + | Err(Outcome::Fail(failure)) => Some(failure.code), | |
| 806 | + | _ => None, | |
| 807 | + | }; | |
| 808 | + | // A public project's telemetry is not public. | |
| 809 | + | assert_eq!(refused(&person("eve", Value::Null), Need::Insider), Some(FailureCode::NotFound)); | |
| 810 | + | assert_eq!(refused(&None, Need::Insider), Some(FailureCode::NotFound)); | |
| 811 | + | let reader = person("ada", json!({ "slug": "acme", "role": "member", "base_permission": "read" })); | |
| 812 | + | assert_eq!(refused(&reader, Need::Insider), None); | |
| 813 | + | assert_eq!(refused(&reader, TelemetryOp::UpdateError.need()), Some(FailureCode::Forbidden)); | |
| 814 | + | let writer = person("bo", json!({ "slug": "acme", "role": "member" })); | |
| 815 | + | assert_eq!(refused(&writer, TelemetryOp::UpdateError.need()), None); | |
| 816 | + | assert_eq!(refused(&writer, TelemetryOp::ListTokens.need()), Some(FailureCode::Forbidden)); | |
| 817 | + | let owner = person("ana", json!({ "slug": "acme", "role": "owner" })); | |
| 818 | + | for op in TelemetryOp::ALL { | |
| 819 | + | assert_eq!(refused(&owner, op.need()), None, "{}", op.name()); | |
| 820 | + | } | |
| 821 | + | assert_eq!(TelemetryOp::GetSettings.need(), Need::Can(Capability::ManageIntegrations)); | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | #[test] | |
| 825 | + | fn each_operation_is_described_with_a_schema() { | |
| 826 | + | for op in TelemetryOp::ALL { | |
| 827 | + | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Telemetry(op)), "{}", op.name()); | |
| 828 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 829 | + | let required = op.input()["required"].as_array().unwrap().clone(); | |
| 830 | + | assert!(required.contains(&json!("workspace")) && required.contains(&json!("project")), "{}", op.name()); | |
| 831 | + | for field in required { | |
| 832 | + | assert!(op.input()["properties"].get(field.as_str().unwrap()).is_some(), "{}: {field}", op.name()); | |
| 833 | + | } | |
| 834 | + | } | |
| 835 | + | } | |
| 836 | + | } |
| 25 | 25 | use crate::deploy_keys::DeployKeysOp; | |
| 26 | 26 | use crate::mirrors::MirrorsOp; | |
| 27 | 27 | use crate::deployments::DeploymentsOp; | |
| 28 | + | use crate::telemetry::TelemetryOp; | |
| 28 | 29 | use crate::packages::PackagesOp; | |
| 29 | 30 | use crate::folios::FoliosOp; | |
| 30 | 31 | use crate::protection::ProtectionOp; | |
| ⋯ | |||
| 300 | 301 | ], | |
| 301 | 302 | }, | |
| 302 | 303 | Tool { | |
| 304 | + | name: "observability", | |
| 305 | + | title: "Observability", | |
| 306 | + | description: "What a project's running software did in production, to debug it: its requests, logs, traces, grouped errors and metrics, from its apps on g1t.page and from OpenTelemetry sent from anywhere. Name the project by workspace and project slug. Start with overview (error rate, latency, the routes and the deployment a regression began with) or errors, then follow a request or trace_id to its logs and spans. Times are RFC 3339 or Unix milliseconds; the last 24 hours unless you say. Needs a role of your own on the project's repository; resolving errors, Triage.", | |
| 307 | + | default_action: None, | |
| 308 | + | actions: &[ | |
| 309 | + | a("overview", Op::Telemetry(TelemetryOp::GetOverview), "Requests, errors, latency and CPU over a period, by route, deployment, service and region, against the period before"), | |
| 310 | + | a("requests", Op::Telemetry(TelemetryOp::ListRequests), "Requests answered, newest first: q, status (5xx, 404), errors_only, route, min_duration_ms"), | |
| 311 | + | a("request", Op::Telemetry(TelemetryOp::GetRequest), "One request with every log line it wrote"), | |
| 312 | + | a("logs", Op::Telemetry(TelemetryOp::ListLogs), "Search a project's logs: words or a phrase in q, level (error,warn), trace_id, request_id"), | |
| 313 | + | a("traces", Op::Telemetry(TelemetryOp::ListTraces), "Traces, newest first: q, errors_only, min_duration_ms"), | |
| 314 | + | a("trace", Op::Telemetry(TelemetryOp::GetTrace), "One trace: every span, with its timing, status and attributes, and its logs"), | |
| 315 | + | a("errors", Op::Telemetry(TelemetryOp::ListErrors), "Errors grouped by exception, most recent first, with the deployment that brought each in"), | |
| 316 | + | a("update_error", Op::Telemetry(TelemetryOp::UpdateError), "Resolve, ignore or reopen an error group, or link the issue made for it"), | |
| 317 | + | a("metrics", Op::Telemetry(TelemetryOp::ListMetrics), "The metrics the project's software sends"), | |
| 318 | + | a("metric", Op::Telemetry(TelemetryOp::GetMetric), "One metric over time: aggregate (avg, sum, p95), group_by an attribute"), | |
| 319 | + | ], | |
| 320 | + | }, | |
| 321 | + | Tool { | |
| 303 | 322 | name: "package", | |
| 304 | 323 | title: "Packages", | |
| 305 | 324 | description: "A workspace's packages in every registry (container images, npm, Cargo, Maven, NuGet, RubyGems, Composer): their versions and downloads, deleting and restoring them within 30 days, their visibility and repository, who has a role on them, and which repositories' workflows may use them (Manage Actions access). Name one by workspace, package_type and package_name.", | |
| ⋯ | |||
| 606 | 625 | }, | |
| 607 | 626 | ]; | |
| 608 | 627 | ||
| 628 | + | /// Operations offered over REST alone, not as an action of any tool: how | |
| 629 | + | /// a project's telemetry is collected (settings, ingest tokens, exports, | |
| 630 | + | /// usage) is for people setting it up, not for agents debugging. | |
| 631 | + | pub const REST_ONLY: &[Op] = &[ | |
| 632 | + | Op::Telemetry(TelemetryOp::GetSettings), | |
| 633 | + | Op::Telemetry(TelemetryOp::UpdateSettings), | |
| 634 | + | Op::Telemetry(TelemetryOp::ListTokens), | |
| 635 | + | Op::Telemetry(TelemetryOp::CreateToken), | |
| 636 | + | Op::Telemetry(TelemetryOp::DeleteToken), | |
| 637 | + | Op::Telemetry(TelemetryOp::ListExports), | |
| 638 | + | Op::Telemetry(TelemetryOp::CreateExport), | |
| 639 | + | Op::Telemetry(TelemetryOp::UpdateExport), | |
| 640 | + | Op::Telemetry(TelemetryOp::DeleteExport), | |
| 641 | + | Op::Telemetry(TelemetryOp::GetUsage), | |
| 642 | + | ]; | |
| 643 | + | ||
| 609 | 644 | /// Operations that cannot be undone, or reach beyond g1t's own records: | |
| 610 | 645 | /// clients ask before running a tool that has any of them. | |
| 611 | 646 | fn destructive(op: Op) -> bool { | |
| ⋯ | |||
| 881 | 916 | .flat_map(|tool| tool.actions.iter()) | |
| 882 | 917 | .filter(|action| action.op == op) | |
| 883 | 918 | .count(); | |
| 884 | − | assert_eq!(count, 1, "{} is {count} actions", op.name()); | |
| 919 | + | let expected = usize::from(!REST_ONLY.contains(&op)); | |
| 920 | + | assert_eq!(count, expected, "{} is {count} actions", op.name()); | |
| 885 | 921 | } | |
| 886 | 922 | for tool in TOOLS { | |
| 887 | 923 | let mut names = std::collections::HashSet::new(); | |
| ⋯ | |||
| 892 | 928 | assert!(tool.action(default).is_some(), "{}", tool.name); | |
| 893 | 929 | } | |
| 894 | 930 | } | |
| 895 | − | assert!(TOOLS.len() <= 20, "{} tools", TOOLS.len()); | |
| 931 | + | assert!(TOOLS.len() <= 21, "{} tools", TOOLS.len()); | |
| 896 | 932 | } | |
| 897 | 933 | ||
| 898 | 934 | #[test] | |
| 25 | 25 | { "binding": "ACTIONS", "service": "g1t-actions" }, | |
| 26 | 26 | // Builds of deployments report through here. | |
| 27 | 27 | { "binding": "DEPLOYMENTS", "service": "g1t-deployments" }, | |
| 28 | + | // Observability: a project's requests, logs, traces, errors and | |
| 29 | + | // metrics, and OTLP sent to /otlp/v1/{traces,logs,metrics}. | |
| 30 | + | { "binding": "TELEMETRY", "service": "g1t-telemetry" }, | |
| 28 | 31 | // The context hub: search_context and get_entity. | |
| 29 | 32 | { "binding": "CONTEXT", "service": "g1t-context" }, | |
| 30 | 33 | { "binding": "SEARCH", "service": "g1t-search" }, |
| 111 | 111 | { label: 'Projects', slug: 'guides/projects' }, | |
| 112 | 112 | { label: 'Deployments', slug: 'guides/deployments' }, | |
| 113 | 113 | { label: 'Deployments API', slug: 'guides/deployments-api' }, | |
| 114 | + | { label: 'Observability', slug: 'guides/observability' }, | |
| 114 | 115 | { label: 'Packages', slug: 'guides/packages' }, | |
| 115 | 116 | { label: 'Container images', slug: 'guides/containers' }, | |
| 116 | 117 | { label: 'npm', slug: 'guides/npm' }, |
| 633 | 633 | | Idle days | 7 | 1 to 90. A preview no one visits this long comes down. | | |
| 634 | 634 | | Build with a workflow | Off | Build through the deploy workflow g1t manages for the project; see [build with a workflow](#build-with-a-workflow). | | |
| 635 | 635 | ||
| 636 | + | ## Logs, traces and errors | |
| 637 | + | ||
| 638 | + | Every request an app answers shows under the project's | |
| 639 | + | [Observability](/guides/observability/) within seconds: its status, | |
| 640 | + | duration, CPU time and the lines it wrote, with its exceptions grouped into | |
| 641 | + | errors and its numbers by deployment. Nothing to set up. Each request | |
| 642 | + | reaches the app with an `x-g1t-request-id` header, its id there, and a | |
| 643 | + | `traceparent`, and the app is deployed with OpenTelemetry's | |
| 644 | + | `OTEL_EXPORTER_OTLP_*` variables set, so an OpenTelemetry SDK in it sends | |
| 645 | + | its spans to the project without configuration. | |
| 646 | + | ||
| 636 | 647 | ## Secrets and variables | |
| 637 | 648 | ||
| 638 | 649 | Builds and running apps read the project's |