Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.

# Unreleased

- Add a tree view to the `serve` UI's search page, grouping results by namespace with expand/collapse controls, and a "Hide deprecated" toggle (on by default) for both the list and tree views. ([#1595](https://github.com/open-telemetry/weaver/pull/1595) by @jerbly)
- Use the OS-native certificate store (via ureq's `platform-verifier` feature) to validate TLS connections for remote registry downloads, instead of a fixed bundled root CA list. ([#1583](https://github.com/open-telemetry/weaver/pull/1583) by @jerbly)
- Fix panic when a registry, policy, or template path uses a commit SHA. ([#1414](https://github.com/open-telemetry/weaver/pull/1414))
- Add a stats dashboard with charts to the `serve` UI. ([#1570](https://github.com/open-telemetry/weaver/pull/1570) by @jerbly)
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/weaver_mcp/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ impl WeaverMcpService {
params.query.as_deref(),
search_type,
stability,
false, // hide_deprecated: not exposed via the MCP search tool
limit,
0, // offset
);
Expand Down
3 changes: 3 additions & 0 deletions crates/weaver_search/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ weaver_semconv = { path = "../weaver_semconv", features = ["openapi"] }
serde.workspace = true
utoipa.workspace = true
schemars.workspace = true

[dev-dependencies]
serde_json.workspace = true
151 changes: 131 additions & 20 deletions crates/weaver_search/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ use weaver_semconv::stability::Stability;
//TODO: Consider using a fuzzy matching crate for improved search capabilities.
// e.g. Tantivy - https://github.com/open-telemetry/weaver/pull/1076#discussion_r2640681775

/// Maximum number of results a single `search` call returns. Requested limits
/// above this are clamped. Kept in sync with the documented maximum of the
/// `/api/v1/registry/search` endpoint (`SearchParams::limit`).
pub const MAX_SEARCH_LIMIT: usize = 1000;

/// Search context for performing fuzzy searches and O(1) lookups across the registry.
pub struct SearchContext {
/// All searchable items for fuzzy search.
Expand Down Expand Up @@ -150,6 +155,7 @@ impl SearchContext {
/// * `query` - Optional search query string (None = browse mode).
/// * `search_type` - Filter by item type.
/// * `stability` - Optional stability filter.
/// * `hide_deprecated` - When true, excludes deprecated items regardless of stability.
/// * `limit` - Maximum number of results.
/// * `offset` - Pagination offset.
///
Expand All @@ -162,10 +168,11 @@ impl SearchContext {
query: Option<&str>,
search_type: SearchType,
stability: Option<Stability>,
hide_deprecated: bool,
limit: usize,
offset: usize,
) -> (Vec<SearchResult>, usize) {
let limit = limit.min(200); // Cap at 200
let limit = limit.min(MAX_SEARCH_LIMIT);

// Filter by type
let mut items: Vec<&SearchableItem> = self
Expand All @@ -179,6 +186,12 @@ impl SearchContext {
items.retain(|item| item.stability() == &stability_filter);
}

// `deprecated` is independent of `stability` - a deprecated item can carry any
// stability level - so this is a separate retain rather than folded into the above.
if hide_deprecated {
items.retain(|item| !item.is_deprecated());
}

// Branch based on whether we have a search query
let (results, total) = if let Some(q) = query {
if q.is_empty() {
Expand All @@ -188,7 +201,7 @@ impl SearchContext {
(results, total)
} else {
// Non-empty query - search mode with scoring
search_mode_with_total(items, q, limit, &self.separator)
search_mode_with_total(items, q, limit, offset, &self.separator)
}
} else {
// No query - browse mode
Expand Down Expand Up @@ -331,6 +344,7 @@ fn search_mode_with_total(
items: Vec<&SearchableItem>,
query: &str,
limit: usize,
offset: usize,
separator: &str,
) -> (Vec<SearchResult>, usize) {
let mut scored_items: Vec<(u32, &SearchableItem)> = items
Expand All @@ -348,12 +362,13 @@ fn search_mode_with_total(
// Sort by score descending
scored_items.sort_by_key(|b| std::cmp::Reverse(b.0));

// Calculate total before taking limit
// Calculate total before paginating
let total = scored_items.len();

// Take top N and convert to results
// Page through the ranked matches and convert to results
let results = scored_items
.into_iter()
.skip(offset)
.take(limit)
.map(|(score, item)| item.to_search_result(score))
.collect();
Expand Down Expand Up @@ -823,7 +838,7 @@ mod tests {
let registry = make_test_registry();
let ctx = SearchContext::from_registry(&registry);

let (results, total) = ctx.search(Some("http"), SearchType::All, None, 10, 0);
let (results, total) = ctx.search(Some("http"), SearchType::All, None, false, 10, 0);

// Should find http.request.method, http.response.status_code,
// http.server.request.duration, http.client
Expand All @@ -837,7 +852,7 @@ mod tests {
let ctx = SearchContext::from_registry(&registry);

// None query = browse mode
let (results, total) = ctx.search(None, SearchType::All, None, 100, 0);
let (results, total) = ctx.search(None, SearchType::All, None, false, 100, 0);

// Should return all items: 5 attributes + 1 metric + 1 span + 1 event + 1 entity = 9
assert_eq!(total, 9);
Expand All @@ -850,25 +865,25 @@ mod tests {
let ctx = SearchContext::from_registry(&registry);

// Filter by Attribute only
let (results, total) = ctx.search(None, SearchType::Attribute, None, 100, 0);
let (results, total) = ctx.search(None, SearchType::Attribute, None, false, 100, 0);
assert_eq!(total, 5); // 5 attributes (3 regular + 1 template + 1 development)
assert_eq!(results.len(), 5);

// Filter by Metric only
let (results, total) = ctx.search(None, SearchType::Metric, None, 100, 0);
let (results, total) = ctx.search(None, SearchType::Metric, None, false, 100, 0);
assert_eq!(total, 1);
assert_eq!(results.len(), 1);

// Filter by Span only
let (_, total) = ctx.search(None, SearchType::Span, None, 100, 0);
let (_, total) = ctx.search(None, SearchType::Span, None, false, 100, 0);
assert_eq!(total, 1);

// Filter by Event only
let (_, total) = ctx.search(None, SearchType::Event, None, 100, 0);
let (_, total) = ctx.search(None, SearchType::Event, None, false, 100, 0);
assert_eq!(total, 1);

// Filter by Entity only
let (_, total) = ctx.search(None, SearchType::Entity, None, 100, 0);
let (_, total) = ctx.search(None, SearchType::Entity, None, false, 100, 0);
assert_eq!(total, 1);
}

Expand All @@ -878,39 +893,70 @@ mod tests {
let ctx = SearchContext::from_registry(&registry);

// Get first 2 items
let (results1, total1) = ctx.search(None, SearchType::All, None, 2, 0);
let (results1, total1) = ctx.search(None, SearchType::All, None, false, 2, 0);
assert_eq!(total1, 9);
assert_eq!(results1.len(), 2);

// Get next 2 items with offset
let (results2, total2) = ctx.search(None, SearchType::All, None, 2, 2);
let (results2, total2) = ctx.search(None, SearchType::All, None, false, 2, 2);
assert_eq!(total2, 9);
assert_eq!(results2.len(), 2);

// Get remaining items
let (results3, _) = ctx.search(None, SearchType::All, None, 100, 4);
let (results3, _) = ctx.search(None, SearchType::All, None, false, 100, 4);
assert_eq!(results3.len(), 5);
}

#[test]
fn test_search_limit_capped_at_200() {
fn test_search_limit_capped_at_max() {
let registry = make_test_registry();
let ctx = SearchContext::from_registry(&registry);

// Request limit > 200 should be capped
let (results, _) = ctx.search(None, SearchType::All, None, 500, 0);
// Request limit > MAX_SEARCH_LIMIT should be capped
let (results, _) = ctx.search(None, SearchType::All, None, false, MAX_SEARCH_LIMIT + 1, 0);

// We only have 9 items, so we get 9 (not testing the cap directly,
// but ensuring it doesn't crash with large limit)
assert_eq!(results.len(), 9);
}

#[test]
fn test_search_query_mode_pagination() {
let registry = make_test_registry();
let ctx = SearchContext::from_registry(&registry);

// Collect all matches in one call, then re-fetch them one page at a time.
let (all_results, total) = ctx.search(Some("http"), SearchType::All, None, false, 100, 0);
assert_eq!(all_results.len(), total);
assert!(total >= 4);

let (page1, _) = ctx.search(Some("http"), SearchType::All, None, false, 2, 0);
let (page2, _) = ctx.search(Some("http"), SearchType::All, None, false, 2, 2);
assert_eq!(page1.len(), 2);

// Pages must continue the ranked list, not repeat the top results.
let paged: Vec<_> = page1.iter().chain(page2.iter()).collect();
for (paged_item, full_item) in paged.iter().zip(all_results.iter()) {
assert_eq!(
serde_json::to_value(paged_item).unwrap(),
serde_json::to_value(full_item).unwrap()
);
}

// Offset past the end returns an empty page but the same total.
let (past_end, past_end_total) =
ctx.search(Some("http"), SearchType::All, None, false, 10, total);
assert!(past_end.is_empty());
assert_eq!(past_end_total, total);
}

#[test]
fn test_search_no_results() {
let registry = make_test_registry();
let ctx = SearchContext::from_registry(&registry);

let (results, total) = ctx.search(Some("zzzznonexistent"), SearchType::All, None, 10, 0);
let (results, total) =
ctx.search(Some("zzzznonexistent"), SearchType::All, None, false, 10, 0);

assert_eq!(total, 0);
assert!(results.is_empty());
Expand Down Expand Up @@ -980,8 +1026,14 @@ mod tests {
let ctx = SearchContext::from_registry(&registry);

// Filter by Stable only
let (results, total) =
ctx.search(None, SearchType::Attribute, Some(Stability::Stable), 100, 0);
let (results, total) = ctx.search(
None,
SearchType::Attribute,
Some(Stability::Stable),
false,
100,
0,
);

// Should return only stable attributes (4: http.request.method, http.response.status_code, db.system, test.template)
assert_eq!(total, 4);
Expand All @@ -998,6 +1050,7 @@ mod tests {
None,
SearchType::Attribute,
Some(Stability::Development),
false,
100,
0,
);
Expand All @@ -1020,6 +1073,64 @@ mod tests {
assert_eq!(dev_item.stability(), &Stability::Development);
}

// =========================================================================
// Deprecated Filtering Tests
// =========================================================================

fn make_registry_with_attributes(attributes: Vec<Attribute>) -> ForgeResolvedRegistry {
ForgeResolvedRegistry {
schema_url: "https://example.com/schemas/1.2.3".try_into().unwrap(),
registry: Registry {
attributes,
attribute_groups: vec![],
metrics: vec![],
spans: vec![],
events: vec![],
entities: vec![],
},
refinements: Refinements {
metrics: vec![],
spans: vec![],
events: vec![],
},
}
}

#[test]
fn test_search_hide_deprecated() {
let registry = make_registry_with_attributes(vec![
make_attribute("service.name", "Service name", "", false),
make_attribute("service.namespace", "Deprecated namespace", "", true),
]);
let ctx = SearchContext::from_registry(&registry);

let (results, total) = ctx.search(None, SearchType::All, None, false, 100, 0);
assert_eq!(total, 2);
assert_eq!(results.len(), 2);

let (results, total) = ctx.search(None, SearchType::All, None, true, 100, 0);
assert_eq!(total, 1);
assert_eq!(results.len(), 1);
}

#[test]
fn test_search_hide_deprecated_independent_of_stability() {
// `make_attribute` always sets stability: Stable regardless of the
// deprecated flag - a deprecated item can carry any stability level,
// so `hide_deprecated` must filter independently of the stability
// filter rather than only affecting items with `stability: deprecated`.
let registry = make_registry_with_attributes(vec![make_attribute(
"service.name",
"Deprecated but stable",
"",
true,
)]);
let ctx = SearchContext::from_registry(&registry);

let (_, total) = ctx.search(None, SearchType::All, Some(Stability::Stable), true, 100, 0);
assert_eq!(total, 0);
}

// =========================================================================
// Namespace Browsing Tests
// =========================================================================
Expand Down
1 change: 1 addition & 0 deletions src/serve/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ pub async fn search_registry(
query,
params.search_type,
params.stability,
params.hide_deprecated,
params.limit,
params.offset,
);
Expand Down
4 changes: 4 additions & 0 deletions src/serve/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ pub struct SearchParams {
pub search_type: SearchType,
/// Filter by stability level.
pub stability: Option<Stability>,
/// Exclude deprecated items, independent of the stability filter (default: false).
#[serde(default)]
pub hide_deprecated: bool,
/// Maximum number of results (default: 50).
// The maximum must match weaver_search::MAX_SEARCH_LIMIT (enforced there).
#[serde(default = "default_search_limit")]
#[param(maximum = 1000)]
pub limit: usize,
Expand Down
19 changes: 19 additions & 0 deletions ui/e2e/fixtures/render-registry/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,25 @@ events:
renamed_to: render.event.thing
note: Replaced by `render.event.thing`.

- name: render.attr
requirement_level: recommended
brief: An event whose full name coincides with the `render.attr` attribute namespace.
note: |
Regression fixture for the tree view nesting this event inside the
`render.attr` folder as if it were the folder's self-describing item -
a namespace collision is just a naming coincidence, not ownership, so
the event must stay a sibling of the folder.
stability: stable

- name: render.coordinator
requirement_level: recommended
brief: An event alphabetically between the `render.container` and `render.entity` namespace folders.
note: |
Regression fixture for tree view ordering - folders and items are
siblings in the same namespace and must sort together into one
alphabetical list, not as a folders-then-items grouping.
stability: stable

entities:
- type: render.entity.host
requirement_level: recommended
Expand Down
Loading
Loading