Skip to content

feat: own the dbt Semantic Layer client, talking to the API directly - #111

Open
josep-reyero wants to merge 8 commits into
mainfrom
feature/LFXV2-2940-dbt-semantic-layer-client
Open

feat: own the dbt Semantic Layer client, talking to the API directly#111
josep-reyero wants to merge 8 commits into
mainfrom
feature/LFXV2-2940-dbt-semantic-layer-client

Conversation

@josep-reyero

Copy link
Copy Markdown
Contributor

Problem

A semantic layer call travels MCP client → lfx-mcp → lfx-lens over HTTP, and
only then to the dbt Semantic Layer. lfx-lens is the text-to-SQL service; the
semantic layer lives there for historical reasons, and the hop buys nothing,
because lfx-lens performs no per-user authorization on those routes.

What it costs is real: extra latency, an extra failure mode, and a second
release train for every semantic layer change. That last one already bit us —
lfx-mcp v0.12.0 sat inert in production until lfx-lens v0.5.5 shipped, because
the MCP tool called an endpoint the deployed lens did not yet have.

What this does

New internal/dbtsl package, peer to internal/lfxv2 and
internal/serviceapi: GraphQL transport, TTL caches, metric and dimension
metadata, the INSIGHTS_METRICS_ALLOWLIST with ranked suggestions and topic
words, the singular-stem and per-word search fallbacks, query execution, and
gated dimension value discovery. Ported from
lfx-lens/ai/services/dbt_semantic_layer.py.

De-branded tool surface. internal/tools/lens.go held both surfaces in 664
lines; it shrinks to query_lfx_lens alone, which still routes through lfx-lens
and keeps its LFXMCP_LENS_API_* settings. The semantic layer moves to
semanticlayer.go with its own config type. Tool names are unchanged — they
carry no lens branding and are already in the production allowlist and in live
client prompts.

Three new settings, LFXMCP_DBT_SL_HOST, LFXMCP_DBT_SL_ENVIRONMENT_ID
and LFXMCP_DBT_SL_TOKEN, wired in a top-level block rather than nested inside
the Auth0 client-credentials guard that wraps the lens client: this client uses
a static service token and has no Auth0 dependency, so nesting it there would
leave it unconfigured for an unrelated reason.

Design note: GraphQL for execution too

There is no Go SDK for the dbt Semantic Layer. Both reference implementations
are Python and split the transport — lfx-lens and dbt Labs' own dbt-mcp both
use the dbtsl SDK with GraphQL for metadata and Arrow Flight for execution.

This uses GraphQL for both, via createQuery then polling query for
jsonResult. That avoids taking on Arrow, gRPC and session lifecycle for no
gain at our volumes: the tool caps limit at 500, well inside the API's
1024-row page, so pagination never engages.

Measured: transport is not the bottleneck. The same query runs 22.7s cold and
1.6s warm
— a 14× difference that can only be the warehouse. Metadata calls
are 0.4–1.2s. The poll loop adds ≤0.2s on fast queries and ≤2s on slow ones.

Design note: project_slug is removed

Not kept as a no-op. Its description promised the where clause was "validated
against that foundation's subtree"
, and the Snowflake scope check that enforced
that is gone — it queried ANALYTICS.SILVER_DIM.PROJECT_SPINE and was the only
part of the lens implementation that was not self-contained.

It was never a security boundary: the tools are staff-gated and project_slug
was optional, so a caller bypassed the check by omitting it. Keeping the
parameter while nothing enforces it would mean a caller passing
project_slug: "cncf" reads a global result as CNCF-scoped — a silent wrong
answer, the same failure class this epic exists to remove.

Scoping now goes in the where clause, which is how it always actually worked
(project_slug only ever validated your filter, it never applied one). This
freed 69 bytes of the query tool's 2048-byte description budget.

When a non-staff tier arrives, scope should be rebuilt on OpenFGA via
internal/lfxv2/access_check.go, not on a warehouse table.

Security note

The dbt client is deliberately not given the serviceapi debug transport.
That calls httputil.DumpRequestOut(req, true), which includes the
Authorization header, and production runs with debugTraffic: true — it would
print this long-lived service token into production logs.

