[detect] Detect skeleton

Near-final v0 implementation for Detect.

Test: fx build && fx test triage-detect-tests && fx test triage

Change-Id: I21aacdb458773f154c33c5de537ffee01ed41304
Reviewed-on: https://fuchsia-review.googlesource.com/c/fuchsia/+/426775
Commit-Queue: Chris Phoenix <cphoenix@google.com>
Testability-Review: Christopher Johnson <crjohns@google.com>
Reviewed-by: Christopher Johnson <crjohns@google.com>
diff --git a/src/diagnostics/BUILD.gn b/src/diagnostics/BUILD.gn
index 53b4ab0..2a6321a 100644
--- a/src/diagnostics/BUILD.gn
+++ b/src/diagnostics/BUILD.gn
@@ -18,6 +18,7 @@
   deps = [
     "archivist:tests",
     "config:tests",
+    "detect:tests",
     "iquery:tests",
     "lib:tests",
     "log-stats:tests",
diff --git a/src/diagnostics/config/triage/BUILD.gn b/src/diagnostics/config/triage/BUILD.gn
index b448f4d..86244d5 100644
--- a/src/diagnostics/config/triage/BUILD.gn
+++ b/src/diagnostics/config/triage/BUILD.gn
@@ -2,6 +2,7 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
+import("//build/config.gni")
 import("//src/diagnostics/triage/build/triage_config_test.gni")
 
 # The list of triage configuration files in the current directory that
@@ -28,6 +29,15 @@
   "timezone.triage",
 ]
 
