Skip to content

fix(ingestion/grafana): don't break SQL parsing for quoted template variables - #19349

Open
daha wants to merge 1 commit into
datahub-project:masterfrom
daha:fix/grafana-quoted-template-variables
Open

fix(ingestion/grafana): don't break SQL parsing for quoted template variables#19349
daha wants to merge 1 commit into
datahub-project:masterfrom
daha:fix/grafana-quoted-template-variables

Conversation

@daha

@daha daha commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #19346

Problem

_clean_grafana_template_variables() replaces ${var} with the quoted literal 'grafana_var'
without checking whether the variable is already inside quotes. Panel SQL containing '${var}'
the common way to interpolate a Grafana variable into a SQL string or cast — becomes
''grafana_var'', which no dialect can parse:

-- input (valid, parses as-is)
WHERE run_id = CAST('${run_id}' AS INTEGER)
-- after cleaning
WHERE run_id = CAST(''grafana_var'' AS INTEGER)   -- ParseError: Expected AS after CAST

The irony is that the unmodified query is fine — sqlglot treats '${var}' as an ordinary string
literal. The cleaning step is what breaks it.

The failure is then invisible. create_lineage_sql_parsed_result swallows the ParseError and
returns a truthy SqlParsingResult with in_tables=[], so _parse_sql returns non-None,
_create_column_lineage bails at its if not parsed_sql.in_tables guard, and
extract_panel_lineage falls through to _create_basic_lineage — which builds the upstream URN
from the Grafana datasource UID:

actual:   urn:li:dataset:(urn:li:dataPlatform:postgres,test_db.postgres_uid,PROD)     <- datasource UID
expected: urn:li:dataset:(urn:li:dataPlatform:postgres,test_db.public.test_table,PROD)

That dataset does not exist in the upstream platform, so ingestion materialises a phantom entity
that looks like genuine lineage. Nothing warns — panel_parsing_warnings stays at 0 and
warnings stays empty.

Measured on a production Grafana instance with ~800 dashboards: 143 of 940 upstreamLineage
aspects (15.2%)
pointed at a fabricated UUID-named dataset; 101 of those are caused by this
issue.

Fix

The sibling _GRAFANA_SIMPLE_VAR_PATTERN already carries (?<!')...(?!') for exactly this
reason, and the function's docstring documents that '$var' is left unchanged. This gives the
braced pattern the same guard, so the documented contract holds for both syntaxes:

-_GRAFANA_BRACED_VAR_PATTERN = re.compile(r"\$\{[^}]+\}")
+_GRAFANA_BRACED_VAR_PATTERN = re.compile(r"(?<!')\$\{[^}]+\}(?!')")

Both lookarounds must hold for a substitution, so '${var}' is left untouched — which is correct,
because it is already a valid SQL string literal. Everything else in the diff is the matching
docstring and comment update.

The ordering of the other passes makes this sufficient on its own:
_GRAFANA_GENERIC_MACRO_PATTERN (\$__\w+) runs earlier but cannot match ${__from}, because
$ is followed by { rather than _; and _GRAFANA_SIMPLE_VAR_PATTERN runs later but cannot
match '${run_id}' for the same reason. A quoted braced variable now survives all passes
unchanged.

Known limitation, shared with the existing sibling pattern and deliberately not addressed here:
a single-quote lookaround is a proxy for "inside a string literal", and it only fires when a quote
is immediately adjacent to the variable. A variable in the middle of a longer literal is still
substituted and still breaks parsing:

'${var}'              -> unchanged                      (fixed by this PR)
'${var}xyz'           -> unchanged                      (fixed by this PR)
'xyz${var}'           -> unchanged                      (fixed by this PR)
'prefix${var}suffix'  -> 'prefix'grafana_var'suffix'    (still broken, unchanged by this PR)

That last case produces byte-identical output before and after this change, so it is a pre-existing
gap rather than a regression, and it is exactly how _GRAFANA_SIMPLE_VAR_PATTERN already behaves.
Closing it properly needs string-literal-aware scanning rather than a wider regex, which is a
larger change than this fix warrants.

Testing

Three tests added, each confirmed failing before the change and passing after.

  1. test_removes_all_grafana_variable_formats[braced_variable_in_quotes] — one new case in the
    existing parametrized list, mirroring the variable_in_quotes case that already asserts this
    contract for '$var'. A second case,
    [braced_variables_quoted_and_unquoted], puts a quoted and an unquoted braced variable in one
    query, pinning the fix as selective rather than "stopped substituting braced variables".
  2. test_cleaned_query_remains_parseable_with_quoted_variables — feeds the cleaned query back
    through sqlglot and asserts the upstream table is still extractable. The existing suite only
    asserted string equality on the cleaner's output, never that the output parses, which is why an
    11-case suite passed throughout.
  3. test_extract_panel_lineage_with_quoted_template_variable — end-to-end, asserts the emitted
    upstream URN is the real table and not the datasource UID. This is the user-visible regression;
    the existing test_extract_panel_lineage_postgres asserts only len(upstreams) == 1, which
    passes identically on both the correct and the fabricated-URN path.

Before:

FAILED test_grafana_lineage.py::test_extract_panel_lineage_with_quoted_template_variable
  - AssertionError: assert 'test_db.public.test_table' in
    'urn:li:dataset:(urn:li:dataPlatform:postgres,test_db.postgres_uid,PROD)'
FAILED test_grafana_query_extraction.py::...::test_removes_all_grafana_variable_formats[braced_variable_in_quotes]
  - assert "WHERE run_id = CAST(''grafana_var'' AS INTEGER)" == "WHERE run_id = CAST('${run_id}' AS INTEGER)"
FAILED test_grafana_query_extraction.py::...::test_cleaned_query_remains_parseable_with_quoted_variables
  - sqlglot.errors.ParseError: Expected AS after CAST. Line 1, Col: 78.
3 failed, 107 passed in 0.68s

After:

111 passed in 0.58s

No existing assertion was changed. ./gradlew :metadata-ingestion:lint is clean (ruff check, ruff
format, mypy).

Follow-up, not in this PR

Four further defects in the same function, each producing the same silent fallback, are
deliberately left out — each is independently arguable and would stall review of a one-line fix.
They are described in the linked issue:

  • $__timeFrom() / $__timeTo() / $__timeGroup(...) are value-producing macros replaced with
    TRUE. An existing test asserts the current behaviour, so fixing it means changing that
    expectation.
  • Macro argument matching uses [^)]*, which is not nesting-aware.
  • _GRAFANA_SIMPLE_VAR_PATTERN treats $ as a sigil mid-identifier, mangling Oracle identifiers
    such as SOME$COL.
  • _parse_sql discards parsed_sql.debug_info.table_error. Surfacing it as a source warning would
    make this whole class of failure discoverable rather than silent. Note also that
    GrafanaSourceReport.report_sql_parsing_{attempt,success,failure} are called only from
    field_utils.py and never from lineage.py, so sql_parsing_failures is structurally always 0
    for the lineage path, while report_lineage_extracted() counts the fabricated URN as a success.
  • The quote guard is adjacency-based, so '%${var}%' — a very common Grafana LIKE idiom — is
    still substituted and still unparseable. Closing it properly means masking string literals before
    substituting, which would let both this pattern and its sibling drop their lookarounds entirely.
  • The integration fixtures in tests/integration/grafana/ contain no Grafana template variables at
    all, so the whole cleaning path has no end-to-end coverage.