Separately and pre-existing: the lens and onboarding clients already dump
their Auth0 bearer tokens in production under that same setting. Out of scope
here, but it should probably be its own ticket.

Verification

CI runs only MegaLinter and the license-header check, so for the record:

  • make check clean; 62 tests pass, 85.4% coverage on internal/dbtsl.
  • A live parity harness behind the parity build tag
    (go test -tags parity ./internal/dbtsl/) passes against the real semantic
    layer: 59/59 allowlisted metrics reachable, country__lf_region returns the
    9 expected values including 'Asia Pacific', a viet search returns
    'Viet Nam', and both search fallbacks rescue a plural and a
    natural-language query.
  • End to end through the real MCP server over stdio, across every domain,
    global and CNCF-scoped: memberships, contributions, events, education,
    maintainers, project health; multi-metric cross-domain outer joins; year
    trends; MetricFlow categorical and time filters. The PII gate rejects
    user__email, and an unknown metric returns ranked suggestions.

The parity harness earned its keep — it caught three defects a stub could not:

  1. dbt's published docs name the createQuery argument order; the deployed
    schema calls it orderBy. Every ordered query would have failed.
  2. Metric values decoded as float64 reached the model as 4.239559e+06
    rather than 4239559. Now decoded as json.Number.
  3. Callers write time grains as metric_time__year, but the GraphQL API takes
    the grain as its own field. The Arrow path hides this, so every time-series
    query would have broken.