+detect_files = [ "detect/wlan-detect.triage" ]
+
+triage_files += detect_files
+
+config_data("triage-detect") {
+  for_pkg = "triage-detect"
+  sources = detect_files
+}
+
 group("triage") {
   testonly = true
   deps = [ ":tests" ]
diff --git a/src/diagnostics/config/triage/detect/wlan-detect.triage b/src/diagnostics/config/triage/detect/wlan-detect.triage
new file mode 100644
index 0000000..2f67180
--- /dev/null
+++ b/src/diagnostics/config/triage/detect/wlan-detect.triage
@@ -0,0 +1,17 @@
+{
+    select: {
+        disconnects: "INSPECT:wlanstack.cmx:root/client_stats/disconnect/*:@time",
+    },
+    eval: {
+        last_24_hours: "Fn([time], time > Now() - Hours(24))",
+        n_disconnects_today: "Count(Filter(last_24_hours, disconnects))",
+    },
+    act: {
+        too_many_disconnects: {
+            type: "Snapshot",
+            trigger: "n_disconnects_today >= 5",
+            repeat: "Hours(24)",
+            signature: "five-disconnects-today",
+        },
+    },
+}
diff --git a/src/diagnostics/detect/BUILD.gn b/src/diagnostics/detect/BUILD.gn
new file mode 100644
index 0000000..4f30cc0
--- /dev/null
+++ b/src/diagnostics/detect/BUILD.gn
@@ -0,0 +1,69 @@
+# Copyright 2020 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/config.gni")
+import("//build/rust/rustc_binary.gni")
+import("//src/sys/build/components.gni")
+
+rustc_binary("bin") {
+  name = "triage-detect"
+  edition = "2018"
+  with_unit_tests = true
+
+  deps = [
+    "//sdk/fidl/fuchsia.diagnostics:fuchsia.diagnostics-rustc",
+    "//sdk/fidl/fuchsia.feedback:fuchsia.feedback-rustc",
+    "//sdk/fidl/fuchsia.logger:fuchsia.logger-rustc",
+    "//src/diagnostics/lib/injectable-time",
+    "//src/diagnostics/lib/triage",
+    "//src/lib/diagnostics/inspect/contrib/rust",
+    "//src/lib/fdio/rust:fdio",
+    "//src/lib/fidl/rust/fidl",
+    "//src/lib/fuchsia-async",
+    "//src/lib/fuchsia-component",
+    "//src/lib/syslog/rust:syslog",
+    "//src/lib/zircon/rust:fuchsia-zircon",
+    "//third_party/rust_crates:anyhow",
+    "//third_party/rust_crates:argh",
+    "//third_party/rust_crates:async-trait",
+    "//third_party/rust_crates:futures",
+    "//third_party/rust_crates:glob",
+    "//third_party/rust_crates:log",
+    "//third_party/rust_crates:maplit",
+    "//third_party/rust_crates:matches",
+    "//third_party/rust_crates:thiserror",
+  ]
+
+  sources = [
+    "src/delay_tracker.rs",
+    "src/diagnostics.rs",
+    "src/main.rs",
+    "src/snapshot.rs",
+    "src/triage_shim.rs",
+  ]
+
+  test_deps = []
+}
+
+fuchsia_component("triage-detect-component") {
+  deps = [
+    ":bin",
+    "//src/diagnostics/config/triage:triage-detect",
+  ]
+  manifest = "meta/triage-detect.cml"
+}
+
+fuchsia_package("triage-detect") {
+  deps = [ ":triage-detect-component" ]
+}
+
+fuchsia_unittest_package("triage-detect-tests") {
+  manifest = "meta/triage-detect-tests.cmx"
+  deps = [ ":bin_test" ]
+}
+
+group("tests") {
+  testonly = true
+  deps = [ ":triage-detect-tests" ]
+}
diff --git a/src/diagnostics/detect/README.md b/src/diagnostics/detect/README.md
new file mode 100644
index 0000000..a500183
--- /dev/null
+++ b/src/diagnostics/detect/README.md
@@ -0,0 +1,6 @@
+# detect
+
+Detect runs on-device, getting diagnostic data periodically and using Triage-lib
+to decide whether to take actions such as generating a crash report/snapshot.
+
+TODO(fxbug.dev/61333): Add more info.
diff --git a/src/diagnostics/detect/meta/triage-detect-tests.cmx b/src/diagnostics/detect/meta/triage-detect-tests.cmx
new file mode 100644
index 0000000..23a53b2
--- /dev/null
+++ b/src/diagnostics/detect/meta/triage-detect-tests.cmx
@@ -0,0 +1,13 @@
+{
+    "program": {
+        "binary": "bin/triage_detect_bin_test"
+    },
+    "sandbox": {
+        "features": [
+            "build-info"
+        ],
+        "services": [
+            "fuchsia.logger.LogSink"
+        ]
+    }
+}
diff --git a/src/diagnostics/detect/meta/triage-detect.cml b/src/diagnostics/detect/meta/triage-detect.cml
new file mode 100644
index 0000000..4e8a160
--- /dev/null
+++ b/src/diagnostics/detect/meta/triage-detect.cml
@@ -0,0 +1,29 @@
+{
+    program: {
+        binary: "bin/triage_detect",
+        args: [
+            "--check-every",
+            "Minutes(2)",
+        ],
+    },
+    use: [
+        { runner: "elf" },
+        {
+            protocol: "fuchsia.feedback.CrashReporter",
+            from: "parent",
+        },
+        {
+            protocol: "fuchsia.logger.LogSink",
+            from: "parent",
+        },
+        {
+            protocol: "fuchsia.diagnostics.FeedbackArchiveAccessor",
+            from: "parent",
+        },
+        {
+            directory: "config-data",
+            rights: [ "r*" ],
+            path: "/config/data",
+        },
+    ],
+}
diff --git a/src/diagnostics/detect/src/delay_tracker.rs b/src/diagnostics/detect/src/delay_tracker.rs
new file mode 100644
index 0000000..875f758
--- /dev/null
+++ b/src/diagnostics/detect/src/delay_tracker.rs
@@ -0,0 +1,98 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/// DelayTracker keeps track of how recently we sent a crash report of each type, and whether we
+/// should mute too-frequent requests.
+use {
+    crate::{Mode, MINIMUM_SIGNATURE_INTERVAL_NANOS},
+    injectable_time::TimeSource,
+    log::warn,
+    std::collections::HashMap,
+    triage::SnapshotTrigger,
+};
+
+pub struct DelayTracker<'a> {
+    last_sent: HashMap<String, i64>,
+    time_source: &'a dyn TimeSource,
+    program_mode: Mode,
+}
+
+impl<'a> DelayTracker<'a> {
+    pub fn new(time_source: &'a dyn TimeSource, program_mode: Mode) -> DelayTracker<'a> {
+        DelayTracker { last_sent: HashMap::new(), time_source, program_mode }
+    }
+
+    fn appropriate_report_interval(&self, desired_interval: i64) -> i64 {
+        if self.program_mode == Mode::Test || desired_interval >= MINIMUM_SIGNATURE_INTERVAL_NANOS {
+            desired_interval
+        } else {
+            MINIMUM_SIGNATURE_INTERVAL_NANOS
+        }
+    }
+
+    // If it's OK to send, remember the time and return true.
+    pub fn ok_to_send(&mut self, snapshot: &SnapshotTrigger) -> bool {
+        let now = self.time_source.now();
+        let interval = self.appropriate_report_interval(snapshot.interval);
+        let should_send = match self.last_sent.get(&snapshot.signature) {
+            None => true,
+            Some(time) => time <= &(now - interval),
+        };
+        if should_send {
+            self.last_sent.insert(snapshot.signature.to_string(), now);
+            if snapshot.interval < MINIMUM_SIGNATURE_INTERVAL_NANOS {
+                // To reduce logspam, put this warning here rather than above where the
+                // calculation is. The calculation may happen every time we check diagnostics; this
+                // will happen at most every MINIMUM_SIGNATURE_INTERVAL (except in tests).
+                warn!(
+                    "Signature {} has interval {} nanos, less than minimum {}",
+                    snapshot.signature, snapshot.interval, MINIMUM_SIGNATURE_INTERVAL_NANOS
+                );
+            }
+        }
+        should_send
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use injectable_time::FakeTime;
+
+    #[test]
+    fn verify_test_mode() {
+        let time = FakeTime::new();
+        let mut tracker = DelayTracker::new(&time, Mode::Test);
+        time.set(1);
+        let trigger_slow = SnapshotTrigger { signature: "slow".to_string(), interval: 10 };
+        let trigger_fast = SnapshotTrigger { signature: "fast".to_string(), interval: 1 };
+        let ok_slow_1 = tracker.ok_to_send(&trigger_slow);
+        let ok_fast_1 = tracker.ok_to_send(&trigger_fast);
+        time.set(3);
+        let ok_slow_2 = tracker.ok_to_send(&trigger_slow);
+        let ok_fast_2 = tracker.ok_to_send(&trigger_fast);
+        // This one should obviously succeed.
+        assert_eq!(ok_slow_1, true);
+        // It should allow a different snapshot signature too.
+        assert_eq!(ok_fast_1, true);
+        // It should reject the first (slow) signature the second time.
+        assert_eq!(ok_slow_2, false);
+        // The second (fast) signature should be accepted repeatedly.
+        assert_eq!(ok_fast_2, true);
+    }
+
+    #[test]
+    fn verify_appropriate_report_interval() {
+        assert!(MINIMUM_SIGNATURE_INTERVAL_NANOS > 1);
+        let time = FakeTime::new();
+        let test_tracker = DelayTracker::new(&time, Mode::Test);
+        let production_tracker = DelayTracker::new(&time, Mode::Production);
+
+        assert_eq!(test_tracker.appropriate_report_interval(1), 1);
+        assert_eq!(
+            production_tracker.appropriate_report_interval(1),
+            MINIMUM_SIGNATURE_INTERVAL_NANOS
+        );
+    }
+}
diff --git a/src/diagnostics/detect/src/diagnostics.rs b/src/diagnostics/detect/src/diagnostics.rs
new file mode 100644
index 0000000..8a61e8c
--- /dev/null
+++ b/src/diagnostics/detect/src/diagnostics.rs
@@ -0,0 +1,96 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Fetches diagnostic data.
+
+use {
+    anyhow::Error,
+    fuchsia_inspect_contrib::reader::{ArchiveReader, DataType},
+    log::error,
+    triage::DiagnosticData,
+    triage::Source,
+};
+
+// Selectors for Inspect data must start with this exact string.
+const INSPECT_PREFIX: &str = "INSPECT:";
+
+// Durable connection to Archivist
+#[derive(Debug)]
+pub struct DiagnosticFetcher {
+    inspect: InspectFetcher,
+}
+
+#[derive(Debug)]
+pub struct Selectors {
+    pub(crate) inspect_selectors: Vec<String>,
+}
+
+impl DiagnosticFetcher {
+    pub fn create(selectors: Selectors) -> Result<DiagnosticFetcher, Error> {
+        Ok(DiagnosticFetcher { inspect: InspectFetcher::create(selectors.inspect_selectors)? })
+    }
+
+    pub async fn get_diagnostics(&mut self) -> Result<Vec<DiagnosticData>, Error> {
+        let inspect_data = DiagnosticData::new(
+            "inspect.json".to_string(),
+            Source::Inspect,
+            self.inspect.fetch().await?,
+        )?;
+        Ok(vec![inspect_data])
+    }
+}
+
+impl Selectors {
+    pub fn new() -> Selectors {
+        Selectors { inspect_selectors: Vec::new() }
+    }
+
+    pub fn with_inspect_selectors(mut self, selectors: Vec<String>) -> Self {
+        self.inspect_selectors.extend(selectors);
+        self
+    }
+}
+
+struct InspectFetcher {
+    // If we have no selectors, we don't want to actually fetch anything.
+    // (Fetching with no selectors fetches all Inspect data.)
+    reader: Option<ArchiveReader>,
+}
+
+impl std::fmt::Debug for InspectFetcher {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("InspectFetcher").field("reader", &"opaque-ArchiveReader").finish()
+    }
+}
+
+impl InspectFetcher {
+    pub fn create(selectors: Vec<String>) -> Result<InspectFetcher, Error> {
+        if selectors.len() == 0 {
+            return Ok(InspectFetcher { reader: None });
+        }
+        let reader = ArchiveReader::new();
+        let get_inspect = |s: String| -> Option<std::string::String> {
+            if &s[..INSPECT_PREFIX.len()] == INSPECT_PREFIX {
+                Some(s[INSPECT_PREFIX.len()..].to_string())
+            } else {
+                error!("All selectors should begin with 'INSPECT:' - '{}'", s);
+                None
+            }
+        };
+        let selectors = selectors.into_iter().filter_map(get_inspect);
+        let reader = reader.add_selectors(selectors);
+        Ok(InspectFetcher { reader: Some(reader) })
+    }
+
+    /// This returns a String in JSON format because that's what TriageLib needs.
+    pub async fn fetch(&mut self) -> Result<String, Error> {
+        match &self.reader {
+            None => Ok("[]".to_string()),
+            Some(reader) => {
+                // TODO(fxbug.dev/62480): Make TriageLib accept structured data
+                Ok(reader.snapshot_raw(DataType::Inspect).await?.to_string())
+            }
+        }
+    }
+}
diff --git a/src/diagnostics/detect/src/main.rs b/src/diagnostics/detect/src/main.rs
new file mode 100644
index 0000000..3d14698
--- /dev/null
+++ b/src/diagnostics/detect/src/main.rs
@@ -0,0 +1,245 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+//! `triage-detect` is responsible for auto-triggering crash reports in Fuchsia.
+
+// TODO(fxbug.dev/61333): Several things
+// need to be answered and implemented before this is deployed.
+//
+// How and whether to gate/space crash report requests - should we queue them (with limited slots)
+//  the way PowerManager code does? (probably not)
+// Should we throttle crash report requests (N per day) and where to enforce this?
+// Do signatures need to be unique for each action? Namespaced between files?
+// Integration test
+// Restrict signature to lowercase-and-hyphens.
+
+mod delay_tracker;
+mod diagnostics;
+mod snapshot;
+mod triage_shim;
+
+use {
+    anyhow::{bail, Context, Error},
+    argh::FromArgs,
+    delay_tracker::DelayTracker,
+    fuchsia_async as fasync, fuchsia_zircon as zx,
+    glob::glob,
+    injectable_time::UtcTime,
+    log::{error, info, warn},
+    snapshot::SnapshotRequest,
+    std::collections::HashMap,
+};
+
+const MINIMUM_CHECK_TIME_NANOS: i64 = 60 * 1_000_000_000;
+const CONFIG_GLOB: &str = "/config/data/*";
+const SIGNATURE_PREFIX: &str = "fuchsia-detect-";
+const MINIMUM_SIGNATURE_INTERVAL_NANOS: i64 = 3600 * 1_000_000_000;
+
+/// Command line args
+#[derive(FromArgs, Debug)]
+struct CommandLine {
+    /// how often to scan Diagnostic data
+    #[argh(option)]
+    check_every: Option<String>,
+
+    /// ignore minimum times for testing. Never check in code with this flag set.
+    #[argh(switch)]
+    test_only: bool,
+}
+
+#[derive(PartialEq, Debug)]
+pub enum Mode {
+    Test,
+    Production,
+}
+
+fn load_configuration_files() -> Result<HashMap<String, String>, Error> {
+    fn file_stem(file_path: &std::path::PathBuf) -> Result<String, Error> {
+        if let Some(s) = file_path.file_stem() {
+            if let Some(s) = s.to_str() {
+                return Ok(s.to_owned());
+            }
+        }
+        bail!("Bad path {:?} - can't find file_stem", file_path)
+    }
+
+    let mut file_contents = HashMap::new();
+    for file_path in glob(CONFIG_GLOB)? {
+        let file_path = file_path?;
+        let stem = file_stem(&file_path)?;
+        let config_text = std::fs::read_to_string(file_path)?;
+        file_contents.insert(stem, config_text);
+    }
+    Ok(file_contents)
+}
+
+fn load_command_line() -> Result<CommandLine, Error> {
+    // We can't just use the one-line argh parse, because that writes to stdout
+    // and stdout doesn't currently work in v2 components. Instead, grab and
+    // log the output.
+    let arg_strings = std::env::args().collect::<Vec<_>>();
+    let arg_strs: Vec<&str> = arg_strings.iter().map(|s| s.as_str()).collect();
+    match CommandLine::from_args(&[arg_strs[0]], &arg_strs[1..]) {
+        Ok(args) => Ok(args),
+        Err(output) => {
+            for line in output.output.split("\n") {
+                warn!("CmdLine: {}", line);
+            }
+            match output.status {
+                Ok(()) => bail!("Exited as requested by command line args"),
+                Err(()) => bail!("Exited due to bad command line args"),
+            }
+        }
+    }
+}
+
+/// appropriate_check_interval determines the interval to check diagnostics, or signals error.
+///
+/// If the command line arg is empty, the interval is set to MINIMUM_CHECK_TIME_NANOS.
+/// If the command line can't be evaluated to an integer, an error is returned.
+/// If the integer is below minimum and mode isn't Test, an error is returned.
+/// If a valid integer is determined, it is returned as a zx::Duration.
+fn appropriate_check_interval(
+    command_line_option: &Option<String>,
+    mode: &Mode,
+) -> Result<zx::Duration, Error> {
+    let check_every = match &command_line_option {
+        None => MINIMUM_CHECK_TIME_NANOS,
+        Some(expression) => triage_shim::evaluate_int_math(&expression).or_else(|e| {
+            bail!("Check_every argument must be Minutes(n), Hours(n), etc. but: {}", e)
+        })?,
+    };
+    if check_every < MINIMUM_CHECK_TIME_NANOS && *mode != Mode::Test {
+        bail!(
+            "Minimum time to check is {} seconds; {} nanos is too small",
+            MINIMUM_CHECK_TIME_NANOS / 1_000_000_000,
+            check_every
+        );
+    }
+    info!(
+        "Checking every {} seconds from command line '{:?}'",
+        check_every / 1_000_000_000,
+        command_line_option
+    );
+    Ok(zx::Duration::from_nanos(check_every))
+}
+
+// on_error logs any errors from `value` and then returns a Result.
+// value must return a Result; error_message must contain one {} to put the error in.
+macro_rules! on_error {
+    ($value:expr, $error_message:expr) => {
+        $value.or_else(|e| {
+            let message = format!($error_message, e);
+            error!("{}", message);
+            bail!("{}", message)
+        })
+    };
+}
+
+#[fasync::run_singlethreaded]
+async fn main() -> Result<(), Error> {
+    fuchsia_syslog::init_with_tags(&["detect"]).context("initializing logging").unwrap();
+    let args = on_error!(load_command_line(), "Command line error: {}")?;
+    let mode = match args.test_only {
+        true => Mode::Test,
+        false => Mode::Production,
+    };
+    let check_every = on_error!(
+        appropriate_check_interval(&args.check_every, &mode),
+        "Invalid command line arg for check time: {}"
+    )?;
+    let configuration =
+        on_error!(load_configuration_files(), "Error reading configuration files: {}")?;
+    let triage_engine = on_error!(
+        triage_shim::TriageLib::new(configuration),
+        "Failed to parse Detect configuration files: {}"
+    )?;
+    info!("Loaded and parsed .triage files");
+    let selectors = triage_engine.selectors();
+    let mut diagnostic_source = diagnostics::DiagnosticFetcher::create(selectors)?;
+    let snapshot_service = snapshot::CrashReportHandlerBuilder::new().build()?;
+    let system_time = UtcTime::new();
+    let mut delay_tracker = DelayTracker::new(&system_time, mode);
+
+    // Start the first scan as soon as the program starts, via the "missed deadline" logic below.
+    let mut next_check_time = fasync::Time::INFINITE_PAST;
+    loop {
+        if next_check_time < fasync::Time::now() {
+            // We missed a deadline, so don't wait at all; start the check. But first
+            // schedule the next check time at now() + check_every.
+            if next_check_time != fasync::Time::INFINITE_PAST {
+                warn!(
+                    "Missed diagnostic check deadline {:?} by {:?} nanos",
+                    next_check_time,
+                    fasync::Time::now() - next_check_time
+                );
+            }
+            next_check_time = fasync::Time::now() + check_every;
+        } else {
+            // Wait until time for the next check.
+            fasync::Timer::new(next_check_time).await;
+            // Now it should be approximately next_check_time o'clock. To avoid drift from
+            // delays, calculate the next check time by adding check_every to the current
+            // next_check_time.
+            next_check_time += check_every;
+        }
+        let diagnostics = diagnostic_source.get_diagnostics().await;
+        let diagnostics = match diagnostics {
+            Ok(diagnostics) => diagnostics,
+            Err(e) => {
+                error!("Fetching diagnostics failed: {}", e);
+                continue;
+            }
+        };
+        let snapshot_requests = triage_engine.evaluate(diagnostics);
+        for snapshot in snapshot_requests.into_iter() {
+            if delay_tracker.ok_to_send(&snapshot) {
+                let signature = format!("{}{}", SIGNATURE_PREFIX, snapshot.signature);
+                if let Err(e) = snapshot_service.request_snapshot(SnapshotRequest::new(signature)) {
+                    error!("Snapshot request failed: {}", e);
+                }
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn verify_appropriate_check_interval() -> Result<(), Error> {
+        let error_a = Some("a".to_string());
+        let error_empty = Some("".to_string());
+        let raw_1 = Some("1".to_string());
+        let raw_1_result = zx::Duration::from_nanos(1);
+        let short_time = Some(format!("Nanos({})", MINIMUM_CHECK_TIME_NANOS - 1));
+        let short_time_result = zx::Duration::from_nanos(MINIMUM_CHECK_TIME_NANOS - 1);
+        let minimum_time = Some(format!("Nanos({})", MINIMUM_CHECK_TIME_NANOS));
+        let minimum_time_result = zx::Duration::from_nanos(MINIMUM_CHECK_TIME_NANOS);
+        let long_time = Some(format!("Nanos({})", MINIMUM_CHECK_TIME_NANOS + 1));
+        let long_time_result = zx::Duration::from_nanos(MINIMUM_CHECK_TIME_NANOS + 1);
+
+        assert!(appropriate_check_interval(&error_a, &Mode::Test).is_err());
+        assert!(appropriate_check_interval(&error_empty, &Mode::Test).is_err());
+        assert_eq!(appropriate_check_interval(&None, &Mode::Test)?, minimum_time_result);
+        assert_eq!(appropriate_check_interval(&raw_1, &Mode::Test)?, raw_1_result);
+        assert_eq!(appropriate_check_interval(&short_time, &Mode::Test)?, short_time_result);
+        assert_eq!(appropriate_check_interval(&minimum_time, &Mode::Test)?, minimum_time_result);
+        assert_eq!(appropriate_check_interval(&long_time, &Mode::Test)?, long_time_result);
+
+        assert!(appropriate_check_interval(&error_a, &Mode::Production).is_err());
+        assert!(appropriate_check_interval(&error_empty, &Mode::Production).is_err());
+        assert_eq!(appropriate_check_interval(&None, &Mode::Production)?, minimum_time_result);
+        assert!(appropriate_check_interval(&raw_1, &Mode::Production).is_err());
+        assert!(appropriate_check_interval(&short_time, &Mode::Production).is_err());
+        assert_eq!(
+            appropriate_check_interval(&minimum_time, &Mode::Production)?,
+            minimum_time_result
+        );
+        assert_eq!(appropriate_check_interval(&long_time, &Mode::Test)?, long_time_result);
+        assert_eq!(appropriate_check_interval(&long_time, &Mode::Production)?, long_time_result);
+        Ok(())
+    }
+}
diff --git a/src/diagnostics/detect/src/snapshot.rs b/src/diagnostics/detect/src/snapshot.rs
new file mode 100644
index 0000000..7e63ce4
--- /dev/null
+++ b/src/diagnostics/detect/src/snapshot.rs
@@ -0,0 +1,327 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Triggers a snapshot via FIDL
+
+use log::{error, warn};
+
+const CRASH_PROGRAM_NAME: &str = "triage_detect";
+
+#[derive(Debug)]
+pub struct SnapshotRequest {
+    signature: String,
+}
+
+impl SnapshotRequest {
+    pub fn new(signature: String) -> SnapshotRequest {
+        SnapshotRequest { signature }
+    }
+}
+
+// Code shamelessly stolen and slightly adapted from
+// garnet/bin/power_manager/src/crash_report_handler.rs
+
+use anyhow::{anyhow, format_err, Error};
+//use async_trait::async_trait;
+use fidl_fuchsia_feedback as fidl_feedback;
+use fuchsia_async as fasync;
+use futures::channel::mpsc;
+use futures::stream::StreamExt;
+use std::cell::RefCell;
+use std::rc::Rc;
+
+/// CrashReportHandler
+///
+/// Summary: Provides a mechanism for filing crash reports.
+///
+/// FIDL dependencies:
+///     - fuchsia.feedback.CrashReporter: CrashReportHandler uses this protocol to communicate
+///       with the CrashReporter service in order to file crash reports.
+///
+
+/// Path to the CrashReporter service.
+const CRASH_REPORTER_SVC: &'static str = "/svc/fuchsia.feedback.CrashReporter";
+
+/// The maximum number of pending crash report requests. This is needed because the FIDL API to file
+/// a crash report does not return until the crash report has been fully generated, which can take
+/// many seconds. Supporting pending crash reports means Detect can file
+/// a new crash report for any other reason within that window, but the CrashReportHandler will
+/// handle rate limiting to the CrashReporter service.
+const MAX_PENDING_CRASH_REPORTS: usize = 10;
+
+/// A builder for constructing the CrashReportHandler node.
+pub struct CrashReportHandlerBuilder {
+    proxy: Option<fidl_feedback::CrashReporterProxy>,
+    max_pending_crash_reports: usize,
+}
+
+// This function is from fuchsia-mirror/garnet/bin/power_manager/src/utils.rs
+/// Create and connect a FIDL proxy to the service at `path`
+fn connect_proxy<T: fidl::endpoints::ServiceMarker>(
+    path: &String,
+) -> Result<T::Proxy, anyhow::Error> {
+    let (proxy, server) = fidl::endpoints::create_proxy::<T>()
+        .map_err(|e| anyhow::format_err!("Failed to create proxy: {}", e))?;
+
+    fdio::service_connect(path, server.into_channel())
+        .map_err(|s| anyhow::format_err!("Failed to connect to service at {}: {}", path, s))?;
+    Ok(proxy)
+}
+
+/// Logs an error message if the passed in `result` is an error.
+#[macro_export]
+macro_rules! log_if_err {
+    ($result:expr, $log_prefix:expr) => {
+        if let Err(e) = $result.as_ref() {
+            log::error!("{}: {}", $log_prefix, e);
+        }
+    };
+}
+
+impl CrashReportHandlerBuilder {
+    pub fn new() -> Self {
+        Self { proxy: None, max_pending_crash_reports: MAX_PENDING_CRASH_REPORTS }
+    }
+
+    #[cfg(test)]
+    pub fn with_proxy(mut self, proxy: fidl_feedback::CrashReporterProxy) -> Self {
+        self.proxy = Some(proxy);
+        self
+    }
+
+    #[cfg(test)]
+    pub fn with_max_pending_crash_reports(mut self, max: usize) -> Self {
+        self.max_pending_crash_reports = max;
+        self
+    }
+
+    pub fn build(self) -> Result<Rc<CrashReportHandler>, Error> {
+        // Connect to the CrashReporter service if a proxy wasn't specified
+        let proxy = if self.proxy.is_some() {
+            self.proxy.unwrap()
+        } else {
+            connect_proxy::<fidl_feedback::CrashReporterMarker>(&CRASH_REPORTER_SVC.to_string())?
+        };
+
+        // Set up the crash report sender that runs asynchronously
+        let (channel, receiver) = mpsc::channel(self.max_pending_crash_reports);
+        CrashReportHandler::begin_crash_report_sender(proxy, receiver);
+
+        Ok(Rc::new(CrashReportHandler {
+            channel_size: self.max_pending_crash_reports,
+            crash_report_sender: RefCell::new(channel),
+        }))
+    }
+}
+
+pub struct CrashReportHandler {
+    /// The channel to send new crash report requests to the asynchronous crash report sender
+    /// future. The maximum pending crash reports are implicitly enforced by the channel length.
+    crash_report_sender: RefCell<mpsc::Sender<SnapshotRequest>>,
+    channel_size: usize,
+}
+
+impl CrashReportHandler {
+    /// Default name to use for `program_name` in the crash report. Using "device" here to align
+    /// with other device/hardware crash types that use the same name (brownout, hardware watchdog
+    /// timeout, power cycles, etc.).
+    const DEFAULT_PROGRAM_NAME: &'static str = CRASH_PROGRAM_NAME;
+
+    /// Handle a FileCrashReport message by sending the specified crash report signature over the
+    /// channel to the crash report sender.
+    pub fn request_snapshot(&self, request: SnapshotRequest) -> Result<(), Error> {
+        // Try to send the crash report signature over the channel. If the channel is full, return
+        // an error
+        match self.crash_report_sender.borrow_mut().try_send(request) {
+            Ok(()) => Ok(()),
+            Err(e) if e.is_full() => {
+                warn!("Too many crash reports pending: {}", e);
+                Err(anyhow!("Pending crash reports exceeds max ({})", self.channel_size))
+            }
+            Err(e) => {
+                warn!("Error sending crash report: {}", e);
+                Err(anyhow!("{}", e))
+            }
+        }
+    }
+
+    /// Spawn and detach a future that receives crash report signatures over the channel and uses
+    /// the proxy to send a File FIDL request to the CrashReporter service with the specified
+    /// signatures.
+    fn begin_crash_report_sender(
+        proxy: fidl_feedback::CrashReporterProxy,
+        mut receive_channel: mpsc::Receiver<SnapshotRequest>,
+    ) {
+        fasync::Task::local(async move {
+            while let Some(request) = receive_channel.next().await {
+                log_if_err!(
+                    Self::send_crash_report(&proxy, request).await,
+                    "Failed to file crash report"
+                );
+            }
+            error!("Crash reporter task ended. Crash reports will no longer be filed. This should not happen.")
+        })
+        .detach();
+    }
+
+    /// Send a File request to the CrashReporter service with the specified crash report signature.
+    async fn send_crash_report(
+        proxy: &fidl_feedback::CrashReporterProxy,
+        payload: SnapshotRequest,
+    ) -> Result<(), Error> {
+        warn!("Filing crash report, signature '{}'", payload.signature);
+        let report = fidl_feedback::CrashReport {
+            program_name: Some(CrashReportHandler::DEFAULT_PROGRAM_NAME.to_string()),
+            specific_report: Some(fidl_feedback::SpecificCrashReport::Generic(
+                fidl_feedback::GenericCrashReport { crash_signature: Some(payload.signature) },
+            )),
+            ..fidl_feedback::CrashReport::empty()
+        };
+
+        let result = proxy.file(report).await.map_err(|e| format_err!("IPC error: {}", e))?;
+        result.map_err(|e| format_err!("Service error: {}", e))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use futures::TryStreamExt;
+    use matches::assert_matches;
+
+    /// Tests that the node responds to the FileCrashReport message and that the expected crash
+    /// report is received by the CrashReporter service.
+    #[fasync::run_singlethreaded(test)]
+    async fn test_crash_report_content() {
+        // The crash report signature to use and verify against
+        let crash_report_signature = "TestCrashReportSignature";
+
+        // Set up the CrashReportHandler node
+        let (proxy, mut stream) =
+            fidl::endpoints::create_proxy_and_stream::<fidl_feedback::CrashReporterMarker>()
+                .unwrap();
+        let crash_report_handler =
+            CrashReportHandlerBuilder::new().with_proxy(proxy).build().unwrap();
+
+        // File a crash report
+        crash_report_handler
+            .request_snapshot(SnapshotRequest::new(crash_report_signature.to_string()))
+            .unwrap();
+
+        // Verify the fake service receives the crash report with expected data
+        if let Ok(Some(fidl_feedback::CrashReporterRequest::File { responder: _, report })) =
+            stream.try_next().await
+        {
+            assert_eq!(
+                report,
+                fidl_feedback::CrashReport {
+                    program_name: Some(CRASH_PROGRAM_NAME.to_string()),
+                    specific_report: Some(fidl_feedback::SpecificCrashReport::Generic(
+                        fidl_feedback::GenericCrashReport {
+                            crash_signature: Some(crash_report_signature.to_string())
+                        },
+                    )),
+                    ..fidl_feedback::CrashReport::empty()
+                }
+            );
+        } else {
+            panic!("Did not receive a crash report");
+        }
+    }
+
+    /// Tests that the number of pending crash reports is correctly bounded.
+    #[test]
+    fn test_crash_report_pending_reports() {
+        let mut exec = fasync::Executor::new().unwrap();
+
+        // Set up the proxy/stream and node outside of the large future used below. This way we can
+        // still poll the stream after the future completes.
+        let (proxy, mut stream) =
+            fidl::endpoints::create_proxy_and_stream::<fidl_feedback::CrashReporterMarker>()
+                .unwrap();
+        let crash_report_handler = CrashReportHandlerBuilder::new()
+            .with_proxy(proxy)
+            .with_max_pending_crash_reports(1)
+            .build()
+            .unwrap();
+
+        // Run most of the test logic inside a top level future for better ergonomics
+        exec.run_singlethreaded(async {
+            // Set up the CrashReportHandler node. The request stream is never serviced, so when the
+            // node makes the FIDL call to file the crash report, the call will block indefinitely.
+            // This lets us test the pending crash report counts.
+
+            // The first FileCrashReport should succeed
+            assert_matches!(
+                crash_report_handler
+                    .request_snapshot(SnapshotRequest::new("TestCrash1".to_string())),
+                Ok(())
+            );
+
+            // The second FileCrashReport should also succeed because since the first is now in
+            // progress, this is now the first "pending" report request
+            assert_matches!(
+                crash_report_handler
+                    .request_snapshot(SnapshotRequest::new("TestCrash2".to_string())),
+                Ok(())
+            );
+
+            // Since the first request has not completed, and there is already one pending request,
+            // this request should fail
+            assert_matches!(
+                crash_report_handler
+                    .request_snapshot(SnapshotRequest::new("TestCrash3".to_string())),
+                Err(_)
+            );
+
+            // Verify the signature of the first crash report
+            if let Ok(Some(fidl_feedback::CrashReporterRequest::File { responder, report })) =
+                stream.try_next().await
+            {
+                // Send a reply to allow the node to process the next crash report
+                let _ = responder.send(&mut Ok(()));
+                assert_eq!(
+                    report,
+                    fidl_feedback::CrashReport {
+                        program_name: Some(CRASH_PROGRAM_NAME.to_string()),
+                        specific_report: Some(fidl_feedback::SpecificCrashReport::Generic(
+                            fidl_feedback::GenericCrashReport {
+                                crash_signature: Some("TestCrash1".to_string())
+                            },
+                        )),
+                        ..fidl_feedback::CrashReport::empty()
+                    }
+                );
+            } else {
+                panic!("Did not receive a crash report");
+            }
+
+            // Verify the signature of the second crash report
+            if let Ok(Some(fidl_feedback::CrashReporterRequest::File { responder, report })) =
+                stream.try_next().await
+            {
+                // Send a reply to allow the node to process the next crash report
+                let _ = responder.send(&mut Ok(()));
+                assert_eq!(
+                    report,
+                    fidl_feedback::CrashReport {
+                        program_name: Some(CRASH_PROGRAM_NAME.to_string()),
+                        specific_report: Some(fidl_feedback::SpecificCrashReport::Generic(
+                            fidl_feedback::GenericCrashReport {
+                                crash_signature: Some("TestCrash2".to_string())
+                            },
+                        )),
+                        ..fidl_feedback::CrashReport::empty()
+                    }
+                );
+            } else {
+                panic!("Did not receive a crash report");
+            }
+        });
+
+        // Verify there are no more crash reports. Use `run_until_stalled` because `next` is
+        // expected to block until a new crash report is ready, which shouldn't happen here.
+        assert!(exec.run_until_stalled(&mut stream.next()).is_pending());
+    }
+}
diff --git a/src/diagnostics/detect/src/triage_shim.rs b/src/diagnostics/detect/src/triage_shim.rs
new file mode 100644
index 0000000..1d3253d
--- /dev/null
+++ b/src/diagnostics/detect/src/triage_shim.rs
@@ -0,0 +1,87 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Interface to the Triage library.
+
+use {
+    crate::diagnostics::Selectors,
+    anyhow::Error,
+    std::collections::HashMap,
+    triage::{ActionTagDirective, ParseResult, SnapshotTrigger},
+};
+
+pub fn evaluate_int_math(expression: &str) -> Result<i64, Error> {
+    triage::evaluate_int_math(expression)
+}
+
+type ConfigFiles = HashMap<String, String>;
+
+type DiagnosticData = triage::DiagnosticData;
+
+pub struct TriageLib {
+    triage_config: triage::ParseResult,
+}
+
+impl TriageLib {
+    pub fn new(configs: ConfigFiles) -> Result<TriageLib, Error> {
+        let triage_config = ParseResult::new(&configs, &ActionTagDirective::AllowAll)?;
+        Ok(TriageLib { triage_config })
+    }
+
+    pub fn selectors(&self) -> Selectors {
+        Selectors::new().with_inspect_selectors(triage::all_selectors(&self.triage_config))
+    }
+
+    pub fn evaluate(&self, data: Vec<DiagnosticData>) -> Vec<SnapshotTrigger> {
+        triage::snapshots(&data, &self.triage_config)
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use maplit::hashmap;
+
+    const CONFIG: &str = r#"{
+            select: {
+                foo: "INSPECT:foo.cm:path/to:leaf",
+            },
+            act: {
+                yes: {
+                    type: "Snapshot",
+                    trigger: "foo==8",
+                    repeat: "Micros(42)",
+                    signature: "got-it",
+                },
+            },
+        }"#;
+
+    const INSPECT: &str = r#"[
+        {
+            "moniker": "foo.cm",
+            "payload": {"path": {"to": {"leaf": 8}}}
+        }
+    ]"#;
+
+    #[test]
+    fn library_calls_work() -> Result<(), Error> {
+        fuchsia_syslog::init_with_tags(&["detect"]).unwrap();
+        let configs = hashmap! { "foo.config".to_string() => CONFIG.to_string() };
+        let lib = TriageLib::new(configs)?;
+        let data = vec![DiagnosticData::new(
+            "inspect.json".to_string(),
+            triage::Source::Inspect,
+            INSPECT.to_string(),
+        )?];
+        let expected_trigger =
+            vec![SnapshotTrigger { signature: "got-it".to_string(), interval: 42_000 }];
+
+        assert_eq!(
+            lib.selectors().inspect_selectors,
+            vec!["INSPECT:foo.cm:path/to:leaf".to_string()]
+        );
+        assert_eq!(lib.evaluate(data), expected_trigger);
+        Ok(())
+    }
+}
diff --git a/src/diagnostics/lib/triage/BUILD.gn b/src/diagnostics/lib/triage/BUILD.gn
index b2bd9a1..a3df50a 100644
--- a/src/diagnostics/lib/triage/BUILD.gn
+++ b/src/diagnostics/lib/triage/BUILD.gn
@@ -12,6 +12,7 @@
   "//third_party/rust_crates:anyhow",
   "//third_party/rust_crates:itertools",
   "//third_party/rust_crates:lazy_static",
+  "//third_party/rust_crates:log",
   "//third_party/rust_crates:maplit",
   "//third_party/rust_crates:nom",
   "//third_party/rust_crates:num-derive",
diff --git a/src/diagnostics/lib/triage/src/act.rs b/src/diagnostics/lib/triage/src/act.rs
index 0741cfe..ad4732f 100644
--- a/src/diagnostics/lib/triage/src/act.rs
+++ b/src/diagnostics/lib/triage/src/act.rs
@@ -2,6 +2,9 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+#[cfg(target_os = "fuchsia")]
+use log::error;
+
 use {
     super::{
         config::DiagnosticData,
@@ -53,6 +56,7 @@
     results: HashMap<String, bool>,
     warnings: Vec<String>,
     gauges: Vec<String>,
+    snapshots: Vec<SnapshotTrigger>,
     sort_gauges: bool,
     sub_results: Vec<(String, Box<ActionResults>)>,
 }
@@ -63,6 +67,7 @@
             results: HashMap::new(),
             warnings: Vec::new(),
             gauges: Vec::new(),
+            snapshots: Vec::new(),
             sort_gauges: true,
             sub_results: Vec::new(),
         }
@@ -80,6 +85,10 @@
         self.gauges.push(gauge);
     }
 
+    pub fn add_snapshot(&mut self, snapshot: SnapshotTrigger) {
+        self.snapshots.push(snapshot);
+    }
+
     pub fn set_sort_gauges(&mut self, v: bool) {
         self.sort_gauges = v;
     }
@@ -105,6 +114,14 @@
     }
 }
 
+/// [SnapshotTrigger] is the information needed to generate a request for a crash report.
+/// It can be returned from the library as part of ActionResults.
+#[derive(Debug, Clone, PartialEq)]
+pub struct SnapshotTrigger {
+    pub interval: i64, // zx::Duration but this library has to run on host.
+    pub signature: String,
+}
+
 /// [Actions] are stored as a map of maps, both with string keys. The outer key
 /// is the namespace for the inner key, which is the name of the [Action].
 pub type Actions = HashMap<String, ActionsSchema>;
@@ -121,6 +138,7 @@
 pub enum Action {
     Warning(Warning),
     Gauge(Gauge),
+    Snapshot(Snapshot),
 }
 
 /// Action that is triggered if a predicate is met.
@@ -139,6 +157,15 @@
     pub tag: Option<String>,    // An optional tag to associate with this Action
 }
 
