GLQL: Parameterised field syntax for analytics dimensions and metrics
## Summary Analytics dimensions and metrics that accept backend parameters (e.g., granularity on time fields, quantile on duration metrics) need a GLQL syntax for users to control these values. This issue tracks the design and implementation of **parameterised fields**, replacing the earlier `timeSegment()` proposal with a generic pattern that works for any parameterised field. ## Resolved design The design was resolved through discussion in [this thread](https://gitlab.com/gitlab-org/glql/-/work_items/130#note_3389296683), with input from @drosse, @pshutsin, @jiaan, and @rob.hunt. ### Syntax Fields that accept backend parameters use positional or named parameter syntax: ```yaml mode: analytics query: type = Pipeline and project = "gitlab-org/gitlab" and finished > -30d dimensions: finished(weekly), status metrics: totalCount, successRate, durationQuantile(0.95) sort: finished desc ``` - Fields with a **single parameter** accept positional or named syntax: `durationQuantile(0.95)` and `durationQuantile(quantile=0.95)` are equivalent - Fields with **multiple parameters** require named syntax: `finished(granularity=weekly, offset=5)` - Named parameters use `=` as the separator (e.g., `granularity=weekly`) - Values are unquoted for known identifiers and numbers - Mixing positional and named arguments in the same field is not allowed - Fields without explicit parameters use a source-defined default automatically - Sort inherits parameters from the matching dimension or metric ### Parameter types Two parameter types exist across all six aggregation engines. No others. **Granularity** (on time-based dimensions): fixed set, validated server-side per engine. | Engine | Fields | Allowed values | | --------------------- | ----------------------- | ---------------------- | | FinishedPipelines | `started`, `finished` | daily, weekly, monthly | | CodeSuggestions | `timestamp` | monthly | | AiUsageEvents | `timestamp` | daily, weekly, monthly | | AgentPlatformSessions | `createdEventAt` | weekly, monthly | | Contributions | `createdAt` | daily, weekly, monthly | | Deployments | `createdAt`, `finishedAt` | daily, weekly, monthly | **Quantile** (on duration metrics): arbitrary float in `[0.01, 0.99]`, not validated server-side (goes straight to ClickHouse). | Engine | Field | | --------------------- | ---------------------------- | | FinishedPipelines | `durationQuantile` | | AgentPlatformSessions | `durationQuantile` | | Deployments | `deploymentDurationQuantile` | ### Compiler types ```rust enum ParameterConstraint { Enum(&'static [&'static str]), Range { min: f64, max: f64 }, } enum ParameterValue { Str(&'static str), Float(f64), } struct FieldParameterDef { graphql_key: &'static str, constraint: ParameterConstraint, default_value: ParameterValue, } ``` `FieldParameterDef::new()` is a const constructor that panics on mismatched variant/constraint pairings (e.g., `Float` default with `Enum` constraint), caught at compile time for static definitions. Shared constants avoid typos across sources: ```rust const GRANULARITY_DAILY: &str = "daily"; const GRANULARITY_WEEKLY: &str = "weekly"; const GRANULARITY_MONTHLY: &str = "monthly"; const QUANTILE_RANGE: ParameterConstraint = ParameterConstraint::Range { min: 0.01, max: 0.99 }; ``` The parser distinguishes positional from named arguments at parse time: ```rust enum FunctionArg { Positional(String), Named(String, String), } ``` The `SourceAnalyzer` trait exposes parameter metadata as a slice, supporting one or many parameters per field: ```rust fn field_parameters(&self, field: &Field) -> &[FieldParameterDef] { &[] // default: field doesn't accept parameters } ``` ### DisplayField variant A new `ParameterizedField` variant on `DisplayField` separates backend parameters from client-side field functions: ```rust pub enum DisplayField { Static(Field), FieldFunction(String, Vec<FunctionArg>), // client-side only (labels("bug")) ParameterizedField(Field, Vec<(String, String)>), // backend params (started, [("granularity", "weekly")]) AliasedDisplayField(Box<DisplayField>, String), } ``` `ParameterizedField` wraps a `Field` and a vector of resolved key-value pairs, so `base_field()` returns `Some(&field)` and it participates fully in validation and sort coupling. `FieldFunction` stays as-is for client-side transforms. ### Resolution flow The parser stays mode-agnostic. It produces `FieldFunction("started", [Positional("weekly")])` or `FieldFunction("started", [Named("granularity", "weekly")])` depending on the syntax used. The analyzer then resolves it to `ParameterizedField(Field::Started, [("granularity", "weekly")])` using `field_parameters()` metadata. Resolution happens at the top of `validate_analytics_fields`, before existing validation. Resolution rules: - **Positional args, single-param field** (`field_parameters().len() == 1`): each positional value is mapped to that parameter's `graphql_key` - **Positional args, multi-param field** (`field_parameters().len() > 1`): compile error: *"finished accepts multiple parameters, use named syntax: finished(granularity=weekly, ...)"* - **Named args**: each key is matched against `graphql_key` entries in `field_parameters()`. Unknown keys are a compile error. - **Mixed positional and named**: compile error - **Too many positional args** for a single-param field: compile error - **Duplicate named keys**: compile error - **Value validation**: resolved values are checked against `ParameterConstraint` (enum membership for `Enum`, bounds for `Range`) ### Sort inheritance Sort inherits parameters from the matching dimension or metric. Users write `sort: started desc` and the compiler looks up the granularity from the resolved dimension. Sort fields don't accept parameters directly (`Sort` holds a `Field`, not a `DisplayField`), so dimension/sort granularity mismatches can't happen. ### v1 limitations - One instance per field name. Duplicate metrics with different parameters (e.g., `durationQuantile(0.5)` and `durationQuantile(0.95)`) are not supported in v1. The planned syntax for this uses aliasing: ```yaml metrics: durationQuantile(0.5) as "p50", durationQuantile(0.95) as "p95" sort: p95 desc ``` - All v1 fields accept a single parameter, so positional syntax is always valid today. When multi-parameter fields are added in future, those fields will require named syntax. Existing single-param queries (positional or named) will continue to work unchanged. ## Worked examples ### Pipeline success rates by week ```yaml mode: analytics query: type = Pipeline and project = "gitlab-org/gitlab" and finished > -30d dimensions: finished(weekly), status metrics: totalCount, successRate sort: finished desc ``` The named form `finished(granularity=weekly)` is also valid. Compiles to: ```graphql query GLQL($before: String, $after: String, $limit: Int) { project(fullPath: "gitlab-org/gitlab") { analytics { finishedPipelines(finishedAtFrom: "2026-05-17 00:00") { aggregated( before: $before, after: $after, first: $limit orderBy: [{ identifier: "finishedAt", direction: DESC, parameters: { granularity: "weekly" } }] ) { nodes { dimensions { finishedAt(granularity: "weekly") status } totalCount successRate } } } } } } ``` ### Code suggestions with default granularity ```yaml mode: analytics query: type = CodeSuggestion and timestamp >= -3m dimensions: timestamp, language metrics: totalCount, acceptanceRate sort: timestamp asc ``` `timestamp` has no explicit parameter, so the compiler applies the default: `monthly` (the only value CodeSuggestions supports). The compile output includes `{ field: "timestamp", parameter: { granularity: "monthly" } }` so the frontend knows what was applied. ### Invalid granularity ```yaml dimensions: timestamp(daily), language ``` Compile error: `timestamp(daily) is not supported for CodeSuggestion. Supported granularities: monthly` ### Quantile ```yaml metrics: totalCount, durationQuantile(0.95) sort: durationQuantile desc ``` The named form `durationQuantile(quantile=0.95)` is also valid. Sort inherits the quantile parameter from the metric: ```graphql orderBy: [{ identifier: "durationQuantile", direction: DESC, parameters: { quantile: 0.95 } }] ``` ## Implementation plan ### GLQL compiler (`gitlab-org/glql`) **MR 1: Add `FieldParameterDef` metadata to `SourceAnalyzer` trait** (!406) - Add `FieldParameterDef`, `ParameterConstraint`, `ParameterValue` and shared constants (`GRANULARITY_*`, `QUANTILE_RANGE`) - `FieldParameterDef::new()` const constructor validates variant/constraint alignment at compile time - Add `field_parameters()` default method on `SourceAnalyzer` (returns `&[]`) - Override for `PipelinesAnalyticsAnalyzer` and `CodeSuggestionsSourceAnalyzer` - Tests: verify metadata returned correctly per source/field, `Display` formatting, default-within-range validation - No behaviour change **MR 2: Add `ParameterizedField` variant and parser support** - Add `FunctionArg` enum (`Positional(String)`, `Named(String, String)`) - Add `ParameterizedField(Field, Vec<(String, String)>)` variant to `DisplayField` with `.name()`, `.key()`, `.label()`, `.base_field()` implementations - Extend parser argument grammar to accept unquoted identifiers, floats, and `key=value` pairs alongside existing quoted strings - Update `FieldFunction` args from `Vec<String>` to `Vec<FunctionArg>` - `FieldFunction.key()` quotes all parameter values (both positional and named) for round-trip safety through the Typed transform path, which re-parses keys via `parse_fields()` - Add passthrough match arm in `transform_fields` - `parse_fields_without_functions()` remains unchanged -- the parser accepts the new syntax via `parse_fields()` but analytics mode does not use it yet (deferred to MR 3 to avoid a validation gap) - Tests: parser round-trips for positional and named forms, DisplayField methods, transform passthrough for ParameterizedField - Known gap: standard mode does not validate field function names at compile time, so `finished(weekly)` in standard mode compiles but produces invalid GraphQL. This is a pre-existing issue tracked in https://gitlab.com/gitlab-org/glql/-/work_items/139 and is not introduced by this work - No behaviour change **MR 3: Add resolution and validation in the analyzer** - Relax `parse_fields_without_functions()` to allow `FieldFunction` in analytics dimensions/metrics (moved from MR 2 to avoid a validation gap -- `FieldFunction.base_field()` returns `None`, so removing the parser-level guard without simultaneous analyzer-level validation would silently accept invalid syntax) - Standard mode rejection: when `validate_standard_fields` encounters a `FieldFunction` whose name matches a parameterisable field (`field_parameters()` returns non-empty), reject with a clear error directing the user to analytics mode. This narrows the pre-existing gap tracked in https://gitlab.com/gitlab-org/glql/-/work_items/139 - Resolution step at top of `validate_analytics_fields`: `FieldFunction` -> `ParameterizedField` using `field_parameters()` metadata - Positional resolution: if `field_parameters().len() == 1`, map value to that parameter's key - Named resolution: match keys against `graphql_key` entries - Apply `default_value` for parameterisable fields without explicit parameters. Note: `default_value` is now `ParameterValue` (typed), so resolution code should match on the enum variant (`Str`/`Float`) rather than parsing strings - Structural validation: - Positional args on multi-param field: compile error listing expected param names - Named key doesn't match any known parameter: compile error - Mixed positional and named args: compile error - Too many positional args for single-param field: compile error - Duplicate named keys: compile error - Value validation against `ParameterConstraint` (enum membership for `Enum`, bounds for `Range`). Use `ParameterValue::Float` for direct numeric comparison against `Range` bounds without parsing - Tests: valid params resolve (both positional and named), invalid params error, defaults applied, non-parameterisable fields rejected, structural validation errors - Behaviour change: new syntax is accepted but codegen still uses hardcoded values **MR 4: Thread parameters through codegen and sort** - Analytics codegen reads parameters from resolved `ParameterizedField` - Sort codegen reads parameters from matching resolved dimension/metric - Use `ParameterValue`'s `Display` impl to format values in GraphQL output (handles string vs float formatting automatically) - Remove hardcoded `"weekly"` and `"0.95"` from `PipelinesAnalyticsAnalyzer` - Tests: generated GraphQL uses resolved parameters, sort parameters match, existing queries work via defaults - Behaviour change: users gain control over granularity and quantile values **MR 5: Expose resolved parameters in compile output** - Include resolved parameter info in serialised compile output per field - Tests: verify compile output shape - No frontend changes, just makes the data available ### GitLab frontend (`gitlab-org/gitlab`) Tracked separately in gitlab#603198 -- use compile output parameter metadata to replace hardcoded `TIME_DIMENSIONS`, surface applied defaults in UI, update docs and query examples. ## References - Original discussion: !367 [comment](https://gitlab.com/gitlab-org/glql/-/merge_requests/367#note_3198368171) - Parameterised fields proposal: [comment](https://gitlab.com/gitlab-org/glql/-/work_items/130#note_3389296683) - Design resolution: [comment](https://gitlab.com/gitlab-org/glql/-/work_items/130#note_3459939784) - Named parameters feedback: [comment](https://gitlab.com/gitlab-org/glql/-/work_items/130#note_3460514814) - Positional/named design: [comment](https://gitlab.com/gitlab-org/glql/-/work_items/130#note_3461349221) - Hardcoded values to replace: `src/analyzer/sources/pipelines/analytics.rs:90-120` - showTrends (depends on this): #102 - Sparklines (depends on this): #105 - Parent epic: &21207
issue