Deploy order

  1. LFXV2-2939 first (lfx-secrets-management#297) grants this service read
    access to the dbt token, and production must be synced before the
    production chart bump or the tools break there.
  2. The matching values PR in lfx-v2-argocd.
  3. Release v0.13.0 (minor: substantially changed tools), then bump
    apps/prod/lfx-mcp.yaml from 0.12.0.

Rollback is clean: revert the pin. lfx-lens still serves the semantic layer
routes until LFXV2-2941, which is sequenced after production verification.

Issue: LFXV2-2940

🤖 Generated with Claude Code

lfx-mcp reaches the semantic layer by calling lfx-lens over HTTP, which
then calls the dbt Semantic Layer. lfx-lens is the text-to-SQL service
and performs no per-user authorization on those routes, so the hop buys
nothing and costs latency, a failure mode, and a second release train.
This is the client that removes it.

There is no Go SDK for the dbt Semantic Layer. Both reference
implementations, lfx-lens and dbt Labs' own dbt-mcp, are Python and
split the transport: GraphQL for metadata, Arrow Flight over gRPC for
execution. This client uses GraphQL for both, which avoids an Arrow,
gRPC and session-lifecycle dependency for no loss at our volumes: the
tools cap limit at 500, well inside the API's 1024-row page.

Ported from lfx-lens ai/services/dbt_semantic_layer.py, minus the
project scope check, which queries Snowflake and is not a security
boundary given the tools are staff-gated and project_slug is optional.

A live parity harness sits behind the 'parity' build tag. It caught
three things a stub could not:

  - The published docs name the createQuery argument 'order'; the
    deployed schema calls it 'orderBy'. Every ordered query would have
    failed. Schema introspection is the source of truth.
  - Metric values decoded as float64 reached the model as 4.239559e+06
    rather than 4239559. Numbers are now decoded as json.Number.
  - Callers write time grains as 'metric_time__year', but the GraphQL
    API takes the grain as its own field. The Arrow path hides this, so
    every time series query would have broken.

Verified against the live semantic layer: 59 of 59 allowlisted metrics
reachable, country__lf_region returns the 9 expected values including
'Asia Pacific', a 'viet' search returns 'Viet Nam', and both search
fallbacks rescue a plural and a natural-language query. Warm queries
return in about 1.6s.

Issue: LFXV2-2940
Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
The semantic layer tools now call the dbt Semantic Layer in process
through internal/dbtsl, instead of proxying through lfx-lens over HTTP.

internal/tools/lens.go held both surfaces in 664 lines. It shrinks to
query_lfx_lens alone, which still routes through lfx-lens and keeps its
LFXMCP_LENS_API_* settings. Everything else moves to semanticlayer.go
with its own config type. The tool descriptions and help texts move
verbatim, since they carry prompt engineering that took three
iterations to land and three tests guard them.

The dbt client is constructed in a top-level block in main.go rather
than nested inside the LFX API guard that wraps the lens client: it
authenticates with a static service token and has no Auth0 dependency,
so nesting it there would leave it unconfigured for an unrelated
reason. It is deliberately not given the serviceapi debug transport,
which dumps the Authorization header and would print the dbt service
token into production logs, where debug traffic is currently on.

project_slug is removed from the query tool rather than kept as an
accepted no-op. Its description promised the where clause was
'validated against that foundation's subtree', and with the Snowflake
scope check gone nothing enforces that. A parameter that silently does
nothing while reading as a scoping guarantee is the same class of
failure as a filter value that returns zero rows instead of an error.
Scoping now happens in the where clause like any other filter, which
is how it always actually worked. This freed 69 bytes of the query
tool's 2048-byte description budget.

Issue: LFXV2-2940
Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
Adds LFXMCP_DBT_SL_HOST and LFXMCP_DBT_SL_ENVIRONMENT_ID as values, and
LFXMCP_DBT_SL_TOKEN from the lfx-mcp-secrets Secret under the
dbt_semantic_service_token key.

No ExternalSecret change is needed: it already merges every AWS secret
tagged service-lfx-mcp into that Secret, and the dbt token gains that
tag in lfx-secrets-management (LFXV2-2939). The token env var is
optional, so a cluster without the tag applied yet still starts, with
the semantic layer tools reporting themselves unconfigured.

Also documents the two data paths in AGENTS.md, since query_lfx_lens
and the semantic layer tools no longer share a backend, and splits them
in the README tool tables.

Issue: LFXV2-2940
Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
Copilot AI review requested due to automatic review settings July 31, 2026 15:20

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

Copilot AI 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.

Pull request overview

Moves semantic-layer operations from LFX Lens into a direct dbt GraphQL client, reducing service dependencies and latency.

Changes:

  • Adds the internal/dbtsl client with caching, discovery, allowlisting, and query execution.
  • Separates semantic-layer tools from Lens and adds extensive tests.
  • Adds server, Helm, and documentation configuration for dbt credentials.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
README.md Documents the standalone semantic-layer tools.
internal/tools/semanticlayer.go Implements direct semantic-layer MCP tools.
internal/tools/semanticlayer_test.go Tests tool behavior and schemas.
internal/tools/lens.go Removes semantic-layer routing from Lens.
internal/tools/lens_test.go Removes migrated Lens tests.
internal/tools/csv.go Extracts CSV argument parsing.
internal/dbtsl/similarity.go Implements fuzzy similarity scoring.
internal/dbtsl/search.go Adds search fallbacks.
internal/dbtsl/query.go Implements GraphQL query execution and polling.
internal/dbtsl/parity_live_test.go Adds live parity tests.
internal/dbtsl/metadata.go Fetches metric and dimension metadata.
internal/dbtsl/dimensionvalues.go Implements guarded dimension-value discovery.
internal/dbtsl/dbtsl_test.go Adds dbt client unit tests.
internal/dbtsl/client.go Defines the GraphQL client and transport.
internal/dbtsl/cache.go Adds bounded TTL caches.
internal/dbtsl/allowlist.go Defines exposed metrics and suggestions.
cmd/lfx-mcp-server/main.go Configures and registers the dbt client.
charts/lfx-mcp/values.yaml Adds dbt Helm values.
charts/lfx-mcp/templates/deployment.yaml Injects dbt configuration and credentials.
AGENTS.md Documents architecture and configuration.

Comment thread internal/tools/semanticlayer.go
Comment thread internal/tools/csv.go
Comment thread internal/dbtsl/allowlist.go
Comment thread internal/dbtsl/cache.go
Comment thread internal/dbtsl/dimensionvalues.go
Comment thread internal/dbtsl/similarity.go
Comment on lines +448 to +452
queryArgs := dbtsl.QueryArgs{
Metrics: metrics,
GroupBy: parseCSV(args.GroupBy),
OrderBy: parseCSV(args.OrderBy),
Limit: args.Limit,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ca8fec3, and this turned out to be the most serious finding on the PR — thank you.

You were right that nothing enforced the ceiling, and the consequence is worse than an upstream default row count. The dbt SL GraphQL API pages results at ~1024 rows. Against the live environment, total_activities grouped by activity_project_id__organization_name with no limit returned exactly 1024 rows and reported row_count: 1024 — a truncated answer indistinguishable from a complete one, which the model would then present as fact. The Arrow Flight transport the Python implementation used streams the whole result, so this is a hazard the port introduced rather than inherited.

Two fixes: the tool now defaults an omitted or negative limit to the 500 it advertises, and Query follows totalPages to the end so a paged result is never silently cut. Same query now returns 500. Test: TestSemanticLayerDefaultsTheLimitToTheAdvertisedCeiling.

Comment thread internal/dbtsl/query.go Outdated
Comment on lines +135 to +137
interval := pollInitialInterval
for {
result, err := c.pollQuery(ctx, queryID)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ca8fec3. Confirmed: stdio runs on a background context and main.go sets only ReadHeaderTimeout, so nothing bounded the loop. Query now carries its own queryMaxWait budget while still honouring earlier caller cancellation.

Reviewing this also surfaced the opposite failure alongside it: pollQuery returning any error aborted immediately, so a single 502 mid-poll discarded a query still running upstream and about to succeed. The Python route retried once on transport errors, so that was a regression. The loop now absorbs a bounded number of consecutive transport errors, while a FAILED status still aborts at once — the same split the Python had between QueryFailedError and transport errors. Tests: TestQueryToleratesATransientPollFailure, TestQueryGivesUpAfterRepeatedPollFailures, TestQueryDoesNotRetryAnApplicationFailure.

Comment on lines +79 to +83
if disallowed := ValidateMetrics(metricNames); len(disallowed) > 0 {
return nil, &UnknownDimensionError{Message: fmt.Sprintf(
"Metrics not in allowlist: %s.", strings.Join(disallowed, ", "),
)}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ca8fec3, and I confirmed the premise against the live API rather than assuming it: FetchDimensions(ctx, nil) returns 295 dimensions — every dimension in the environment, so an empty metric list does exactly what you describe.

One correction to the severity: it is not reachable today. The only caller, handleSLGetDimensionValues, rejects an empty metrics before this point. But the gate is documented as an invariant of this function, and it was not one, so it now holds where it is claimed. Test: TestFetchDimensionValuesRejectsAnEmptyMetricList.

Comment thread internal/dbtsl/similarity.go Outdated
Comment on lines +65 to +69
if runLengths[j] > length {
length = runLengths[j]
aStart = i - length + 1
bStart = j - length + 1
}
Review of the branch surfaced four defects, three of them found by
running the code against the live semantic layer rather than a stub.

Results were silently truncated. The GraphQL API pages at about 1024
rows where Arrow Flight, which the Python implementation used, streams
the whole result, so this is a hazard the port introduced rather than
inherited. createQuery only sent a limit when one was given, and the
tool never defaulted it, so the common case of a model omitting limit
issued an unbounded query. Live, 'total_activities grouped by
organization_name' returned exactly 1024 rows and reported row_count
1024: a truncated answer indistinguishable from a complete one. Query
now follows totalPages to the end, and the tool defaults an omitted or
negative limit to the 500 its description advertises. The same query
now returns 500.

similarityRatio did not reproduce difflib. The j scan ran backwards,
which is the compact way to write the rolling array but inverts
difflib's tie-break: among equal-length runs it kept the latest j where
difflib keeps the earliest, stranding the rest of a against a shorter
tail. Measured against real difflib output, 25% of ordered pairs of
allowlisted metric names disagreed. The point of hand-porting rather
than using an edit distance was that suggestions would not change in
the port, so this defeated the exercise. The test did not catch it
because all eight hand-picked pairs happened to be ones where the
tie-break does not bite; it is replaced by a sweep of all 3490 pairs
against ratios generated by difflib itself, which fails on 879 of them
against the old implementation.

The poll loop had no bound of its own and no tolerance for a transient
failure. stdio runs on a background context and the HTTP server sets
only ReadHeaderTimeout, so a query stuck in RUNNING polled until the
process exited; and a single 502 mid-poll discarded a query that was
still running upstream, which the Python route had retried. Query now
carries its own budget and absorbs a bounded number of consecutive
transport errors, while a FAILED status still aborts at once.

FetchDimensionValues accepted an empty metric list. Nothing in an empty
list is disallowed, so it passed the allowlist check and then asked for
dimensions scoped to no metric, which the live API answers with all 295
dimensions in the environment, including ones no allowlisted metric
exposes. The only caller rejects an empty list first, so this was not
reachable, but the gate is documented as living in the client.

Also adds the per-file package comments the repo requires.

Issue: LFXV2-2940
Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
Copilot AI review requested due to automatic review settings July 31, 2026 15:50
@josep-reyero

Copy link
Copy Markdown
Contributor Author

Pushed ca8fec3, addressing the Copilot review plus an independent review pass.

The one that matters: results were being silently truncated.

The dbt SL GraphQL API pages at ~1024 rows. Arrow Flight, which the Python implementation used, streams the whole result — so this is a hazard the GraphQL port introduced rather than inherited, and the package doc actively hid it by asserting "pagination never engages". createQuery only sent a limit when one was given, and the tool never defaulted it, so the common case of a model omitting limit issued an unbounded query.

Live, against production data:

total_activities grouped by activity_project_id__organization_name, no limit
  before:  row_count 1024   <- the page boundary, not the answer
  after:   row_count 500    <- the ceiling the tool advertises

A truncated result reported as complete is the same class of failure this tool set exists to prevent — it is the row-count analogue of a wrong filter literal returning zero rows instead of an error. Fixed on both sides: Query now follows totalPages to the end, and an omitted or negative limit defaults to 500.

Also fixed

  • similarityRatio did not reproduce difflib. The backwards j scan inverted difflib's tie-break, so among equal-length runs it kept the latest j instead of the earliest, stranding the rest of a against a shorter tail. 879 of 3490 ordered pairs of allowlisted metric names (25%) disagreed with real difflib. Since the whole point of hand-porting rather than using an edit distance was that suggestions would not shift in the port, this defeated the exercise.
  • The poll loop had no bound of its own — stdio runs on a background context and the HTTP server sets only ReadHeaderTimeout, so a query stuck in RUNNING polled until the process exited. It also had the opposite problem: any transport error aborted immediately, so a single 502 mid-poll discarded a query still running upstream, which the Python route had retried. Now bounded, and tolerant of a few consecutive transport errors, while FAILED still aborts at once.
  • FetchDimensionValues accepted an empty metric list. Verified live: FetchDimensions(ctx, nil) returns 295 dimensions, every one in the environment. Not reachable through the tool (the handler rejects empty metrics first), but the gate is documented as living in the client.
  • Per-file package comments on all nine new files, per AGENTS.md.

On the test that let the difflib bug through

It used eight hand-picked pairs and passed both before and after the fix — every pair happened to be one where the tie-break does not bite. It is replaced by a sweep of the full domain against ratios generated by difflib itself, checked in as a fixture. I verified the replacement has teeth by running it against the old implementation (879/3490 fail) and the new one (0 fail). Worth stating plainly: the original test was the actual defect here, not just the code.

Verification

make check clean, go test ./... and go test -race pass. The live parity harness is green end to end (11/11 TestLive* against the real semantic layer), and the paging fix was confirmed through the real MCP server over stdio, not just against stubs.

Copilot AI 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.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (2)

internal/dbtsl/metadata.go:58

  • The dimension metadata query omits queryableTimeGranularities, so get_dimensions can silently drop supported grains. The dbt reference client requests both fields and combines them (for example, its fixtures have queryableGranularities: ["day"] and queryableTimeGranularities: ["month"]); this implementation currently reports only the former as queryable_time_granularities. Request and decode both fields, then merge them when building DimensionInfo (and cover a grain present only in queryableTimeGranularities).
      queryableGranularities

internal/dbtsl/parity_live_test.go:82

  • This parity check still passes when one or more allowlisted metrics disappear upstream; it only fails when the entire intersection is empty. That defeats the stated rename-detection purpose and allows a stale metric to remain accepted by ValidateMetrics even though queries for it fail. Make a non-empty missing list fail the test rather than only logging it.
	if len(missing) > 0 {
		t.Logf("allowlisted but not reachable upstream: %v", missing)
	}

revive rejects the branch: allowlist.go named a local slice 'close',
shadowing the builtin. This is what was failing MegaLinter, not the
jscpd duplication report, which .mega-linter.yml lists under
DISABLE_ERRORS_LINTERS and is non-blocking. Renamed to 'closest'.

metricsPaginated and dimensionsPaginated were read a page at a time
with no check that a page was all there was. That is the same defect
just fixed for query results, and it was inherited from the Python
rather than introduced, but the consequence here is a partial
allowlist: metrics that exist reported as unknown, and a suggested
topic search returning nothing. FetchDimensions is worse, since
FetchDimensionValues uses exactly that list to decide what may be read,
so a missing dimension reads as one that does not exist.

Both now follow totalPages. Live, both report totalPages 1 today, with
all 295 dimensions on a single page, so this changes no current
behaviour and only removes the assumption.

Issue: LFXV2-2940
Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
Copilot AI review requested due to automatic review settings July 31, 2026 15:57
Enumerating a slug dimension works but is the wrong tool for the job:
the values come back capped and alphabetical, so the slug being looked
for is usually not in the list, and it is slow to build
(project_spine_slug measured 14.8s live against 1-2s for other
dimensions). search_projects answers that question directly, by name.

The guidance is a runtime note on the result rather than a line in the
tool description. Description bytes are the scarcest resource on this
surface — explore has 7 of its 2048 left, so this could only have been
bought by deleting other guidance — and a note fires exactly when the
mistake is made instead of being paid for on every call. It is emitted
as its own content block so it cannot be read as part of the data, and
it lives in internal/tools rather than internal/dbtsl, since the name
of an MCP tool is not something the semantic layer client should know.

pollMaxInterval stays at 2s. A 1s ceiling was considered: the cap only
engages past about 3s, so it does nothing for the warm queries that
return in 0.9-1.6s, and where it does engage it trades up to 1s of wall
time for 10 extra requests on a 22.5s query — polling hardest exactly
when the warehouse is slowest, to save 4%. Recorded next to the
constant so it is not re-litigated.

Also drops the lens test harness left dead by the lens.go split. It
existed for the semantic layer HTTP tests, which now stub dbtsl
directly. No coverage is lost: query_lfx_lens had no behavioural test
before this branch either, only description assertions.

Issue: LFXV2-2940
Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (3)

internal/dbtsl/dimensionvalues.go:134

  • search is described as a plain substring, but % and _ are inserted into ILIKE without escaping and therefore remain SQL wildcards. A literal search such as 100% or _unknown can match unrelated values. Escape LIKE metacharacters as well as quotes and add an explicit ESCAPE clause supported by the Semantic Layer SQL dialect.
	if search != "" {
		args.Where = []string{fmt.Sprintf(
			"{{ Dimension('%s') }} ILIKE '%%%s%%'", dimension, escapeSQLLiteral(search),
		)}

internal/dbtsl/dimensionvalues.go:180

  • This cannot reliably indicate truncation. Exactly limit total values is reported as truncated even when the result is complete; conversely, because NULLs are removed after the upstream limit is applied, a page containing NULL plus additional unseen values can produce fewer than limit values and be reported complete. Fetch an extra non-null row (for example, filter NULL and request limit+1) and derive Truncated before slicing the returned values to limit.
func newDimensionValues(dimension string, values []string, limit int) *DimensionValues {
	return &DimensionValues{
		Dimension:  dimension,
		Values:     values,
		ValueCount: len(values),
		Truncated:  len(values) >= limit,

internal/dbtsl/allowlist.go:274

  • The fuzzy fallback does not preserve difflib.get_close_matches ranking for equal ratios. AllowedMetricNames starts in ascending order and this stable sort keeps ties ascending, while Python's nlargest over (score, candidate) ranks equal-score candidates in descending lexical order. Add the candidate name as a descending tie-break so the port does not reorder tied suggestions.
	sort.SliceStable(closest, func(i, j int) bool { return closest[i].ratio > closest[j].ratio })

Copilot AI review requested due to automatic review settings July 31, 2026 16:03

Copilot AI 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.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (2)

internal/dbtsl/parity_live_test.go:82

  • This parity check still passes when most allowlisted metrics disappear upstream: missing is only logged, while the only assertion requires one reachable metric. A renamed or removed metric would therefore leave the production allowlist stale without failing the harness. Make the missing set fail the test.
	if len(missing) > 0 {
		t.Logf("allowlisted but not reachable upstream: %v", missing)
	}

internal/dbtsl/dbtsl_test.go:1053

  • This regression test does not distinguish json.Number from the old float64 decoding: 4,239,559 is exactly representable, and encoding/json marshals that float as 4239559 without an exponent, so all current assertions also pass with UseNumber removed. Assert the decoded type (or use an integer above 2^53) so the test actually protects exact numeric decoding.
func TestParseQueryResultKeepsLargeIntegersExact(t *testing.T) {
	raw := `{"schema":{"fields":[{"name":"total_contributors","type":"integer"}],"primaryKey":[]},
	         "data":[{"total_contributors":4239559}]}`

The test guarding json.Number decoding passed with UseNumber removed,
so it protected nothing. 4239559, the count actually observed in the
wild, is exactly representable as a float64 and re-encodes as
"4239559" with no exponent, so every assertion held either way.

Uses 2^53+1 instead, which cannot survive a float64 round trip, and
asserts the decoded type rather than inferring it from the rendering.
Without UseNumber it now fails with 9.007199254740992e+15 — both the
exponent and the lost +1.

Same defect as the difflib test fixed earlier on this branch: a guard
written from the observed symptom rather than the mechanism, passing
in both directions.

Issue: LFXV2-2940
Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
Copilot AI review requested due to automatic review settings July 31, 2026 16:19

Copilot AI 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.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

internal/tools/semanticlayer.go:66

  • This standalone query-tool description says it covers all events and maintainers, while only redirecting narrative questions to Lens. Because either semantic-layer tool can be enabled independently, callers that only see this schema will miss the exceptions documented by the explore tool and route event sponsorships or maintainer trends here. Include those exceptions in this description as well.
const querySemanticLayerDescription = `The LFX Insights Semantic Layer is the query and data-exploration tool for Linux Foundation data; this half runs the query. Covers contributions, memberships, events, education, maintainers and project health — and anything sliced by country or region. ALWAYS call explore_lfx_semantic_layer first unless you already have the exact metric, dimension and entity names — never guess or assemble one: a wrong name errors, and a wrong filter value returns no rows rather than an error, so confirm literals with get_dimension_values. Use query_lfx_lens for narrative or "why" questions that do not reduce to a metric.

internal/dbtsl/allowlist.go:139

  • NoMetricsDetail renders this list as recommended recovery topics, so every failed metric search currently tells callers to try sponsorship. That conflicts with the tool descriptions and tests that assign event sponsorships to query_lfx_lens; remove it from the advertised fallback topics.
	"sponsorship",

internal/dbtsl/parity_live_test.go:82

  • This parity check still passes when one or more allowlisted metrics disappear upstream, so an upstream rename will not actually fail the harness despite the test's stated purpose. Make missing metrics a test failure to preserve the allowlist/API parity guarantee.
	if len(missing) > 0 {
		t.Logf("allowlisted but not reachable upstream: %v", missing)
	}

README.md:293

  • The new user-facing guidance is broader than the tool contract: event sponsorships and maintainer trends both reduce to metrics, but the semantic-layer descriptions explicitly route them to query_lfx_lens. Qualify this recommendation so users are not directed to the wrong backend.
Prefer these over `query_lfx_lens` for anything that reduces to a metric: the
answer is repeatable and auditable, where generated SQL is neither.

Comment on lines +132 to +134
args.Where = []string{fmt.Sprintf(
"{{ Dimension('%s') }} ILIKE '%%%s%%'", dimension, escapeSQLLiteral(search),
)}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed against the live API and fixed in 2065510. Good catch — this was worse in practice than on paper.

search "_"    -> 20 values: [Argentina Bahamas Bahrain Bangladesh Barbados ...]   # every country
search "%"    -> 20 values: [Angola Antarctica Antigua and Barbuda Austria ...]   # everything
search "viet" ->  1 value:  [Viet Nam]                                            # correct

This action exists to hand back exact literals, because a filter naming a real dimension with an unknown value returns zero rows rather than an error. Returning a list of values that did not match inverts that: it reads as though they did.

One correction to the suggested fix. ESCAPE is supported, but backslash is the wrong escape character — it is also the escape character of the SQL string literal, so it is consumed during string parsing before ILIKE ever sees it. I tried it first and the pattern '%\_%' ESCAPE '\\' matched Germany and Yemen, neither of which contains an underscore. Using !, which has no meaning inside a string literal, gives the right result: literal _ and % searches now return 0 rows and viet still returns Viet Nam.

Worth noting where the test for this landed. A stub server cannot catch it — it echoes whatever rows the test queued, so the ILIKE pattern is never evaluated. The unit test only pins the clause that gets built; the one that would actually have caught the bug is in the parity harness (TestLiveDimensionValuesTreatsSearchAsALiteral), where the real ILIKE runs and a literal _ search has to return nothing.

search is documented as a plain substring, but % and _ went into the
ILIKE pattern unescaped and stayed wildcards. Live, a search for "_"
returned every country name in the environment and "%" did the same.
That is the worst failure this action can have: it exists to hand back
exact literals so a filter matches something, and a list of unrelated
values reads as though those values matched.

Metacharacters are now escaped and an ESCAPE clause accompanies the
pattern. The escape character is '!', not the obvious backslash:
backslash is also the escape character of the SQL string literal, so it
is consumed during string parsing before ILIKE sees it. Verified live,
a '%\_%' pattern matched Germany and Yemen.

The unit tests could not have caught this, and the new one only pins
the clause that gets built: a stub echoes whatever rows the test
queued, so nothing about the pattern is actually evaluated. The test
that matters is in the parity harness, where the real ILIKE runs and a
literal search for "_" must return nothing.

Issue: LFXV2-2940
Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
Copilot AI review requested due to automatic review settings July 31, 2026 16:29

Copilot AI 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.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

internal/dbtsl/query.go:361

  • The nested GroupByInput in an order clause also requires timeGranularity; grain is not the GraphQL field name. As written, ordering by metric_time__year (or another time grain) is rejected even if the corresponding group-by is fixed.
				groupBy["grain"] = grain

Comment thread internal/dbtsl/query.go
input := map[string]any{"name": name}
if base, grain, ok := splitTimeGrain(name); ok {
input["name"] = base
input["grain"] = grain

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Checked this against the deployed schema and it does not hold — grain is a real field and the queries run.

Introspecting GroupByInput on our environment:

name:            String          (NON_NULL)
timeGranularity: String          (SCALAR)
grain:           TimeGranularity (ENUM)
datePart:        DatePart        (ENUM)
alias:           String          (SCALAR)

Both exist. timeGranularity is a loose string, grain is the typed enum, and the enum is the better choice here precisely because the server validates it — a bad grain is rejected at the API rather than silently producing the wrong bucket.

The claim that this "makes GraphQL reject every grouped time-series query before execution" is testable, so I tested it. Raw mutation with grain: YEAR straight at the API returns a queryId and compiles, no error. End to end through this client:

--- PASS: TestLiveQueryWithTimeGrain (1.12s)
    columns: [METRIC_TIME__YEAR TOTAL_CONTRIBUTORS], rows: 5
      map[METRIC_TIME__YEAR:2030-01-01T00:00:00.000Z TOTAL_CONTRIBUTORS:1]
      ...

That test is in the parity harness and runs against the live semantic layer, so it is exactly the assertion this comment predicts would fail.

I think the reasoning is from the current Python SDK's serialization rather than our deployed GraphQL schema. Worth flagging that the two disagree in the other direction too: the published docs name the createQuery ordering argument order, while the deployed schema calls it orderBy — introspection caught that one during this port, and it would have broken every ordered query. Introspection against the live endpoint is the source of truth for this client, not the SDK or the docs.

No change made.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants