Skip to content

Commit 635c682

Browse files
authored
Live check health and stop endpoints (#1193)
* initial * refactor * fmt * keep old stop behaviour when http mode not set * added missing test coverage * clippy
1 parent 54ee5f5 commit 635c682

File tree

9 files changed

+614
-92
lines changed

9 files changed

+614
-92
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

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

5+
# Unreleased
6+
7+
- 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)
8+
- 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)
9+
510
# [0.21.2] - 2026-02-03
611

712
- 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)

crates/weaver_forge/src/lib.rs

Lines changed: 98 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,75 @@ impl TemplateEngine {
326326
Ok(result)
327327
}
328328

329+
/// Generate artifacts from a serializable context and return the rendered
330+
/// output as a String instead of writing to files or stdout.
331+
///
332+
/// This is useful when the output needs to be captured (e.g., for HTTP responses).
333+
pub fn generate_to_string<T: Serialize>(&self, context: &T) -> Result<String, Error> {
334+
let files = self.file_loader.all_files();
335+
let tmpl_matcher = self.target_config.template_matcher()?;
336+
337+
let context = serde_json::to_value(context).map_err(|e| ContextSerializationFailed {
338+
error: e.to_string(),
339+
})?;
340+
341+
let mut results = Vec::new();
342+
for file_to_process in files {
343+
for template in tmpl_matcher.matches(file_to_process.clone()) {
344+
let yaml_params = Self::init_params(template.params.clone())?;
345+
let params = Self::prepare_jq_context(&yaml_params)?;
346+
let filter = Filter::new(template.filter.as_str());
347+
let filtered_result = filter.apply(context.clone(), &params)?;
348+
349+
match template.application_mode {
350+
ApplicationMode::Single => {
351+
let is_empty = filtered_result.is_null()
352+
|| (filtered_result.is_array()
353+
&& filtered_result.as_array().expect("is_array").is_empty());
354+
if !is_empty {
355+
let (output, _) = self.render_template(
356+
NewContext {
357+
ctx: &filtered_result,
358+
}
359+
.try_into()?,
360+
&yaml_params,
361+
&file_to_process,
362+
None,
363+
)?;
364+
results.push(output);
365+
}
366+
}
367+
ApplicationMode::Each => {
368+
if let serde_json::Value::Array(values) = &filtered_result {
369+
for value in values {
370+
let (output, _) = self.render_template(
371+
NewContext { ctx: value }.try_into()?,
372+
&yaml_params,
373+
&file_to_process,
374+
None,
375+
)?;
376+
results.push(output);
377+
}
378+
} else {
379+
let (output, _) = self.render_template(
380+
NewContext {
381+
ctx: &filtered_result,
382+
}
383+
.try_into()?,
384+
&yaml_params,
385+
&file_to_process,
386+
None,
387+
)?;
388+
results.push(output);
389+
}
390+
}
391+
}
392+
}
393+
}
394+
395+
Ok(results.join(""))
396+
}
397+
329398
/// Generate artifacts from a serializable context and a template directory,
330399
/// in parallel.
331400
///
@@ -538,17 +607,16 @@ impl TemplateEngine {
538607
}
539608
}
540609

