Add OpenTelemetry metric type classification to fix Prometheus compatibility

  Problem

  The OpenTelemetry plugin was exporting all metrics as gauge type, causing 
Prometheus/Grafana to show warnings like:
  PromQL info: metric might not be a counter, name does not end in 
total/sum/_count/_bucket: "proxmox_node_network_transmit_bytes"

  Solution

  This patch implements proper metric type classification to distinguish 
between:

  Counter metrics (cumulative values):
  - Network traffic: transmit, receive, netin, netout
  - Disk I/O: diskread, diskwrite
  - Block operations: *_operations, *_merged
  - CPU time (cpustat context): user, system, idle, iowait, etc.

  Gauge metrics (instantaneous values):
  - Memory usage, CPU percentages, storage space, etc.

  Changes

  1. Added _classify_metric_type() function - Determines if a metric should be 
counter or gauge based on semantic meaning
  2. Enhanced _convert_node_metrics_recursive() - Uses classification to:
    - Export counters as OpenTelemetry sum type with _total suffix
    - Export gauges as OpenTelemetry gauge type without suffix
  3. Improved unit mapping - Added mappings for network, disk I/O, and 
time-related metrics

  Result

  - Counter metrics get _total suffix: proxmox_node_network_transmit_bytes_total
  - Proper OpenTelemetry sum format with isMonotonic: true
  - Eliminates Prometheus compatibility warnings
  - Maintains consistency with established monitoring conventions

  Signed-off-by: Nansen Su

---
 PVE/ExtMetric.pm                    |   2 +
 PVE/Status/Makefile                 |   1 +
 PVE/Status/OpenTelemetry.pm         | 679 ++++++++++++++++++++++++++++
 www/manager6/dc/MetricServerView.js | 245 ++++++++++
 4 files changed, 927 insertions(+)
 create mode 100644 PVE/Status/OpenTelemetry.pm

diff --git a/PVE/ExtMetric.pm b/PVE/ExtMetric.pm
index 02e7c327..ebc2817b 100644
--- a/PVE/ExtMetric.pm
+++ b/PVE/ExtMetric.pm
@@ -6,9 +6,11 @@ use warnings;
 use PVE::Status::Plugin;
 use PVE::Status::Graphite;
 use PVE::Status::InfluxDB;
+use PVE::Status::OpenTelemetry;
 
 PVE::Status::Graphite->register();
 PVE::Status::InfluxDB->register();