+/// Action that displays percentage of value.
+#[derive(Clone, Debug, Deserialize)]
+pub struct Snapshot {
+    pub trigger: Metric, // Take snapshot when this is true
+    pub repeat: Metric,  // Expression evaluating to time delay before repeated triggers
+    pub signature: String, // Sent in the crash report
+                         // There's no tag option because snapshot conditions are always news worth seeing.
+}
+
 impl Gauge {
     pub fn get_formatted_value(&self, metric_value: MetricValue) -> String {
         match metric_value {
@@ -162,6 +189,7 @@
         match self {
             Action::Warning(action) => action.tag.clone(),
             Action::Gauge(action) => action.tag.clone(),
+            Action::Snapshot(_) => None,
         }
     }
 }
@@ -182,6 +210,7 @@
                 match action {
                     Action::Warning(warning) => self.update_warnings(warning, namespace, name),
                     Action::Gauge(gauge) => self.update_gauges(gauge, namespace, name),
+                    Action::Snapshot(snapshot) => self.update_snapshots(snapshot, namespace, name),
                 };
             }
         }
@@ -189,6 +218,18 @@
         &self.action_results
     }
 
+    /// Evaluate and return snapshots. Consume self.
+    pub fn into_snapshots(mut self) -> Vec<SnapshotTrigger> {
+        for (namespace, actions) in self.actions.iter() {
+            for (name, action) in actions.iter() {
+                if let Action::Snapshot(snapshot) = action {
+                    self.update_snapshots(snapshot, namespace, name)
+                }
+            }
+        }
+        self.action_results.snapshots
+    }
+
     /// Update warnings if condition is met.
     fn update_warnings(&mut self, action: &Warning, namespace: &String, name: &String) {
         let was_triggered = match self.metric_state.eval_action_metric(namespace, &action.trigger) {
@@ -213,6 +254,53 @@
         self.action_results.set_result(&format!("{}::{}", namespace, name), was_triggered);
     }
 
+    /// Update snapshots if condition is met.
+    fn update_snapshots(&mut self, action: &Snapshot, namespace: &str, name: &str) {
+        let was_triggered = match self.metric_state.eval_action_metric(namespace, &action.trigger) {
+            MetricValue::Bool(true) => {
+                let interval = self.metric_state.eval_action_metric(namespace, &action.repeat);
+                match interval {
+                    MetricValue::Int(interval) => {
+                        let signature = action.signature.clone();
+                        let output = SnapshotTrigger { interval, signature };
+                        self.action_results.add_snapshot(output);
+                        true
+                    }
+                    _ => {
+                        self.action_results.add_warning(format!(
+                            "Bad interval in config '{}': {:?}",
+                            namespace, interval
+                        ));
+                        #[cfg(target_os = "fuchsia")]
+                        error!("Bad interval in config '{}': {:?}", namespace, interval);
+                        false
+                    }
+                }
+            }
+            MetricValue::Bool(false) => false,
+            MetricValue::Missing(reason) => {
+                #[cfg(target_os = "fuchsia")]
+                error!("Snapshot trigger was missing: {}", reason);
+                self.action_results
+                    .add_warning(format!("[MISSING] In config '{}': {}", namespace, reason));
+                false
+            }
+            other => {
+                #[cfg(target_os = "fuchsia")]
+                error!(
+                    "[ERROR] Unexpected value type in config '{}' (need boolean): {}",
+                    namespace, other
+                );
+                self.action_results.add_warning(format!(
+                    "[ERROR] Unexpected value type in config '{}' (need boolean): {}",
+                    namespace, other
+                ));
+                false
+            }
+        };
+        self.action_results.set_result(&format!("{}::{}", namespace, name), was_triggered);
+    }
+
     /// Update gauges.
     fn update_gauges(&mut self, action: &Gauge, namespace: &String, name: &String) {
         let value = self.metric_state.eval_action_metric(namespace, &action.value);
@@ -225,7 +313,9 @@
     use {
         super::*,
         crate::config::Source,
-        crate::metrics::{Metric, Metrics},
+        crate::metrics::{fetch::SelectorString, Metric, Metrics},
+        anyhow::Error,
+        std::convert::TryFrom,
     };
 
     /// Tells whether any of the stored values include a substring.
@@ -385,4 +475,51 @@
             action_context.action_results.get_warnings()
         );
     }