541-
#[allow(clippy::print_stdout)] // This is used for the OutputDirective::Stdout variant
542-
#[allow(clippy::print_stderr)] // This is used for the OutputDirective::Stderr variant
543-
fn evaluate_template(
610+
/// Set up a Jinja engine, render a template, and return the output string
611+
/// along with the `TemplateObject` (which may have been mutated by the
612+
/// template to override the file name).
613+
fn render_template(
544614
&self,
545615
ctx: serde_json::Value,
546-
file_path: Option<&String>,
547616
params: &BTreeMap<String, serde_yaml::Value>,
548617
template_path: &Path,
549-
output_directive: &OutputDirective,
550-
output_dir: &Path,
551-
) -> Result<(), Error> {
618+
file_path_config: Option<&String>,
619+
) -> Result<(String, TemplateObject), Error> {
552620
let mut engine = self.template_engine()?;
553621

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

560628
// Pre-determine the file path for the generated file based on the template file path
561629
// if defined, otherwise use the default file path based on the template file name.
562-
let file_path = match file_path {
630+
let file_path = match file_path_config {
563631
Some(file_path) => {
564632
engine
565633
.render_str(file_path, ctx.clone())
@@ -603,13 +671,28 @@ impl TemplateEngine {
603671
}
604672
})?;
605673

606-
let output = template
607-
.render(ctx.clone())
608-
.map_err(|e| TemplateEvaluationFailed {
609-
template: template_path.to_path_buf(),
610-
error_id: e.to_string(),
611-
error: error_summary(e),
612-
})?;
674+
let output = template.render(ctx).map_err(|e| TemplateEvaluationFailed {
675+
template: template_path.to_path_buf(),
676+
error_id: e.to_string(),
677+
error: error_summary(e),
678+
})?;
679+
680+
Ok((output, template_object))
681+
}
682+
683+
#[allow(clippy::print_stdout)] // This is used for the OutputDirective::Stdout variant
684+
#[allow(clippy::print_stderr)] // This is used for the OutputDirective::Stderr variant
685+
fn evaluate_template(
686+
&self,
687+
ctx: serde_json::Value,
688+
file_path: Option<&String>,
689+
params: &BTreeMap<String, serde_yaml::Value>,
690+
template_path: &Path,
691+
output_directive: &OutputDirective,
692+
output_dir: &Path,
693+
) -> Result<(), Error> {
694+
let (output, template_object) =
695+
self.render_template(ctx, params, template_path, file_path)?;
613696
match output_directive {
614697
OutputDirective::Stdout => {
615698
println!("{output}");

crates/weaver_forge/src/output_processor.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,29 @@ impl OutputProcessor {
187187
}
188188
}
189189

190+
/// Serialize/render data to a String without writing to stdout/file.
191+
pub fn generate_to_string<T: Serialize>(&self, data: &T) -> Result<String, Error> {
192+
match &self.kind {
193+
OutputKind::Builtin { format, .. } => format.serialize(data),
194+
OutputKind::Template(t) => t.engine.generate_to_string(data),
195+
OutputKind::Mute => Ok(String::new()),
196+
}
197+
}
198+
199+
/// Returns the MIME content type for the configured format.
200+
#[must_use]
201+
pub fn content_type(&self) -> &'static str {
202+
match &self.kind {
203+
OutputKind::Builtin { format, .. } => match format {
204+
BuiltinFormat::Json => "application/json",
205+
BuiltinFormat::Yaml => "application/yaml",
206+
BuiltinFormat::Jsonl => "application/x-ndjson",
207+
},
208+
OutputKind::Template(_) => "text/plain",
209+
OutputKind::Mute => "text/plain",
210+
}
211+
}
212+
190213
/// Returns true if file output is being used.
191214
#[must_use]
192215
pub fn is_file_output(&self) -> bool {
@@ -460,4 +483,130 @@ mod tests {
460483
let mute = OutputProcessor::new("mute", "test", None, None, None).unwrap();
461484
assert!(!mute.is_line_oriented());
462485
}
486+
487+
#[test]
488+
fn test_generate_to_string_json() {
489+
let output = OutputProcessor::new("json", "test", None, None, None).unwrap();
490+
let result = output.generate_to_string(&test_data()).unwrap();
491+
let parsed: TestData = serde_json::from_str(&result).unwrap();
492+
assert_eq!(parsed, test_data());
493+
}
494+
495+
#[test]
496+
fn test_generate_to_string_yaml() {
497+
let output = OutputProcessor::new("yaml", "test", None, None, None).unwrap();
498+
let result = output.generate_to_string(&test_data()).unwrap();
499+
let parsed: TestData = serde_yaml::from_str(&result).unwrap();
500+
assert_eq!(parsed, test_data());
501+
}
502+
503+
#[test]
504+
fn test_generate_to_string_jsonl() {
505+
let output = OutputProcessor::new("jsonl", "test", None, None, None).unwrap();
506+
let result = output.generate_to_string(&test_data()).unwrap();
507+
let parsed: TestData = serde_json::from_str(&result).unwrap();
508+
assert_eq!(parsed, test_data());
509+
}
510+
511+
#[test]
512+
fn test_generate_to_string_mute() {
513+
let output = OutputProcessor::new("mute", "test", None, None, None).unwrap();
514+
let result = output.generate_to_string(&test_data()).unwrap();
515+
assert!(result.is_empty());
516+
}
517+
518+
#[test]
519+
fn test_generate_to_string_template() {
520+
let output =
521+
OutputProcessor::new("simple", "test", Some(&EMBEDDED_TEMPLATES), None, None).unwrap();
522+
let result = output.generate_to_string(&test_data()).unwrap();
523+
assert!(result.contains("test"), "should contain name");
524+
assert!(result.contains("42"), "should contain value");
525+
}
526+
527+
#[test]
528+
fn test_generate_to_string_template_each_array() {
529+
#[derive(Serialize)]
530+
struct Items {
531+
items: Vec<TestData>,
532+
}
533+
534+
let output =
535+
OutputProcessor::new("each_test", "test", Some(&EMBEDDED_TEMPLATES), None, None)
536+
.unwrap();
537+
538+
let data = Items {
539+
items: vec![
540+
TestData {
541+
name: "a".to_owned(),
542+
value: 1,
543+
},
544+
TestData {
545+
name: "b".to_owned(),
546+
value: 2,
547+
},
548+
TestData {
549+
name: "c".to_owned(),
550+
value: 3,
551+
},
552+
],
553+
};
554+
let result = output.generate_to_string(&data).unwrap();
555+
assert!(
556+
result.contains("a=1"),
557+
"should contain first item: {result}"
558+
);
559+
assert!(
560+
result.contains("b=2"),
561+
"should contain second item: {result}"
562+
);
563+
assert!(
564+
result.contains("c=3"),
565+
"should contain third item: {result}"
566+
);
567+
}
568+
569+
#[test]
570+
fn test_generate_to_string_template_each_non_array() {
571+
// When the filter returns a non-array, each mode renders it as a single item
572+
#[derive(Serialize)]
573+
struct Items {
574+
items: TestData,
575+
}
576+
577+
let output =
578+
OutputProcessor::new("each_test", "test", Some(&EMBEDDED_TEMPLATES), None, None)
579+
.unwrap();
580+
581+
let data = Items {
582+
items: TestData {
583+
name: "solo".to_owned(),
584+
value: 99,
585+
},
586+
};
587+
let result = output.generate_to_string(&data).unwrap();
588+
assert!(
589+
result.contains("solo=99"),
590+
"should contain the single item: {result}"
591+
);
592+
}
593+
594+
#[test]
595+
fn test_content_type() {
596+
let json = OutputProcessor::new("json", "test", None, None, None).unwrap();
597+
assert_eq!(json.content_type(), "application/json");
598+
599+
let yaml = OutputProcessor::new("yaml", "test", None, None, None).unwrap();
600+
assert_eq!(yaml.content_type(), "application/yaml");
601+
602+
let jsonl = OutputProcessor::new("jsonl", "test", None, None, None).unwrap();
603+
assert_eq!(jsonl.content_type(), "application/x-ndjson");
604+
605+
let template =
606+
OutputProcessor::new("simple", "test", Some(&EMBEDDED_TEMPLATES), None, None).unwrap();
607+
assert_eq!(template.content_type(), "text/plain");
608+
609+
let mute = OutputProcessor::new("mute", "test", None, None, None).unwrap();
610+
assert_eq!(mute.content_type(), "text/plain");
611+
}
463612
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{ ctx.name }}={{ ctx.value }}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
templates:
2+
- template: item.txt.j2
3+
filter: .items
4+
application_mode: each

crates/weaver_live_check/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ As mentioned, a list of `PolicyFinding` is returned in the report for each sampl
100100
"level": "violation",
101101
"id": "missing_attribute",
102102
"message": "Attribute `hello` does not exist in the registry.",
103-
"context": {"attribute_name": "hello"},
103+
"context": { "attribute_name": "hello" },
104104
"signal_name": "http.client.request.duration",
105105
"signal_type": "metric"
106106
}
@@ -157,9 +157,11 @@ To override the default Otel jq preprocessor provide a path to the jq file throu
157157

158158
## Output
159159

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

162-
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.
162+
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.
163+
164+
Set `--output=http` to have the report sent as the response to the `/stop` endpoint on the admin port.
163165

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

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

229231
## OTLP Log Record Emission
230232

231-
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.
233+
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.
232234

233235
### Enabling OTLP Emission
234236

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

259261
**Severity**:
262+
260263
- `Error` (17) for `violation` level findings
261264
- `Warn` (13) for `improvement` level findings
262265
- `Info` (9) for `information` level findings
263266

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

266269
**Resource Attributes**:
270+
267271
- `service.name`: set by OTEL_SERVICE_NAME or OTEL_RESOURCE_ATTRIBUTES environment variables, defaulting to `weaver`
268272

269273
**Log Attributes**:
274+
270275
- `weaver.finding.id`: Finding type identifier (e.g., "required_attribute_not_present")
271276
- `weaver.finding.level`: Finding level as string ("violation", "improvement", "information")
272277
- `weaver.finding.context.<key>`: Key-value pairs provided in the context. Each pair is recorded as a single attribute.

0 commit comments

Comments
 (0)