Skip to main content

table/requests/
semantic.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Table semantic layer vocabulary.
16//!
17//! A thin layer of semantic metadata attached to a table via `table_options`, so
18//! machine consumers (LLM agents, alert/dashboard builders, MCP servers, ETL) can
19//! align a table with the observability concept it stands for without guessing
20//! from column names. See `docs/rfcs/2026-05-28-table-semantic-layer.md`.
21//!
22//! The vocabulary is intentionally small: a key earns its place only when it
23//! records something a consumer cannot cheaply and reliably recover from the
24//! schema/data itself. Keys whose value is already in the metric name by
25//! convention, is a constant, or duplicates an existing column are deliberately
26//! omitted rather than stamped for completeness.
27//!
28//! All public keys share the [`SEMANTIC_PREFIX`] namespace and are string-valued.
29//! [`is_semantic_option_key`] gates them through
30//! [`crate::requests::validate_table_option`], so they are accepted both on the
31//! ingestion auto-create path and on explicit `CREATE TABLE ... WITH (...)` DDL.
32
33use common_catalog::consts::{
34    RESOURCE_ATTRIBUTES_COLUMN, SCOPE_ATTRIBUTES_COLUMN, SPAN_ATTRIBUTES_COLUMN,
35};
36use datatypes::prelude::ConcreteDataType;
37use datatypes::schema::{ColumnSchema, Schema};
38
39use crate::requests::TABLE_DATA_MODEL_TRACE_V2;
40
41/// Reserved prefix for every public semantic table-option key.
42pub const SEMANTIC_PREFIX: &str = "greptime.semantic.";
43
44/// Internal `QueryContext` extension key carrying the per-table semantic index
45/// (a `{table_name -> {semantic_key: value}}` JSON blob) from the ingestion
46/// encode path to the auto-create site. Deliberately OUTSIDE [`SEMANTIC_PREFIX`]
47/// so it is not a valid table option and never leaks into a table's options.
48pub const SEMANTIC_PER_TABLE_INDEX_KEY: &str = "greptime.internal.semantic.per_table_index";
49
50// ---- Common keys (all signals) ----
51
52/// Information-schema visibility: `catalog` for providers that filter rows to
53/// their catalog, `public_global` for shared data safe for non-admin reads, or
54/// `global` for shared data without that guarantee. Only registered system-table
55/// metadata is trusted for authorization.
56pub const SEMANTIC_TABLE_SCOPE: &str = "greptime.semantic.table_scope";
57
58/// Signal kind: one of [`SIGNAL_TYPE_TRACE`] / [`SIGNAL_TYPE_LOG`] /
59/// [`SIGNAL_TYPE_METRIC`] / [`SIGNAL_TYPE_EVENT`].
60pub const SEMANTIC_SIGNAL_TYPE: &str = "greptime.semantic.signal_type";
61/// Ingestion ecosystem, e.g. [`SOURCE_OPENTELEMETRY`] / [`SOURCE_PROMETHEUS`].
62pub const SEMANTIC_SOURCE: &str = "greptime.semantic.source";
63/// Source protocol version, e.g. Prometheus remote write `1.0` / `2.0`.
64pub const SEMANTIC_SOURCE_VERSION: &str = "greptime.semantic.source_version";
65/// Internal ingestion pipeline / data model, e.g. `greptime_trace_v1`. The
66/// signal-agnostic successor to the engine-specific `table_data_model` option.
67pub const SEMANTIC_PIPELINE: &str = "greptime.semantic.pipeline";
68
69// ---- Trace keys ----
70
71/// Semantic-conventions version the rows conform to (e.g. the OTel schema URL),
72/// or [`SEMANTIC_VALUE_UNKNOWN`] / [`SEMANTIC_VALUE_MIXED`] when not single-valued.
73pub const SEMANTIC_TRACE_CONVENTIONS: &str = "greptime.semantic.trace.conventions";
74
75// ---- Metric keys ----
76
77/// Instrument kind: `counter` / `gauge` / `histogram` / `summary` /
78/// `updown_counter` / `gauge_histogram` / `info` / `stateset`.
79pub const SEMANTIC_METRIC_TYPE: &str = "greptime.semantic.metric.type";
80/// UCUM unit, e.g. `s`, `By`, `{request}`. Discarded by the row encoders, so it
81/// is unrecoverable once ingested.
82pub const SEMANTIC_METRIC_UNIT: &str = "greptime.semantic.metric.unit";
83/// Catalog-level `cumulative` / `delta` / `mixed` description for OTLP metrics.
84/// Per-series query behavior is determined from stored row identity instead.
85pub const SEMANTIC_METRIC_TEMPORALITY: &str = "greptime.semantic.metric.temporality";
86/// [`METADATA_QUALITY_DECLARED`] when the protocol stated the type, or
87/// [`METADATA_QUALITY_INFERRED`] when guessed from a name suffix.
88pub const SEMANTIC_METRIC_METADATA_QUALITY: &str = "greptime.semantic.metric.metadata_quality";
89/// Pre-translation OTel name when the table name was Prometheus-ised; the key a
90/// consumer uses to look the metric up in the OTel semantic conventions.
91pub const SEMANTIC_METRIC_ORIGINAL_NAME: &str = "greptime.semantic.metric.original_name";
92
93// ---- Entity keys (open sub-namespace) ----
94
95/// Reserved prefix for the entity-identity sub-namespace:
96/// `greptime.semantic.entity.<type>.{id|descriptive|scope}`. Unlike the rest of
97/// the vocabulary (a closed whitelist), entity types are open-ended (`service`,
98/// `host`, `k8s.pod`, `process`, `agent`, custom, ...), so keys here are validated
99/// by prefix + shape rather than membership. See
100/// `docs/rfcs/2026-06-25-entity-relationships-and-graph-query.md`.
101pub const SEMANTIC_ENTITY_PREFIX: &str = "greptime.semantic.entity.";
102
103/// The well-known entity-identity key auto-stamped on OTLP trace tables; its value
104/// is the `service_name` tag column, declaring the logical `service` entity.
105pub const SEMANTIC_ENTITY_SERVICE_ID: &str = "greptime.semantic.entity.service.id";
106
107/// The role a set of columns plays for an entity: `id` (identifying
108/// attributes), `descriptive`, or `scope`. Columns may be tags or fields; DDL
109/// validation only requires that they exist and render as stable strings
110/// ([`has_stable_string_form`]).
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum EntityRole {
113    /// Identifying columns. Their **order is part of the identity**: the
114    /// entity id is their values joined in the declared order, so two tables
115    /// naming the same entity must list them the same way (broad to narrow)
116    /// or they name two entities.
117    Id,
118    Descriptive,
119    Scope,
120}
121
122impl EntityRole {
123    fn parse(role: &str) -> Option<Self> {
124        match role {
125            "id" => Some(EntityRole::Id),
126            "descriptive" => Some(EntityRole::Descriptive),
127            "scope" => Some(EntityRole::Scope),
128            _ => None,
129        }
130    }
131}
132
133// ---- Value constants ----
134
135pub const SIGNAL_TYPE_TRACE: &str = "trace";
136pub const SIGNAL_TYPE_LOG: &str = "log";
137pub const SIGNAL_TYPE_METRIC: &str = "metric";
138pub const SIGNAL_TYPE_EVENT: &str = "event";
139
140pub const SOURCE_OPENTELEMETRY: &str = "opentelemetry";
141pub const SOURCE_PROMETHEUS: &str = "prometheus";
142pub const SOURCE_INFLUXDB: &str = "influxdb";
143pub const SOURCE_OPENTSDB: &str = "opentsdb";
144pub const SOURCE_LOKI: &str = "loki";
145pub const SOURCE_ELASTICSEARCH: &str = "elasticsearch";
146
147pub const METADATA_QUALITY_DECLARED: &str = "declared";
148pub const METADATA_QUALITY_INFERRED: &str = "inferred";
149
150pub const METRIC_TEMPORALITY_CUMULATIVE: &str = "cumulative";
151pub const METRIC_TEMPORALITY_DELTA: &str = "delta";
152
153/// Sentinel for a key that cannot be determined at stamp time.
154pub const SEMANTIC_VALUE_UNKNOWN: &str = "unknown";
155/// Sentinel for a single-valued key that saw conflicting sources.
156pub const SEMANTIC_VALUE_MIXED: &str = "mixed";
157
158/// Every recognised public semantic table-option key. The set is a closed
159/// whitelist: keys under [`SEMANTIC_PREFIX`] that are not listed here are rejected,
160/// so an unknown key like `greptime.semantic.unknown_key` does not silently land
161/// in a table's options. Adding a key to the vocabulary means adding it here.
162pub const SEMANTIC_OPTION_KEYS: &[&str] = &[
163    SEMANTIC_TABLE_SCOPE,
164    SEMANTIC_SIGNAL_TYPE,
165    SEMANTIC_SOURCE,
166    SEMANTIC_SOURCE_VERSION,
167    SEMANTIC_PIPELINE,
168    SEMANTIC_TRACE_CONVENTIONS,
169    SEMANTIC_METRIC_TYPE,
170    SEMANTIC_METRIC_UNIT,
171    SEMANTIC_METRIC_TEMPORALITY,
172    SEMANTIC_METRIC_METADATA_QUALITY,
173    SEMANTIC_METRIC_ORIGINAL_NAME,
174];
175
176/// Returns true if `ty` is a syntactically valid entity type, e.g. `service`,
177/// `host`, `k8s.pod`, `service.instance`. An entity type is one or more
178/// dot-separated segments, each a non-empty `[a-z0-9_]+` token. The dotted form
179/// carries the two-entity-layer convention (`service` vs `service.instance`).
180fn is_valid_entity_type(ty: &str) -> bool {
181    !ty.is_empty()
182        && ty.split('.').all(|seg| {
183            !seg.is_empty()
184                && seg
185                    .bytes()
186                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
187        })
188}
189
190/// Parses a well-formed entity-identity option key of the shape
191/// `greptime.semantic.entity.<type>.{id|descriptive|scope}` into
192/// `(entity_type, role)`. The `<type>` may itself contain dots; the role is the
193/// final dot-separated segment. This is the single parser of the key format —
194/// DDL validation and the read-time derivation both go through it.
195pub fn parse_entity_option_key(key: &str) -> Option<(&str, EntityRole)> {
196    let rest = key.strip_prefix(SEMANTIC_ENTITY_PREFIX)?;
197    let (ty, role) = rest.rsplit_once('.')?;
198    if !is_valid_entity_type(ty) {
199        return None;
200    }
201    Some((ty, EntityRole::parse(role)?))
202}
203
204/// Returns true if `key` is a well-formed entity-identity option key.
205pub fn is_entity_option_key(key: &str) -> bool {
206    parse_entity_option_key(key).is_some()
207}
208
209/// Resolves a Trace V2 entity reference to a JSON2 root and a literal attribute key.
210/// Everything after the first dot belongs to the key, matching Trace V1's
211/// flattened column names. Existing physical columns take precedence.
212pub fn trace_v2_attribute<'a>(
213    schema: &Schema,
214    data_model: Option<&str>,
215    column: &'a str,
216) -> Option<(&'a str, &'a str)> {
217    if data_model != Some(TABLE_DATA_MODEL_TRACE_V2)
218        || schema.column_schema_by_name(column).is_some()
219    {
220        return None;
221    }
222    let (root, key) = column.split_once('.')?;
223    (!key.is_empty()
224        && schema
225            .column_schema_by_name(root)
226            .is_some_and(is_v2_attribute_column))
227    .then_some((root, key))
228}
229
230fn is_v2_attribute_column(schema: &ColumnSchema) -> bool {
231    matches!(
232        schema.name.as_str(),
233        RESOURCE_ATTRIBUTES_COLUMN | SCOPE_ATTRIBUTES_COLUMN | SPAN_ATTRIBUTES_COLUMN
234    ) && schema.data_type.is_json2()
235}
236
237/// Returns true if a column of `data_type` renders as a stable string — the
238/// requirement for entity id/descriptive/scope columns. The read-time
239/// derivation casts them to strings, so a type without a stable string form
240/// would fail only when the graph is scanned; DDL validation rejects it up
241/// front instead.
242pub fn has_stable_string_form(data_type: &ConcreteDataType) -> bool {
243    !matches!(
244        data_type,
245        ConcreteDataType::Binary(_)
246            | ConcreteDataType::Json(_)
247            | ConcreteDataType::Vector(_)
248            | ConcreteDataType::List(_)
249            | ConcreteDataType::Struct(_)
250            | ConcreteDataType::Dictionary(_)
251            | ConcreteDataType::Null(_)
252    )
253}
254
255/// Tokenizes an entity option's comma-separated column list (trimmed, empty
256/// tokens dropped). [`validate_semantic_option`] rejects empty tokens at DDL
257/// time, so readers only ever drop what validation already refused.
258pub fn parse_entity_columns(value: &str) -> Vec<String> {
259    value
260        .split(',')
261        .map(str::trim)
262        .filter(|column| !column.is_empty())
263        .map(String::from)
264        .collect()
265}
266
267/// Returns true if `key` is a recognised semantic table-option key.
268///
269/// Two acceptance rules: membership in the closed [`SEMANTIC_OPTION_KEYS`]
270/// whitelist, OR the open entity sub-namespace ([`is_entity_option_key`], validated
271/// by prefix + shape). Everything else under [`SEMANTIC_PREFIX`] is rejected, and
272/// the internal [`SEMANTIC_PER_TABLE_INDEX_KEY`] (outside the prefix) never matches.
273pub fn is_semantic_option_key(key: &str) -> bool {
274    SEMANTIC_OPTION_KEYS.contains(&key) || is_entity_option_key(key)
275}
276
277/// Validates a `greptime.semantic.*` option's `value` against its allowed domain.
278///
279/// Open-value keys (unit, original_name, pipeline, conventions) accept any
280/// non-empty string. Closed-domain keys accept a fixed set, plus the `unknown`
281/// sentinel, plus `mixed` for the keys where one long-lived table can
282/// legitimately see multiple values. Entity keys ([`is_entity_option_key`]) take a
283/// comma-separated column-name list (each token non-empty); column existence and
284/// the stable-string-form rule for entity columns are enforced later against
285/// the table schema at DDL time, not here. Keys that are neither whitelisted nor a well-formed entity key
286/// are rejected.
287pub fn validate_semantic_option(key: &str, value: &str) -> bool {
288    if is_entity_option_key(key) {
289        return !value.is_empty() && value.split(',').all(|column| !column.trim().is_empty());
290    }
291    match key {
292        SEMANTIC_TABLE_SCOPE => matches!(value, "catalog" | "public_global" | "global"),
293        SEMANTIC_PIPELINE
294        | SEMANTIC_SOURCE_VERSION
295        | SEMANTIC_METRIC_UNIT
296        | SEMANTIC_METRIC_ORIGINAL_NAME
297        | SEMANTIC_TRACE_CONVENTIONS => !value.is_empty(),
298
299        SEMANTIC_SIGNAL_TYPE => matches!(value, "trace" | "log" | "metric" | "event" | "unknown"),
300        SEMANTIC_SOURCE => matches!(
301            value,
302            "opentelemetry"
303                | "prometheus"
304                | "influxdb"
305                | "opentsdb"
306                | "elasticsearch"
307                | "loki"
308                | "custom"
309                | "mixed"
310                | "unknown"
311        ),
312        SEMANTIC_METRIC_TYPE => matches!(
313            value,
314            "counter"
315                | "gauge"
316                | "histogram"
317                | "summary"
318                | "updown_counter"
319                | "gauge_histogram"
320                | "info"
321                | "stateset"
322                | "mixed"
323                | "unknown"
324        ),
325        SEMANTIC_METRIC_TEMPORALITY => {
326            matches!(
327                value,
328                METRIC_TEMPORALITY_CUMULATIVE | METRIC_TEMPORALITY_DELTA | "mixed" | "unknown"
329            )
330        }
331        SEMANTIC_METRIC_METADATA_QUALITY => matches!(value, "declared" | "inferred" | "unknown"),
332
333        _ => false,
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use datatypes::types::JsonType;
340
341    use super::*;
342
343    #[test]
344    fn test_trace_v2_attribute_references() {
345        let schema = Schema::new(vec![
346            ColumnSchema::new(
347                RESOURCE_ATTRIBUTES_COLUMN,
348                ConcreteDataType::Json(JsonType::null()),
349                true,
350            ),
351            ColumnSchema::new(
352                SCOPE_ATTRIBUTES_COLUMN,
353                ConcreteDataType::string_datatype(),
354                true,
355            ),
356            ColumnSchema::new(
357                "resource_attributes.physical",
358                ConcreteDataType::string_datatype(),
359                true,
360            ),
361        ]);
362        let model = Some(TABLE_DATA_MODEL_TRACE_V2);
363        for key in ["host.name", "host..name", "host[0]", "host.*", r#"a"b\c.d"#] {
364            let column = format!("resource_attributes.{key}");
365            assert_eq!(
366                trace_v2_attribute(&schema, model, &column),
367                Some((RESOURCE_ATTRIBUTES_COLUMN, key))
368            );
369        }
370        for column in [
371            "resource_attributes",
372            "resource_attributes.",
373            "scope_attributes.name",
374            "span_attributes.name",
375            "resource_attributes.physical",
376            "missing.key",
377        ] {
378            assert_eq!(trace_v2_attribute(&schema, model, column), None, "{column}");
379        }
380        for model in [None, Some(crate::requests::TABLE_DATA_MODEL_TRACE_V1)] {
381            assert_eq!(
382                trace_v2_attribute(&schema, model, "resource_attributes.host.name"),
383                None
384            );
385        }
386    }
387
388    #[test]
389    fn test_is_semantic_option_key() {
390        assert!(is_semantic_option_key(SEMANTIC_SIGNAL_TYPE));
391        assert!(is_semantic_option_key(SEMANTIC_METRIC_TYPE));
392        assert!(is_semantic_option_key(SEMANTIC_PIPELINE));
393
394        // Unknown keys under the prefix are not whitelisted.
395        assert!(!is_semantic_option_key("greptime.semantic.future.key"));
396        assert!(!is_semantic_option_key("greptime.semantic.unknown_key"));
397        // Keys cut from the vocabulary are no longer accepted.
398        assert!(!is_semantic_option_key(
399            "greptime.semantic.metric.monotonic"
400        ));
401        assert!(!is_semantic_option_key(
402            "greptime.semantic.resource.attributes_dropped"
403        ));
404        // Near-misses must not match.
405        assert!(!is_semantic_option_key("greptime.semanticx"));
406        assert!(!is_semantic_option_key("semantic.signal_type"));
407        assert!(!is_semantic_option_key("table_data_model"));
408        // The internal transport key must never be treated as a table option.
409        assert!(!is_semantic_option_key(SEMANTIC_PER_TABLE_INDEX_KEY));
410    }
411
412    #[test]
413    fn test_validate_semantic_option() {
414        // Enum keys reject out-of-domain values.
415        assert!(validate_semantic_option(SEMANTIC_TABLE_SCOPE, "catalog"));
416        assert!(validate_semantic_option(
417            SEMANTIC_TABLE_SCOPE,
418            "public_global"
419        ));
420        assert!(validate_semantic_option(SEMANTIC_TABLE_SCOPE, "global"));
421        assert!(!validate_semantic_option(SEMANTIC_TABLE_SCOPE, "cluster"));
422        assert!(!validate_semantic_option(SEMANTIC_TABLE_SCOPE, "unknown"));
423        assert!(!validate_semantic_option(SEMANTIC_TABLE_SCOPE, ""));
424        assert!(validate_semantic_option(SEMANTIC_SIGNAL_TYPE, "metric"));
425        assert!(!validate_semantic_option(SEMANTIC_SIGNAL_TYPE, "spans"));
426        assert!(validate_semantic_option(SEMANTIC_METRIC_TYPE, "counter"));
427        assert!(validate_semantic_option(SEMANTIC_METRIC_TYPE, "mixed"));
428        assert!(!validate_semantic_option(SEMANTIC_METRIC_TYPE, "bogus"));
429
430        // Sentinels and open values.
431        assert!(validate_semantic_option(
432            SEMANTIC_METRIC_TEMPORALITY,
433            "unknown"
434        ));
435        assert!(validate_semantic_option(SEMANTIC_METRIC_UNIT, "By"));
436        assert!(!validate_semantic_option(SEMANTIC_METRIC_UNIT, ""));
437        assert!(validate_semantic_option(
438            SEMANTIC_PIPELINE,
439            "greptime_trace_v1"
440        ));
441
442        // A cut key validates to false regardless of value.
443        assert!(!validate_semantic_option(
444            "greptime.semantic.metric.monotonic",
445            "true"
446        ));
447        // Unknown key is rejected regardless of value.
448        assert!(!validate_semantic_option(
449            "greptime.semantic.future.key",
450            "x"
451        ));
452
453        // Drift guard: every value stamped by the ingestion path must validate.
454        assert!(validate_semantic_option(
455            SEMANTIC_SIGNAL_TYPE,
456            SIGNAL_TYPE_TRACE
457        ));
458        assert!(validate_semantic_option(
459            SEMANTIC_SIGNAL_TYPE,
460            SIGNAL_TYPE_METRIC
461        ));
462        assert!(validate_semantic_option(
463            SEMANTIC_SIGNAL_TYPE,
464            SIGNAL_TYPE_LOG
465        ));
466        assert!(validate_semantic_option(
467            SEMANTIC_SOURCE,
468            SOURCE_OPENTELEMETRY
469        ));
470        assert!(validate_semantic_option(SEMANTIC_SOURCE, SOURCE_PROMETHEUS));
471        assert!(validate_semantic_option(SEMANTIC_SOURCE_VERSION, "2.0"));
472        assert!(validate_semantic_option(
473            SEMANTIC_METRIC_METADATA_QUALITY,
474            METADATA_QUALITY_INFERRED
475        ));
476        assert!(validate_semantic_option(
477            SEMANTIC_METRIC_METADATA_QUALITY,
478            METADATA_QUALITY_DECLARED
479        ));
480        assert!(validate_semantic_option(
481            SEMANTIC_TRACE_CONVENTIONS,
482            SEMANTIC_VALUE_UNKNOWN
483        ));
484        // An empty value never validates, for any whitelisted key.
485        for key in SEMANTIC_OPTION_KEYS {
486            assert!(
487                !validate_semantic_option(key, ""),
488                "empty value should never validate for {key}"
489            );
490        }
491    }
492
493    #[test]
494    fn test_entity_option_key() {
495        // Well-formed entity keys are accepted by the open sub-namespace, and are
496        // therefore recognised as semantic option keys.
497        for key in [
498            "greptime.semantic.entity.service.id",
499            "greptime.semantic.entity.k8s.pod.id",
500            "greptime.semantic.entity.service.instance.descriptive",
501            "greptime.semantic.entity.host.scope",
502            "greptime.semantic.entity.agent.id",
503        ] {
504            assert!(is_entity_option_key(key), "should accept {key}");
505            assert!(is_semantic_option_key(key), "should recognise {key}");
506        }
507
508        // Malformed: empty type segment, bogus role, missing role, bare prefix,
509        // invalid (uppercase) charset in the type.
510        for key in [
511            "greptime.semantic.entity..id",
512            "greptime.semantic.entity.service.bogusrole",
513            "greptime.semantic.entity.service",
514            "greptime.semantic.entity.",
515            "greptime.semantic.entity.Service.id",
516        ] {
517            assert!(!is_entity_option_key(key), "should reject {key}");
518            assert!(!is_semantic_option_key(key), "should not recognise {key}");
519        }
520
521        // A non-entity semantic key is not an entity key.
522        assert!(!is_entity_option_key(SEMANTIC_SIGNAL_TYPE));
523
524        // Drift guard: the auto-stamped constant is a well-formed entity key.
525        assert!(is_entity_option_key(SEMANTIC_ENTITY_SERVICE_ID));
526        assert!(is_semantic_option_key(SEMANTIC_ENTITY_SERVICE_ID));
527    }
528
529    #[test]
530    fn test_validate_entity_option() {
531        // Single- and composite-id column lists validate.
532        assert!(validate_semantic_option(
533            "greptime.semantic.entity.service.id",
534            "service_name"
535        ));
536        assert!(validate_semantic_option(
537            "greptime.semantic.entity.process.id",
538            "pid,start_time"
539        ));
540        // Empty value and blank/empty tokens do not.
541        assert!(!validate_semantic_option(
542            "greptime.semantic.entity.service.id",
543            ""
544        ));
545        assert!(!validate_semantic_option(
546            "greptime.semantic.entity.process.id",
547            "pid,"
548        ));
549        // A malformed entity key validates to false regardless of value.
550        assert!(!validate_semantic_option(
551            "greptime.semantic.entity.service.bogusrole",
552            "service_name"
553        ));
554    }
555}