+
+    #[test]
+    fn snapshots_update_correctly() -> Result<(), Error> {
+        let metrics = Metrics::new();
+        let actions = Actions::new();
+        let data = vec![];
+        let mut action_context = ActionContext::new(&metrics, &actions, &data);
+        let selector =
+            Metric::Selector(SelectorString::try_from("INSPECT:foo:bar:baz".to_string())?);
+        let true_value = Metric::Eval("1==1".to_string());
+        let false_value = Metric::Eval("1==2".to_string());
+        let five_value = Metric::Eval("5".to_string());
+        let foo_value = Metric::Eval("'foo'".to_string());
+        let missing_value = Metric::Eval("foo".to_string());
+        let snapshot_5_sig = SnapshotTrigger { interval: 5, signature: "signature".to_string() };
+        // Tester re-uses the same action_context, so results will accumulate.
+        macro_rules! tester {
+            ($trigger:expr, $repeat:expr, $func:expr) => {
+                let selector_interval_action = Snapshot {
+                    trigger: $trigger.clone(),
+                    repeat: $repeat.clone(),
+                    signature: "signature".to_string(),
+                };
+                action_context.update_snapshots(&selector_interval_action, "", "");
+                assert!($func(&action_context.action_results.snapshots));
+            };
+        }
+        type VT = Vec<SnapshotTrigger>;
+
+        // Verify it doesn't crash on bad inputs
+        tester!(true_value, selector, |s: &VT| s.is_empty());
+        tester!(true_value, foo_value, |s: &VT| s.is_empty());
+        tester!(true_value, missing_value, |s: &VT| s.is_empty());
+        tester!(selector, five_value, |s: &VT| s.is_empty());
+        tester!(foo_value, five_value, |s: &VT| s.is_empty());
+        tester!(five_value, five_value, |s: &VT| s.is_empty());
+        tester!(missing_value, five_value, |s: &VT| s.is_empty());
+        assert_eq!(action_context.action_results.warnings.len(), 7);
+        // False trigger shouldn't add a result
+        tester!(false_value, five_value, |s: &VT| s.is_empty());
+        tester!(true_value, five_value, |s| s == &vec![snapshot_5_sig.clone()]);
+        // We can have more than one of the same trigger in the results.
+        tester!(true_value, five_value, |s| s
+            == &vec![snapshot_5_sig.clone(), snapshot_5_sig.clone()]);
+        assert_eq!(action_context.action_results.warnings.len(), 7);
+        Ok(())
+    }
 }
