operator/statement/semantic_graph/
conventions.rs1use std::collections::{BTreeMap, HashSet};
23use std::sync::LazyLock;
24
25use serde::Deserialize;
26
27#[derive(Debug, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct EdgeRule {
32 pub src: String,
33 pub dst: String,
34 pub rel: String,
35}
36
37#[derive(Debug, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct VirtualDstCandidate {
42 pub column: String,
43 pub connection_type: String,
44}
45
46#[derive(Debug, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct ImplicitEntity {
51 pub entity: String,
52 pub id: Vec<String>,
55 #[serde(default)]
58 pub descriptive: Vec<String>,
59 #[serde(default)]
62 pub descriptive_rest: bool,
63}
64
65#[derive(Debug, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct Conventions {
69 pub co_declared_edges: Vec<EdgeRule>,
70 pub trace_co_declared_edges: Vec<EdgeRule>,
71 pub virtual_dst_candidates: Vec<VirtualDstCandidate>,
72 pub otlp_trace_entities: Vec<ImplicitEntity>,
73 pub prometheus_info_metrics: BTreeMap<String, Vec<ImplicitEntity>>,
74}
75
76pub const ENTITY_TYPE_SERVICE: &str = "service";
79pub const ENTITY_TYPE_SERVICE_INSTANCE: &str = "service.instance";
80pub const ENTITY_TYPE_HOST: &str = "host";
81pub const ENTITY_TYPE_PROCESS: &str = "process";
82pub const ENTITY_TYPE_K8S_POD: &str = "k8s.pod";
83pub const ENTITY_TYPE_K8S_NODE: &str = "k8s.node";
84pub const ENTITY_TYPE_K8S_CONTAINER: &str = "k8s.container";
85pub const ENTITY_TYPE_K8S_WORKLOAD: &str = "k8s.workload";
86pub const ENTITY_TYPE_K8S_SERVICE: &str = "k8s.service";
87pub const ENTITY_TYPE_GEN_AI_AGENT: &str = "gen_ai.agent";
88pub const ENTITY_TYPE_GEN_AI_MODEL: &str = "gen_ai.model";
89pub const ENTITY_TYPE_GEN_AI_TOOL: &str = "gen_ai.tool";
90
91const ENTITY_TYPES: [&str; 12] = [
92 ENTITY_TYPE_SERVICE,
93 ENTITY_TYPE_SERVICE_INSTANCE,
94 ENTITY_TYPE_HOST,
95 ENTITY_TYPE_PROCESS,
96 ENTITY_TYPE_K8S_POD,
97 ENTITY_TYPE_K8S_NODE,
98 ENTITY_TYPE_K8S_CONTAINER,
99 ENTITY_TYPE_K8S_WORKLOAD,
100 ENTITY_TYPE_K8S_SERVICE,
101 ENTITY_TYPE_GEN_AI_AGENT,
102 ENTITY_TYPE_GEN_AI_MODEL,
103 ENTITY_TYPE_GEN_AI_TOOL,
104];
105
106pub const REL_TYPE_CALLS: &str = "calls";
109pub const REL_TYPE_RUNS_ON: &str = "runs_on";
110pub const REL_TYPE_CONTAINS: &str = "contains";
111pub const REL_TYPE_PART_OF: &str = "part_of";
112pub const REL_TYPE_USES: &str = "uses";
113pub const REL_TYPE_INVOKES: &str = "invokes";
114pub const REL_TYPE_DEPENDS_ON: &str = "depends_on";
115pub const REL_TYPE_OWNS: &str = "owns";
116pub const PROVENANCE_TRACE: &str = "trace";
117pub const PROVENANCE_ATTRIBUTE: &str = "attribute";
118pub const PROVENANCE_DECLARED: &str = "declared";
119pub const PROVENANCE_AGENT: &str = "agent";
120
121const REL_TYPES: [&str; 8] = [
122 REL_TYPE_CALLS,
123 REL_TYPE_RUNS_ON,
124 REL_TYPE_CONTAINS,
125 REL_TYPE_PART_OF,
126 REL_TYPE_USES,
127 REL_TYPE_INVOKES,
128 REL_TYPE_DEPENDS_ON,
129 REL_TYPE_OWNS,
130];
131
132pub const CONNECTION_TYPE_DATABASE: &str = "database";
135pub const CONNECTION_TYPE_VIRTUAL_NODE: &str = "virtual_node";
136
137const CONNECTION_TYPES: [&str; 2] = [CONNECTION_TYPE_DATABASE, CONNECTION_TYPE_VIRTUAL_NODE];
138
139static CONVENTIONS: LazyLock<Result<Conventions, String>> =
140 LazyLock::new(|| parse(include_str!("conventions.yaml")));
141
142pub fn conventions() -> Result<&'static Conventions, String> {
145 CONVENTIONS.as_ref().map_err(Clone::clone)
146}
147
148fn parse(yaml: &str) -> Result<Conventions, String> {
149 let conventions: Conventions =
150 serde_yaml_ng::from_str(yaml).map_err(|e| format!("malformed conventions: {e}"))?;
151 validate(&conventions)?;
152 Ok(conventions)
153}
154
155fn validate(conventions: &Conventions) -> Result<(), String> {
156 let mut seen_edges = HashSet::new();
157 for rule in conventions
158 .co_declared_edges
159 .iter()
160 .chain(&conventions.trace_co_declared_edges)
161 {
162 for ty in [&rule.src, &rule.dst] {
163 if !ENTITY_TYPES.contains(&ty.as_str()) {
164 return Err(format!("unknown entity type `{ty}` in edge vocabulary"));
165 }
166 }
167 if !REL_TYPES.contains(&rule.rel.as_str()) {
168 return Err(format!(
169 "unknown rel_type `{}` in edge vocabulary",
170 rule.rel
171 ));
172 }
173 if !seen_edges.insert((&rule.src, &rule.dst, &rule.rel)) {
174 return Err(format!(
175 "duplicate edge rule `{} -{}-> {}`",
176 rule.src, rule.rel, rule.dst
177 ));
178 }
179 }
180
181 let mut seen_columns = HashSet::new();
182 for candidate in &conventions.virtual_dst_candidates {
183 if candidate.column.is_empty() {
184 return Err("empty virtual destination column".to_string());
185 }
186 if !CONNECTION_TYPES.contains(&candidate.connection_type.as_str()) {
187 return Err(format!(
188 "unknown connection_type `{}` for virtual destination `{}`",
189 candidate.connection_type, candidate.column
190 ));
191 }
192 if !seen_columns.insert(&candidate.column) {
193 return Err(format!(
194 "duplicate virtual destination column `{}`",
195 candidate.column
196 ));
197 }
198 }
199
200 let per_table = conventions
201 .prometheus_info_metrics
202 .iter()
203 .map(|(table, entities)| (table.as_str(), entities))
204 .chain(std::iter::once((
205 "otlp traces",
206 &conventions.otlp_trace_entities,
207 )));
208 for (table, entities) in per_table {
209 let mut seen_types = HashSet::new();
210 for implicit in entities {
211 if !ENTITY_TYPES.contains(&implicit.entity.as_str()) {
212 return Err(format!(
213 "unknown entity type `{}` for info metric `{table}`",
214 implicit.entity
215 ));
216 }
217 if !seen_types.insert(&implicit.entity) {
218 return Err(format!(
219 "duplicate entity type `{}` for info metric `{table}`",
220 implicit.entity
221 ));
222 }
223 if implicit.id.is_empty() || implicit.id.iter().any(String::is_empty) {
224 return Err(format!(
225 "entity `{}` of info metric `{table}` needs non-empty id columns",
226 implicit.entity
227 ));
228 }
229 if implicit.descriptive_rest && !implicit.descriptive.is_empty() {
230 return Err(format!(
231 "entity `{}` of info metric `{table}` sets both descriptive and \
232 descriptive_rest",
233 implicit.entity
234 ));
235 }
236 }
237 }
238 Ok(())
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn embedded_conventions_parse_and_validate() {
247 conventions().unwrap();
248 }
249
250 fn broken(edges: &str, info_metrics: &str) -> String {
253 format!(
254 "co_declared_edges: [{edges}]\ntrace_co_declared_edges: []\n\
255 virtual_dst_candidates: []\notlp_trace_entities: []\n\
256 prometheus_info_metrics: {{{info_metrics}}}"
257 )
258 }
259
260 #[test]
261 fn validation_rejects_broken_conventions() {
262 let err = |edges, info| parse(&broken(edges, info)).unwrap_err();
263
264 assert!(err("{src: host, dst: service, rel: pets}", "").contains("unknown rel_type"));
265 assert!(
266 err("{src: k8s.pods, dst: k8s.node, rel: runs_on}", "").contains("unknown entity type")
267 );
268 assert!(
269 err(
270 "{src: host, dst: service, rel: uses}, {src: host, dst: service, rel: uses}",
271 ""
272 )
273 .contains("duplicate edge rule")
274 );
275 assert!(
276 err(
277 "",
278 "t: [{entity: host, id: [x], descriptive: [y], descriptive_rest: true}]"
279 )
280 .contains("descriptive_rest")
281 );
282 assert!(
284 parse(&broken(
285 "{src: host, dst: service, rel: uses, direction: down}",
286 ""
287 ))
288 .is_err()
289 );
290 }
291}