+PVE::Status::OpenTelemetry->register();
 PVE::Status::Plugin->init();
 
 sub foreach_plug($&) {
diff --git a/PVE/Status/Makefile b/PVE/Status/Makefile
index c2f2edbc..eebce6b7 100644
--- a/PVE/Status/Makefile
+++ b/PVE/Status/Makefile
@@ -3,6 +3,7 @@ include ../../defines.mk
 PERLSOURCE =                   \
        Graphite.pm             \
        InfluxDB.pm             \
+       OpenTelemetry.pm        \
        Plugin.pm
 
 all:
diff --git a/PVE/Status/OpenTelemetry.pm b/PVE/Status/OpenTelemetry.pm
new file mode 100644
index 00000000..cf973573
--- /dev/null
+++ b/PVE/Status/OpenTelemetry.pm
@@ -0,0 +1,679 @@
+package PVE::Status::OpenTelemetry;
+
+use strict;
+use warnings;
+
+use Compress::Zlib;
+use Encode;
+use HTTP::Request;
+use JSON;
+use LWP::UserAgent;
+use MIME::Base64 qw(decode_base64);
+
+use PVE::Cluster;
+use PVE::Status::Plugin;
+use PVE::Tools qw(extract_param);
+
+use base qw(PVE::Status::Plugin);
+
+sub type {
+    return 'opentelemetry';
+}
+
+sub properties {
+    return {
+        'otel-protocol' => {
+            type => 'string',
+            enum => ['http', 'https'],
+            description => 'HTTP protocol',
+            default => 'https',
+        },
+        'otel-path' => {
+            type => 'string',
+            description => 'OTLP endpoint path',
+            default => '/v1/metrics',
+            optional => 1,
+        },
+        'otel-timeout' => {
+            type => 'integer',
+            description => 'HTTP request timeout in seconds',
+            default => 5,
+            minimum => 1,
+            maximum => 10,
+        },
+        'otel-headers' => {
+            type => 'string',
+            description => 'Custom HTTP headers (JSON format, base64 encoded)',
+            optional => 1,
+            maxLength => 1024,
+        },
+        'otel-verify-ssl' => {
+            type => 'boolean',
+            description => 'Verify SSL certificates',
+            default => 1,
+        },
+        'otel-max-body-size' => {
+            type => 'integer',
+            description => 'Maximum request body size in bytes',
+            default => 10_000_000,
+            minimum => 1024,
+        },
+        'otel-resource-attributes' => {
+            type => 'string',
+            description => 'Additional resource attributes as JSON, base64 
encoded',
+            optional => 1,
+            maxLength => 1024,
+        },
+        'otel-compression' => {
+            type => 'string',
+            enum => ['none', 'gzip'],
+            description => 'Compression algorithm for requests',
+            default => 'gzip',
+            optional => 1,
+        },
+    };
+}
+
+sub options {
+    return {
+        server => { optional => 0 },
+        port => { optional => 1 },
+        disable => { optional => 1 },
+        'otel-protocol' => { optional => 1 },
+        'otel-path' => { optional => 1 },
+        'otel-timeout' => { optional => 1 },
+        'otel-headers' => { optional => 1 },
+        'otel-verify-ssl' => { optional => 1 },
+        'otel-max-body-size' => { optional => 1 },
+        'otel-resource-attributes' => { optional => 1 },
+        'otel-compression' => { optional => 1 },
+    };
+}
+
+sub _connect {
+    my ($class, $cfg, $id) = @_;
+
+    my $connection = {
+        id => $id,
+        cfg => $cfg,
+        metrics => [],
+        stats => {
+            total_metrics => 0,
+            successful_batches => 0,
+            failed_batches => 0,
+        }
+    };
+
+    return $connection;
+}
+
+sub _disconnect {
+    my ($class, $connection) = @_;
+    # No persistent connection to cleanup
+}
+
+sub _get_otlp_url {
+    my ($class, $cfg) = @_;
+    my $proto = $cfg->{'otel-protocol'} || 'https';
+    my $port = $cfg->{port} || ($proto eq 'https' ? 4318 : 4317);
+    my $path = $cfg->{'otel-path'} || '/v1/metrics';
+    
+    return "${proto}://$cfg->{server}:${port}${path}";
+}
+
+sub _decode_base64_json {
+    my ($class, $encoded_str) = @_;
+    return '' unless defined $encoded_str && $encoded_str ne '';
+    
+    my $decoded_str = decode_base64($encoded_str);
+    die "base64 decode failed" if !defined $decoded_str;
+    
+    return $decoded_str;
+}
+
+sub _parse_headers {
+    my ($class, $headers_str) = @_;
+    return {} unless defined $headers_str && $headers_str ne '';
+    
+    my $decoded_str = $class->_decode_base64_json($headers_str);
+    
+    my $headers = {};
+    eval {
+        my $json = JSON->new->decode($decoded_str);
+        die "headers must be a JSON hash" if ref($json) ne 'HASH';
+        $headers = $json;
+    };
+    if ($@) {
+        warn "Failed to parse headers '$headers_str' - $@";
+    }
+    return $headers;
+}
+
+sub _parse_resource_attributes {
+    my ($class, $json_str) = @_;
+    return [] unless defined $json_str && $json_str ne '';
+    
+    my $decoded_str = $class->_decode_base64_json($json_str);
+    
+    my $attributes = [];
+    eval {
+        # Ensure the JSON string is properly decoded as UTF-8
+        my $utf8_json = utf8::is_utf8($decoded_str) ? $decoded_str 
+            : Encode::decode('utf-8', $decoded_str);
+        my $parsed = JSON->new->utf8(0)->decode($utf8_json);
+        die "resource attributes must be a JSON hash" if ref($parsed) ne 
'HASH';
+        for my $key (keys %$parsed) {
+            push @$attributes, {
+                key => $key,
+                value => { stringValue => $parsed->{$key} }
+            };
+        }
+    };
+    if ($@) {
+        warn "Failed to parse resource attributes '$json_str' - $@";
+    }
+    return $attributes;
+}
+
+sub _compress_json {
+    my ($class, $data) = @_;
+    
+    my $json_str = JSON->new->utf8->encode($data);
+    my $compressed = Compress::Zlib::memGzip($json_str);
+    
+    die "gzip compression failed: $Compress::Zlib::gzerrno" if !defined 
$compressed;
+    
+    return $compressed;
+}
+
+sub _build_otlp_metrics {
+    my ($class, $metrics_data, $cfg) = @_;
+    
+    my $cluster_name = 'single-node';
+    eval {
+        my $clinfo = PVE::Cluster::get_clinfo();
+        if ($clinfo && $clinfo->{cluster} && $clinfo->{cluster}->{name}) {
+            $cluster_name = $clinfo->{cluster}->{name};
+        }
+    };
+    # If reading fails, use default cluster name
+    
+    my $node_name = PVE::INotify::nodename();
+    my $pve_version = PVE::pvecfg::version_text();
+    
+    return {
+        resourceMetrics => [{
+            resource => {
+                attributes => [
+                    { key => 'service.name', 
+                      value => { stringValue => 'proxmox-ve' } },
+                    { key => 'service.version', 
+                      value => { stringValue => $pve_version } },
+                    { key => 'proxmox.cluster', 
+                      value => { stringValue => $cluster_name } },
+                    { key => 'proxmox.node', 
+                      value => { stringValue => $node_name } },
+                    @{$class->_parse_resource_attributes(
+                        $cfg->{'otel-resource-attributes'})}
+                ]
+            },
+            scopeMetrics => [{
+                scope => {},
+                metrics => $metrics_data
+            }]
+        }]
+    };
+}
+
+
+# Classify metric type (counter vs gauge) and determine suffix
+sub _classify_metric_type {
+    my ($class, $key, $metric_prefix) = @_;
+    
+    # Counter type (cumulative values) - need _total suffix
+    if ($key =~ /^(transmit|receive|netin|netout|diskread|diskwrite)$/ ||
+        $key =~ /_operations$/ ||
+        $key =~ /_merged$/ ||
+        $key =~ 
/^(rd_|wr_|read|write|sent|recv|tx|rx|packets|errors|dropped|collisions)/ ||
+        ($metric_prefix =~ /_cpustat$/ && $key =~ 
/^(user|system|idle|nice|steal|guest|irq|softirq|iowait|wait)$/) ||
+        $metric_prefix =~ /_network$/) {
+        return ('counter', '_total');
+    }
+    
+    # Gauge type (instantaneous values) - no suffix
+    return ('gauge', '');
+}
+
+sub _convert_node_metrics_recursive {
+    my ($class, $data, $ctime, $metric_prefix, $attributes) = @_;
+    
+    my @metrics = ();
+    
+    # Skip non-metric fields
+    my $skip_fields = {
+        name => 1,
+        tags => 1,
+        vmid => 1,
+        type => 1,
+        status => 1,
+        template => 1,
+        pid => 1,
+        agent => 1,
+        serial => 1,
+        ctime => 1,
+        nics => 1,      # Skip nics - handled separately with device labels
+        storages => 1,  # Skip storages - handled separately with storage 
labels
+    };
+    
+    # Unit mapping for common metrics
+    my $unit_mapping = {
+        # Memory and storage (bytes)
+        mem => 'bytes',
+        memory => 'bytes',
+        swap => 'bytes',
+        disk => 'bytes',
+        size => 'bytes',
+        used => 'bytes',
+        free => 'bytes',
+        total => 'bytes',
+        avail => 'bytes',
+        available => 'bytes',
+        arcsize => 'bytes',
+        blocks => 'bytes',
+        bavail => 'bytes',
+        bfree => 'bytes',
+        
+        # Network (bytes)
+        net => 'bytes',
+        receive => 'bytes',
+        transmit => 'bytes',
+        netin => 'bytes',
+        netout => 'bytes',
+        diskread => 'bytes',
+        diskwrite => 'bytes',
+        
+        # CPU and time 
+        cpu => 'percent',
+        wait => 'seconds',
+        iowait => 'seconds',
+        user => 'seconds',
+        system => 'seconds',
+        idle => 'seconds',
+        nice => 'seconds',
+        steal => 'seconds',
+        guest => 'seconds',
+        irq => 'seconds',
+        softirq => 'seconds',
+        uptime => 'seconds',
+        
+        # Time measurements (nanoseconds)
+        time_ns => 'seconds',
+        total_time_ns => 'seconds',
+        
+        # Load average (dimensionless)
+        avg => '1',
+        avg1 => '1',
+        avg5 => '1',
+        avg15 => '1',
+        
+        # Counters and ratios (dimensionless)
+        cpus => '1',
+        operations => '1',
+        merged => '1',
+        ratio => '1',
+        count => '1',
+        
+        # File system (files are counted, not sized)
+        files => '1',
+        ffree => '1',
+        fused => '1',
+        favail => '1',
+        
+        # Percentages (dimensionless 0-100 scale)
+        per => 'percent',
+        fper => 'percent',
+        percent => 'percent',
+        
+        # Boolean-like flags (dimensionless)
+        enabled => '1',
+        shared => '1',
+        active => '1',
+    };
+    
+    for my $key (sort keys %$data) {
+        next if $skip_fields->{$key};
+        my $value = $data->{$key};
+        next if !defined($value);
+        
+        # Classify metric type and get suffix
+        my ($metric_type, $suffix) = $class->_classify_metric_type($key, 
$metric_prefix);
+        my $metric_name = "${metric_prefix}_${key}${suffix}";
+        
+        if (ref($value) eq 'HASH') {
+            # Recursive call for nested hashes
+            push @metrics, $class->_convert_node_metrics_recursive(
+                $value, $ctime, "${metric_prefix}_${key}", $attributes);
+        } elsif (!ref($value) && $value ne '' && $value =~ 
/^[+-]?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?$/) {
+            # Numeric value - create metric
+            my $unit = '1';  # default unit
+            
+            # Try to determine unit based on key name
+            for my $pattern (keys %$unit_mapping) {
+                if ($key =~ /\Q$pattern\E/) {
+                    $unit = $unit_mapping->{$pattern};
+                    last;
+                }
+            }
+            
+            # Determine if it's an integer or double
+            my $data_point = {
+                timeUnixNano => $ctime * 1_000_000_000,
+                attributes => $attributes,
+            };
+            
+            if ($value =~ /\./ || $value =~ /[eE]/) {
+                $data_point->{asDouble} = $value + 0;  # Convert to number
+            } else {
+                $data_point->{asInt} = int($value);
+            }
+            
+            # Create metric with appropriate type
+            my $metric = {
+                name => $metric_name,
+                unit => $unit,
+            };
+            
+            if ($metric_type eq 'counter') {
+                $metric->{sum} = {
+                    dataPoints => [$data_point],
+                    aggregationTemporality => 2,  # 
AGGREGATION_TEMPORALITY_CUMULATIVE
+                    isMonotonic => \1  # JSON boolean true
+                };
+            } else {
+                $metric->{gauge} = { dataPoints => [$data_point] };
+            }
+            
+            push @metrics, $metric;
+        }
+    }
+    
+    return @metrics;
+}
+
+sub update_node_status {
+    my ($class, $txn, $node, $data, $ctime) = @_;
+    
+    my @metrics = ();
+    my $base_attributes = [
+        { key => 'node', value => { stringValue => $node } }
+    ];
+    
+    # Convert all node metrics recursively
+    push @metrics, $class->_convert_node_metrics_recursive($data, $ctime, 
'proxmox_node', $base_attributes);
+    
+    # Handle special cases that need different attributes
+    # Network metrics with device labels
+    if (defined $data->{nics}) {
+        for my $iface (keys %{$data->{nics}}) {
+            my $nic_attributes = [
+                { key => 'node', value => { stringValue => $node } },
+                { key => 'device', value => { stringValue => $iface } }
+            ];
+            
+            # Use recursive processing for network metrics with 
device-specific attributes
+            push @metrics, 
$class->_convert_node_metrics_recursive($data->{nics}->{$iface}, $ctime, 
'proxmox_node_network', $nic_attributes);
+        }
+    }
+    
+    # Storage metrics with storage labels
+    if (defined $data->{storages}) {
+        for my $storage (keys %{$data->{storages}}) {
+            my $storage_attributes = [
+                { key => 'node', value => { stringValue => $node } },
+                { key => 'storage', value => { stringValue => $storage } }
+            ];
+            
+            # Use recursive processing for storage metrics with 
storage-specific attributes
+            push @metrics, 
$class->_convert_node_metrics_recursive($data->{storages}->{$storage}, $ctime, 
'proxmox_node_storage', $storage_attributes);
+        }
+    }
+    
+    push @{$txn->{metrics}}, @metrics;
+}
+
+sub update_qemu_status {
+    my ($class, $txn, $vmid, $data, $ctime, $nodename) = @_;
+    
+    my @metrics = ();
+    my $vm_attributes = [
+        { key => 'vmid', value => { stringValue => $vmid } },
+        { key => 'node', value => { stringValue => $nodename } },
+        { key => 'name', value => { stringValue => $data->{name} || '' } },
+        { key => 'type', value => { stringValue => 'qemu' } }
+    ];
+    
+    # Use recursive processing for all VM metrics
+    push @metrics, $class->_convert_node_metrics_recursive($data, $ctime, 
'proxmox_vm', $vm_attributes);
+    
+    push @{$txn->{metrics}}, @metrics;
+}
+
+sub update_lxc_status {
+    my ($class, $txn, $vmid, $data, $ctime, $nodename) = @_;
+    
+    my @metrics = ();
+    my $vm_attributes = [
+        { key => 'vmid', value => { stringValue => $vmid } },
+        { key => 'node', value => { stringValue => $nodename } },
+        { key => 'name', value => { stringValue => $data->{name} || '' } },
+        { key => 'type', value => { stringValue => 'lxc' } }
+    ];
+    
+    # Use recursive processing for all LXC metrics
+    push @metrics, $class->_convert_node_metrics_recursive($data, $ctime, 
'proxmox_vm', $vm_attributes);
+    
+    push @{$txn->{metrics}}, @metrics;
+}
+
+sub update_storage_status {
+    my ($class, $txn, $nodename, $storeid, $data, $ctime) = @_;
+    
+    my @metrics = ();
+    my $storage_attributes = [
+        { key => 'node', value => { stringValue => $nodename } },
+        { key => 'storage', value => { stringValue => $storeid } }
+    ];
+    
+    # Use recursive processing for all storage metrics
+    push @metrics, $class->_convert_node_metrics_recursive($data, $ctime, 
'proxmox_storage', $storage_attributes);
+    
+    push @{$txn->{metrics}}, @metrics;
+}
+
+sub flush_data {
+    my ($class, $txn) = @_;
+    
+    return if !$txn->{connection};
+    return if !$txn->{metrics} || !@{$txn->{metrics}};
+    
+    my $metrics = delete $txn->{metrics};
+    $txn->{metrics} = [];
+    
+    eval {
+        $class->_send_metrics_batched($txn->{connection}, $metrics, 
$txn->{cfg});
+        $txn->{stats}->{successful_batches}++;
+    };
+    
+    if (my $err = $@) {
+        $txn->{stats}->{failed_batches}++;
+        die "OpenTelemetry export failed '$txn->{id}': $err";
+    }
+}
+
+sub _send_metrics_batched {
+    my ($class, $connection, $metrics, $cfg) = @_;
+    
+    my $max_body_size = $cfg->{'otel-max-body-size'} || 10_000_000;
+    my $total_metrics = @$metrics;
+    
+    # Estimate metrics per batch based on size heuristics
+    my $estimated_batch_size = $class->_estimate_batch_size($metrics, 
$max_body_size, $cfg);
+    
+    # If estimated batch size covers all metrics, try sending everything at 
once
+    if ($estimated_batch_size >= $total_metrics) {
+        my $otlp_data = $class->_build_otlp_metrics($metrics, $cfg);
+        my $serialized_size = $class->_get_serialized_size($otlp_data, $cfg);
+        
+        if ($serialized_size <= $max_body_size) {
+            $class->send($connection, $otlp_data, $cfg);
+            return;
+        }
+        # If estimation was wrong, fall through to batching
+    }
+    
+    # Send in batches
+    for (my $i = 0; $i < $total_metrics; $i += $estimated_batch_size) {
+        my $end_idx = $i + $estimated_batch_size - 1;
+        $end_idx = $total_metrics - 1 if $end_idx >= $total_metrics;
+        
+        my @batch_metrics = @$metrics[$i..$end_idx];
+        my $batch_otlp = $class->_build_otlp_metrics(\@batch_metrics, $cfg);
+        
+        # Verify batch size is within limits
+        my $batch_size_bytes = $class->_get_serialized_size($batch_otlp, $cfg);
+        if ($batch_size_bytes > $max_body_size) {
+            # Fallback: send metrics one by one
+            for my $single_metric (@batch_metrics) {
+                my $single_otlp = 
$class->_build_otlp_metrics([$single_metric], $cfg);
+                $class->send($connection, $single_otlp, $cfg);
+            }
+        } else {
+            $class->send($connection, $batch_otlp, $cfg);
+        }
+    }
+}
+
+sub _estimate_batch_size {
+    my ($class, $metrics, $max_body_size, $cfg) = @_;
+    
+    return 1 if @$metrics == 0;
+    
+    # Sample first few metrics to estimate size per metric
+    my $sample_size = @$metrics > 10 ? 10 : @$metrics;
+    my @sample_metrics = @$metrics[0..$sample_size-1];
+    
+    my $sample_otlp = $class->_build_otlp_metrics(\@sample_metrics, $cfg);
+    my $sample_bytes = $class->_get_serialized_size($sample_otlp, $cfg);
+    
+    # Calculate average bytes per metric with overhead
+    my $bytes_per_metric = $sample_bytes / $sample_size;
+    
+    # Add 20% safety margin for OTLP structure overhead
+    $bytes_per_metric *= 1.2;
+    
+    # Calculate how many metrics fit in max_body_size
+    my $estimated_count = int($max_body_size / $bytes_per_metric);
+    
+    # Ensure at least 1 metric per batch, and cap at total metrics
+    $estimated_count = 1 if $estimated_count < 1;
+    $estimated_count = @$metrics if $estimated_count > @$metrics;
+    
+    return $estimated_count;
+}
+
+
+sub _get_serialized_size {
+    my ($class, $data, $cfg) = @_;
+    
+    my $serialized;
+    if (($cfg->{'otel-compression'} // 'gzip') eq 'gzip') {
+        $serialized = $class->_compress_json($data);
+    } else {
+        $serialized = JSON->new->utf8->encode($data);
+    }
+    
+    return length($serialized);
+}
+
+sub send {
+    my ($class, $connection, $data, $cfg) = @_;
+    
+    my $ua = LWP::UserAgent->new(
+        timeout => $cfg->{'otel-timeout'} || 5,
+        ssl_opts => { verify_hostname => $cfg->{'otel-verify-ssl'} // 1 }
+    );
+    
+    my $url = $class->_get_otlp_url($cfg);
+    
+    my $request_data;
+    my %headers = (
+        'Content-Type' => 'application/json',
+    );
+    
+    # Safely add parsed headers
+    my $parsed_headers = $class->_parse_headers($cfg->{'otel-headers'});
+    if ($parsed_headers && ref($parsed_headers) eq 'HASH') {
+        %headers = (%headers, %$parsed_headers);
+    }
+    
+    if (($cfg->{'otel-compression'} // 'gzip') eq 'gzip') {
+        $request_data = $class->_compress_json($data);
+        $headers{'Content-Encoding'} = 'gzip';
+    } else {
+        $request_data = JSON->new->utf8->encode($data);
+    }
+    
+    my $req = HTTP::Request->new('POST', $url, [%headers], $request_data);
+    
+    my $response = $ua->request($req);
+    die "OTLP request failed: " . $response->status_line unless 
$response->is_success;
+}
+
+sub test_connection {
+    my ($class, $cfg) = @_;
+    
+    my $ua = LWP::UserAgent->new(
+        timeout => $cfg->{'otel-timeout'} || 5,
+        ssl_opts => { verify_hostname => $cfg->{'otel-verify-ssl'} // 1 }
+    );
+    
+    my $url = $class->_get_otlp_url($cfg);
+    
+    # Send empty metrics payload for testing
+    my $test_data = {
+        resourceMetrics => [{
+            resource => { attributes => [] },
+            scopeMetrics => [{
+                scope => {},
+                metrics => []
+            }]
+        }]
+    };
+    
+    my $request_data;
+    my %headers = (
+        'Content-Type' => 'application/json',
+    );
+    
+    # Safely add parsed headers
+    my $parsed_headers = $class->_parse_headers($cfg->{'otel-headers'});
+    if ($parsed_headers && ref($parsed_headers) eq 'HASH') {
+        %headers = (%headers, %$parsed_headers);
+    }
+    
+    if (($cfg->{'otel-compression'} // 'gzip') eq 'gzip') {
+        $request_data = $class->_compress_json($test_data);
+        $headers{'Content-Encoding'} = 'gzip';
+    } else {
+        $request_data = JSON->new->utf8->encode($test_data);
+    }
+    
+    my $req = HTTP::Request->new('POST', $url, [%headers], $request_data);
+    
+    my $response = $ua->request($req);
+    die "Connection test failed: " . $response->status_line unless 
$response->is_success;
+    
+    return 1;
+}
+
+1;
diff --git a/www/manager6/dc/MetricServerView.js 
b/www/manager6/dc/MetricServerView.js
index baae7d71..862d61d6 100644
--- a/www/manager6/dc/MetricServerView.js
+++ b/www/manager6/dc/MetricServerView.js
@@ -14,6 +14,8 @@ Ext.define('PVE.dc.MetricServerView', {
                     return 'InfluxDB';
                 case 'graphite':
                     return 'Graphite';
+                case 'opentelemetry':
+                    return 'OpenTelemetry';
                 default:
                     return Proxmox.Utils.unknownText;
             }
@@ -106,6 +108,11 @@ Ext.define('PVE.dc.MetricServerView', {
                     iconCls: 'fa fa-fw fa-bar-chart',
                     handler: 'addServer',
                 },
+                {
+                    text: 'OpenTelemetry',
+                    iconCls: 'fa fa-fw fa-bar-chart',
+                    handler: 'addServer',
+                },
             ],
         },
         {
@@ -164,6 +171,17 @@ Ext.define('PVE.dc.MetricServerBaseEdit', {
                 success: function (response, options) {
                     let values = response.result.data;
                     values.enable = !values.disable;
+                    
+                    // Handle OpenTelemetry advanced fields conversion
+                    if (values.type === 'opentelemetry') {
+                        if (values['otel-headers']) {
+                            values.headers_advanced = 
Ext.util.Base64.decode(values['otel-headers']);
+                        }
+                        if (values['otel-resource-attributes']) {
+                            values.resource_attributes_advanced = 
Ext.util.Base64.decode(values['otel-resource-attributes']);
+                        }
+                    }
+                    
                     me.down('inputpanel').setValues(values);
                 },
             });
@@ -499,3 +517,230 @@ Ext.define('PVE.dc.GraphiteEdit', {
         },
     ],
 });
+
+Ext.define('PVE.dc.OpenTelemetryEdit', {
+    extend: 'PVE.dc.MetricServerBaseEdit',
+    xtype: 'pveOpenTelemetryEdit',
+
+    subject: gettext('OpenTelemetry Server'),
+
+    items: [
+        {
+            xtype: 'inputpanel',
+            cbind: {
+                isCreate: '{isCreate}',
+            },
+            onGetValues: function(values) {
+                values.disable = values.enable ? 0 : 1;
+                delete values.enable;
+
+                // Rename advanced fields to their final names and encode as 
base64 (same as webhook)
+                if (values.headers_advanced && values.headers_advanced.trim()) 
{
+                    values['otel-headers'] = 
Ext.util.Base64.encode(values.headers_advanced);
+                } else {
+                    values['otel-headers'] = '';
+                }
+                delete values.headers_advanced;
+
+                if (values.resource_attributes_advanced && 
values.resource_attributes_advanced.trim()) {
+                    values['otel-resource-attributes'] = 
Ext.util.Base64.encode(values.resource_attributes_advanced);
+                } else {
+                    values['otel-resource-attributes'] = '';
+                }
+                delete values.resource_attributes_advanced;
+
+                return values;
+            },
+
+            column1: [
+                {
+                    xtype: 'hidden',
+                    name: 'type',
+                    value: 'opentelemetry',
+                    cbind: {
+                        submitValue: '{isCreate}',
+                    },
+                },
+                {
+                    xtype: 'pmxDisplayEditField',
+                    name: 'id',
+                    fieldLabel: gettext('Name'),
+                    allowBlank: false,
+                    cbind: {
+                        editable: '{isCreate}',
+                        value: '{serverid}',
+                    },
+                },
+                {
+                    xtype: 'proxmoxtextfield',
+                    name: 'server',
+                    fieldLabel: gettext('Server'),
+                    allowBlank: false,
+                    emptyText: gettext('otel-collector.example.com'),
+                },
+                {
+                    xtype: 'proxmoxintegerfield',
+                    name: 'port',
+                    fieldLabel: gettext('Port'),
+                    value: 4318,
+                    minValue: 1,
+                    maxValue: 65535,
+                    allowBlank: false,
+                },
+                {
+                    xtype: 'proxmoxKVComboBox',
+                    name: 'otel-protocol',
+                    fieldLabel: gettext('Protocol'),
+                    value: 'https',
+                    comboItems: [
+                        ['http', 'HTTP'],
+                        ['https', 'HTTPS'],
+                    ],
+                    allowBlank: false,
+                },
+                {
+                    xtype: 'proxmoxtextfield',
+                    name: 'otel-path',
+                    fieldLabel: gettext('Path'),
+                    value: '/v1/metrics',
+                    allowBlank: false,
+                },
+            ],
+
+            column2: [
+                {
+                    xtype: 'checkbox',
+                    name: 'enable',
+                    fieldLabel: gettext('Enabled'),
+                    inputValue: 1,
+                    uncheckedValue: 0,
+                    checked: true,
+                },
+                {
+                    xtype: 'proxmoxintegerfield',
+                    name: 'otel-timeout',
+                    fieldLabel: gettext('Timeout (s)'),
+                    value: 5,
+                    minValue: 1,
+                    maxValue: 300,
+                    allowBlank: false,
+                },
+                {
+                    xtype: 'proxmoxcheckbox',
+                    name: 'otel-verify-ssl',
+                    fieldLabel: gettext('Verify SSL'),
+                    inputValue: 1,
+                    uncheckedValue: 0,
+                    defaultValue: 1,
+                    cbind: {
+                        value: function(get) {
+                            return get('isCreate') ? 1 : undefined;
+                        }
+                    },
+                },
+                {
+                    xtype: 'proxmoxintegerfield',
+                    name: 'otel-max-body-size',
+                    fieldLabel: gettext('Max Body Size (bytes)'),
+                    value: 10000000,
+                    minValue: 1024,
+                    allowBlank: false,
+                },
+                {
+                    xtype: 'proxmoxKVComboBox',
+                    name: 'otel-compression',
+                    fieldLabel: gettext('Compression'),
+                    value: 'gzip',
+                    comboItems: [
+                        ['none', gettext('None')],
+                        ['gzip', 'Gzip'],
+                    ],
+                    allowBlank: false,
+                },
+            ],
+
+
+            columnB: [
+                {
+                    xtype: 'fieldset',
+                    title: gettext('Advanced JSON Configuration'),
+                    collapsible: true,
+                    collapsed: true,
+                    items: [
+                        {
+                            xtype: 'textarea',
+                            name: 'headers_advanced',
+                            fieldLabel: gettext('HTTP Headers (JSON)'),
+                            labelAlign: 'top',
+                            emptyText: gettext('{\n  "Authorization": "Bearer 
token",\n  "X-Custom-Header": "value"\n}'),
+                            rows: 4,
+                            validator: function(value) {
+                                if (!value || value.trim() === '') {
+                                    return true;
+                                }
+                                try {
+                                    JSON.parse(value);
+                                    return true;
+                                } catch (_e) {
+                                    return gettext('Invalid JSON format');
+                                }
+                            },
+                        },
+                        {
+                            xtype: 'textarea',
+                            name: 'resource_attributes_advanced',
+                            fieldLabel: gettext('Resource Attributes (JSON)'),
+                            labelAlign: 'top',
+                            emptyText: gettext('{\n  "environment": 
"production",\n  "datacenter": "dc1",\n  "region": "us-east-1"\n}'),
+                            rows: 4,
+                            validator: function(value) {
+                                if (!value || value.trim() === '') {
+                                    return true;
+                                }
+                                try {
+                                    JSON.parse(value);
+                                    return true;
+                                } catch (_e) {
+                                    return gettext('Invalid JSON format');
+                                }
+                            },
+                        },
+                    ],
+                },
+            ],
+        },
+    ],
+
+    initComponent: function() {
+        var me = this;
+        var initialLoad = true;
+
+        me.callParent();
+
+        // Auto-adjust port when protocol changes (only for user interaction)
+        me.on('afterrender', function() {
+            var protocolField = me.down('[name=otel-protocol]');
+            var portField = me.down('[name=port]');
+
+            if (protocolField && portField) {
+                // Set flag to false after initial load
+                me.on('loadrecord', function() {
+                    setTimeout(function() {
+                        initialLoad = false;
+                    }, 100);
+                });
+
+                protocolField.on('change', function(field, newValue) {
+                    // Only auto-adjust port if this is user interaction, not 
initial load
+                    if (!initialLoad) {
+                        if (newValue === 'https') {
+                            portField.setValue(4318);
+                        } else {
+                            portField.setValue(4317);
+                        }
+                    }
+                });
+            }
+        });
+    },
+});
\ No newline at end of file
-- 
2.50.0



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


Reply via email to