diff --git a/src/diagnostics/lib/triage/src/lib.rs b/src/diagnostics/lib/triage/src/lib.rs
index b4a1d98..04e836d 100644
--- a/src/diagnostics/lib/triage/src/lib.rs
+++ b/src/diagnostics/lib/triage/src/lib.rs
@@ -2,7 +2,11 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-use {crate::act::ActionContext, anyhow::Error};
+use {
+    crate::act::ActionContext,
+    crate::metrics::{MetricState, MetricValue},
+    anyhow::{bail, Error},
+};
 
 pub(crate) mod act; // Perform appropriate actions.
 pub(crate) mod config; // Read the config file(s) for metric and action specs.
@@ -11,7 +15,7 @@
 pub(crate) mod result_format; // Formats the triage results.
 pub(crate) mod validate; // Check config - including that metrics/triggers work correctly.
 
-pub use act::ActionResults;
+pub use act::{ActionResults, SnapshotTrigger};
 pub use config::{ActionTagDirective, DiagnosticData, ParseResult, Source};
 pub use result_format::ActionResultFormatter;
 
@@ -25,3 +29,24 @@
         ActionContext::new(&parse_result.metrics, &parse_result.actions, diagnostic_data);
     Ok(action_context.process().clone())
 }
+
+pub fn snapshots(data: &Vec<DiagnosticData>, parse_result: &ParseResult) -> Vec<SnapshotTrigger> {
+    let evaluator = ActionContext::new(&parse_result.metrics, &parse_result.actions, data);
+    evaluator.into_snapshots()
+}
+
+pub fn all_selectors(parse: &ParseResult) -> Vec<String> {
+    parse.all_selectors()
+}
+
+pub fn evaluate_int_math(expression: &str) -> Result<i64, Error> {
+    match MetricState::evaluate_math(&config::parse::parse_expression(expression)?) {
+        MetricValue::Int(i) => Ok(i),
+        MetricValue::Float(f) => match MetricState::safe_float_to_int(f) {
+            Some(i) => Ok(i),
+            None => bail!("Non-numeric float result {}", f),
+        },
+        MetricValue::Missing(msg) => bail!("Eval error: {}", msg),
+        bad_type => bail!("Non-numeric result: {:?}", bad_type),
+    }
+}
diff --git a/src/diagnostics/lib/triage/src/metrics.rs b/src/diagnostics/lib/triage/src/metrics.rs
index cac6273..3679fa8 100644
--- a/src/diagnostics/lib/triage/src/metrics.rs
+++ b/src/diagnostics/lib/triage/src/metrics.rs
@@ -9,7 +9,7 @@
     super::config::{self, DataFetcher, DiagnosticData, Source},
     fetch::{InspectFetcher, KeyValueFetcher, SelectorString, SelectorType, TextFetcher},
     fuchsia_inspect_node_hierarchy::{ArrayContent, Property as DiagnosticProperty},
