diff --git a/Cargo.lock b/Cargo.lock index 101209da..66c94653 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3147,12 +3147,15 @@ name = "ourios-server" version = "0.0.0" dependencies = [ "axum", + "opentelemetry", "opentelemetry-proto", + "opentelemetry_sdk", "ourios-core", "ourios-ingester", "ourios-miner", "ourios-parquet", "ourios-querier", + "ourios-semconv", "ourios-telemetry", "ourios-wal", "prost", diff --git a/crates/ourios-semconv/src/lib.rs b/crates/ourios-semconv/src/lib.rs index fa29761a..7a5caa23 100644 --- a/crates/ourios-semconv/src/lib.rs +++ b/crates/ourios-semconv/src/lib.rs @@ -89,6 +89,12 @@ pub const OURIOS_MINER_TEMPLATE_COUNT: &str = "ourios.miner.template.count"; /// `ourios.miner.template.version_changes` (counter, unit `{change}`). pub const OURIOS_MINER_TEMPLATE_VERSION_CHANGES: &str = "ourios.miner.template.version_changes"; +/// `ourios.query.duration` (histogram, unit `s`). +pub const OURIOS_QUERY_DURATION: &str = "ourios.query.duration"; + +/// `ourios.query.row_groups` (counter, unit `{row_group}`). +pub const OURIOS_QUERY_ROW_GROUPS: &str = "ourios.query.row_groups"; + /// `ourios.sink.buffer.usage` (updowncounter, unit `By`). pub const OURIOS_SINK_BUFFER_USAGE: &str = "ourios.sink.buffer.usage"; @@ -121,6 +127,12 @@ pub const OURIOS_IO_DIRECTION: &str = "ourios.io.direction"; /// `ourios.miner.template_change` attribute key. pub const OURIOS_MINER_TEMPLATE_CHANGE: &str = "ourios.miner.template_change"; +/// `ourios.query.kind` attribute key. +pub const OURIOS_QUERY_KIND: &str = "ourios.query.kind"; + +/// `ourios.query.row_group.state` attribute key. +pub const OURIOS_QUERY_ROW_GROUP_STATE: &str = "ourios.query.row_group.state"; + /// `ourios.service` attribute key. pub const OURIOS_SERVICE: &str = "ourios.service"; diff --git a/crates/ourios-server/Cargo.toml b/crates/ourios-server/Cargo.toml index 9063e6ba..fe2f868f 100644 --- a/crates/ourios-server/Cargo.toml +++ b/crates/ourios-server/Cargo.toml @@ -27,6 +27,11 @@ ourios-ingester = { path = "../ourios-ingester" } ourios-parquet = { path = "../ourios-parquet" } # OTel SDK + OTLP push MeterProvider bootstrap (RFC 0001 §6.8). ourios-telemetry = { path = "../ourios-telemetry" } +# OTel meter API for the querier role's metrics (RFC 0016 §3.6); the SDK +# lives in ourios-telemetry, library code resolves against the global meter. +opentelemetry = { version = "0.32", default-features = false, features = ["metrics"] } +# Generated semantic-convention name constants for the query metrics. +ourios-semconv = { path = "../ourios-semconv" } # The receiver role's durable WAL + miner + miner config (one pipeline # over a single `Wal`, RFC 0003 §6.5 / RFC 0008 §3.1). ourios-wal = { path = "../ourios-wal" } @@ -67,6 +72,10 @@ tokio = { version = "1", default-features = false, features = ["io-util", "time" # Drive the querier `Router` in-process (`ServiceExt::oneshot`) for the # RFC 0016 handler tests — no socket, no HTTP client (the ingester's pattern). tower = { version = "0.5", default-features = false, features = ["util"] } +# In-memory metric exporter for the RFC0016.6 query-metrics test: install a +# global meter provider and read the exported stream (the ingester pattern). +ourios-telemetry = { path = "../ourios-telemetry", features = ["testing"] } +opentelemetry_sdk = { version = "0.32", default-features = false, features = ["metrics", "testing"] } [lints] workspace = true diff --git a/crates/ourios-server/src/querier.rs b/crates/ourios-server/src/querier.rs index e664589d..0f67cf20 100644 --- a/crates/ourios-server/src/querier.rs +++ b/crates/ourios-server/src/querier.rs @@ -22,7 +22,7 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use axum::Router; use axum::body::Bytes; @@ -30,6 +30,8 @@ use axum::extract::{DefaultBodyLimit, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::post; +use opentelemetry::metrics::{Counter, Histogram}; +use opentelemetry::{KeyValue, global}; use serde::Serialize; use tokio::net::TcpListener; use tokio::sync::watch; @@ -41,6 +43,7 @@ use ourios_miner::reconstruct::Reconstruction; use ourios_querier::dsl::ir::Stage; use ourios_querier::dsl::{self, Statement}; use ourios_querier::{DriftResult, LogBody, LogRow, Querier, QueryResult, QueryStats}; +use ourios_semconv as semconv; /// The `X-Ourios-Tenant` request header (RFC 0016 §3.3) — kept out of the DSL /// body so the grammar stays tenant-agnostic. @@ -97,11 +100,89 @@ impl QuerierHandle { } } -/// Shared handler state: the engine + the default window. +/// `ourios.query.kind` attribute values (RFC 0016 §3.6). +const QUERY_KIND_LOGS: &str = "logs"; +const QUERY_KIND_DRIFT: &str = "drift"; +/// `ourios.query.row_group.state` attribute values. +const ROW_GROUP_SCANNED: &str = "scanned"; +const ROW_GROUP_PRUNED: &str = "pruned"; +/// The upstream OpenTelemetry `error.type` attribute key — a failed query is +/// recorded on the duration metric with this set, not as a bespoke error metric +/// (the "recording errors on metrics" convention). +const ERROR_TYPE: &str = "error.type"; + +/// The querier role's OpenTelemetry instruments (RFC 0016 §3.6): a query-duration +/// histogram (by kind, and `error.type` on failure) and the scanned-vs-pruned +/// row-group counter. Built against the global meter, so they resolve to +/// whatever `MeterProvider` the process installed (RFC 0001 §6.8). +struct QuerierMetrics { + duration: Histogram, + row_groups: Counter, +} + +impl QuerierMetrics { + fn new() -> Self { + let meter = global::meter("ourios.query"); + let duration = meter + .f64_histogram(semconv::OURIOS_QUERY_DURATION) + .with_unit("s") + .build(); + let row_groups = meter + .u64_counter(semconv::OURIOS_QUERY_ROW_GROUPS) + .with_unit("{row_group}") + .build(); + // Seed each pruning state with a zero so both series are visible before + // the first query. The `state` attribute is required (there is no + // attribute-free series), so this seeds once per value rather than the + // ingester's single attribute-free `add(0, &[])`. + row_groups.add(0, &Self::state_attrs(ROW_GROUP_SCANNED)); + row_groups.add(0, &Self::state_attrs(ROW_GROUP_PRUNED)); + Self { + duration, + row_groups, + } + } + + fn state_attrs(state: &'static str) -> [KeyValue; 1] { + [KeyValue::new(semconv::OURIOS_QUERY_ROW_GROUP_STATE, state)] + } + + /// Record a successful query: its wall-clock duration (by kind) and the + /// scanned/pruned row-group split (the two states partition the candidates, + /// so the B1 pruned fraction is derivable in the backend). + fn record_ok(&self, kind: &'static str, elapsed: Duration, stats: &QueryStats) { + self.duration.record( + elapsed.as_secs_f64(), + &[KeyValue::new(semconv::OURIOS_QUERY_KIND, kind)], + ); + self.row_groups.add( + stats.row_groups_scanned, + &Self::state_attrs(ROW_GROUP_SCANNED), + ); + self.row_groups.add( + stats.row_groups_pruned, + &Self::state_attrs(ROW_GROUP_PRUNED), + ); + } + + /// Record a failed query: its duration, tagged with `error.type`. + fn record_err(&self, kind: &'static str, elapsed: Duration, error_type: &'static str) { + self.duration.record( + elapsed.as_secs_f64(), + &[ + KeyValue::new(semconv::OURIOS_QUERY_KIND, kind), + KeyValue::new(ERROR_TYPE, error_type), + ], + ); + } +} + +/// Shared handler state: the engine, the default window, and the metrics. #[derive(Clone)] struct QuerierState { querier: Arc, default_window_nanos: u64, + metrics: Arc, } /// Build the querier role's axum router over `state` (RFC 0016 §3.3). Split out @@ -110,6 +191,7 @@ pub fn router(bucket_root: PathBuf, default_window_nanos: u64) -> Router { let state = QuerierState { querier: Arc::new(Querier::new(bucket_root)), default_window_nanos, + metrics: Arc::new(QuerierMetrics::new()), }; Router::new() .route("/v1/query", post(handle_query)) @@ -178,22 +260,61 @@ async fn handle_query( }; let now = now_unix_nano(); + let started = Instant::now(); match statement { Statement::Logs(mut query) => { apply_limit(&mut query.stages, DEFAULT_LIMIT, MAX_LIMIT); - match state + let result = state .querier .run_query(&query, &tenant, now, state.default_window_nanos, None) - .await - { - Ok(result) => json_ok(&LogQueryResponse::from(&result)), - Err(e) => query_error_response(&e), + .await; + let elapsed = started.elapsed(); + match result { + Ok(result) => { + state + .metrics + .record_ok(QUERY_KIND_LOGS, elapsed, &result.stats); + json_ok(&LogQueryResponse::from(&result)) + } + Err(e) => { + state + .metrics + .record_err(QUERY_KIND_LOGS, elapsed, query_error_type(&e)); + query_error_response(&e) + } } } - Statement::Drift(query) => match state.querier.run_drift(&query, &tenant, now).await { - Ok(result) => json_ok(&DriftResponse::from(&result)), - Err(e) => query_error_response(&e), - }, + Statement::Drift(query) => { + let result = state.querier.run_drift(&query, &tenant, now).await; + let elapsed = started.elapsed(); + match result { + Ok(result) => { + state + .metrics + .record_ok(QUERY_KIND_DRIFT, elapsed, &result.stats); + json_ok(&DriftResponse::from(&result)) + } + Err(e) => { + state + .metrics + .record_err(QUERY_KIND_DRIFT, elapsed, query_error_type(&e)); + query_error_response(&e) + } + } + } + } +} + +/// The stable `error.type` token for a [`QueryError`] (RFC 0016 §3.6) — a low +/// cardinality class, never the engine's detail (H6). +fn query_error_type(error: &ourios_querier::QueryError) -> &'static str { + use ourios_querier::QueryError; + match error { + QueryError::TenantRequired => "tenant_required", + QueryError::InvalidQuery { .. } => "invalid_query", + QueryError::Storage { .. } => "storage", + // OpenTelemetry's fallback for an unclassified error class. + _ => "_OTHER", } } diff --git a/crates/ourios-server/tests/rfc0016_5_7_served_querier.rs b/crates/ourios-server/tests/rfc0016_5_7_served_querier.rs index 46444aa1..ded9d7eb 100644 --- a/crates/ourios-server/tests/rfc0016_5_7_served_querier.rs +++ b/crates/ourios-server/tests/rfc0016_5_7_served_querier.rs @@ -153,7 +153,17 @@ async fn rfc0016_5_role_gating_and_graceful_shutdown() { matches!(saw_querier_line, Err(_) | Ok(false)), "no querier listener is bound when the role is disabled, saw {saw_querier_line:?}", ); - terminate_and_assert_clean(disabled).await; + // Kill + reap the compactor-only process before spawning the next one: + // `Child::kill` is SIGKILL followed by a `wait`, so it exits deterministically + // (unlike `kill_on_drop`, which only fires a best-effort, un-awaited signal). + // Its graceful-shutdown path is asserted in the enabled case below, which has + // a deterministic readiness signal (the printed address) before the signal — + // the disabled case prints nothing, so a blind SIGTERM would race the + // signal-handler setup. + disabled + .kill() + .await + .expect("kill the disabled-role process"); // Act: now enable the querier role on an ephemeral port. let mut child = Command::new(env!("CARGO_BIN_EXE_ourios-server")) diff --git a/crates/ourios-server/tests/rfc0016_6_query_metrics.rs b/crates/ourios-server/tests/rfc0016_6_query_metrics.rs new file mode 100644 index 00000000..b79103ab --- /dev/null +++ b/crates/ourios-server/tests/rfc0016_6_query_metrics.rs @@ -0,0 +1,200 @@ +//! RFC0016.6 — pruning is observable. +//! +//! A selective query over a multi-row-group corpus returns non-zero +//! `row_groups_pruned` in the response, and the querier emits the OpenTelemetry +//! query metrics (`ourios.query.duration` + `ourios.query.row_groups`, the +//! latter split into the `scanned`/`pruned` states whose sum is the candidate +//! total — the B1 pruned fraction is derived in the backend, per the +//! OpenTelemetry usage/state convention; RFC 0016 §3.6). +//! +//! This test installs a process-global in-memory `MeterProvider`, so it lives +//! in its own integration binary (its own process) and runs single-threaded — +//! mirroring `ourios-ingester`'s `perf_metrics` test. + +use std::collections::HashMap; +use std::path::Path; + +use axum::body::{Body, to_bytes}; +use axum::http::{Request, header}; +use opentelemetry_sdk::metrics::data::{ + AggregatedMetrics, MetricData, ResourceMetrics, ScopeMetrics, +}; +use ourios_core::record::{BodyKind, MinedRecord, Param}; +use ourios_core::tenant::TenantId; +use ourios_parquet::{PartitionKey, Writer}; +use ourios_semconv as semconv; +use ourios_server::querier::router; +use tower::ServiceExt; + +/// 2026-04-02T10:58:00 UTC — comfortably in the past, so the default look-back +/// window covers it (matching the querier engine's pruning fixtures). +const TS0: u64 = 1_775_127_480_000_000_000; +const HOUR_NS: u64 = 3_600_000_000_000; +/// A look-back wide enough to cover the whole corpus regardless of wall clock, +/// so only the `template_id` predicate — not time — drives pruning. +const HUGE_WINDOW: u64 = 100 * 365 * 24 * 60 * 60 * 1_000_000_000; + +fn mined_at(tenant: &str, template_id: u64, ts_ns: u64) -> MinedRecord { + MinedRecord { + tenant_id: TenantId::new(tenant), + template_id, + template_version: 1, + severity_number: 9, + severity_text: Some("INFO".to_string()), + scope_name: Some("lib.cart".to_string()), + scope_version: Some("1.0.0".to_string()), + scope_attributes: Vec::new(), + resource_schema_url: None, + scope_schema_url: None, + time_unix_nano: ts_ns, + observed_time_unix_nano: None, + attributes: Vec::new(), + dropped_attributes_count: 0, + resource_attributes: Vec::new(), + trace_id: None, + span_id: None, + flags: 0, + event_name: None, + body_kind: BodyKind::String, + params: vec![Param { + type_tag: ourios_core::audit::ParamType::Num, + value: "42".to_string(), + }], + separators: vec![String::new(), " ".to_string()], + body: None, + confidence: 1.0, + lossy_flag: false, + } +} + +fn write_records(bucket: &Path, recs: &[MinedRecord]) { + let mut by_part: HashMap> = HashMap::new(); + for r in recs { + by_part + .entry(PartitionKey::derive(r).expect("derive partition")) + .or_default() + .push(r.clone()); + } + for (part, rs) in by_part { + let mut w = Writer::open(bucket, part).expect("open writer"); + w.append_records(&rs).expect("append"); + w.close().expect("close"); + } +} + +fn metric_names(rms: &[ResourceMetrics]) -> Vec { + rms.iter() + .flat_map(ResourceMetrics::scope_metrics) + .flat_map(ScopeMetrics::metrics) + .map(|m| m.name().to_string()) + .collect() +} + +fn metric_data<'a>(rms: &'a [ResourceMetrics], name: &str) -> &'a AggregatedMetrics { + rms.iter() + .flat_map(ResourceMetrics::scope_metrics) + .flat_map(ScopeMetrics::metrics) + .find(|m| m.name() == name) + .unwrap_or_else(|| panic!("metric {name} missing")) + .data() +} + +/// Sum the `ourios.query.row_groups` counter restricted to a `state` value. +fn row_groups_in_state(rms: &[ResourceMetrics], state: &str) -> u64 { + let AggregatedMetrics::U64(MetricData::Sum(sum)) = + metric_data(rms, semconv::OURIOS_QUERY_ROW_GROUPS) + else { + panic!("row_groups should be a u64 sum (counter)"); + }; + sum.data_points() + .filter(|dp| { + dp.attributes().any(|kv| { + kv.key.as_str() == semconv::OURIOS_QUERY_ROW_GROUP_STATE + && kv.value.as_str() == state + }) + }) + .map(opentelemetry_sdk::metrics::data::SumDataPoint::value) + .sum() +} + +/// Scenario RFC0016.6 — pruning is observable. +/// See `docs/rfcs/0016-query-serving-endpoint.md` §5. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rfc0016_6_pruning_is_observable() { + // Arrange — install an in-memory global meter, THEN build the router so its + // instruments resolve against it. A multi-hour corpus where each file holds + // a distinct `template_id` (distinct hour ⇒ distinct partition ⇒ distinct + // file/row group), so a `template_id` predicate prunes the others. + let (guard, exporter) = ourios_telemetry::init_in_memory("ourios-test"); + let bucket = tempfile::tempdir().unwrap(); + let recs: Vec = (0..4) + .map(|k| mined_at("acme", 1 + k, TS0 + k * HOUR_NS)) + .collect(); + write_records(bucket.path(), &recs); + + let app = router(bucket.path().to_path_buf(), HUGE_WINDOW); + let request = Request::builder() + .method("POST") + .uri("/v1/query") + .header(header::CONTENT_TYPE, "text/plain") + .header("X-Ourios-Tenant", "acme") + .body(Body::from("template_id == 1")) + .expect("build request"); + + // Act + let response = app.oneshot(request).await.expect("oneshot"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); + + // Assert — the response exposes the pruning win. + assert_eq!(status, axum::http::StatusCode::OK, "served a 200: {json}"); + assert_eq!(json["rows"], 1, "the one template-1 row matches"); + let pruned = json["stats"]["row_groups_pruned"] + .as_u64() + .expect("row_groups_pruned is a number"); + assert!( + pruned > 0, + "the other templates' files are pruned by statistics, got {json}", + ); + + // Assert — the OTel query metrics are emitted. + guard.force_flush().expect("force_flush"); + let rms = exporter.get_finished_metrics().expect("metrics exported"); + let names = metric_names(&rms); + for expected in [ + semconv::OURIOS_QUERY_DURATION, + semconv::OURIOS_QUERY_ROW_GROUPS, + ] { + assert!( + names.iter().any(|n| n == expected), + "exported stream missing {expected}, got {names:?}", + ); + } + + // The duration histogram recorded exactly the one query. + let AggregatedMetrics::F64(MetricData::Histogram(hist)) = + metric_data(&rms, semconv::OURIOS_QUERY_DURATION) + else { + panic!("query.duration should be an f64 histogram"); + }; + assert_eq!( + hist.data_points() + .map(opentelemetry_sdk::metrics::data::HistogramDataPoint::count) + .sum::(), + 1, + "one query → one duration observation", + ); + + // The pruned-state counter matches the response's pruned count (and the + // scanned/pruned states sum to the candidate total). + let pruned_metric = row_groups_in_state(&rms, "pruned"); + let scanned_metric = row_groups_in_state(&rms, "scanned"); + assert_eq!( + pruned_metric, pruned, + "the pruned-state counter matches the response's row_groups_pruned", + ); + assert!(scanned_metric >= 1, "at least one row group was scanned"); +} diff --git a/crates/ourios-server/tests/rfc0016_query_endpoint.rs b/crates/ourios-server/tests/rfc0016_query_endpoint.rs index 988d4c86..52ee86be 100644 --- a/crates/ourios-server/tests/rfc0016_query_endpoint.rs +++ b/crates/ourios-server/tests/rfc0016_query_endpoint.rs @@ -325,11 +325,6 @@ async fn rfc0016_oversize_body_is_rejected() { // querier compose) are process-level: they spawn the `ourios-server` binary // and drive SIGTERM, so they live in the unix-gated `rfc0016_5_7_served_querier` // integration test (mirroring the receiver's `rfc0003_16_served_binary`). - -/// Scenario RFC0016.6 — pruning is observable. -/// See `docs/rfcs/0016-query-serving-endpoint.md` §5. -#[test] -#[ignore = "RFC0016.6 — red until pruning stats + OTel query metrics are emitted (green)"] -fn rfc0016_6_pruning_is_observable() { - todo!("RFC0016.6: selective query → row_groups_pruned > 0 + latency/pruning-ratio metric") -} +// +// RFC0016.6 (pruning observable + OTel query metrics) installs a process-global +// in-memory meter, so it lives in its own `rfc0016_6_query_metrics` binary. diff --git a/docs/rfcs/0016-query-serving-endpoint.md b/docs/rfcs/0016-query-serving-endpoint.md index 0632d5fb..cf3d1895 100644 --- a/docs/rfcs/0016-query-serving-endpoint.md +++ b/docs/rfcs/0016-query-serving-endpoint.md @@ -1,7 +1,7 @@ --- rfc: 0016 title: Query-serving endpoint — the HTTP query API over the logs DSL -status: red +status: green author: Jens Holdgaard Pedersen drafting-assistance: Claude created: 2026-06-19 @@ -11,6 +11,19 @@ superseded-by: — # RFC 0016 — Query-serving endpoint: the HTTP query API over the logs DSL +> **Status note.** **`green`** (2026-06-22; `red` 2026-06-19). All seven §5 +> scenarios pass. The querier role is wired into `ourios-server` as an +> env-gated HTTP endpoint (`POST /v1/query`) over the RFC 0007 engine, +> mirroring the receiver role's `serve`/`Handle` topology: `.1`–`.4` (the +> request/dispatch/error handler driven in-process) landed in #283; `.5`/`.7` +> (role gating + graceful shutdown + receiver/querier compose) and the §3.6 +> query metrics (`.6`) followed. Per the OpenTelemetry usage/state convention, +> the pruning signal is emitted as raw scanned/pruned row-group counts +> (`ourios.query.row_groups`, `state = scanned | pruned`) plus a +> `ourios.query.duration` histogram — the B1 pruned fraction is derived in the +> backend, not pre-computed. gRPC and authn/z beyond tenant-scoping remain +> deferred (§7). + ## 1. Summary Wire the validated query engine (RFC 0007) into `ourios-server` as a diff --git a/semconv/registry/attributes.yaml b/semconv/registry/attributes.yaml index 70201df4..20d7cab3 100644 --- a/semconv/registry/attributes.yaml +++ b/semconv/registry/attributes.yaml @@ -92,3 +92,36 @@ groups: hard ceiling (RFC0014.4) — indicates memory backpressure. stability: development brief: Why the record sink flushed a partition (RFC 0014 §3.2). + - id: ourios.query.kind + type: + members: + - id: logs + value: "logs" + stability: development + brief: A logs DSL query (`run_query`). + - id: drift + value: "drift" + stability: development + brief: A template-drift query (`run_drift`). + stability: development + brief: The kind of query served by the querier endpoint (RFC 0016). + - id: ourios.query.row_group.state + type: + members: + - id: scanned + value: "scanned" + stability: development + brief: A row group `DataFusion` read to answer the query. + - id: pruned + value: "pruned" + stability: development + brief: >- + A row group skipped via partition / statistics pruning + (pillar #1's footer-skip; the B1 win). + stability: development + brief: >- + Whether a candidate row group was scanned or pruned. The two + states partition the query's candidate row groups (their sum is + the total), so the B1 pruned fraction is derived in the backend + as `pruned / (scanned + pruned)` (RFC 0016; OTel usage/state + convention — record raw counts, derive the ratio). diff --git a/semconv/registry/metrics.yaml b/semconv/registry/metrics.yaml index 22ca1ff8..055916ba 100644 --- a/semconv/registry/metrics.yaml +++ b/semconv/registry/metrics.yaml @@ -333,3 +333,31 @@ groups: the hard ceiling (RFC0014.4). instrument: updowncounter unit: "By" + + - id: metric.ourios.query.duration + type: metric + metric_name: ourios.query.duration + stability: development + brief: >- + Duration of a query served by the querier endpoint, by kind + (RFC 0016). Follows the OTel `{operation}.duration` convention; a + failed query carries the `error.type` attribute. + instrument: histogram + unit: "s" + attributes: + - ref: ourios.query.kind + requirement_level: required + + - id: metric.ourios.query.row_groups + type: metric + metric_name: ourios.query.row_groups + stability: development + brief: >- + Candidate row groups a query scanned vs. pruned (pillar #1's + footer-skip; the B1 win). The two states partition the candidates, + so the pruned fraction is derived as `pruned / (scanned + pruned)`. + instrument: counter + unit: "{row_group}" + attributes: + - ref: ourios.query.row_group.state + requirement_level: required