Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to this project will be documented in this file.

# Unreleased

- New feature ([#1153](https://github.com/open-telemetry/weaver/issues/1153)) - Live-check now has a `/health` endpoint that can be used in long-running scenarios to confirm readiness and liveness of the live-check server. ([#1193](https://github.com/open-telemetry/weaver/pull/1193) by @jerbly)
- New feature ([#1100](https://github.com/open-telemetry/weaver/issues/1100)) - Set `--output=http` to have live-check send its report as the response to `/stop`. ([#1193](https://github.com/open-telemetry/weaver/pull/1193) by @jerbly)

# [0.21.2] - 2026-02-03

- New Experimental feature: `weaver serve` command to serve a REST API and web UI. ([#1076](https://github.com/open-telemetry/weaver/pull/1076) by @jerbly)
Expand Down
113 changes: 98 additions & 15 deletions crates/weaver_forge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,75 @@ impl TemplateEngine {
Ok(result)
}

/// Generate artifacts from a serializable context and return the rendered
/// output as a String instead of writing to files or stdout.
///
/// This is useful when the output needs to be captured (e.g., for HTTP responses).
pub fn generate_to_string<T: Serialize>(&self, context: &T) -> Result<String, Error> {
let files = self.file_loader.all_files();
let tmpl_matcher = self.target_config.template_matcher()?;

let context = serde_json::to_value(context).map_err(|e| ContextSerializationFailed {
error: e.to_string(),
})?;

let mut results = Vec::new();
for file_to_process in files {
for template in tmpl_matcher.matches(file_to_process.clone()) {
let yaml_params = Self::init_params(template.params.clone())?;
let params = Self::prepare_jq_context(&yaml_params)?;
let filter = Filter::new(template.filter.as_str());
let filtered_result = filter.apply(context.clone(), &params)?;

match template.application_mode {
ApplicationMode::Single => {
let is_empty = filtered_result.is_null()
|| (filtered_result.is_array()
&& filtered_result.as_array().expect("is_array").is_empty());
if !is_empty {
let (output, _) = self.render_template(
NewContext {
ctx: &filtered_result,
}
.try_into()?,
&yaml_params,
&file_to_process,
None,
)?;
results.push(output);
}
}
ApplicationMode::Each => {
if let serde_json::Value::Array(values) = &filtered_result {
for value in values {
let (output, _) = self.render_template(
NewContext { ctx: value }.try_into()?,
&yaml_params,
&file_to_process,
None,
)?;
results.push(output);
}
} else {
let (output, _) = self.render_template(
NewContext {
ctx: &filtered_result,
}
.try_into()?,
&yaml_params,
&file_to_process,
None,
)?;
results.push(output);
}
}
}
}
}

Ok(results.join(""))
}

/// Generate artifacts from a serializable context and a template directory,
/// in parallel.
///
Expand Down Expand Up @@ -538,17 +607,16 @@ impl TemplateEngine {
}
}

#[allow(clippy::print_stdout)] // This is used for the OutputDirective::Stdout variant
#[allow(clippy::print_stderr)] // This is used for the OutputDirective::Stderr variant
fn evaluate_template(
/// Set up a Jinja engine, render a template, and return the output string
/// along with the `TemplateObject` (which may have been mutated by the
/// template to override the file name).
fn render_template(
&self,
ctx: serde_json::Value,
file_path: Option<&String>,
params: &BTreeMap<String, serde_yaml::Value>,
template_path: &Path,
output_directive: &OutputDirective,
output_dir: &Path,
) -> Result<(), Error> {
file_path_config: Option<&String>,
) -> Result<(String, TemplateObject), Error> {
let mut engine = self.template_engine()?;

// Add the Weaver parameters to the template context
Expand All @@ -559,7 +627,7 @@ impl TemplateEngine {

// Pre-determine the file path for the generated file based on the template file path
// if defined, otherwise use the default file path based on the template file name.
let file_path = match file_path {
let file_path = match file_path_config {
Some(file_path) => {
engine
.render_str(file_path, ctx.clone())
Expand Down Expand Up @@ -603,13 +671,28 @@ impl TemplateEngine {
}
})?;

let output = template
.render(ctx.clone())
.map_err(|e| TemplateEvaluationFailed {
template: template_path.to_path_buf(),
error_id: e.to_string(),
error: error_summary(e),
})?;
let output = template.render(ctx).map_err(|e| TemplateEvaluationFailed {
template: template_path.to_path_buf(),
error_id: e.to_string(),
error: error_summary(e),
})?;

Ok((output, template_object))
}

#[allow(clippy::print_stdout)] // This is used for the OutputDirective::Stdout variant
#[allow(clippy::print_stderr)] // This is used for the OutputDirective::Stderr variant
fn evaluate_template(
&self,
ctx: serde_json::Value,
file_path: Option<&String>,
params: &BTreeMap<String, serde_yaml::Value>,
template_path: &Path,
output_directive: &OutputDirective,
output_dir: &Path,
) -> Result<(), Error> {
let (output, template_object) =
self.render_template(ctx, params, template_path, file_path)?;
match output_directive {
OutputDirective::Stdout => {
println!("{output}");
Expand Down
149 changes: 149 additions & 0 deletions crates/weaver_forge/src/output_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,29 @@ impl OutputProcessor {
}
}

/// Serialize/render data to a String without writing to stdout/file.
pub fn generate_to_string<T: Serialize>(&self, data: &T) -> Result<String, Error> {
match &self.kind {
OutputKind::Builtin { format, .. } => format.serialize(data),
OutputKind::Template(t) => t.engine.generate_to_string(data),
OutputKind::Mute => Ok(String::new()),
}
}

/// Returns the MIME content type for the configured format.
#[must_use]
pub fn content_type(&self) -> &'static str {
match &self.kind {
OutputKind::Builtin { format, .. } => match format {
BuiltinFormat::Json => "application/json",
BuiltinFormat::Yaml => "application/yaml",
BuiltinFormat::Jsonl => "application/x-ndjson",
},
OutputKind::Template(_) => "text/plain",
OutputKind::Mute => "text/plain",
}
}

/// Returns true if file output is being used.
#[must_use]
pub fn is_file_output(&self) -> bool {
Expand Down Expand Up @@ -460,4 +483,130 @@ mod tests {
let mute = OutputProcessor::new("mute", "test", None, None, None).unwrap();
assert!(!mute.is_line_oriented());
}

#[test]
fn test_generate_to_string_json() {
let output = OutputProcessor::new("json", "test", None, None, None).unwrap();
let result = output.generate_to_string(&test_data()).unwrap();
let parsed: TestData = serde_json::from_str(&result).unwrap();
assert_eq!(parsed, test_data());
}

#[test]
fn test_generate_to_string_yaml() {
let output = OutputProcessor::new("yaml", "test", None, None, None).unwrap();
let result = output.generate_to_string(&test_data()).unwrap();
let parsed: TestData = serde_yaml::from_str(&result).unwrap();
assert_eq!(parsed, test_data());
}

#[test]
fn test_generate_to_string_jsonl() {
let output = OutputProcessor::new("jsonl", "test", None, None, None).unwrap();
let result = output.generate_to_string(&test_data()).unwrap();
let parsed: TestData = serde_json::from_str(&result).unwrap();
assert_eq!(parsed, test_data());
}

#[test]
fn test_generate_to_string_mute() {
let output = OutputProcessor::new("mute", "test", None, None, None).unwrap();
let result = output.generate_to_string(&test_data()).unwrap();
assert!(result.is_empty());
}

#[test]
fn test_generate_to_string_template() {
let output =
OutputProcessor::new("simple", "test", Some(&EMBEDDED_TEMPLATES), None, None).unwrap();
let result = output.generate_to_string(&test_data()).unwrap();
assert!(result.contains("test"), "should contain name");
assert!(result.contains("42"), "should contain value");
}

#[test]
fn test_generate_to_string_template_each_array() {
#[derive(Serialize)]
struct Items {
items: Vec<TestData>,
}

let output =
OutputProcessor::new("each_test", "test", Some(&EMBEDDED_TEMPLATES), None, None)
.unwrap();

let data = Items {
items: vec![
TestData {
name: "a".to_owned(),
value: 1,
},
TestData {
name: "b".to_owned(),
value: 2,
},
TestData {
name: "c".to_owned(),
value: 3,
},
],
};
let result = output.generate_to_string(&data).unwrap();
assert!(
result.contains("a=1"),
"should contain first item: {result}"
);
assert!(
result.contains("b=2"),
"should contain second item: {result}"
);
assert!(
result.contains("c=3"),
"should contain third item: {result}"
);
}

#[test]
fn test_generate_to_string_template_each_non_array() {
// When the filter returns a non-array, each mode renders it as a single item
#[derive(Serialize)]
struct Items {
items: TestData,
}

let output =
OutputProcessor::new("each_test", "test", Some(&EMBEDDED_TEMPLATES), None, None)
.unwrap();

let data = Items {
items: TestData {
name: "solo".to_owned(),
value: 99,
},
};
let result = output.generate_to_string(&data).unwrap();
assert!(
result.contains("solo=99"),
"should contain the single item: {result}"
);
}

#[test]
fn test_content_type() {
let json = OutputProcessor::new("json", "test", None, None, None).unwrap();
assert_eq!(json.content_type(), "application/json");

let yaml = OutputProcessor::new("yaml", "test", None, None, None).unwrap();
assert_eq!(yaml.content_type(), "application/yaml");

let jsonl = OutputProcessor::new("jsonl", "test", None, None, None).unwrap();
assert_eq!(jsonl.content_type(), "application/x-ndjson");

let template =
OutputProcessor::new("simple", "test", Some(&EMBEDDED_TEMPLATES), None, None).unwrap();
assert_eq!(template.content_type(), "text/plain");

let mute = OutputProcessor::new("mute", "test", None, None, None).unwrap();
assert_eq!(mute.content_type(), "text/plain");
}
}
1 change: 1 addition & 0 deletions crates/weaver_forge/templates/each_test/item.txt.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{ ctx.name }}={{ ctx.value }}
4 changes: 4 additions & 0 deletions crates/weaver_forge/templates/each_test/weaver.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
templates:
- template: item.txt.j2
filter: .items
application_mode: each
13 changes: 9 additions & 4 deletions crates/weaver_live_check/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ As mentioned, a list of `PolicyFinding` is returned in the report for each sampl
"level": "violation",
"id": "missing_attribute",
"message": "Attribute `hello` does not exist in the registry.",
"context": {"attribute_name": "hello"},
"context": { "attribute_name": "hello" },
"signal_name": "http.client.request.duration",
"signal_type": "metric"
}
Expand Down Expand Up @@ -157,9 +157,11 @@ To override the default Otel jq preprocessor provide a path to the jq file throu

## Output

The output follows existing Weaver paradigms providing overridable jinja template based processing.
The output follows existing Weaver paradigms providing overridable jinja template based processing alongside builtin standard formats.

Out-of-the-box the output is streamed (when available) to templates providing `ansi` (default) or `json` output via the `--format` option. To override streaming and only produce a report when the input is closed, use `--no-stream`. Streaming is automatically disabled if your `--output` is a path to a directory; by default, output is printed to stdout.
By default the output is streamed (when available) to an `ansi` template. Use the `--format` option to pick one of the builtin standard formats: `json`, `jsonl` and `yaml` or a template name. To override streaming and only produce a report when the input is closed, use `--no-stream`. Streaming is automatically disabled if your `--output` is a path to a directory; by default, output is printed to stdout.

Set `--output=http` to have the report sent as the response to the `/stop` endpoint on the admin port.

To provide your own custom templates use the `--templates` option.

Expand Down Expand Up @@ -228,7 +230,7 @@ This could be parsed for a more sophisticated way to determine pass/fail in CI f

## OTLP Log Record Emission

In addition to the templated output formats (ANSI, JSON), live check can emit policy findings as OTLP log records. This enables real-time monitoring and analysis of semantic convention validation results through OpenTelemetry observability backends.
In addition to the output formats, live check can emit policy findings as OTLP log records. This enables real-time monitoring and analysis of semantic convention validation results through OpenTelemetry observability backends.

### Enabling OTLP Emission

Expand Down Expand Up @@ -257,16 +259,19 @@ Each policy finding is emitted as an OTLP log record with the following structur
**Body**: The finding message (e.g., "Required attribute 'server.address' is not present.")

**Severity**:

- `Error` (17) for `violation` level findings
- `Warn` (13) for `improvement` level findings
- `Info` (9) for `information` level findings

**Event Name**: `weaver.live_check.finding`

**Resource Attributes**:

- `service.name`: set by OTEL_SERVICE_NAME or OTEL_RESOURCE_ATTRIBUTES environment variables, defaulting to `weaver`

**Log Attributes**:

- `weaver.finding.id`: Finding type identifier (e.g., "required_attribute_not_present")
- `weaver.finding.level`: Finding level as string ("violation", "improvement", "information")
- `weaver.finding.context.<key>`: Key-value pairs provided in the context. Each pair is recorded as a single attribute.
Expand Down
Loading