-    injectable_time::TimeSource,
+    injectable_time::{FakeTime, TimeSource},
     lazy_static::lazy_static,
     serde::{Deserialize, Deserializer},
     serde_json::Value as JsonValue,
@@ -17,9 +17,6 @@
     variable::VariableName,
 };
 
-#[cfg(test)]
-use injectable_time::FakeTime;
-
 /// The contents of a single Metric. Metrics produce a value for use in Actions or other Metrics.
 #[derive(Clone, Debug)]
 pub enum Metric {
@@ -60,6 +57,10 @@
 
 /// Contains all the information needed to look up and evaluate a Metric - other
 /// [Metric]s that may be referred to, and a source of input values to calculate on.
+///
+/// Note: MetricState uses a single Now() value for all evaluations. If a MetricState is
+/// retained and used for multiple evaluations at different times, provide a way to update
+/// the `now` field.
 pub struct MetricState<'a> {
     pub metrics: &'a Metrics,
     pub fetcher: Fetcher<'a>,
@@ -651,7 +652,6 @@
     }
 
     /// Evaluate an Expression which contains only base values, not referring to other Metrics.
-    #[cfg(test)]
     pub fn evaluate_math(e: &Expression) -> MetricValue {
         let values = HashMap::new();
         let fetcher = Fetcher::TrialData(TrialDataFetcher::new(&values));
@@ -896,7 +896,7 @@
         }
     }
 
