Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| 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. | 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 | } |