feat(otel): add Prometheus + Loki consumer source - #140
Conversation
Adds `type: otel` as a federated data source — Skardi pulls metrics
and logs from Prometheus-compatible and Loki backends, exposing them
through DataFusion as `metrics` / `logs` tables (with predicate
pushdown) plus `prom_query` / `prom_range` / `loki_query` /
`loki_range` escape-hatch table functions.
Closes the OTEL consumer side of the design doc at
openspec/changes/add-otel-data-source/. Sections 1-10 of the task
list are complete; 7 sub-tasks are tracked as partial with inline
scope notes pointing at the deferred work (notably `labels['k']`
matcher recognition in the translator and aggregate pushdown, which
together need DataFusion `map_extract` Expr shape validation against
a running planner).
What lands:
- new `otel` cargo feature on `skardi` + `skardi-server` + `skardi-cli`
- `DataSourceType::Otel` variant + ctx.yaml `otel:` block parsing
- OtelError / OtelHttpClient / OtelSourceConfig / OtelAuth foundation,
with env-only credentials and inline-secret rejection at config load
- SQL → backend-query translator with the v1 supported-predicate
matrix (name=, ts range, line LIKE, LIMIT)
- Prometheus provider: predicate-pushdown `metrics` TableProvider +
prom_query / prom_range escape-hatch functions + Arrow batch
construction with the row cap enforced
- Loki provider: predicate-pushdown `logs` TableProvider +
loki_query / loki_range escape-hatch functions, handling both
streams and matrix response shapes
- SQL validator wires OTEL backend-specific table names as ReadOnly
- Server: dashboard renders OTEL sources with read-only badge +
`Prometheus @ <url>` / `Loki @ <url>` subtitle; startup info logs
per registered OTEL source
- Metrics: `skardi_otel_queries_total{source, backend, outcome}` +
`skardi_otel_query_duration_seconds{source, backend}`, with
`Outcome` enum mapping each `OtelError` variant to a stable label
- CLI: `skardi sql --ctx ctx.yaml ...` works against OTEL sources
- demo/otel_service_health/ with 3 pipelines (tier-1, tier-3 loki,
tier-3 prom_query) + README documenting the v1 sharp edges
- 35 OTEL integration tests + 50+ OTEL unit tests, all wiremock-
driven; new CI job `check-otel` runs them in a fast lane on every
PR
Tests:
- `cargo test --workspace` → 457/457 (default unchanged)
- `cargo test --workspace --features skardi/otel` → all green
(537 skardi unit + 5 access_mode + 9 loki + 8 prom + 3 demo_smoke
+ 8 otel_config + 50 pre-existing server tests)
- `cargo fmt --all` clean, no new clippy warnings in OTEL files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
BtXin
left a comment
There was a problem hiding this comment.
Code review — feat(otel): add Prometheus + Loki consumer source
Reviewed against add-otel-data-source. Spec ↔ code correspondence is unusually high; tests are genuinely solid for v1. Findings below — most are polish, but #1 and #2 are merge-blockers IMO.
Strengths
- Honest task list. The 7 partial items in
tasks.mdcarry inline scope notes; nothing is silently aspirational. - Defense-in-depth on credentials. Hand-rolled
Deserialize for OtelAuthat mod.rs:75-137 rejectstoken:/password:/username:at YAML parse with field-named errors. - Single outbound HTTP chokepoint. http.rs — auth,
extra_headers, per-request timeout converge here; future rate-limiter has one place to land. - Pinned outcome labels.
outcome_strings_are_stablein metrics.rs:147-158 prevents silent Grafana breakage when refactoring. - Wire-level test assertions. otel_prometheus.rs:121 pins
query=http_requests_total{}; otel_loki.rs:145 pins the full LogQL string. - LIKE → regex correctly escapes metacharacters — translator.rs:737-744 (
LIKE 'a.b%'must not matchaXb…). - Brace-collision pinned. inferencer.rs:1174 proves the
{identifier}regex won't snag PromQL{service="api"}.
Issues (prioritized)
1. prometheus-http-query declared but never used — merge-blocker
Cargo.toml:27 + Cargo.toml:59 pull it in (with rustls-tls); zero use prometheus_http_query anywhere. Doc comment at prometheus.rs:27-30 explicitly justifies hand-rolled JSON parsing. This contradicts Decision 3 in design.md and adds dead compile time + TLS stack to every --features otel build. Drop the dep and update Decision 3.
2. Translator errors have empty source_name — operator UX
translator.rs:494-498, 507, 519, 624, 641, 646 construct OtelError::Backend { source_name: String::new(), … }. These propagate untouched; the rendered message is otel source : expected a timestamp literal…``. Thread source_name: &str into `literal_as_timestamp` / `try_line_like` / `try_ts_predicate`, or rewrap in `translate_*_filters`. Violates the invariant that every `OtelError` variant carries the source name.
3. _DURATION_REEXPORT_REMINDER hack
translator.rs:677-679 — #[allow(dead_code)] const _DURATION_REEXPORT_REMINDER: Option<Duration> = None; silences an unused-import warning. The use std::time::Duration; at line 56 has no other use site. Delete both.
4. spec.selector == \"{}\" is a stringly-typed sentinel
loki.rs:627 detects "no stream-label predicate" via literal string comparison. Once 3.5.3 lands and real selectors get emitted, this silently breaks. Replace with LokiQuerySpec::has_stream_label: bool or selector: Option<String>.
5. HTTP 200 + status:\"error\" returns UpstreamStatus { status: 200 }
prometheus.rs:224-232, loki.rs:219-227. Confusing for operators chasing a 5xx that isn't there. Add a LogicalError variant or prefix the message with "logical error (HTTP 200)".
6. supports_filters_pushdown returns Exact for all filters
prometheus.rs:604-609, loki.rs:605-610. Deliberate per Decision 4 (fast-fail beats unbounded fan-out), but differs from tasks.md 4.1's text. Update the task spec to match. Consider an alert on outcome=\"unsupported_pushdown\" — agent SQL variation will hit this in prod.
7. Float-precision loss in Loki matrix timestamps
loki.rs:368: (ts_secs * 1e9) as i64 loses sub-µs precision past ~15 decimal digits. Benign for rate(…) over minutes, but consider ts_secs.trunc() * 1_000_000_000 + (ts_secs.fract() * 1e9) as i64.
8. Null arg errors instead of defaulting
prometheus.rs:833-838, loki.rs:843-848. The comment correctly identifies that pipeline param substitution emits NULL for unbound params, then returns plan_err!. Treating Null as None (so resolve_window applies defaults) would unblock the parameterized-window case the demo currently hardcodes around.
9. Multiple OTEL sources silently overwrite UDTFs
prometheus.rs:939-946, loki.rs:910-917, and the CLI mirrors this at cli/src/main.rs:961-1024. Two OTEL sources → second registration wins silently. Single-source-per-process is documented but not enforced. Add tracing::warn! or hard-error on duplicate registration.
10. "RFC-3339/ISO-8601" doc inaccuracy
prometheus.rs:856-864, loki.rs:835 call parse_from_rfc3339 (strict — no 2024-01-02T15:04:05 without offset). Either widen the parser or drop "ISO-8601" from the docs/errors.
11. extract_substring_like ignores SQL ESCAPE clause
translator.rs:220-231 — the DataFusion Like Expr exposes escape_char: Option<char> but it's ignored. Low likelihood, but LIKE 'a%b' ESCAPE '\\' would produce a wrong substring path. Either honor it or assert escape_char.is_none() and fall through to UnsupportedPushdown.
Test coverage
37+ tests across otel_prometheus.rs (8), otel_loki.rs (9), otel_access_mode.rs (5), otel_config.rs (8), otel_demo_smoke.rs (3), plus extensive unit coverage. Gaps (all flagged in the PR body as deferred):
labels['k']matcher pushdown (3.5.2 / 3.5.3) — no tests, no implementation. The v1 tier-1 SQL surface is materially less useful than the spec advertises until these land; demotop_error_logs.yamlhas to useloki_rangefor this reason.- Aggregate pushdown (4.2) — translator hard-errors on any aggregate.
otel_demo_smokeassertsrows > 0but doesn't pin upstream query params — tightening toquery_param(…)matchers would catch translator regressions caught only at the wire today.
Security
- Inline-secret rejection at deserializer +
OtelError::InlineCredentialRejected, tested e2e in otel_config.rs:127-150. - Outbound TLS via rustls; bearer/basic auth never logged.
extra_headersvalidated at startup so a bad header inctx.yamlfails the server, not the first scan.max_result_rows(50k),max_window(24h),request_timeout(10s) bound blast radius. Per-source rate-limiting is a documented v1 non-goal.
Suggested next moves
The two easy wins (#1 and #3) as a single patch:
--- a/crates/skardi/Cargo.toml
-otel = [\"dep:reqwest\", \"dep:humantime-serde\", \"dep:prometheus-http-query\", \"dep:opentelemetry\"]
+otel = [\"dep:reqwest\", \"dep:humantime-serde\", \"dep:opentelemetry\"]
-prometheus-http-query = { version = \"0.8\", default-features = false, features = [\"rustls-tls\"], optional = true }
--- a/crates/skardi/src/sources/providers/otel/translator.rs
-use std::time::Duration;
-#[allow(dead_code)]
-const _DURATION_REEXPORT_REMINDER: Option<Duration> = None;…and update design.md Decision 3 to drop the prometheus-http-query rationale (the actual rationale lives at prometheus.rs:27-30).
#2 (translator source_name plumbing) is a small but real correctness fix — error messages without the source name will be a recurring operator complaint.
Issues 4–11 are smaller polish; none block correctness for the v1 shape.
Re: sharp edge #4 — the projection bug is broader than the PR body suggestsRan the v1 against the bundled observability stack ( Repro A — tier-3 +
|
The Prometheus and Loki TableProvider implementations ignored the `projection` argument from DataFusion's planner while still advertising the full schema via `ExecutionPlan::properties().schema()`. Whenever an outer query picked a column subset, aliased a literal over a real column, or unioned across two `prom_query` results, the planner built downstream column references against the projected schema and tripped the `Input field name <X> does not match with the projection expression <Y>` assertion in `datafusion-physical-expr`. PromExec and LokiExec now carry the pushed-down projection, project their advertised schema, and project the emitted RecordBatch in execute(). Regression tests pin the three reviewer-reproduced shapes plus the Loki parallel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tier-1 metrics/logs queries with `WHERE labels['k'] OP <literal>` now
fold into the upstream selector instead of falling through to the
escape hatch. Recognized operators: `=`, `!=`, `LIKE`, `NOT LIKE`,
`IN`, `NOT IN`. The recognizer is anchored on DataFusion's
`get_field(labels, '<k>')` lowering for SQL subscripts against Map
columns; both backends share a single `LabelMatcher` IR since LogQL is
a strict superset of PromQL's selector grammar.
Examples (verified end-to-end via wiremock):
SELECT … FROM metrics WHERE name='x' AND labels['service']='api'
→ http_requests_total{service="api"}
SELECT … FROM metrics WHERE labels['service'] IN ('api','checkout')
→ http_requests_total{service=~"^(api|checkout)$"}
SELECT … FROM logs WHERE labels['app']='checkout' AND line LIKE '%timeout%'
→ {app="checkout"} |= "timeout"
Closes tasks 3.5.2 and 3.5.3; updates 3.5.5/3.5.6/4.4/5.1/5.7 notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Is this still relevant? @BtXin |
Summary
Adds
type: otelas a federated data source — Skardi pulls metrics and logs from Prometheus-compatible and Loki backends and exposes them through DataFusion asmetrics/logstables (with predicate pushdown) plusprom_query/prom_range/loki_query/loki_rangeescape-hatch table functions. Closes the OTEL consumer side of the design doc atopenspec/changes/add-otel-data-source/.Drafted for review of architecture + sharp-edge tradeoffs before final merge.
43 / 50 tasks complete; the 7 remaining are tracked as partial with inline scope notes pointing at the deferred follow-ups (most notably
labels['k']matcher recognition in the translator and aggregate pushdown — both need DataFusionmap_extractExpr-shape validation against a running planner).What lands
otelcargo feature onskardi+skardi-server+skardi-cli;DataSourceType::Otelvariant;OtelError/OtelHttpClient/OtelSourceConfig/OtelAuthwith env-only credentials and inline-secret rejection at config loadotel/translator.rs) — SQL → backend-query IR with the v1 supported-predicate matrix (name=,tsrange,line LIKE,LIMIT); unsupported predicates surface asOtelUnsupportedPushdownwith aprom_query(...)/loki_query(...)hint baked into the error messagemetricsTableProvider+prom_query/prom_rangeescape-hatch UDTFs; Arrow batch construction with the row cap enforced; JSON parsing hand-rolled against the documented API so ourOtelHttpClient(auth +extra_headers+ timeout) stays in playlogsTableProvider+loki_query/loki_rangeUDTFs; handles bothstreamsandmatrixresponse shapes; empty-selector queries reject at the table boundary with a clear pointer atloki_rangemetrics/logstable names asReadOnly;INSERT/UPDATE/DELETEagainst them fail at config load with a table-named errorread-onlybadge +Prometheus @ <url>/Loki @ <url>subtitle; startup info logs per registered OTEL sourceskardi_otel_queries_total{source, backend, outcome}+skardi_otel_query_duration_seconds{source, backend}, withOutcomeenum mapping eachOtelErrorvariant to a stable label string (pinned by unit tests so dashboards can't silently break)skardi sql --ctx ctx.yaml ...works againsttype: otelsources via the same registration path the server usesdemo/otel_service_health/withctx.yaml+ 3 pipelines (tier-1metrics, tier-3loki_range, tier-3prom_query) + README documenting the v1 sharp edgescheck-otelfast-lane job in.github/workflows/ci.ymlrunning otel-gated unit + integration tests in ~2 minutes per PRSharp edges shipping in v1 (intentional, documented)
labels['k']matchers aren't pushed down yet. The translator can't yet recognize them; tier-1 queries referencing them fall through toUnsupportedPushdownpointing at the escape hatch. Tracked under tasks 3.5.2 / 3.5.3.metricshappen at the DataFusion level, not in PromQL. Counter-vs-gauge semantic mismatch — for counter metrics, useprom_query('sum by(service)(rate(...[5m]))'). Tracked under task 4.2; documented indesign.mdDecision 4 "v1 sharp edge".{param}→NULLbreaksINTERVALshapes at load-time validation) and for params inside SQL string literals (JSON strings get single-quoted, producing nested quotes insideprom_query('...{...}...')). Demo pipelines hardcode these values. Fix is in the pipeline substituter, separate from this change.SELECT labels['k']fromprom_queryresults currently tickles a DataFusion projection bug. Workaround: project the wholelabelsMap and destructure client-side. Tracked separately.Test plan
cargo test --workspace— 457 / 457 pass (default features; +1 over baseline from a new pipeline-brace-safety test)cargo test --workspace --features skardi/otel— all green: 537 skardi unit + 5 access_mode + 9 loki integration + 8 prom integration + 3 demo_smoke + 8 otel_config + 50 pre-existing server testscargo fmt --all -- --checkcleancargo clippy --workspace --features skardi/otel --all-targets— no new warnings in OTEL filescargo check --workspaceclean;cargo check --workspace --features skardi/otelcleanobservability/docker-compose.yml(docker compose -f observability/docker-compose.yml up -d prometheus loki) + record a screencast of an agent calling/service-health/execute(task 10.4, the meta task this PR opening completes)Reviewer notes
Suggested reading order if you only have 30 minutes:
openspec/changes/add-otel-data-source/proposal.md— why this exists, what's in / out of scopeopenspec/changes/add-otel-data-source/design.md— 10 design decisions, especially Decision 4 (tier-1 vs tier-3) and Decision 11 (pipelines as the canonical surface)docs/otel/README.md— operator-facing reference (config, schema, predicate matrix, sharp edges)demo/otel_service_health/— runnable democrates/skardi/src/sources/providers/otel/translator.rs— the architectural heart (where SQL meets the backend HTTP API)openspec/changes/add-otel-data-source/tasks.md— the 43 ✓ + 7 partial scope notes show exactly what shipped vs deferred🤖 Generated with Claude Code