-    fn safe_float_to_int(float: f64) -> Option<i64> {
+    pub fn safe_float_to_int(float: f64) -> Option<i64> {
         if !float.is_finite() {
             return None;
         }
diff --git a/src/diagnostics/lib/triage/src/validate.rs b/src/diagnostics/lib/triage/src/validate.rs
index 0c9ac9a..02eb309 100644
--- a/src/diagnostics/lib/triage/src/validate.rs
+++ b/src/diagnostics/lib/triage/src/validate.rs
@@ -113,23 +113,26 @@
                 println!("Action {} not found in trial {}", action_name, trial_name);
                 return true;
             }
-            Some(action) => match action {
-                Action::Warning(properties) => {
-                    match metric_state.eval_action_metric(namespace, &properties.trigger) {
-                        MetricValue::Bool(actual) if actual == expected => return false,
-                        other => {
-                            println!(
-                        "Test {} failed: trigger '{}' of action {} returned {:?}, expected {}",
-                        trial_name, properties.trigger, action_name, other, expected
-                    );
-                            return true;
-                        }
+            Some(action) => {
+                let trigger = match action {
+                    Action::Warning(properties) => &properties.trigger,
+                    Action::Snapshot(properties) => &properties.trigger,
+                    _ => {
+                        println!("Action {:?} cannot be tested", action);
+                        return true;
+                    }
+                };
+                match metric_state.eval_action_metric(namespace, trigger) {
+                    MetricValue::Bool(actual) if actual == expected => return false,
+                    other => {
+                        println!(
+                            "Test {} failed: trigger '{}' of action {} returned {:?}, expected {}",
+                            trial_name, trigger, action_name, other, expected
+                        );
+                        return true;
                     }
                 }
-                _ => {
-                    return false;
-                }
-            },
+            }
         },
     }
 }
diff --git a/src/diagnostics/validator/inspect/src/main.rs b/src/diagnostics/validator/inspect/src/main.rs
index d8c2b14..4c83414 100644
--- a/src/diagnostics/validator/inspect/src/main.rs
+++ b/src/diagnostics/validator/inspect/src/main.rs
@@ -13,14 +13,12 @@
     anyhow::{format_err, Error},
     argh::FromArgs,
     fidl_test_inspect_validate as validate, fuchsia_async as fasync, fuchsia_syslog as syslog,
-    log::*,
     serde::Serialize,
     std::str::FromStr,
 };
 
 fn init_syslog() {
     syslog::init_with_tags(&[]).expect("should not fail");
-    debug!("Driver did init logger");
 }
 
 /// Validate Inspect VMO formats written by 'puppet' programs controlled by