use std::{
collections::HashMap,
fmt,
sync::{Arc, PoisonError, RwLock},
time::{Duration, Instant},
};
use logfire::{ExponentialHistogram, Logfire};
use monty_proto::{pb, pb::os_call::Call};
use opentelemetry::{
KeyValue,
metrics::{Counter, UpDownCounter},
};
use super::TelemetryAdapter;
static LIVE_WORKERS: Instrument = Instrument {
kind: MetricKind::UpDownCounter,
name: "monty.pool.workers.live",
unit: "{worker}",
description: "Workers the pool is keeping alive: pooled, checked out, or being spawned.",
};
static IDLE_WORKERS: Instrument = Instrument {
kind: MetricKind::UpDownCounter,
name: "monty.pool.workers.idle",
unit: "{worker}",
description: "Workers immediately available for checkout.",
};
static SUSPENDED_WORKERS: Instrument = Instrument {
kind: MetricKind::UpDownCounter,
name: "monty.pool.workers.suspended",
unit: "{worker}",
description: "Workers blocked waiting for the host to answer a suspension.",
};
static CHECKOUT_WAIT: Instrument = Instrument {
kind: MetricKind::Histogram,
name: "monty.pool.checkout.wait",
unit: "s",
description: "Time spent acquiring a worker for a checkout.",
};
static WORKER_TERMINATED: Instrument = Instrument {
kind: MetricKind::Counter,
name: "monty.pool.worker.terminated",
unit: "{worker}",
description: "Workers discarded by the pool, by reason.",
};
static SESSION_RESUMED: Instrument = Instrument {
kind: MetricKind::Counter,
name: "monty.pool.session.resumed",
unit: "{session}",
description: "Sessions the pool tried to resume after a relay shutdown, by outcome.",
};
static SESSION_DURATION: Instrument = Instrument {
kind: MetricKind::Histogram,
name: "monty.pool.session.duration",
unit: "s",
description: "Lifetime of a checked-out session.",
};
static RUN_DURATION: Instrument = Instrument {
kind: MetricKind::Histogram,
name: "monty.run.duration",
unit: "s",
description: "Wall time of one feed, including time spent waiting on the host.",
};
static RUN_EXECUTION: Instrument = Instrument {
kind: MetricKind::Histogram,
name: "monty.run.execution_time",
unit: "s",
description: "Sandbox execution time of one feed, excluding host round-trips.",
};
static TURN_DURATION: Instrument = Instrument {
kind: MetricKind::Histogram,
name: "monty.turn.duration",
unit: "s",
description: "Wall time of one non-execution turn, by kind.",
};
static SUSPENSIONS: Instrument = Instrument {
kind: MetricKind::Counter,
name: "monty.run.suspensions",
unit: "{suspension}",
description: "Suspensions the sandbox raised for the host to answer, by kind.",
};
static EXT_CALL: Instrument = Instrument {
kind: MetricKind::Histogram,
name: "monty.ext.call.duration",
unit: "s",
description: "Time the host took to answer a suspension, by kind.",
};
static SNAPSHOT_BYTES: Instrument = Instrument {
kind: MetricKind::Histogram,
name: "monty.snapshot.bytes",
unit: "By",
description: "Size of a session dump.",
};
static PRINT_BYTES: Instrument = Instrument {
kind: MetricKind::Counter,
name: "monty.print.bytes",
unit: "By",
description: "Bytes the sandbox printed, by stream.",
};
static FRAME_BYTES: Instrument = Instrument {
kind: MetricKind::Histogram,
name: "monty.wire.frame.bytes",
unit: "By",
description: "Size of one protocol frame, by direction.",
};
struct Instrument {
kind: MetricKind,
name: &'static str,
unit: &'static str,
description: &'static str,
}
#[derive(Clone)]
pub struct Metrics(Arc<Sink>);
enum Sink {
Adapter(Arc<dyn TelemetryAdapter>),
Logfire(Instruments),
}
impl Metrics {
pub(super) fn for_adapter(adapter: Arc<dyn TelemetryAdapter>) -> Self {
Self(Arc::new(Sink::Adapter(adapter)))
}
#[must_use]
pub fn for_logfire(logfire: Logfire) -> Self {
Self(Arc::new(Sink::Logfire(Instruments::new(logfire))))
}
pub(crate) fn live_workers(&self, delta: i64) {
self.record(&LIVE_WORKERS, MetricValue::I64(delta), &[]);
}
pub(crate) fn idle_workers(&self, delta: i64) {
self.record(&IDLE_WORKERS, MetricValue::I64(delta), &[]);
}
fn suspended_workers(&self, delta: i64) {
self.record(&SUSPENDED_WORKERS, MetricValue::I64(delta), &[]);
}
pub(crate) fn checkout_wait(&self, elapsed: Duration, outcome: &'static str) {
self.record(
&CHECKOUT_WAIT,
MetricValue::seconds(elapsed),
&[KeyValue::new("outcome", outcome)],
);
}
pub(crate) fn worker_terminated(&self, reason: &'static str) {
self.record(
&WORKER_TERMINATED,
MetricValue::I64(1),
&[KeyValue::new("reason", reason)],
);
}
pub(crate) fn session_duration(&self, elapsed: Duration, outcome: &'static str) {
self.record(
&SESSION_DURATION,
MetricValue::seconds(elapsed),
&[KeyValue::new("outcome", outcome)],
);
}
pub(crate) fn session_resumed(&self, outcome: &'static str) {
self.record(
&SESSION_RESUMED,
MetricValue::I64(1),
&[KeyValue::new("outcome", outcome)],
);
}
fn record(&self, instrument: &Instrument, value: MetricValue, attributes: &[KeyValue]) {
match self.0.as_ref() {
Sink::Adapter(adapter) => adapter.record_metric(&Measurement {
kind: instrument.kind,
name: instrument.name,
unit: instrument.unit,
description: instrument.description,
value,
attributes,
}),
Sink::Logfire(instruments) => instruments.record(instrument, value, attributes),
}
}
}
impl fmt::Debug for Metrics {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Metrics")
}
}
pub struct Measurement<'a> {
pub kind: MetricKind,
pub name: &'static str,
pub unit: &'static str,
pub description: &'static str,
pub value: MetricValue,
pub attributes: &'a [KeyValue],
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MetricKind {
Counter,
UpDownCounter,
Histogram,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MetricValue {
I64(i64),
F64(f64),
}
impl MetricValue {
fn seconds(duration: Duration) -> Self {
Self::F64(duration.as_secs_f64())
}
fn bytes(len: usize) -> Self {
Self::count(len)
}
fn count(n: usize) -> Self {
Self::I64(i64::try_from(n).unwrap_or(i64::MAX))
}
fn as_f64(self) -> f64 {
match self {
Self::I64(value) => value as f64,
Self::F64(value) => value,
}
}
#[expect(clippy::cast_possible_truncation, reason = "float→int casts saturate")]
fn as_i64(self) -> i64 {
match self {
Self::I64(value) => value,
Self::F64(value) => value as i64,
}
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "float→int casts saturate, including negatives to zero"
)]
fn as_u64(self) -> u64 {
match self {
Self::I64(value) => u64::try_from(value).unwrap_or(0),
Self::F64(value) => value as u64,
}
}
}
pub(crate) const fn outcome(ok: bool) -> &'static str {
if ok { "ok" } else { "error" }
}
struct Instruments {
logfire: Logfire,
built: RwLock<HashMap<&'static str, Handle>>,
}
impl Instruments {
fn new(logfire: Logfire) -> Self {
Self {
logfire,
built: RwLock::new(HashMap::new()),
}
}
fn record(&self, instrument: &Instrument, value: MetricValue, attributes: &[KeyValue]) {
{
let built = self.built.read().unwrap_or_else(PoisonError::into_inner);
if let Some(handle) = built.get(instrument.name) {
handle.record(value, attributes);
return;
}
}
let mut built = self.built.write().unwrap_or_else(PoisonError::into_inner);
built
.entry(instrument.name)
.or_insert_with(|| self.build(instrument))
.record(value, attributes);
}
fn build(&self, instrument: &Instrument) -> Handle {
let metrics = self.logfire.metrics();
match instrument.kind {
MetricKind::Counter => Handle::Counter(
metrics
.u64_counter(instrument.name)
.with_unit(instrument.unit)
.with_description(instrument.description)
.build(),
),
MetricKind::UpDownCounter => Handle::UpDownCounter(
metrics
.i64_up_down_counter(instrument.name)
.with_unit(instrument.unit)
.with_description(instrument.description)
.build(),
),
MetricKind::Histogram => Handle::Histogram(
metrics
.f64_exponential_histogram(instrument.name, MAX_HISTOGRAM_SCALE)
.with_unit(instrument.unit)
.with_description(instrument.description)
.build(),
),
}
}
}
const MAX_HISTOGRAM_SCALE: i8 = 20;
enum Handle {
Counter(Counter<u64>),
UpDownCounter(UpDownCounter<i64>),
Histogram(ExponentialHistogram<f64>),
}
impl Handle {
fn record(&self, value: MetricValue, attributes: &[KeyValue]) {
match self {
Self::Counter(counter) => counter.add(value.as_u64(), attributes),
Self::UpDownCounter(counter) => counter.add(value.as_i64(), attributes),
Self::Histogram(histogram) => histogram.record(value.as_f64(), attributes),
}
}
}
pub(crate) struct TurnMetrics {
metrics: Metrics,
feed: Option<Instant>,
run_active: bool,
pending: Option<Suspension>,
turn: Option<(&'static str, Instant)>,
reported_micros: u64,
}
impl TurnMetrics {
pub(crate) const fn new(metrics: Metrics) -> Self {
Self {
metrics,
feed: None,
run_active: false,
pending: None,
turn: None,
reported_micros: 0,
}
}
pub(crate) fn frame(&self, direction: &'static str, len: usize) {
self.metrics.record(
&FRAME_BYTES,
MetricValue::bytes(len),
&[KeyValue::new("direction", direction)],
);
}
pub(crate) fn begin_turn(&mut self, request: &pb::ParentRequest) {
let now = Instant::now();
match &request.kind {
Some(pb::parent_request::Kind::Configure(_)) => {
self.feed = None;
self.run_active = false;
self.abandon_pending();
self.turn = Some(("configure", now));
self.reported_micros = 0;
}
Some(pb::parent_request::Kind::Feed(_)) => {
self.abandon_pending();
self.feed = Some(now);
self.run_active = true;
}
Some(pb::parent_request::Kind::Load(_)) => {
self.run_active = false;
self.reported_micros = 0;
self.turn = Some(("load", now));
}
Some(pb::parent_request::Kind::Dump(_)) => self.turn = Some(("dump", now)),
Some(pb::parent_request::Kind::InstallDependencies(_)) => {
self.turn = Some(("install_dependencies", now));
}
Some(pb::parent_request::Kind::Reset(_)) => {
self.feed = None;
self.run_active = false;
self.abandon_pending();
self.turn = Some(("reset", now));
}
Some(pb::parent_request::Kind::Shutdown(_)) => {
self.feed = None;
self.run_active = false;
self.abandon_pending();
self.turn = None;
}
Some(pb::parent_request::Kind::ResumeCall(r)) => self.close_suspension(ext_result(r.result.as_ref())),
Some(pb::parent_request::Kind::ResumeNameLookup(r)) => {
let outcome = match r.kind {
Some(pb::resume_name_lookup::Kind::Value(_)) => "value",
Some(pb::resume_name_lookup::Kind::Undefined(_)) => "undefined",
Some(pb::resume_name_lookup::Kind::Error(_)) => "error",
None => "missing",
};
self.close_suspension(outcome);
}
Some(pb::parent_request::Kind::ResumeFutures(r)) => {
let outcome = match (&self.pending, r.results.as_slice()) {
(
Some(Suspension {
kind: SuspensionKind::FunctionCall(Some(call_id)),
..
}),
[result],
) if *call_id == result.call_id => ext_result(result.result.as_ref()),
_ => "resolved",
};
self.close_suspension(outcome);
}
Some(pb::parent_request::Kind::AbortFeed(_)) => self.close_suspension("aborted"),
None => {}
}
}
pub(crate) fn event(&mut self, event: &pb::ChildEvent) {
if matches!(self.turn, Some(("load", _))) {
self.reported_micros = self.reported_micros.max(event.total_execution_micros);
}
match &event.kind {
Some(pb::child_event::Kind::Print(p)) => {
let totals = print_bytes_by_stream(&p.segments);
for (stream, bytes) in PRINT_STREAM_NAMES.into_iter().zip(totals) {
if bytes > 0 {
self.metrics.record(
&PRINT_BYTES,
MetricValue::bytes(bytes),
&[KeyValue::new("stream", stream)],
);
}
}
}
Some(pb::child_event::Kind::FunctionCall(c)) => {
self.suspend(SuspensionKind::FunctionCall(c.allow_eager_await.then_some(c.call_id)));
}
Some(pb::child_event::Kind::OsCall(c)) => self.suspend(SuspensionKind::OsCall(os_call(c.call.as_ref()))),
Some(pb::child_event::Kind::NameLookup(_)) => self.suspend(SuspensionKind::NameLookup),
Some(pb::child_event::Kind::ResolveFutures(_)) => self.suspend(SuspensionKind::ResolveFutures),
Some(pb::child_event::Kind::Complete(_)) => self.end_run("complete", event),
Some(pb::child_event::Kind::Error(_)) => {
if self.turn.is_some() {
self.end_turn("error");
} else {
self.end_run("error", event);
}
}
Some(pb::child_event::Kind::TypingError(_)) => self.end_run("typing_error", event),
Some(pb::child_event::Kind::DumpResult(d)) => {
self.metrics.record(
&SNAPSHOT_BYTES,
MetricValue::bytes(d.state.len()),
&[KeyValue::new("op", "dump")],
);
self.end_turn("ok");
}
Some(pb::child_event::Kind::Ok(_)) => self.end_turn("ok"),
Some(pb::child_event::Kind::FatalError(_)) => self.end_terminal("fatal_error", event),
Some(pb::child_event::Kind::Shutdown(_)) => self.end_terminal("shutdown", event),
None => {}
}
}
fn suspend(&mut self, kind: SuspensionKind) {
if matches!(self.turn, Some(("load", _))) {
self.end_turn("ok");
}
self.run_active = true;
self.metrics.record(
&SUSPENSIONS,
MetricValue::I64(1),
&[KeyValue::new("kind", kind.label())],
);
self.abandon_pending();
self.pending = Some(Suspension {
start: Instant::now(),
kind,
});
self.metrics.suspended_workers(1);
}
fn close_suspension(&mut self, outcome: &'static str) {
let Some(suspension) = self.take_pending() else {
return;
};
let elapsed = MetricValue::seconds(suspension.start.elapsed());
let mut attributes = vec![
KeyValue::new("kind", suspension.kind.label()),
KeyValue::new("outcome", outcome),
];
if let SuspensionKind::OsCall(function) = suspension.kind {
attributes.push(KeyValue::new("function", function));
}
self.metrics.record(&EXT_CALL, elapsed, &attributes);
}
fn abandon_pending(&mut self) {
let _ = self.take_pending();
}
fn take_pending(&mut self) -> Option<Suspension> {
let suspension = self.pending.take();
if suspension.is_some() {
self.metrics.suspended_workers(-1);
}
suspension
}
fn end_run(&mut self, outcome: &'static str, event: &pb::ChildEvent) {
self.run_active = false;
self.abandon_pending();
self.end_turn(outcome);
if let Some(start) = self.feed.take() {
self.metrics.record(
&RUN_DURATION,
MetricValue::seconds(start.elapsed()),
&[KeyValue::new("outcome", outcome)],
);
}
let total = event.total_execution_micros;
let delta = total.saturating_sub(self.reported_micros);
self.reported_micros = self.reported_micros.max(total);
self.metrics
.record(&RUN_EXECUTION, MetricValue::seconds(Duration::from_micros(delta)), &[]);
}
fn end_terminal(&mut self, outcome: &'static str, event: &pb::ChildEvent) {
if self.run_active {
self.end_run(outcome, event);
} else {
self.end_turn(outcome);
}
}
fn end_turn(&mut self, outcome: &'static str) {
if let Some((turn, start)) = self.turn.take() {
self.metrics.record(
&TURN_DURATION,
MetricValue::seconds(start.elapsed()),
&[KeyValue::new("turn", turn), KeyValue::new("outcome", outcome)],
);
}
}
}
impl Drop for TurnMetrics {
fn drop(&mut self) {
self.abandon_pending();
}
}
struct Suspension {
start: Instant,
kind: SuspensionKind,
}
enum SuspensionKind {
FunctionCall(Option<u32>),
OsCall(&'static str),
NameLookup,
ResolveFutures,
}
impl SuspensionKind {
const fn label(&self) -> &'static str {
match self {
Self::FunctionCall(_) => "function",
Self::OsCall(_) => "os",
Self::NameLookup => "name_lookup",
Self::ResolveFutures => "futures",
}
}
}
fn ext_result(result: Option<&pb::ExtFunctionResult>) -> &'static str {
match result.and_then(|result| result.kind.as_ref()) {
Some(pb::ext_function_result::Kind::ReturnValue(_)) => "value",
Some(pb::ext_function_result::Kind::Error(_)) => "error",
Some(pb::ext_function_result::Kind::Future(_)) => "future",
Some(pb::ext_function_result::Kind::NotFound(_)) => "not_found",
Some(pb::ext_function_result::Kind::NotHandled(_)) => "not_handled",
None => "missing",
}
}
fn os_call(call: Option<&Call>) -> &'static str {
match call {
Some(Call::Exists(_)) => "exists",
Some(Call::IsFile(_)) => "is_file",
Some(Call::IsDir(_)) => "is_dir",
Some(Call::IsSymlink(_)) => "is_symlink",
Some(Call::ReadText(_)) => "read_text",
Some(Call::ReadBytes(_)) => "read_bytes",
Some(Call::Stat(_)) => "stat",
Some(Call::Iterdir(_)) => "iterdir",
Some(Call::Resolve(_)) => "resolve",
Some(Call::Absolute(_)) => "absolute",
Some(Call::Unlink(_)) => "unlink",
Some(Call::Rmdir(_)) => "rmdir",
Some(Call::WriteText(_)) => "write_text",
Some(Call::AppendText(_)) => "append_text",
Some(Call::WriteBytes(_)) => "write_bytes",
Some(Call::AppendBytes(_)) => "append_bytes",
Some(Call::Open(_)) => "open",
Some(Call::Mkdir(_)) => "mkdir",
Some(Call::Rename(_)) => "rename",
Some(Call::Getenv(_)) => "getenv",
Some(Call::GetEnviron(_)) => "get_environ",
Some(Call::DateToday(_)) => "date_today",
Some(Call::DateTimeNow(_)) => "date_time_now",
Some(Call::Urandom(_)) => "urandom",
Some(Call::Time(_)) => "time",
Some(Call::Sleep(_)) => "sleep",
Some(Call::AsyncSleep(_)) => "async_sleep",
Some(Call::SystemSleep(_)) => "system_sleep",
Some(Call::AsyncSystemSleep(_)) => "async_system_sleep",
Some(Call::Import(_)) => "import",
Some(Call::ImportStubs(_)) => "import_stubs",
Some(Call::Scan(_)) => "scan",
None => "unknown",
}
}
const PRINT_STREAM_NAMES: [&str; 3] = ["stdout", "stderr", "unspecified"];
fn print_bytes_by_stream(segments: &[pb::PrintSegment]) -> [usize; 3] {
let mut totals = [0usize; 3];
for segment in segments {
let index = match pb::PrintStream::try_from(segment.stream) {
Ok(pb::PrintStream::Stdout) => 0,
Ok(pb::PrintStream::Stderr) => 1,
_ => 2,
};
totals[index] = totals[index].saturating_add(segment.text.len());
}
totals
}
#[cfg(test)]
mod tests {
use std::{
sync::{Arc, Barrier, Mutex, PoisonError},
thread,
time::Duration,
};
use logfire::{Logfire, config::MetricsOptions};
use monty_proto::{WireFunctionCall, ext_result_to_proto, pb, pb::os_call::Call};
use monty_types::{CallArgs, ExtFunctionResult, MontyObject, NameLookupResult, SourceRange};
use opentelemetry::{
KeyValue,
trace::{SpanId, TraceId},
};
use opentelemetry_sdk::{
logs::SdkLogRecord,
metrics::{
InMemoryMetricExporter, PeriodicReader,
data::{AggregatedMetrics, MetricData},
},
trace::SpanData,
};
use super::{Measurement, MetricValue, Metrics, TelemetryAdapter, TurnMetrics, print_bytes_by_stream};
fn position() -> SourceRange {
SourceRange {
filename: "main.py".to_owned(),
start: 0,
end: 7,
}
}
struct Capture {
logfire: Logfire,
exporter: InMemoryMetricExporter,
}
struct Recorded {
name: String,
value: Aggregate,
attributes: Vec<(String, String)>,
}
enum Aggregate {
I64(i64),
U64(u64),
Histogram {
count: usize,
sum: f64,
min: Option<f64>,
max: Option<f64>,
},
}
impl Capture {
fn attributes(&self, name: &str) -> Vec<Vec<(String, String)>> {
let mut series = self.select(name, |recorded| {
let mut attributes = recorded.attributes.clone();
attributes.sort();
attributes
});
series.sort();
series
}
fn i64_sum(&self, name: &str) -> i64 {
self.select(name, |recorded| match recorded.value {
Aggregate::I64(value) => value,
_ => panic!("{name} was not an i64 sum"),
})
.into_iter()
.sum()
}
fn u64_sum(&self, name: &str) -> u64 {
self.select(name, |recorded| match recorded.value {
Aggregate::U64(value) => value,
_ => panic!("{name} was not a u64 sum"),
})
.into_iter()
.sum()
}
fn histograms(&self, name: &str) -> Vec<(usize, f64, Option<f64>, Option<f64>)> {
self.select(name, |recorded| match recorded.value {
Aggregate::Histogram { count, sum, min, max } => (count, sum, min, max),
_ => panic!("{name} was not a histogram"),
})
}
fn has(&self, name: &str) -> bool {
self.records().iter().any(|recorded| recorded.name == name)
}
fn select<T>(&self, name: &str, map: impl Fn(&Recorded) -> T) -> Vec<T> {
self.records()
.iter()
.filter(|recorded| recorded.name == name)
.map(map)
.collect()
}
fn records(&self) -> Vec<Recorded> {
self.exporter.reset();
self.logfire.force_flush().unwrap();
let exported = self.exporter.get_finished_metrics().unwrap();
let mut records = Vec::new();
for resource in &exported {
for scope in resource.scope_metrics() {
for metric in scope.metrics() {
flatten(metric.name(), metric.data(), &mut records);
}
}
}
records
}
}
fn flatten(name: &str, data: &AggregatedMetrics, records: &mut Vec<Recorded>) {
match data {
AggregatedMetrics::I64(MetricData::Sum(sum)) => {
for point in sum.data_points() {
records.push(Recorded {
name: name.to_owned(),
value: Aggregate::I64(point.value()),
attributes: attributes(point.attributes()),
});
}
}
AggregatedMetrics::U64(MetricData::Sum(sum)) => {
for point in sum.data_points() {
records.push(Recorded {
name: name.to_owned(),
value: Aggregate::U64(point.value()),
attributes: attributes(point.attributes()),
});
}
}
AggregatedMetrics::F64(MetricData::ExponentialHistogram(histogram)) => {
for point in histogram.data_points() {
records.push(Recorded {
name: name.to_owned(),
value: Aggregate::Histogram {
count: point.count(),
sum: point.sum(),
min: point.min(),
max: point.max(),
},
attributes: attributes(point.attributes()),
});
}
}
other => panic!("unexpected Monty metric aggregate: {other:?}"),
}
}
fn attributes<'a>(values: impl Iterator<Item = &'a KeyValue>) -> Vec<(String, String)> {
values.map(|kv| (kv.key.to_string(), kv.value.to_string())).collect()
}
#[derive(Default)]
struct MeasurementCapture(Mutex<Vec<(String, String, i64)>>);
impl TelemetryAdapter for MeasurementCapture {
fn start_span(&self, _: &SpanData) -> bool {
true
}
fn end_span(&self, _: &SpanData) -> bool {
true
}
fn emit_log(&self, _: SpanId, _: &SdkLogRecord) -> bool {
true
}
fn disable_root(&self, _: TraceId, _: SpanId) {}
fn record_metric(&self, measurement: &Measurement<'_>) {
let stream = measurement
.attributes
.iter()
.find(|kv| kv.key.as_str() == "stream")
.map_or_else(String::new, |kv| kv.value.to_string());
let value = match measurement.value {
MetricValue::I64(value) => value,
other @ MetricValue::F64(_) => panic!("{} recorded {other:?}", measurement.name),
};
self.0
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push((measurement.name.to_owned(), stream, value));
}
}
fn recorder() -> (TurnMetrics, Arc<Capture>) {
let exporter = InMemoryMetricExporter::default();
let logfire = logfire::configure()
.local()
.send_to_logfire(false)
.with_metrics(Some(
MetricsOptions::default().with_additional_reader(PeriodicReader::builder(exporter.clone()).build()),
))
.finish()
.unwrap();
let metrics = TurnMetrics::new(Metrics::for_logfire(logfire.clone()));
(metrics, Arc::new(Capture { logfire, exporter }))
}
fn request(kind: pb::parent_request::Kind) -> pb::ParentRequest {
pb::ParentRequest {
kind: Some(kind),
trace_parent: None,
}
}
fn event(kind: pb::child_event::Kind) -> pb::ChildEvent {
pb::ChildEvent {
kind: Some(kind),
total_execution_micros: 0,
max_suspensions: None,
restored_script_name: None,
session_id: None,
feed_execution_micros: 0,
max_feed_duration_micros: None,
max_turn_duration_micros: None,
max_total_sleep_micros: None,
}
}
fn feed() -> pb::ParentRequest {
request(pb::parent_request::Kind::Feed(pb::Feed {
code: "double(2)".to_owned(),
inputs: vec![].into(),
values: None,
skip_type_check: false,
cwd: "/".to_owned(),
}))
}
fn call_event(function_name: &str) -> pb::ChildEvent {
event(pb::child_event::Kind::FunctionCall(WireFunctionCall::new(
function_name.to_owned(),
CallArgs::new(),
1,
None,
false,
position(),
)))
}
fn resume_call(kind: pb::ext_function_result::Kind) -> pb::ParentRequest {
request(pb::parent_request::Kind::ResumeCall(pb::ResumeCall {
call_id: 1,
result: Some(pb::ExtFunctionResult { kind: Some(kind) }),
values: None,
}))
}
fn resume_return(value: MontyObject) -> pb::ParentRequest {
let (result, values) = ext_result_to_proto(ExtFunctionResult::Return(value));
request(pb::parent_request::Kind::ResumeCall(pb::ResumeCall {
call_id: 1,
result: Some(result),
values,
}))
}
fn attribute<'a>(attributes: &'a [(String, String)], key: &str) -> Option<&'a str> {
attributes
.iter()
.find(|(k, _)| k == key)
.map(|(_, value)| value.as_str())
}
fn assert_close(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
}
fn assert_optional_close(actual: Option<f64>, expected: Option<f64>) {
match (actual, expected) {
(Some(actual), Some(expected)) => assert_close(actual, expected),
(None, None) => {}
_ => panic!("{actual:?} != {expected:?}"),
}
}
#[test]
fn a_feed_records_its_run_and_its_round_trip() {
let (mut metrics, capture) = recorder();
metrics.begin_turn(&feed());
metrics.event(&call_event("double"));
metrics.begin_turn(&resume_return(MontyObject::int(4)));
metrics.event(&event(pb::child_event::Kind::Complete(pb::Complete::from(
MontyObject::int(4),
))));
assert_eq!(
capture.attributes("monty.run.suspensions"),
[[("kind".to_owned(), "function".to_owned())]]
);
assert_eq!(
capture.attributes("monty.ext.call.duration"),
[[
("kind".to_owned(), "function".to_owned()),
("outcome".to_owned(), "value".to_owned())
]]
);
assert_eq!(
capture.attributes("monty.run.duration"),
[[("outcome".to_owned(), "complete".to_owned())]]
);
assert_eq!(capture.histograms("monty.run.execution_time")[0].0, 1);
assert_eq!(capture.u64_sum("monty.run.suspensions"), 1);
}
#[test]
fn sandbox_chosen_names_never_reach_attributes() {
let (mut metrics, capture) = recorder();
let outcomes = [
pb::ext_function_result::Kind::NotFound("attacker_chosen".to_owned()),
pb::ext_function_result::Kind::Error(pb::RaisedException {
exc_type: "AttributeError".to_owned(),
message: None,
traceback: vec![].into(),
data: None,
}),
pb::ext_function_result::Kind::ReturnValue(0),
];
metrics.begin_turn(&feed());
for (index, kind) in outcomes.into_iter().enumerate() {
metrics.event(&call_event(&format!("attacker_chosen_{index}")));
metrics.begin_turn(&resume_call(kind));
}
for attributes in capture.attributes("monty.ext.call.duration") {
let keys: Vec<&str> = attributes.iter().map(|(key, _)| key.as_str()).collect();
assert_eq!(keys, ["kind", "outcome"], "{attributes:?}");
}
let outcomes: Vec<_> = capture
.attributes("monty.ext.call.duration")
.iter()
.map(|attributes| attribute(attributes, "outcome").unwrap().to_owned())
.collect();
assert_eq!(outcomes, ["error", "not_found", "value"]);
}
#[test]
fn a_print_event_records_one_measurement_per_stream() {
let alternating: Vec<pb::PrintSegment> = (0..64)
.map(|i| pb::PrintSegment {
stream: if i % 2 == 0 {
pb::PrintStream::Stdout as i32
} else {
pb::PrintStream::Stderr as i32
},
text: "a".to_owned(),
})
.collect();
assert_eq!(print_bytes_by_stream(&alternating), [32, 32, 0]);
let capture = Arc::new(MeasurementCapture::default());
let mut metrics = TurnMetrics::new(Metrics::for_adapter(capture.clone()));
metrics.begin_turn(&feed());
metrics.event(&event(pb::child_event::Kind::Print(pb::Print {
segments: alternating.into(),
})));
let recorded = capture.0.lock().unwrap_or_else(PoisonError::into_inner).clone();
assert_eq!(
recorded,
[
("monty.print.bytes".to_owned(), "stdout".to_owned(), 32),
("monty.print.bytes".to_owned(), "stderr".to_owned(), 32),
]
);
}
#[test]
fn a_print_event_skips_streams_with_no_output() {
let capture = Arc::new(MeasurementCapture::default());
let mut metrics = TurnMetrics::new(Metrics::for_adapter(capture.clone()));
metrics.begin_turn(&feed());
metrics.event(&event(pb::child_event::Kind::Print(pb::Print {
segments: vec![pb::PrintSegment {
stream: pb::PrintStream::Stdout as i32,
text: "hello\n".to_owned(),
}]
.into(),
})));
let recorded = capture.0.lock().unwrap_or_else(PoisonError::into_inner).clone();
assert_eq!(recorded, [("monty.print.bytes".to_owned(), "stdout".to_owned(), 6)]);
}
#[test]
fn os_calls_carry_their_protocol_name() {
let (mut metrics, capture) = recorder();
metrics.begin_turn(&feed());
metrics.event(&event(pb::child_event::Kind::OsCall(pb::OsCall {
call_id: 1,
values: None,
allow_eager_await: false,
position: Some((&position()).into()),
call: Some(Call::ReadText("/mnt/f.txt".to_owned())),
})));
metrics.begin_turn(&resume_return(MontyObject::string("hello".to_owned())));
assert_eq!(
capture.attributes("monty.ext.call.duration"),
[[
("function".to_owned(), "read_text".to_owned()),
("kind".to_owned(), "os".to_owned()),
("outcome".to_owned(), "value".to_owned())
]]
);
}
#[test]
fn execution_time_is_the_delta_of_a_cumulative_clock() {
let (mut metrics, capture) = recorder();
for total in [100, 250] {
metrics.begin_turn(&feed());
metrics.event(&pb::ChildEvent {
kind: Some(pb::child_event::Kind::Complete(pb::Complete { value: 0, values: None })),
total_execution_micros: total,
max_suspensions: None,
restored_script_name: None,
session_id: None,
feed_execution_micros: 0,
max_feed_duration_micros: None,
max_turn_duration_micros: None,
max_total_sleep_micros: None,
});
}
let execution = capture.histograms("monty.run.execution_time");
let [(count, sum, min, max)] = execution.as_slice() else {
panic!("expected one execution-time series")
};
assert_eq!(*count, 2);
assert_close(*sum, Duration::from_micros(250).as_secs_f64());
assert_optional_close(*min, Some(Duration::from_micros(100).as_secs_f64()));
assert_optional_close(*max, Some(Duration::from_micros(150).as_secs_f64()));
}
#[test]
fn an_exception_ends_the_run_without_describing_itself() {
let (mut metrics, capture) = recorder();
metrics.begin_turn(&feed());
metrics.event(&event(pb::child_event::Kind::Error(pb::Error {
exception: Some(pb::RaisedException {
exc_type: "MyCustomError".to_owned(),
message: None,
traceback: vec![].into(),
data: None,
}),
})));
assert_eq!(
capture.attributes("monty.run.duration"),
[[("outcome".to_owned(), "error".to_owned())]]
);
}
#[test]
fn terminal_events_end_runs_and_housekeeping_turns() {
let terminal_events = [
(
pb::child_event::Kind::FatalError(pb::FatalError {
message: "worker failed".to_owned(),
}),
"fatal_error",
),
(
pb::child_event::Kind::Shutdown(pb::ShutdownDump { dump: None }),
"shutdown",
),
];
for (kind, outcome) in terminal_events {
let (mut metrics, capture) = recorder();
metrics.begin_turn(&feed());
metrics.event(&event(kind));
assert_eq!(
capture.attributes("monty.run.duration"),
[[("outcome".to_owned(), outcome.to_owned())]]
);
assert_eq!(capture.histograms("monty.run.execution_time")[0].0, 1);
}
let (mut metrics, capture) = recorder();
metrics.begin_turn(&request(pb::parent_request::Kind::InstallDependencies(
pb::InstallDependencies {
requirements: vec![].into(),
},
)));
metrics.event(&event(pb::child_event::Kind::Shutdown(pb::ShutdownDump { dump: None })));
assert_eq!(
capture.attributes("monty.turn.duration"),
[[
("outcome".to_owned(), "shutdown".to_owned()),
("turn".to_owned(), "install_dependencies".to_owned())
]]
);
assert!(!capture.has("monty.run.execution_time"));
}
#[test]
fn a_load_rebases_the_execution_clock() {
let (mut metrics, capture) = recorder();
metrics.begin_turn(&request(pb::parent_request::Kind::Load(pb::Load {
state: vec![].into(),
})));
metrics.event(&pb::ChildEvent {
kind: Some(pb::child_event::Kind::Ok(pb::Ok {})),
total_execution_micros: 10_000_000,
max_suspensions: None,
restored_script_name: Some("dumped.py".to_owned()),
session_id: None,
feed_execution_micros: 0,
max_feed_duration_micros: None,
max_turn_duration_micros: None,
max_total_sleep_micros: None,
});
metrics.begin_turn(&feed());
metrics.event(&pb::ChildEvent {
kind: Some(pb::child_event::Kind::Complete(pb::Complete { value: 0, values: None })),
total_execution_micros: 10_000_100,
max_suspensions: None,
restored_script_name: None,
session_id: None,
feed_execution_micros: 0,
max_feed_duration_micros: None,
max_turn_duration_micros: None,
max_total_sleep_micros: None,
});
let execution = capture.histograms("monty.run.execution_time");
assert_eq!(execution[0].0, 1);
assert_close(execution[0].1, Duration::from_micros(100).as_secs_f64());
}
#[test]
fn a_restored_suspension_closes_the_load_turn() {
let (mut metrics, capture) = recorder();
metrics.begin_turn(&request(pb::parent_request::Kind::Load(pb::Load {
state: vec![].into(),
})));
metrics.event(&pb::ChildEvent {
kind: Some(pb::child_event::Kind::NameLookup(pb::NameLookup {
name: "value".to_owned(),
object_id: None,
position: Some((&position()).into()),
})),
total_execution_micros: 10_000_000,
max_suspensions: None,
restored_script_name: None,
session_id: None,
feed_execution_micros: 0,
max_feed_duration_micros: None,
max_turn_duration_micros: None,
max_total_sleep_micros: None,
});
let turns = capture.attributes("monty.turn.duration");
assert_eq!(
turns,
[[
("outcome".to_owned(), "ok".to_owned()),
("turn".to_owned(), "load".to_owned())
]]
);
metrics.begin_turn(&request(pb::parent_request::Kind::ResumeNameLookup(
NameLookupResult::from(MontyObject::int(1)).into(),
)));
metrics.event(&pb::ChildEvent {
kind: Some(pb::child_event::Kind::Complete(pb::Complete { value: 0, values: None })),
total_execution_micros: 10_000_050,
max_suspensions: None,
restored_script_name: None,
session_id: None,
feed_execution_micros: 0,
max_feed_duration_micros: None,
max_turn_duration_micros: None,
max_total_sleep_micros: None,
});
let execution = capture.histograms("monty.run.execution_time");
assert_eq!(execution[0].0, 1);
assert_close(execution[0].1, Duration::from_micros(50).as_secs_f64());
assert!(!capture.has("monty.run.duration"));
}
#[test]
fn a_failed_housekeeping_turn_is_not_a_run() {
let (mut metrics, capture) = recorder();
metrics.begin_turn(&request(pb::parent_request::Kind::InstallDependencies(
pb::InstallDependencies {
requirements: vec!["pydantic".to_owned()].into(),
},
)));
metrics.event(&event(pb::child_event::Kind::Error(pb::Error {
exception: Some(pb::RaisedException {
exc_type: "ValueError".to_owned(),
message: None,
traceback: vec![].into(),
data: None,
}),
})));
assert_eq!(
capture.attributes("monty.turn.duration"),
[[
("outcome".to_owned(), "error".to_owned()),
("turn".to_owned(), "install_dependencies".to_owned())
]]
);
assert!(!capture.has("monty.run.duration"));
assert!(!capture.has("monty.run.execution_time"));
}
#[test]
fn worker_adjustments_are_commutative_under_concurrency() {
const THREADS: usize = 8;
const ITERATIONS: usize = 100;
let (_, capture) = recorder();
let metrics = Metrics::for_logfire(capture.logfire.clone());
let barrier = Arc::new(Barrier::new(THREADS));
let threads: Vec<_> = (0..THREADS)
.map(|_| {
let metrics = metrics.clone();
let barrier = Arc::clone(&barrier);
thread::spawn(move || {
barrier.wait();
for _ in 0..ITERATIONS {
metrics.live_workers(1);
metrics.idle_workers(1);
metrics.idle_workers(-1);
metrics.live_workers(-1);
}
})
})
.collect();
for thread in threads {
thread.join().unwrap();
}
assert_eq!(capture.i64_sum("monty.pool.workers.live"), 0);
assert_eq!(capture.i64_sum("monty.pool.workers.idle"), 0);
}
#[test]
fn suspended_workers_tracks_host_round_trips() {
let (mut metrics, capture) = recorder();
metrics.begin_turn(&feed());
metrics.event(&call_event("double"));
assert_eq!(capture.i64_sum("monty.pool.workers.suspended"), 1);
metrics.begin_turn(&resume_return(MontyObject::int(4)));
assert_eq!(capture.i64_sum("monty.pool.workers.suspended"), 0);
metrics.event(&call_event("double"));
metrics.begin_turn(&request(pb::parent_request::Kind::AbortFeed(pb::AbortFeed {
exception: None,
})));
assert_eq!(capture.i64_sum("monty.pool.workers.suspended"), 0);
assert!(capture.attributes("monty.ext.call.duration").contains(&vec![
("kind".to_owned(), "function".to_owned()),
("outcome".to_owned(), "aborted".to_owned()),
]));
metrics.event(&event(pb::child_event::Kind::Error(pb::Error { exception: None })));
metrics.begin_turn(&feed());
metrics.event(&call_event("double"));
assert_eq!(capture.i64_sum("monty.pool.workers.suspended"), 1);
drop(metrics);
assert_eq!(capture.i64_sum("monty.pool.workers.suspended"), 0);
}
#[test]
fn a_dump_reports_its_size_without_ending_the_run() {
let (mut metrics, capture) = recorder();
metrics.begin_turn(&feed());
metrics.begin_turn(&request(pb::parent_request::Kind::Dump(pb::Dump {})));
metrics.event(&event(pb::child_event::Kind::DumpResult(pb::DumpResult {
state: vec![0; 32].into(),
})));
let snapshots = capture.histograms("monty.snapshot.bytes");
assert_eq!(snapshots[0].0, 1);
assert_close(snapshots[0].1, 32.0);
let turns = capture.attributes("monty.turn.duration");
assert_eq!(attribute(&turns[0], "turn"), Some("dump"));
assert!(!capture.has("monty.run.duration"));
}
#[test]
fn a_logfire_host_records_into_its_own_meter() {
let exporter = InMemoryMetricExporter::default();
let logfire = logfire::configure()
.local()
.send_to_logfire(false)
.with_metrics(Some(
MetricsOptions::default().with_additional_reader(PeriodicReader::builder(exporter.clone()).build()),
))
.finish()
.unwrap();
let mut metrics = TurnMetrics::new(Metrics::for_logfire(logfire.clone()));
metrics.begin_turn(&feed());
metrics.event(&call_event("double"));
metrics.begin_turn(&resume_return(MontyObject::int(4)));
metrics.event(&event(pb::child_event::Kind::Complete(pb::Complete {
value: 0,
values: None,
})));
logfire.force_flush().unwrap();
let exported = exporter.get_finished_metrics().unwrap();
let mut found = Vec::new();
for resource in &exported {
for scope in resource.scope_metrics() {
for metric in scope.metrics() {
found.push((metric.name().to_owned(), is_exponential(metric.data())));
}
}
}
found.sort();
assert_eq!(
found,
[
("monty.ext.call.duration".to_owned(), true),
("monty.pool.workers.suspended".to_owned(), false),
("monty.run.duration".to_owned(), true),
("monty.run.execution_time".to_owned(), true),
("monty.run.suspensions".to_owned(), false),
]
);
}
fn is_exponential(data: &AggregatedMetrics) -> bool {
match data {
AggregatedMetrics::F64(data) => matches!(data, MetricData::ExponentialHistogram(_)),
AggregatedMetrics::U64(data) => matches!(data, MetricData::ExponentialHistogram(_)),
AggregatedMetrics::I64(data) => matches!(data, MetricData::ExponentialHistogram(_)),
}
}
}