Whether _create_basic_lineage should emit a made-up dataset name at all is a design question for
maintainers, and is also left alone here.

Checklist

  • The PR conforms to DataHub's Contributing Guideline (particularly PR Title Format)
  • Links to related issues
  • Tests for the changes have been added/updated
  • Docs related to the changes have been added/updated — n/a, no config or API surface change
  • Entry in Updating DataHub — n/a, bugfix with no breaking change

🤖 Generated with Claude Code


Summary by cubic

Preserves quoted braced Grafana variables during cleaning so SQL keeps parsing. Previously '${var}' became ''grafana_var'', parsing failed, and lineage fell back to a datasource-UID dataset; now quoted ${...} is left as-is so upstreams resolve to the real table.

  • Replace the braced-var regex with (?<!')\$\{[^}]+\}(?!') and align docs/comments; pass ordering continues to substitute unquoted variables.
  • Tests: add quoted braced-var, mixed quoted/unquoted, a parseability check using sqlglot, and an end-to-end lineage test that asserts the real table URN with a real SchemaResolver.
  • Limitation unchanged: the guard is quote-adjacent, not literal-aware; %${var}% can still be substituted and fail to parse. No config or migration changes.

Written for commit d2d8de4. Summary will update on new commits.

Review in cubic

@github-actions

Copy link
Copy Markdown
Contributor

Linear: ING-3336

Thanks for your contribution! We have created an internal ticket to track this PR. A member of the core DataHub team will be assigned to review it within the next few business days - you will get a follow-up comment once a reviewer is assigned.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Re-trigger cubic

…ariables

_clean_grafana_template_variables() substituted the quoted literal 'grafana_var'
for ${var} without checking whether the variable was already inside quotes, so
'${var}' became ''grafana_var'' and the query stopped parsing. The ParseError is
swallowed by create_lineage_sql_parsed_result, which returns a truthy result with
no in_tables, so lineage silently fell back to an upstream URN named after the
Grafana datasource UID - a dataset that does not exist in the source platform.

Give the braced pattern the same negative lookbehind/lookahead that the sibling
_GRAFANA_SIMPLE_VAR_PATTERN already carries, so an already-quoted variable is
left alone in both syntaxes.

Those lookarounds test for an adjacent quote rather than string-literal
containment, so a variable in the middle of a longer literal ('%${var}%') is
still substituted and still fails to parse. That case is byte-identical before
and after this change and is shared with the sibling pattern, so the docstring
states what the guard actually does instead of promising more than it delivers.

Tests: a quoted braced variable is preserved; a query mixing quoted and unquoted
braced variables pins the fix as selective rather than as a blanket stop on
braced substitution; and extract_panel_lineage emits the real table URN rather
than the datasource UID. That last query pairs the quoted variable with a
$__timeFilter macro, so cleaning has to run for it to resolve at all and the
test cannot go vacuous. All three fail without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@daha
daha force-pushed the fix/grafana-quoted-template-variables branch from 1a41a91 to d2d8de4 Compare August 20, 2026 15:39
@maggiehays maggiehays added the needs-review Label for PRs that need review from a maintainer. label Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution PR or Issue raised by member(s) of DataHub Community ingestion PR or Issue related to the ingestion of metadata needs-review Label for PRs that need review from a maintainer.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Grafana source: quoted template variables break SQL parsing, producing upstream lineage to a non-existent dataset named after the datasource UID

2 participants