Repository: denoland/deno
Pull request: feat: v8js metrics

Implement https://opentelemetry.io/docs/specs/semconv/runtime/v8js-metrics/

Changed files:
filename: ext/telemetry/lib.rs
patch: @@ -7,6 +7,7 @@ use std::borrow::Cow;
 use std::cell::RefCell;
 use std::collections::HashMap;
 use std::env;
+use std::ffi::c_void;
 use std::fmt::Debug;
 use std::pin::Pin;
 use std::rc::Rc;
@@ -17,6 +18,7 @@ use std::task::Context;
 use std::task::Poll;
 use std::thread;
 use std::time::Duration;
+use std::time::Instant;
 use std::time::SystemTime;
 
 use deno_core::futures::channel::mpsc;
@@ -39,6 +41,7 @@ use opentelemetry::logs::AnyValue;
 use opentelemetry::logs::LogRecord as LogRecordTrait;
 use opentelemetry::logs::Severity;
 use opentelemetry::metrics::AsyncInstrumentBuilder;
+pub use opentelemetry::metrics::Gauge;
 pub use opentelemetry::metrics::Histogram;
 use opentelemetry::metrics::InstrumentBuilder;
 pub use opentelemetry::metrics::MeterProvider;
@@ -94,6 +97,8 @@ use tokio::task::JoinSet;
 deno_core::extension!(
   deno_telemetry,
   ops = [
+    op_otel_collect_isolate_metrics,
+    op_otel_enable_isolate_metrics,
     op_otel_log,
     op_otel_log_foreign,
     op_otel_span_attribute1,
@@ -2201,3 +2206,151 @@ fn op_otel_metric_observation_done(state: &mut OpState) {
     let _ = done.send(());
   }
 }
+
+struct GcMetricDataInner {
+  start: Instant,
+  duration: Histogram<f64>,
+}
+
+struct GcMetricData(RefCell<GcMetricDataInner>);
+
+impl GcMetricData {
+  extern "C" fn prologue_callback(
+    isolate: *mut v8::Isolate,
+    _gc_type: v8::GCType,
+    _flags: v8::GCCallbackFlags,
+    _data: *mut c_void,
+  ) {
+    // SAFETY: Isolate is valid during callback
+    let isolate = unsafe { &mut *isolate };
+    let this = isolate.get_slot::<Self>().unwrap();
+    this.0.borrow_mut().start = Instant::now();
+  }
+
+  extern "C" fn epilogue_callback(
+    isolate: *mut v8::Isolate,
+    gc_type: v8::GCType,
+    _flags: v8::GCCallbackFlags,
+    _data: *mut c_void,
+  ) {
+    // SAFETY: Isolate is valid during callback
+    let isolate = unsafe { &mut *isolate };
+    let this = isolate.get_slot::<Self>().unwrap();
+    let this = this.0.borrow_mut();
+
+    let elapsed = this.start.elapsed();
+
+    // https://opentelemetry.io/docs/specs/semconv/runtime/v8js-metrics/#metric-v8jsgcduration
+    let gc_type = KeyValue::new(
+      "v8js.gc.type",
+      match gc_type {
+        v8::GCType::kGCTypeScavenge => "minor",
+        v8::GCType::kGCTypeMinorMarkSweep => "minor",
+        v8::GCType::kGCTypeMarkSweepCompact => "major",
+        v8::GCType::kGCTypeIncrementalMarking => "incremental",
+        v8::GCType::kGCTypeProcessWeakCallbacks => "weakcb",
+        _ => return,
+      },
+    );
+
+    this.duration.record(elapsed.as_secs_f64(), &[gc_type]);
+  }
+}
+
+#[derive(Clone)]
+struct HeapMetricData {
+  heap_limit: Gauge<u64>,
+  heap_size: Gauge<u64>,
+  available_size: Gauge<u64>,
+  physical_size: Gauge<u64>,
+}
+
+#[op2(fast)]
+fn op_otel_enable_isolate_metrics(scope: &mut v8::HandleScope) {
+  if scope.get_slot::<GcMetricData>().is_some() {
+    return;
+  }
+
+  let meter = OTEL_GLOBALS.get().unwrap().meter_provider.meter("v8js");
+
+  // https://opentelemetry.io/docs/specs/semconv/runtime/v8js-metrics/#metric-v8jsgcduration
+  let duration = meter
+    .f64_histogram("v8js.gc.duration")
+    .with_unit("S")
+    .with_description("Garbage collection duration")
+    .with_boundaries(vec![0.01, 0.1, 1.0, 10.0])
+    .build();
+
+  scope.set_slot(GcMetricData(RefCell::new(GcMetricDataInner {
+    start: Instant::now(),
+    duration,
+  })));
+
+  scope.add_gc_prologue_callback(
+    GcMetricData::prologue_callback,
+    std::ptr::null_mut(),
+    v8::GCType::kGCTypeAll,
+  );
+
+  scope.add_gc_epilogue_callback(
+    GcMetricData::epilogue_callback,
+    std::ptr::null_mut(),
+    v8::GCType::kGCTypeAll,
+  );
+
+  let heap_limit = meter
+    .u64_gauge("v8js.memory.heap.limit")
+    .with_unit("By")
+    .with_description("Total heap memory size pre-allocated.")
+    .build();
+  let heap_size = meter
+    .u64_gauge("v8js.memory.heap.size")
+    .with_unit("By")
+    .with_description("Heap Memory size allocated.")
+    .build();
+  let available_size = meter
+    .u64_gauge("v8js.memory.space.available_size")
+    .with_unit("By")
+    .with_description("Heap space available size.")
+    .build();
+  let physical_size = meter
+    .u64_gauge("v8js.memory.space.physical_size")
+    .with_unit("By")
+    .with_description("Committed size of a heap space.")
+    .build();
+
+  scope.set_slot(HeapMetricData {
+    heap_limit,
+    heap_size,
+    available_size,
+    physical_size,
+  });
+}
+
+#[op2(fast)]
+fn op_otel_collect_isolate_metrics(scope: &mut v8::HandleScope) {
+  let data = scope.get_slot::<HeapMetricData>().unwrap().clone();
+  for i in 0..scope.get_number_of_data_slots() {
+    let Some(space) = scope.get_heap_space_statistics(i as _) else {
+      continue;
+    };
+    // SAFETY: api has wrong lifetime, 'static is correct:
+    // https://github.com/denoland/rusty_v8/pull/1744
+    let space_name: &'static std::ffi::CStr =
+      unsafe { std::ffi::CStr::from_ptr(space.space_name().as_ptr()) };
+    let Ok(space_name) = space_name.to_str() else {
+      continue;
+    };
+    let attributes = [KeyValue::new("v8js.heap.space.name", space_name)];
+    data.heap_limit.record(space.space_size() as _, &attributes);
+    data
+      .heap_size
+      .record(space.space_used_size() as _, &attributes);
+    data
+      .available_size
+      .record(space.space_available_size() as _, &attributes);
+    data
+      .physical_size
+      .record(space.physical_space_size() as _, &attributes);
+  }
+}

filename: ext/telemetry/telemetry.ts
patch: @@ -2,6 +2,8 @@
 
 import { core, primordials } from "ext:core/mod.js";
 import {
+  op_otel_collect_isolate_metrics,
+  op_otel_enable_isolate_metrics,
   op_otel_log,
   op_otel_log_foreign,
   op_otel_metric_attribute3,
@@ -75,6 +77,7 @@ const { AsyncVariable, getAsyncContext, setAsyncContext } = core;
 export let TRACING_ENABLED = false;
 export let METRICS_ENABLED = false;
 export let PROPAGATORS: TextMapPropagator[] = [];
+let ISOLATE_METRICS = false;
 
 // Note: These start at 0 in the JS library,
 // but start at 1 when serialized with JSON.
@@ -1061,6 +1064,10 @@ class ObservableResult {
 }
 
 async function observe(): Promise<void> {
+  if (ISOLATE_METRICS) {
+    op_otel_collect_isolate_metrics();
+  }
+
   const promises: Promise<void>[] = [];
   // Primordials are not needed, because this is a SafeMap.
   // deno-lint-ignore prefer-primordials
@@ -1668,6 +1675,12 @@ export function builtinTracer(): Tracer {
   return builtinTracerCache;
 }
 
+function enableIsolateMetrics() {
+  op_otel_enable_isolate_metrics();
+  ISOLATE_METRICS = true;
+  startObserving();
+}
+
 // We specify a very high version number, to allow any `@opentelemetry/api`
 // version to load this module. This does cause @opentelemetry/api to not be
 // able to register anything itself with the global registration methods.
@@ -1678,7 +1691,6 @@ export function bootstrap(
     0 | 1,
     0 | 1,
     (typeof otelConsoleConfig)[keyof typeof otelConsoleConfig],
-    0 | 1,
     ...Array<(typeof otelPropagators)[keyof typeof otelPropagators]>,
   ],
 ): void {
@@ -1732,6 +1744,7 @@ export function bootstrap(
     }
     if (METRICS_ENABLED) {
       otel.metrics = MeterProvider;
+      enableIsolateMetrics();
     }
   }
 }

filename: tests/specs/cli/otel_basic/main.ts
patch: @@ -40,6 +40,8 @@ const server = Deno.serve(
           data.spans.sort((a, b) =>
             Number(BigInt(`0x${a.spanId}`) - BigInt(`0x${b.spanId}`))
           );
+          // v8js metrics are non-deterministic
+          data.metrics = data.metrics.filter((m) => !m.name.startsWith("v8js"));
           data.metrics.sort((a, b) => a.name.localeCompare(b.name));
           for (const metric of data.metrics) {
             if ("histogram" in metric) {