feat(exec,query): add DQL execution guardrails + dqlbench regression harness#178
feat(exec,query): add DQL execution guardrails + dqlbench regression harness#178danaharrison wants to merge 2 commits into
Conversation
Adds pkg/dqlcost, a pure-Go DQL cost linter with eight regex-based
rules (COST001-COST009; COST007 intentionally omitted after tenant
measurements showed column pruning does not reduce billable scan),
plus a conservative rewriter that applies safe mechanical fixes.
Wired into three CLI entry points:
dtctl verify query --cost-lint [--strict-cost] [--rewrite-cost]
Runs the linter on the canonical query; --strict-cost exits non-zero
on warn-or-higher findings (CI-friendly); --rewrite-cost prints a
rewritten version to stdout for pipe/diff inspection.
dtctl exec copilot nl2dql --cost-lint [--rewrite-cost]
Lints CoPilot-generated DQL before printing; optionally rewrites.
Catches expensive shapes at generation time rather than runtime.
dtctl apply --cost-lint [--strict-cost] [--rewrite-cost]
Walks dashboard/notebook YAML, extracts every tile `query:` string
via yaml.Node (preserving formatting and comments), and lints each.
--rewrite-cost writes a sibling `.rewritten.<ext>` file and exits
without submitting — never mutates the original.
Rule severities and rewriter defaults are calibrated from live tenant
measurements. Two notable calibrations:
- COST003 (transform-wrapped filter, e.g. `lower(x) == "..."`) is
SeverityError, not warn, because it was measured at ~9x scan
amplification and ~15x latency penalty.
- COST005 (`limit` before `summarize`) is SeverityInfo and NOT in the
default rewriter: tenant data showed Grail short-circuits the scan
when `limit` appears early, so `limit | summarize` actually scans
less than `summarize | limit`. Semantics differ (partial sample vs
full aggregate), so the linter flags for intent review only.
Refs: dynatrace-oss#175
…harness Adds two complementary cost-safety features: ## pkg/exec/dql.go — GuardrailDefaults A package-level GuardrailDefaults struct with a 500 GiB scan ceiling auto-applied when the caller leaves DefaultScanLimitGbytes unset. The helper applyGuardrailDefaults() runs at the top of ExecuteQueryWithOptions, honors an opt-out flag (DisableGuardrails), and preserves caller-specified values exactly. MaxResultRecords is intentionally left at 0 so the server default continues to apply — matches existing test expectations and avoids surprising behavior for integrations that rely on server-side defaults. ## cmd/query.go — --no-guardrails New --no-guardrails flag on `dtctl query` for power users who need unrestricted scans (e.g. one-off exports, backfills). Default behavior: guardrails active. Help text on --default-scan-limit-gbytes updated to note the 500 GiB auto-applied default. ## test/dqlbench/ — regression harness Fixture-based before/after harness for ongoing cost evaluation: - fixtures/*.yaml — one per prompt/query with expect_rules, after_rewrite_no_rules, must_contain, must_not_contain, and budget.max_scanned_bytes fields. - harness/harness.go — fixture loader. - harness/harness_test.go — snapshot mode (no tenant): asserts expected rules fire on raw query and are cleared after rewriter runs. - harness/bench_test.go — tenant mode (//go:build dqlbench): executes each fixture via DQLExecutor.ExecuteQueryWithOptions with IncludeContributions=true + MetadataFields=["all"], captures GrailMetadata.ScannedBytes, and writes a markdown comparison report to test/dqlbench/reports/<timestamp>.md. - makefile — test-dqlbench-unit (snapshot, no tenant) and test-dqlbench (tenant via DTCTL_INTEGRATION_ENV/TOKEN). - .gitignore — excludes test/dqlbench/reports/ (generated artifacts). Three seed fixtures included: errors-last-24h, log-timeseries-7d, cost-optimized (gold-path). Refs: dynatrace-oss#175
|
|
Feedback on the guardrails addition (delta over #177)Thanks for this contribution, Dana — the domain knowledge here is really impressive, especially the COST005 calibration and the measurement-driven severity choices. The benchmark harness is a great idea for keeping rule quality honest over time. I have one architectural suggestion on how the guardrail defaults are sourced. The current approach uses a package-level
Proposal: source defaults from
|
|
I agree with @discostu105 on all points, both on the proposed approach through the config file and opting in to a one shot override option, which should be named in closer alignment with query limits, rather than "guardrails", which is very broad and unspecific. |
Summary
Adds DQL execution guardrails to
pkg/exec/dql.go(500 GiB scan ceiling auto-applied by default,--no-guardrailsopt-out ondtctl query) and atest/dqlbench/regression harness for ongoing before/after cost measurement.Based on #177 (linter + CLI wiring) — the harness imports
pkg/dqlcostto verify that linted queries actually produce cleaner findings after the rewriter runs. Please merge #176 → #177 → this PR in order.Refs: #175
Why
Two complementary gaps left after #176 and #177:
DQLExecuteOptions.DefaultScanLimitGbytesexists as an opt-in knob but is unset by default, so a runaway query (bad filter, missingfrom:, unexpectedly wide bucket) has no client-side brake. When the query is being built by an LLM from a user prompt, this is exactly the failure mode that hits production dashboards.What changed
pkg/exec/dql.go—GuardrailDefaultsExecuteQueryWithOptions.DefaultScanLimitGbytesonly when the caller passed 0 (preserves explicit caller values).opts.DisableGuardrails— opt-out for power users.MaxResultRecordsintentionally left at 0 so the server's own default continues to apply (matches long-standing test expectations; no behavior change for integrations that rely on server defaults).cmd/query.go—--no-guardrailsdtctl query.--default-scan-limit-gbytesupdated to mention the 500 GiB auto-applied default.test/dqlbench/— regression harnessexpect_rules,after_rewrite_no_rules,must_contain,must_not_contain,budget.max_scanned_bytes.make test-dqlbench-unit, no tenant required) — loads every fixture, runsdqlcost.Linton the raw query, asserts expected rules fire; runsdqlcost.Rewriteand re-lints, asserts specified rules no longer fire.make test-dqlbench,//go:build dqlbench) — executes each fixture viaDQLExecutor.ExecuteQueryWithOptions(IncludeContributions=true, MetadataFields=["all"]), capturesGrailMetadata.ScannedBytesfor raw and rewritten forms, writes a timestamped markdown comparison report totest/dqlbench/reports/<ts>.md. Budget assertion fires when rewritten scan exceedsbudget.max_scanned_bytes.errors-last-24h(missingfrom:/scanLimit),log-timeseries-7d(wrong shape — should use metric),cost-optimized(gold path, should produce zero findings).test-dqlbench-unitandtest-dqlbench(the latter reuses the existing.integrationtests.env/DTCTL_INTEGRATION_*env var pattern already used bytest-integration)..gitignore:test/dqlbench/reports/excluded (generated artifacts).Tests in place
pkg/execunit tests: four new test cases indql_guardrails_test.gocovering fill-zero, preserve-caller-values, honor-disable, and nil-safe paths. Package regression suite (TestDQLExecutor_ExecuteQueryWithOptions_CustomHeadersand related) unchanged — all existing expectations preserved.go build ./...,go test ./pkg/exec/... ./pkg/dqlcost/... ./test/dqlbench/harness/...all clean.Benefits
For Dynatrace customers:
from:clauses, and LLM-generated DQL with bad shape — without requiring every dtctl user to remember the existing opt-in flag.--no-guardrailspreserves power-user workflows (exports, backfills, ad-hoc analytics) — no regression for existing scripts.For Dynatrace:
Test plan
go test ./pkg/exec/...— existing + new guardrail tests passmake test-dqlbench-unit— 3 fixtures greendtctl query 'fetch logs, from:now()-5m | limit 1' --agent— scanned bytes metadata visibledtctl query 'fetch logs, from:now()-5m | limit 1' --no-guardrails --agent— flag accepted; no guardrail in request bodytest/dqlbench/layout, naming conventions, and whether the harness should live undertest/e2e/instead