mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-17 16:15:43 +00:00
Add bounded local telemetry export commands (#7720)
feat(connect): add bounded telemetry producers
This commit is contained in:
Generated
+2
@@ -9526,12 +9526,14 @@ dependencies = [
|
||||
"mime_guess",
|
||||
"moka",
|
||||
"opentelemetry",
|
||||
"opentelemetry-proto",
|
||||
"opentelemetry_sdk",
|
||||
"p256 0.14.0",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"proptest",
|
||||
"prost 0.14.4",
|
||||
"quick-xml",
|
||||
"rand 0.10.2",
|
||||
"rcgen",
|
||||
|
||||
@@ -307,6 +307,7 @@ bytes = { workspace = true, features = ["serde"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
flate2 = { workspace = true }
|
||||
flatbuffers.workspace = true
|
||||
prost.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
quick-xml.workspace = true
|
||||
rustfs-signer.workspace = true
|
||||
@@ -364,6 +365,7 @@ chacha20poly1305 = { workspace = true }
|
||||
# Observability and Metrics
|
||||
metrics = { workspace = true }
|
||||
opentelemetry = { workspace = true }
|
||||
opentelemetry-proto = { workspace = true, features = ["trace"] }
|
||||
tracing-opentelemetry = { workspace = true }
|
||||
# Data structures
|
||||
hashbrown = { workspace = true, features = ["serde", "rayon"] }
|
||||
|
||||
@@ -131,6 +131,113 @@ pub enum ConnectCommands {
|
||||
Profile(ConnectProfileOpts),
|
||||
/// Capture allow-listed local log events and write a signed export
|
||||
Logs(ConnectLogsOpts),
|
||||
/// Record, forward, or replay consent-bound telemetry
|
||||
Telemetry(ConnectTelemetryOpts),
|
||||
}
|
||||
|
||||
#[derive(Args, Clone)]
|
||||
pub struct ConnectTelemetryOpts {
|
||||
#[command(subcommand)]
|
||||
pub command: ConnectTelemetryCommands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Clone)]
|
||||
pub enum ConnectTelemetryCommands {
|
||||
/// Capture process-local telemetry when an approved typed source is available
|
||||
Record(ConnectTelemetryRecordOpts),
|
||||
/// Forward an OTLP protobuf batch from stdin to a customer-approved collector
|
||||
Otlp(ConnectTelemetryOtlpOpts),
|
||||
/// Replay reviewed trace JSON read from stdin and write a signed export
|
||||
Replay(ConnectTelemetryReplayOpts),
|
||||
}
|
||||
|
||||
#[derive(Args, Clone)]
|
||||
pub struct ConnectTelemetryArtifactOpts {
|
||||
/// Directory containing an enrolled Connect device identity
|
||||
#[arg(long = "state-dir")]
|
||||
pub state_dir: PathBuf,
|
||||
|
||||
/// New local archive path; an existing file is never replaced
|
||||
#[arg(long)]
|
||||
pub output: PathBuf,
|
||||
|
||||
/// Organization resource name bound to the export
|
||||
#[arg(long, value_parser = NonEmptyStringValueParser::new())]
|
||||
pub organization: String,
|
||||
|
||||
/// Cluster resource name bound to the export
|
||||
#[arg(long, value_parser = NonEmptyStringValueParser::new())]
|
||||
pub cluster: String,
|
||||
|
||||
/// Cluster-device resource name bound to the export
|
||||
#[arg(long, value_parser = NonEmptyStringValueParser::new())]
|
||||
pub device: String,
|
||||
|
||||
/// UUIDv7 diagnostic run identifier issued by Connect
|
||||
#[arg(long = "run-uid", value_parser = NonEmptyStringValueParser::new())]
|
||||
pub run_uid: String,
|
||||
|
||||
/// UUIDv7 artifact identifier issued by Connect
|
||||
#[arg(long = "artifact-uid", value_parser = NonEmptyStringValueParser::new())]
|
||||
pub artifact_uid: String,
|
||||
|
||||
/// UUIDv7 consent identifier issued by Connect
|
||||
#[arg(long = "consent-uid", value_parser = NonEmptyStringValueParser::new())]
|
||||
pub consent_uid: String,
|
||||
|
||||
/// Consent policy revision bound to this operation
|
||||
#[arg(long = "policy-revision")]
|
||||
pub policy_revision: u64,
|
||||
|
||||
/// Consent expiry as UTC Unix seconds
|
||||
#[arg(long = "consent-expires-at")]
|
||||
pub consent_expires_at_unix: i64,
|
||||
|
||||
/// Artifact expiry as UTC Unix seconds
|
||||
#[arg(long = "expires-at")]
|
||||
pub expires_at_unix: i64,
|
||||
|
||||
/// Confirm this explicit local L3 telemetry operation
|
||||
#[arg(long = "acknowledge-l3", required = true, action = clap::ArgAction::SetTrue)]
|
||||
pub acknowledge_l3: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Clone)]
|
||||
pub struct ConnectTelemetryRecordOpts {
|
||||
#[command(flatten)]
|
||||
pub artifact: ConnectTelemetryArtifactOpts,
|
||||
|
||||
/// Capture duration in milliseconds
|
||||
#[arg(long = "duration-millis")]
|
||||
pub duration_millis: u64,
|
||||
|
||||
/// Maximum exported span count
|
||||
#[arg(long = "max-spans", default_value_t = 1_024)]
|
||||
pub max_spans: usize,
|
||||
}
|
||||
|
||||
#[derive(Args, Clone)]
|
||||
pub struct ConnectTelemetryOtlpOpts {
|
||||
#[command(flatten)]
|
||||
pub artifact: ConnectTelemetryArtifactOpts,
|
||||
|
||||
/// Customer-approved OTLP/HTTP traces endpoint
|
||||
#[arg(long, value_parser = NonEmptyStringValueParser::new())]
|
||||
pub endpoint: String,
|
||||
|
||||
/// Forward timeout in milliseconds
|
||||
#[arg(long = "timeout-millis")]
|
||||
pub timeout_millis: u64,
|
||||
|
||||
/// Environment variable containing the local Authorization header value
|
||||
#[arg(long = "authorization-env", value_parser = NonEmptyStringValueParser::new())]
|
||||
pub authorization_env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Clone)]
|
||||
pub struct ConnectTelemetryReplayOpts {
|
||||
#[command(flatten)]
|
||||
pub artifact: ConnectTelemetryArtifactOpts,
|
||||
}
|
||||
|
||||
/// `connect logs` options.
|
||||
@@ -690,6 +797,8 @@ pub enum CommandResult {
|
||||
ConnectProfile(ConnectProfileOpts),
|
||||
/// Consent-bound local Connect log export
|
||||
ConnectLogs(ConnectLogsOpts),
|
||||
/// Consent-bound local Connect telemetry operation
|
||||
ConnectTelemetry(ConnectTelemetryCommands),
|
||||
}
|
||||
|
||||
/// Create default ServerOpts from environment variables
|
||||
@@ -947,6 +1056,43 @@ mod tests {
|
||||
assert!(error.to_string().contains("--acknowledge-l3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_telemetry_record_requires_explicit_l3_acknowledgement() {
|
||||
let arguments = [
|
||||
"rustfs",
|
||||
"connect",
|
||||
"telemetry",
|
||||
"record",
|
||||
"--state-dir",
|
||||
"/var/lib/rustfs/connect",
|
||||
"--output",
|
||||
"/tmp/telemetry.zip",
|
||||
"--organization",
|
||||
"organizations/019e3ae0-0000-7000-8000-000000000001",
|
||||
"--cluster",
|
||||
"organizations/019e3ae0-0000-7000-8000-000000000001/clusters/019e3ae0-0000-7000-8000-000000000002",
|
||||
"--device",
|
||||
"organizations/019e3ae0-0000-7000-8000-000000000001/clusters/019e3ae0-0000-7000-8000-000000000002/clusterDevices/019e3ae0-0000-7000-8000-000000000003",
|
||||
"--run-uid",
|
||||
"019e3ae0-0000-7000-8000-000000000004",
|
||||
"--artifact-uid",
|
||||
"019e3ae0-0000-7000-8000-000000000005",
|
||||
"--consent-uid",
|
||||
"019e3ae0-0000-7000-8000-000000000006",
|
||||
"--policy-revision",
|
||||
"1",
|
||||
"--consent-expires-at",
|
||||
"4102444800",
|
||||
"--expires-at",
|
||||
"4102444700",
|
||||
"--duration-millis",
|
||||
"10",
|
||||
];
|
||||
let error = Cli::try_parse_from(arguments).expect_err("unacknowledged telemetry capture must fail");
|
||||
assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument);
|
||||
assert!(error.to_string().contains("--acknowledge-l3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_help_lists_allocator_reclaim_environment() {
|
||||
let result = Cli::try_parse_from(["rustfs", "server", "--help"]);
|
||||
|
||||
@@ -54,6 +54,10 @@ pub use cli::{CommandResult, InfoOpts, InfoType};
|
||||
pub use cli::{ConnectLicenseArtifactOpts, ConnectLicenseCommands, ConnectLicenseScopeOpts};
|
||||
pub use cli::{ConnectLogsMode, ConnectLogsOpts};
|
||||
pub use cli::{ConnectProfileOpts, ConnectProfileTool, ConnectThreadProfileScope};
|
||||
pub use cli::{
|
||||
ConnectTelemetryArtifactOpts, ConnectTelemetryCommands, ConnectTelemetryOtlpOpts, ConnectTelemetryRecordOpts,
|
||||
ConnectTelemetryReplayOpts,
|
||||
};
|
||||
pub use cli::{DiagnoseFormat, DiagnoseOpts};
|
||||
pub use cli::{InspectBucketMetaOpts, InspectCommands, InspectOpts};
|
||||
pub use cli::{TlsCommands, TlsInspectOpts, TlsOpts};
|
||||
|
||||
@@ -142,6 +142,7 @@ impl Opt {
|
||||
ConnectCommands::License(opts) => Ok(CommandResult::ConnectLicense(opts.command)),
|
||||
ConnectCommands::Profile(opts) => Ok(CommandResult::ConnectProfile(opts)),
|
||||
ConnectCommands::Logs(opts) => Ok(CommandResult::ConnectLogs(opts)),
|
||||
ConnectCommands::Telemetry(opts) => Ok(CommandResult::ConnectTelemetry(opts.command)),
|
||||
},
|
||||
Some(Commands::Server(opts)) => Self::server_command_result(Self::from_server_opts(*opts)),
|
||||
None => {
|
||||
|
||||
@@ -17,6 +17,10 @@ mod profile_cpu;
|
||||
mod profile_memory;
|
||||
mod profile_threads;
|
||||
mod schedule;
|
||||
mod trace_analysis;
|
||||
mod trace_otlp;
|
||||
mod trace_record;
|
||||
mod trace_replay;
|
||||
|
||||
pub use logs::{
|
||||
CaptureMode, LOGS_CAPABILITY, LOGS_SCHEMA_VERSION, LocalLogConsent, LogCaptureError, LogCaptureRequest, LogProvenance,
|
||||
@@ -34,3 +38,17 @@ pub use schedule::{
|
||||
DiagnosticCollectionPolicy, DiagnosticReceipt, DiagnosticScheduleError, DiagnosticScheduleRuntime, DiagnosticScheduleStatus,
|
||||
ReceiptOutcome, run_local_environment_once, spawn_environment_schedule,
|
||||
};
|
||||
pub use trace_analysis::{OperationSummary, TraceAnalysis, TraceAnalysisError, analyze_trace};
|
||||
pub use trace_otlp::{
|
||||
LocalOtlpHeaders, MAX_OTLP_BODY_BYTES, OtlpBatch, OtlpForwardError, OtlpReceipt, export_trace_otlp, export_trace_otlp_result,
|
||||
};
|
||||
pub use trace_record::{
|
||||
LocalTelemetryConsent, MAX_SAFE_INTEGER, MAX_TELEMETRY_DURATION, MAX_TELEMETRY_RESULT_BYTES, MAX_TELEMETRY_SPANS,
|
||||
ObservedTelemetrySpan, RecordedTrace, SavedTelemetryExport, SignedTelemetryExport, TELEMETRY_OTLP_CAPABILITY,
|
||||
TELEMETRY_RECORD_CAPABILITY, TELEMETRY_REPLAY_CAPABILITY, TELEMETRY_SCHEMA_VERSION, TelemetryArtifactConsent,
|
||||
TelemetryArtifactError, TelemetryArtifactRequest, TelemetryCoverage, TelemetryDiagnosticResult, TelemetryOperation,
|
||||
TelemetryOutcome, TelemetryProducerError, TelemetryProvenance, TelemetryReasonCode, TelemetrySpan, TelemetrySpanStatus,
|
||||
TelemetryTool, TraceRecordCapture, TraceRecordCompletion, TraceRecordLimits, encode_signed_telemetry_export,
|
||||
record_diagnostic_result, record_trace, record_trace_bus, save_signed_telemetry_export,
|
||||
};
|
||||
pub use trace_replay::{LocallyReviewedTraceArtifact, ReplayedTrace, TraceReplayError, replay_trace, replay_trace_result};
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Deterministic local summaries over redacted telemetry spans.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::trace_record::{
|
||||
LocalTelemetryConsent, TelemetryOperation, TelemetryProducerError, TelemetrySpanStatus, acquire_telemetry_lease,
|
||||
};
|
||||
use super::trace_replay::ReplayedTrace;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct TraceAnalysis {
|
||||
pub input_artifact_sha256: String,
|
||||
pub span_count: u64,
|
||||
pub error_count: u64,
|
||||
pub total_duration_micros: u64,
|
||||
pub operations: Vec<OperationSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct OperationSummary {
|
||||
pub operation: TelemetryOperation,
|
||||
pub span_count: u64,
|
||||
pub error_count: u64,
|
||||
pub total_duration_micros: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum TraceAnalysisError {
|
||||
#[error("another telemetry operation is already running")]
|
||||
Busy,
|
||||
#[error("local telemetry consent is expired")]
|
||||
ConsentExpired,
|
||||
#[error("telemetry analysis was cancelled")]
|
||||
Cancelled,
|
||||
#[error("telemetry analysis counters overflowed")]
|
||||
CounterOverflow,
|
||||
}
|
||||
|
||||
pub fn analyze_trace(
|
||||
replay: &ReplayedTrace,
|
||||
consent: LocalTelemetryConsent,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<TraceAnalysis, TraceAnalysisError> {
|
||||
consent.remaining().map_err(map_consent_error)?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(TraceAnalysisError::Cancelled);
|
||||
}
|
||||
let _lease = acquire_telemetry_lease().map_err(map_consent_error)?;
|
||||
|
||||
let mut operations = [
|
||||
OperationSummary {
|
||||
operation: TelemetryOperation::GetObject,
|
||||
span_count: 0,
|
||||
error_count: 0,
|
||||
total_duration_micros: 0,
|
||||
},
|
||||
OperationSummary {
|
||||
operation: TelemetryOperation::PutObject,
|
||||
span_count: 0,
|
||||
error_count: 0,
|
||||
total_duration_micros: 0,
|
||||
},
|
||||
OperationSummary {
|
||||
operation: TelemetryOperation::HeadObject,
|
||||
span_count: 0,
|
||||
error_count: 0,
|
||||
total_duration_micros: 0,
|
||||
},
|
||||
OperationSummary {
|
||||
operation: TelemetryOperation::ListObjects,
|
||||
span_count: 0,
|
||||
error_count: 0,
|
||||
total_duration_micros: 0,
|
||||
},
|
||||
OperationSummary {
|
||||
operation: TelemetryOperation::InternalRpc,
|
||||
span_count: 0,
|
||||
error_count: 0,
|
||||
total_duration_micros: 0,
|
||||
},
|
||||
];
|
||||
let mut error_count = 0u64;
|
||||
let mut total_duration_micros = 0u64;
|
||||
for span in &replay.spans {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(TraceAnalysisError::Cancelled);
|
||||
}
|
||||
let summary = &mut operations[operation_index(span.operation)];
|
||||
summary.span_count = summary.span_count.checked_add(1).ok_or(TraceAnalysisError::CounterOverflow)?;
|
||||
summary.total_duration_micros = summary
|
||||
.total_duration_micros
|
||||
.checked_add(span.duration_micros)
|
||||
.ok_or(TraceAnalysisError::CounterOverflow)?;
|
||||
total_duration_micros = total_duration_micros
|
||||
.checked_add(span.duration_micros)
|
||||
.ok_or(TraceAnalysisError::CounterOverflow)?;
|
||||
if span.status == TelemetrySpanStatus::Error {
|
||||
summary.error_count = summary
|
||||
.error_count
|
||||
.checked_add(1)
|
||||
.ok_or(TraceAnalysisError::CounterOverflow)?;
|
||||
error_count = error_count.checked_add(1).ok_or(TraceAnalysisError::CounterOverflow)?;
|
||||
}
|
||||
}
|
||||
let span_count = u64::try_from(replay.spans.len()).map_err(|_| TraceAnalysisError::CounterOverflow)?;
|
||||
Ok(TraceAnalysis {
|
||||
input_artifact_sha256: replay.input_artifact_sha256.clone(),
|
||||
span_count,
|
||||
error_count,
|
||||
total_duration_micros,
|
||||
operations: operations.into_iter().filter(|summary| summary.span_count != 0).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
const fn operation_index(operation: TelemetryOperation) -> usize {
|
||||
match operation {
|
||||
TelemetryOperation::GetObject => 0,
|
||||
TelemetryOperation::PutObject => 1,
|
||||
TelemetryOperation::HeadObject => 2,
|
||||
TelemetryOperation::ListObjects => 3,
|
||||
TelemetryOperation::InternalRpc => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_consent_error(error: TelemetryProducerError) -> TraceAnalysisError {
|
||||
match error {
|
||||
TelemetryProducerError::Busy => TraceAnalysisError::Busy,
|
||||
TelemetryProducerError::ConsentExpired => TraceAnalysisError::ConsentExpired,
|
||||
_ => TraceAnalysisError::CounterOverflow,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Customer-side OTLP/HTTP forwarding with bounded payloads and local secrets.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
|
||||
use prost::Message as _;
|
||||
use reqwest::{
|
||||
Client, StatusCode, Url,
|
||||
header::{self, HeaderMap},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::trace_record::{
|
||||
LocalTelemetryConsent, MAX_TELEMETRY_DURATION, TelemetryArtifactRequest, TelemetryDiagnosticResult, TelemetryLease,
|
||||
TelemetryProducerError, TelemetryTool, acquire_telemetry_lease, ensure_result_size,
|
||||
};
|
||||
|
||||
pub const MAX_OTLP_BODY_BYTES: usize = 1_048_576;
|
||||
|
||||
/// An encoded OTLP/HTTP protobuf batch from the local OpenTelemetry adapter.
|
||||
/// The bytes are never included in the diagnostic result or error values.
|
||||
pub struct OtlpBatch {
|
||||
body: Vec<u8>,
|
||||
span_count: u64,
|
||||
_lease: TelemetryLease,
|
||||
}
|
||||
|
||||
impl OtlpBatch {
|
||||
pub fn new(body: Vec<u8>) -> Result<Self, OtlpForwardError> {
|
||||
if body.is_empty() || body.len() > MAX_OTLP_BODY_BYTES {
|
||||
return Err(OtlpForwardError::InvalidBatch);
|
||||
}
|
||||
let lease = acquire_telemetry_lease().map_err(map_producer_error)?;
|
||||
let request = ExportTraceServiceRequest::decode(body.as_slice()).map_err(|_| OtlpForwardError::InvalidBatch)?;
|
||||
if request.resource_spans.len() > 1024 {
|
||||
return Err(OtlpForwardError::InvalidBatch);
|
||||
}
|
||||
let scope_count = request.resource_spans.iter().try_fold(0_usize, |count, resource| {
|
||||
count
|
||||
.checked_add(resource.scope_spans.len())
|
||||
.filter(|count| *count <= 1024)
|
||||
.ok_or(OtlpForwardError::InvalidBatch)
|
||||
})?;
|
||||
if scope_count == 0 {
|
||||
return Err(OtlpForwardError::InvalidBatch);
|
||||
}
|
||||
let span_count = request
|
||||
.resource_spans
|
||||
.iter()
|
||||
.flat_map(|resource| &resource.scope_spans)
|
||||
.try_fold(0_u64, |count, scope| {
|
||||
let spans = u64::try_from(scope.spans.len()).map_err(|_| OtlpForwardError::InvalidBatch)?;
|
||||
count.checked_add(spans).ok_or(OtlpForwardError::InvalidBatch)
|
||||
})?;
|
||||
if span_count == 0 || span_count > 1024 {
|
||||
return Err(OtlpForwardError::InvalidBatch);
|
||||
}
|
||||
Ok(Self {
|
||||
body,
|
||||
span_count,
|
||||
_lease: lease,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn export_trace_otlp_result(
|
||||
request: &TelemetryArtifactRequest,
|
||||
endpoint: Url,
|
||||
headers: LocalOtlpHeaders,
|
||||
batch: OtlpBatch,
|
||||
consent: LocalTelemetryConsent,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<TelemetryDiagnosticResult<OtlpReceipt>, OtlpForwardError> {
|
||||
let started = Instant::now();
|
||||
let receipt = export_trace_otlp(endpoint, headers, batch, consent, timeout, cancel).await?;
|
||||
Ok(TelemetryDiagnosticResult::succeeded(
|
||||
request,
|
||||
TelemetryTool::Otlp,
|
||||
started.elapsed(),
|
||||
receipt,
|
||||
))
|
||||
}
|
||||
|
||||
/// Locally supplied collector authentication. This type has no `Debug` or
|
||||
/// serialization implementation, keeping credentials out of results and logs.
|
||||
pub struct LocalOtlpHeaders(HeaderMap);
|
||||
|
||||
impl LocalOtlpHeaders {
|
||||
pub fn new(headers: HeaderMap) -> Self {
|
||||
Self(headers)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct OtlpReceipt {
|
||||
pub accepted_span_count: u64,
|
||||
pub rejected_span_count: u64,
|
||||
pub exported_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum OtlpForwardError {
|
||||
#[error("another telemetry operation is already running")]
|
||||
Busy,
|
||||
#[error("OTLP endpoint must be HTTPS or an HTTP loopback address without credentials, query or fragment")]
|
||||
Endpoint,
|
||||
#[error("OTLP batch must contain 1..1024 spans and 1..1048576 bytes")]
|
||||
InvalidBatch,
|
||||
#[error("OTLP forward timeout must be between 1ms and 30s")]
|
||||
InvalidTimeout,
|
||||
#[error("OTLP forwarding was cancelled")]
|
||||
Cancelled,
|
||||
#[error("local OTLP collector rejected the batch")]
|
||||
Rejected,
|
||||
#[error("local OTLP collector is unavailable")]
|
||||
Unavailable,
|
||||
#[error("OTLP receipt exceeds its result limit")]
|
||||
ResultTooLarge,
|
||||
#[error("local telemetry consent is expired")]
|
||||
ConsentExpired,
|
||||
}
|
||||
|
||||
pub async fn export_trace_otlp(
|
||||
endpoint: Url,
|
||||
headers: LocalOtlpHeaders,
|
||||
batch: OtlpBatch,
|
||||
consent: LocalTelemetryConsent,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<OtlpReceipt, OtlpForwardError> {
|
||||
validate_endpoint(&endpoint)?;
|
||||
if timeout.is_zero() || timeout > MAX_TELEMETRY_DURATION {
|
||||
return Err(OtlpForwardError::InvalidTimeout);
|
||||
}
|
||||
let remaining = consent.remaining().map_err(map_consent_error)?;
|
||||
let consent_binds = remaining <= timeout;
|
||||
let timeout = timeout.min(remaining);
|
||||
let timeout_millis = usize::try_from(timeout.as_millis()).map_err(|_| OtlpForwardError::InvalidTimeout)?;
|
||||
if timeout_millis == 0 {
|
||||
return Err(OtlpForwardError::InvalidTimeout);
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
let OtlpBatch {
|
||||
body,
|
||||
span_count,
|
||||
_lease,
|
||||
} = batch;
|
||||
let byte_budget = MAX_OTLP_BODY_BYTES
|
||||
.checked_mul(timeout_millis.min(1000))
|
||||
.and_then(|bytes| bytes.checked_div(1000))
|
||||
.ok_or(OtlpForwardError::InvalidBatch)?;
|
||||
if body.len() > byte_budget {
|
||||
return Err(OtlpForwardError::InvalidBatch);
|
||||
}
|
||||
let exported_bytes = u64::try_from(body.len()).map_err(|_| OtlpForwardError::InvalidBatch)?;
|
||||
let mut headers = headers.0;
|
||||
for transport_header in [
|
||||
header::HOST,
|
||||
header::CONTENT_LENGTH,
|
||||
header::CONTENT_TYPE,
|
||||
header::CONNECTION,
|
||||
header::TRANSFER_ENCODING,
|
||||
] {
|
||||
headers.remove(transport_header);
|
||||
}
|
||||
let client = Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|_| OtlpForwardError::Unavailable)?;
|
||||
|
||||
let request = client
|
||||
.post(endpoint)
|
||||
.headers(headers)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/x-protobuf")
|
||||
.body(body);
|
||||
let mut response = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err(OtlpForwardError::Cancelled),
|
||||
_ = tokio::time::sleep_until(deadline) => return Err(deadline_error(consent_binds)),
|
||||
response = request.send() => response.map_err(|_| OtlpForwardError::Unavailable)?,
|
||||
};
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(if response.status().is_client_error() {
|
||||
OtlpForwardError::Rejected
|
||||
} else {
|
||||
OtlpForwardError::Unavailable
|
||||
});
|
||||
}
|
||||
loop {
|
||||
let chunk = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err(OtlpForwardError::Cancelled),
|
||||
_ = tokio::time::sleep_until(deadline) => return Err(deadline_error(consent_binds)),
|
||||
chunk = response.chunk() => chunk.map_err(|_| OtlpForwardError::Unavailable)?,
|
||||
};
|
||||
let Some(chunk) = chunk else {
|
||||
break;
|
||||
};
|
||||
if !chunk.is_empty() {
|
||||
// A successful OTLP response may carry partial-success details.
|
||||
// Until the approved protobuf adapter is wired, refusing such a
|
||||
// response avoids claiming every span was accepted.
|
||||
return Err(OtlpForwardError::Rejected);
|
||||
}
|
||||
}
|
||||
|
||||
let receipt = OtlpReceipt {
|
||||
accepted_span_count: span_count,
|
||||
rejected_span_count: 0,
|
||||
exported_bytes,
|
||||
};
|
||||
ensure_result_size(&receipt).map_err(|_| OtlpForwardError::ResultTooLarge)?;
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
fn deadline_error(consent_binds: bool) -> OtlpForwardError {
|
||||
if consent_binds {
|
||||
OtlpForwardError::ConsentExpired
|
||||
} else {
|
||||
OtlpForwardError::Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_endpoint(endpoint: &Url) -> Result<(), OtlpForwardError> {
|
||||
let loopback_http = endpoint.scheme() == "http"
|
||||
&& endpoint
|
||||
.host_str()
|
||||
.is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1"));
|
||||
if (endpoint.scheme() != "https" && !loopback_http)
|
||||
|| !endpoint.username().is_empty()
|
||||
|| endpoint.password().is_some()
|
||||
|| endpoint.query().is_some()
|
||||
|| endpoint.fragment().is_some()
|
||||
{
|
||||
return Err(OtlpForwardError::Endpoint);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_consent_error(error: TelemetryProducerError) -> OtlpForwardError {
|
||||
match error {
|
||||
TelemetryProducerError::ConsentExpired => OtlpForwardError::ConsentExpired,
|
||||
_ => OtlpForwardError::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_producer_error(error: TelemetryProducerError) -> OtlpForwardError {
|
||||
match error {
|
||||
TelemetryProducerError::Busy => OtlpForwardError::Busy,
|
||||
_ => OtlpForwardError::InvalidBatch,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! In-memory replay of a locally reviewed telemetry record.
|
||||
//!
|
||||
//! This module never opens a path and never replays S3 operations. The caller
|
||||
//! supplies reviewed bytes; replay validates the exact closed record shape and
|
||||
//! returns only its redacted operation/timing/status observations.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::trace_record::{
|
||||
LocalTelemetryConsent, MAX_SAFE_INTEGER, MAX_TELEMETRY_RESULT_BYTES, MAX_TELEMETRY_SPANS, RecordedTrace,
|
||||
TelemetryArtifactRequest, TelemetryDiagnosticResult, TelemetryProducerError, TelemetrySpan, TelemetryTool,
|
||||
acquire_telemetry_lease, ensure_result_size,
|
||||
};
|
||||
|
||||
pub struct LocallyReviewedTraceArtifact<'a> {
|
||||
bytes: &'a [u8],
|
||||
}
|
||||
|
||||
impl<'a> LocallyReviewedTraceArtifact<'a> {
|
||||
pub fn new(bytes: &'a [u8]) -> Result<Self, TraceReplayError> {
|
||||
if bytes.is_empty() || bytes.len() > MAX_TELEMETRY_RESULT_BYTES {
|
||||
return Err(TraceReplayError::InvalidArtifact);
|
||||
}
|
||||
Ok(Self { bytes })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ReplayedTrace {
|
||||
pub input_artifact_sha256: String,
|
||||
pub spans: Vec<TelemetrySpan>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum TraceReplayError {
|
||||
#[error("another telemetry operation is already running")]
|
||||
Busy,
|
||||
#[error("reviewed telemetry artifact is invalid")]
|
||||
InvalidArtifact,
|
||||
#[error("telemetry replay was cancelled")]
|
||||
Cancelled,
|
||||
#[error("local telemetry consent is expired")]
|
||||
ConsentExpired,
|
||||
#[error("telemetry replay result exceeds 262144 bytes")]
|
||||
ResultTooLarge,
|
||||
}
|
||||
|
||||
pub fn replay_trace(
|
||||
artifact: LocallyReviewedTraceArtifact<'_>,
|
||||
consent: LocalTelemetryConsent,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<ReplayedTrace, TraceReplayError> {
|
||||
consent.remaining().map_err(map_consent_error)?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(TraceReplayError::Cancelled);
|
||||
}
|
||||
let _lease = acquire_telemetry_lease().map_err(map_consent_error)?;
|
||||
let record: RecordedTrace = serde_json::from_slice(artifact.bytes).map_err(|_| TraceReplayError::InvalidArtifact)?;
|
||||
if record.spans.len() > MAX_TELEMETRY_SPANS
|
||||
|| record.dropped_span_count > MAX_SAFE_INTEGER
|
||||
|| record.spans.iter().any(|span| span.duration_micros > MAX_SAFE_INTEGER)
|
||||
{
|
||||
return Err(TraceReplayError::InvalidArtifact);
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(TraceReplayError::Cancelled);
|
||||
}
|
||||
let result = ReplayedTrace {
|
||||
input_artifact_sha256: hex_lower(&Sha256::digest(artifact.bytes)),
|
||||
spans: record.spans,
|
||||
};
|
||||
ensure_result_size(&result).map_err(|_| TraceReplayError::ResultTooLarge)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn replay_trace_result(
|
||||
request: &TelemetryArtifactRequest,
|
||||
artifact: LocallyReviewedTraceArtifact<'_>,
|
||||
consent: LocalTelemetryConsent,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<TelemetryDiagnosticResult<ReplayedTrace>, TraceReplayError> {
|
||||
let started = Instant::now();
|
||||
let replay = replay_trace(artifact, consent, cancel)?;
|
||||
Ok(TelemetryDiagnosticResult::succeeded(
|
||||
request,
|
||||
TelemetryTool::Replay,
|
||||
started.elapsed(),
|
||||
replay,
|
||||
))
|
||||
}
|
||||
|
||||
fn map_consent_error(error: TelemetryProducerError) -> TraceReplayError {
|
||||
match error {
|
||||
TelemetryProducerError::Busy => TraceReplayError::Busy,
|
||||
TelemetryProducerError::ConsentExpired => TraceReplayError::ConsentExpired,
|
||||
_ => TraceReplayError::InvalidArtifact,
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut output, byte| {
|
||||
let _ = write!(output, "{byte:02x}");
|
||||
output
|
||||
})
|
||||
}
|
||||
@@ -46,13 +46,22 @@ pub use config::{HeartbeatConfig, HeartbeatConfigError, HeartbeatSchedule};
|
||||
pub use credential_store::{CredentialStore, DeviceCredential};
|
||||
pub use diagnostics::{
|
||||
CPU_PROFILE_CAPABILITY, CaptureMode, DiagnosticCollectionPolicy, DiagnosticReceipt, DiagnosticScheduleError,
|
||||
DiagnosticScheduleRuntime, DiagnosticScheduleStatus, LOGS_CAPABILITY, LOGS_SCHEMA_VERSION, LocalLogConsent,
|
||||
LocalProfileConsent, LogCaptureError, LogCaptureRequest, LogProvenance, MAX_PROFILE_DURATION, MEMORY_PROFILE_CAPABILITY,
|
||||
PROFILE_SCHEMA_VERSION, ProfileCaptureRequest, ProfileData, ProfileError, ProfileOutcome, ProfileProvenance,
|
||||
ProfileReasonCode, ProfileResult, ProfileTool, ReceiptOutcome, SavedLogExport, SavedProfileExport, SignedLogExport,
|
||||
SignedProfileExport, THREAD_PROFILE_CAPABILITY, ThreadProfileScope, capture_cpu_profile, capture_thread_profile,
|
||||
encode_signed_profile_export, export_cpu_profile, export_logs, export_memory_profile, export_thread_profile,
|
||||
run_local_environment_once, save_signed_log_export, save_signed_profile_export, spawn_environment_schedule,
|
||||
DiagnosticScheduleRuntime, DiagnosticScheduleStatus, LOGS_CAPABILITY, LOGS_SCHEMA_VERSION, LocalLogConsent, LocalOtlpHeaders,
|
||||
LocalProfileConsent, LocalTelemetryConsent, LocallyReviewedTraceArtifact, LogCaptureError, LogCaptureRequest, LogProvenance,
|
||||
MAX_OTLP_BODY_BYTES, MAX_PROFILE_DURATION, MAX_SAFE_INTEGER, MAX_TELEMETRY_DURATION, MAX_TELEMETRY_RESULT_BYTES,
|
||||
MAX_TELEMETRY_SPANS, MEMORY_PROFILE_CAPABILITY, ObservedTelemetrySpan, OperationSummary, OtlpBatch, OtlpForwardError,
|
||||
OtlpReceipt, PROFILE_SCHEMA_VERSION, ProfileCaptureRequest, ProfileData, ProfileError, ProfileOutcome, ProfileProvenance,
|
||||
ProfileReasonCode, ProfileResult, ProfileTool, ReceiptOutcome, RecordedTrace, ReplayedTrace, SavedLogExport,
|
||||
SavedProfileExport, SavedTelemetryExport, SignedLogExport, SignedProfileExport, SignedTelemetryExport,
|
||||
TELEMETRY_OTLP_CAPABILITY, TELEMETRY_RECORD_CAPABILITY, TELEMETRY_REPLAY_CAPABILITY, TELEMETRY_SCHEMA_VERSION,
|
||||
THREAD_PROFILE_CAPABILITY, TelemetryArtifactConsent, TelemetryArtifactError, TelemetryArtifactRequest, TelemetryCoverage,
|
||||
TelemetryDiagnosticResult, TelemetryOperation, TelemetryOutcome, TelemetryProducerError, TelemetryProvenance,
|
||||
TelemetryReasonCode, TelemetrySpan, TelemetrySpanStatus, TelemetryTool, ThreadProfileScope, TraceAnalysis,
|
||||
TraceAnalysisError, TraceRecordCapture, TraceRecordCompletion, TraceRecordLimits, TraceReplayError, analyze_trace,
|
||||
capture_cpu_profile, capture_thread_profile, encode_signed_profile_export, encode_signed_telemetry_export,
|
||||
export_cpu_profile, export_logs, export_memory_profile, export_thread_profile, export_trace_otlp, export_trace_otlp_result,
|
||||
record_diagnostic_result, record_trace, record_trace_bus, replay_trace, replay_trace_result, run_local_environment_once,
|
||||
save_signed_log_export, save_signed_profile_export, save_signed_telemetry_export, spawn_environment_schedule,
|
||||
};
|
||||
pub use environment::{
|
||||
ENVIRONMENT_CAPABILITY, ENVIRONMENT_SCHEMA_VERSION, EnvironmentCollectionRequest, EnvironmentError,
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
use crate::{
|
||||
config::{
|
||||
CommandResult, Config, ConnectLicenseCommands, ConnectLicenseScopeOpts, ConnectLogsMode, ConnectLogsOpts,
|
||||
ConnectProfileOpts, ConnectProfileTool, ConnectThreadProfileScope, Opt,
|
||||
ConnectProfileOpts, ConnectProfileTool, ConnectTelemetryArtifactOpts, ConnectTelemetryCommands,
|
||||
ConnectThreadProfileScope, Opt,
|
||||
},
|
||||
startup_lifecycle::{StartupRuntimeLifecycle, run_startup_runtime_lifecycle},
|
||||
startup_preflight::{StartupServerPreflightError, bootstrap_external_prefix_compat, init_startup_server_preflight},
|
||||
@@ -26,7 +27,8 @@ use crate::{
|
||||
storage_api::startup::storage::bootstrap_instance_ctx,
|
||||
};
|
||||
use std::io::{Error, Read as _, Result};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, instrument};
|
||||
|
||||
const LOG_COMPONENT_MAIN: &str = "main";
|
||||
@@ -136,6 +138,7 @@ async fn async_main() -> Result<()> {
|
||||
CommandResult::ConnectLicense(command) => return execute_connect_license(command),
|
||||
CommandResult::ConnectProfile(options) => return execute_connect_profile(options).await,
|
||||
CommandResult::ConnectLogs(options) => return execute_connect_logs(options).await,
|
||||
CommandResult::ConnectTelemetry(command) => return execute_connect_telemetry(command).await,
|
||||
CommandResult::Server(config) => config,
|
||||
};
|
||||
|
||||
@@ -247,6 +250,231 @@ async fn execute_connect_logs(options: ConnectLogsOpts) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_connect_telemetry(command: ConnectTelemetryCommands) -> Result<()> {
|
||||
use crate::connect::{
|
||||
LocalOtlpHeaders, LocallyReviewedTraceArtifact, MAX_OTLP_BODY_BYTES, MAX_TELEMETRY_RESULT_BYTES, OtlpBatch,
|
||||
RecordedTrace, TelemetryDiagnosticResult, TelemetryProducerError, TelemetryTool, TraceRecordLimits, analyze_trace,
|
||||
export_trace_otlp_result, record_diagnostic_result, record_trace_bus, replay_trace_result,
|
||||
};
|
||||
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
|
||||
|
||||
match command {
|
||||
ConnectTelemetryCommands::Record(options) => {
|
||||
let (key, request, consent) = telemetry_context(&options.artifact)?;
|
||||
request.validate().map_err(Error::other)?;
|
||||
if options.duration_millis == 0
|
||||
|| options.duration_millis > 30_000
|
||||
|| options.max_spans == 0
|
||||
|| options.max_spans > 1_024
|
||||
{
|
||||
return Err(Error::other("telemetry record limits are invalid"));
|
||||
}
|
||||
let cancel = CancellationToken::new();
|
||||
let started = Instant::now();
|
||||
let capture = record_trace_bus(
|
||||
consent,
|
||||
TraceRecordLimits {
|
||||
duration: Duration::from_millis(options.duration_millis),
|
||||
max_spans: options.max_spans,
|
||||
},
|
||||
&cancel,
|
||||
);
|
||||
tokio::pin!(capture);
|
||||
let capture = tokio::select! {
|
||||
biased;
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(Error::other)?;
|
||||
cancel.cancel();
|
||||
return Err(Error::other("telemetry record cancelled"));
|
||||
}
|
||||
result = capture.as_mut() => result,
|
||||
};
|
||||
match capture {
|
||||
Ok(capture) => {
|
||||
let result = record_diagnostic_result(&request, capture, started.elapsed());
|
||||
save_telemetry_result(&options.artifact, &request, &result, &key, &cancel, None)
|
||||
}
|
||||
Err(TelemetryProducerError::SourceUnavailable) => {
|
||||
let result = TelemetryDiagnosticResult::<RecordedTrace>::unsupported(
|
||||
&request,
|
||||
TelemetryTool::Record,
|
||||
started.elapsed(),
|
||||
);
|
||||
println!("{}", serde_json::to_string(&result).map_err(Error::other)?);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(Error::other(error)),
|
||||
}
|
||||
}
|
||||
ConnectTelemetryCommands::Otlp(options) => {
|
||||
let (key, request, consent) = telemetry_context(&options.artifact)?;
|
||||
request.validate().map_err(Error::other)?;
|
||||
let body = read_bounded_stdin(MAX_OTLP_BODY_BYTES)?;
|
||||
let batch = OtlpBatch::new(body).map_err(Error::other)?;
|
||||
let endpoint = reqwest::Url::parse(&options.endpoint).map_err(|_| Error::other("invalid OTLP endpoint"))?;
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(name) = options.authorization_env.as_deref() {
|
||||
if !valid_environment_name(name) {
|
||||
return Err(Error::other("invalid OTLP authorization environment variable name"));
|
||||
}
|
||||
let value = std::env::var(name).map_err(|_| Error::other("OTLP authorization is unavailable"))?;
|
||||
let value = HeaderValue::from_str(&value).map_err(|_| Error::other("OTLP authorization is invalid"))?;
|
||||
headers.insert(AUTHORIZATION, value);
|
||||
}
|
||||
let cancel = CancellationToken::new();
|
||||
let result = export_trace_otlp_result(
|
||||
&request,
|
||||
endpoint,
|
||||
LocalOtlpHeaders::new(headers),
|
||||
batch,
|
||||
consent,
|
||||
Duration::from_millis(options.timeout_millis),
|
||||
&cancel,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::other)?;
|
||||
save_telemetry_result(&options.artifact, &request, &result, &key, &cancel, None)
|
||||
}
|
||||
ConnectTelemetryCommands::Replay(options) => {
|
||||
let (key, request, consent) = telemetry_context(&options.artifact)?;
|
||||
request.validate().map_err(Error::other)?;
|
||||
let bytes = read_bounded_stdin(MAX_TELEMETRY_RESULT_BYTES)?;
|
||||
let cancel = CancellationToken::new();
|
||||
let result = replay_trace_result(
|
||||
&request,
|
||||
LocallyReviewedTraceArtifact::new(&bytes).map_err(Error::other)?,
|
||||
consent,
|
||||
&cancel,
|
||||
)
|
||||
.map_err(Error::other)?;
|
||||
let analysis = analyze_trace(
|
||||
result
|
||||
.data()
|
||||
.ok_or_else(|| Error::other("telemetry replay returned no data"))?,
|
||||
consent,
|
||||
&cancel,
|
||||
)
|
||||
.map_err(Error::other)?;
|
||||
save_telemetry_result(
|
||||
&options.artifact,
|
||||
&request,
|
||||
&result,
|
||||
&key,
|
||||
&cancel,
|
||||
Some(serde_json::to_value(analysis).map_err(Error::other)?),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn telemetry_context(
|
||||
options: &ConnectTelemetryArtifactOpts,
|
||||
) -> Result<(
|
||||
crate::connect::DeviceIdentity,
|
||||
crate::connect::TelemetryArtifactRequest,
|
||||
crate::connect::LocalTelemetryConsent,
|
||||
)> {
|
||||
use crate::connect::{
|
||||
IdentityStore, LocalTelemetryConsent, TelemetryArtifactConsent, TelemetryArtifactRequest, TelemetryProvenance,
|
||||
};
|
||||
use rand::{TryRng as _, rngs::SysRng};
|
||||
|
||||
let key = IdentityStore::new(options.state_dir.join("identity"))
|
||||
.load()
|
||||
.map_err(Error::other)?
|
||||
.ok_or_else(|| Error::other("connect telemetry requires an enrolled device identity"))?;
|
||||
let executable_sha256 = hash_current_executable()?;
|
||||
let produced_at_unix = unix_now()?;
|
||||
let remaining = options
|
||||
.consent_expires_at_unix
|
||||
.checked_sub(produced_at_unix)
|
||||
.and_then(|seconds| u64::try_from(seconds).ok())
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.ok_or_else(|| Error::other("local telemetry consent is expired"))?;
|
||||
let consent = LocalTelemetryConsent::new(
|
||||
Instant::now()
|
||||
.checked_add(Duration::from_secs(remaining))
|
||||
.ok_or_else(|| Error::other("local telemetry consent is expired"))?,
|
||||
)
|
||||
.map_err(Error::other)?;
|
||||
let mut nonce = [0_u8; 32];
|
||||
SysRng.try_fill_bytes(&mut nonce).map_err(Error::other)?;
|
||||
let request = TelemetryArtifactRequest {
|
||||
organization_name: options.organization.clone(),
|
||||
cluster_name: options.cluster.clone(),
|
||||
device_name: options.device.clone(),
|
||||
run_uid: options.run_uid.clone(),
|
||||
artifact_uid: options.artifact_uid.clone(),
|
||||
schema_version: crate::connect::TELEMETRY_SCHEMA_VERSION,
|
||||
consent: TelemetryArtifactConsent {
|
||||
consent_uid: options.consent_uid.clone(),
|
||||
policy_revision: options.policy_revision,
|
||||
expires_at_unix: options.consent_expires_at_unix,
|
||||
confirmed: options.acknowledge_l3,
|
||||
},
|
||||
produced_at_unix,
|
||||
expires_at_unix: options.expires_at_unix,
|
||||
nonce,
|
||||
provenance: TelemetryProvenance::new(
|
||||
crate::version::build::COMMIT_HASH,
|
||||
executable_sha256,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
enabled_build_features(),
|
||||
),
|
||||
};
|
||||
Ok((key, request, consent))
|
||||
}
|
||||
|
||||
fn save_telemetry_result<T: serde::Serialize>(
|
||||
options: &ConnectTelemetryArtifactOpts,
|
||||
request: &crate::connect::TelemetryArtifactRequest,
|
||||
result: &crate::connect::TelemetryDiagnosticResult<T>,
|
||||
key: &crate::connect::DeviceIdentity,
|
||||
cancel: &CancellationToken,
|
||||
analysis: Option<serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
let export = crate::connect::encode_signed_telemetry_export(request, result, key, cancel).map_err(Error::other)?;
|
||||
let receipt = crate::connect::save_signed_telemetry_export(&options.output, &export, cancel).map_err(Error::other)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"toolId": export.tool.id(),
|
||||
"outcome": export.outcome.as_str(),
|
||||
"reasonCode": export.reason_code.as_str(),
|
||||
"artifactUid": receipt.artifact_uid,
|
||||
"archiveSizeBytes": receipt.archive_size_bytes,
|
||||
"archiveSha256": receipt.archive_sha256,
|
||||
"analysis": analysis,
|
||||
"upload": "NOT_PERFORMED",
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_bounded_stdin(limit: usize) -> Result<Vec<u8>> {
|
||||
let mut bytes = Vec::with_capacity(limit.min(64 * 1024));
|
||||
std::io::stdin()
|
||||
.take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
|
||||
.read_to_end(&mut bytes)?;
|
||||
if bytes.is_empty() || bytes.len() > limit {
|
||||
return Err(Error::other("telemetry stdin is empty or exceeds its limit"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn valid_environment_name(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
|
||||
}
|
||||
|
||||
fn unix_now() -> Result<i64> {
|
||||
let duration = SystemTime::now().duration_since(UNIX_EPOCH).map_err(Error::other)?;
|
||||
i64::try_from(duration.as_secs()).map_err(Error::other)
|
||||
}
|
||||
|
||||
async fn execute_connect_profile(options: ConnectProfileOpts) -> Result<()> {
|
||||
use crate::connect::{
|
||||
IdentityStore, LocalProfileConsent, ProfileCaptureRequest, ProfileProvenance, ThreadProfileScope, export_cpu_profile,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
use serial_test::serial;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rustfs::connect::{LocalTelemetryConsent, LocallyReviewedTraceArtifact, TraceAnalysisError, analyze_trace, replay_trace};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn consent() -> LocalTelemetryConsent {
|
||||
LocalTelemetryConsent::new(Instant::now() + Duration::from_secs(1)).expect("future consent")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_analysis_is_deterministic_and_uses_only_observed_values() {
|
||||
let bytes = br#"{"spans":[{"operation":"GET_OBJECT","durationMicros":500,"status":"OK"},{"operation":"GET_OBJECT","durationMicros":250,"status":"ERROR"},{"operation":"INTERNAL_RPC","durationMicros":25,"status":"OK"}],"droppedSpanCount":0}"#;
|
||||
let replay = replay_trace(
|
||||
LocallyReviewedTraceArtifact::new(bytes).expect("bounded artifact"),
|
||||
consent(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.expect("valid replay");
|
||||
let first = analyze_trace(&replay, consent(), &CancellationToken::new()).expect("analysis succeeds");
|
||||
let second = analyze_trace(&replay, consent(), &CancellationToken::new()).expect("analysis is repeatable");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.span_count, 3);
|
||||
assert_eq!(first.error_count, 1);
|
||||
assert_eq!(first.total_duration_micros, 775);
|
||||
assert_eq!(first.operations.len(), 2);
|
||||
assert_eq!(first.operations[0].span_count, 2);
|
||||
assert_eq!(first.operations[0].error_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_analysis_honors_stop() {
|
||||
let replay = replay_trace(
|
||||
LocallyReviewedTraceArtifact::new(br#"{"spans":[],"droppedSpanCount":0}"#).expect("bounded artifact"),
|
||||
consent(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.expect("valid replay");
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
let error = analyze_trace(&replay, consent(), &cancel).expect_err("cancelled analysis must fail");
|
||||
assert_eq!(error, TraceAnalysisError::Cancelled);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use serial_test::serial;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use opentelemetry_proto::tonic::{
|
||||
collector::trace::v1::ExportTraceServiceRequest,
|
||||
trace::v1::{ResourceSpans, ScopeSpans, Span},
|
||||
};
|
||||
use prost::Message as _;
|
||||
use reqwest::{Url, header};
|
||||
use rustfs::connect::{
|
||||
LocalOtlpHeaders, LocalTelemetryConsent, LocallyReviewedTraceArtifact, OtlpBatch, OtlpForwardError, TraceReplayError,
|
||||
export_trace_otlp, replay_trace,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn consent() -> LocalTelemetryConsent {
|
||||
LocalTelemetryConsent::new(Instant::now() + Duration::from_secs(2)).expect("future consent")
|
||||
}
|
||||
|
||||
fn encoded_batch(span_count: usize) -> Vec<u8> {
|
||||
ExportTraceServiceRequest {
|
||||
resource_spans: vec![ResourceSpans {
|
||||
scope_spans: vec![ScopeSpans {
|
||||
spans: vec![Span::default(); span_count],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
}
|
||||
.encode_to_vec()
|
||||
}
|
||||
|
||||
fn encoded_named_batch(name_length: usize) -> Vec<u8> {
|
||||
let mut span = Span::default();
|
||||
span.name = "x".repeat(name_length);
|
||||
ExportTraceServiceRequest {
|
||||
resource_spans: vec![ResourceSpans {
|
||||
scope_spans: vec![ScopeSpans {
|
||||
spans: vec![span],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
}
|
||||
.encode_to_vec()
|
||||
}
|
||||
|
||||
async fn collector(status: &str) -> (Url, tokio::task::JoinHandle<Vec<u8>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind collector");
|
||||
let address = listener.local_addr().expect("collector address");
|
||||
let status = status.to_owned();
|
||||
let task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept OTLP request");
|
||||
let mut received = vec![0u8; 4096];
|
||||
let size = stream.read(&mut received).await.expect("read OTLP request");
|
||||
received.truncate(size);
|
||||
let response = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
stream.write_all(response.as_bytes()).await.expect("write response");
|
||||
received
|
||||
});
|
||||
(Url::parse(&format!("http://{address}/v1/traces")).expect("collector URL"), task)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connect_trace_otlp_forwards_bounded_protobuf_and_keeps_auth_out_of_receipt() {
|
||||
let (endpoint, server) = collector("200 OK").await;
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert(header::AUTHORIZATION, header::HeaderValue::from_static("Bearer local-secret"));
|
||||
let receipt = export_trace_otlp(
|
||||
endpoint,
|
||||
LocalOtlpHeaders::new(headers),
|
||||
OtlpBatch::new(encoded_batch(2)).expect("valid batch"),
|
||||
consent(),
|
||||
Duration::from_secs(1),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.expect("collector accepts batch");
|
||||
let request = String::from_utf8(server.await.expect("collector task")).expect("HTTP is UTF-8");
|
||||
assert!(request.to_ascii_lowercase().contains("authorization: bearer local-secret"));
|
||||
assert!(request.to_ascii_lowercase().contains("content-type: application/x-protobuf"));
|
||||
assert_eq!(receipt.accepted_span_count, 2);
|
||||
assert_eq!(receipt.exported_bytes, encoded_batch(2).len() as u64);
|
||||
assert!(
|
||||
!serde_json::to_string(&receipt)
|
||||
.expect("receipt JSON")
|
||||
.contains("local-secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connect_trace_otlp_reports_fixed_failure_without_response_or_secret_body() {
|
||||
let (endpoint, server) = collector("401 Unauthorized").await;
|
||||
let error = export_trace_otlp(
|
||||
endpoint,
|
||||
LocalOtlpHeaders::new(header::HeaderMap::new()),
|
||||
OtlpBatch::new(encoded_batch(1)).expect("valid batch"),
|
||||
consent(),
|
||||
Duration::from_secs(1),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.expect_err("rejection must fail");
|
||||
server.await.expect("collector task");
|
||||
assert_eq!(error, OtlpForwardError::Rejected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_otlp_rejects_remote_plaintext_and_oversized_batches() {
|
||||
assert!(OtlpBatch::new(encoded_batch(1)).is_ok());
|
||||
assert!(OtlpBatch::new(encoded_batch(1024)).is_ok());
|
||||
assert!(OtlpBatch::new(encoded_batch(0)).is_err());
|
||||
assert!(OtlpBatch::new(encoded_batch(1025)).is_err());
|
||||
assert!(OtlpBatch::new(vec![0; 1_048_577]).is_err());
|
||||
assert!(OtlpBatch::new(vec![1]).is_err());
|
||||
let endpoint = Url::parse("http://collector.example/v1/traces").expect("URL");
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime");
|
||||
let error = runtime
|
||||
.block_on(export_trace_otlp(
|
||||
endpoint,
|
||||
LocalOtlpHeaders::new(header::HeaderMap::new()),
|
||||
OtlpBatch::new(encoded_batch(1)).expect("valid batch"),
|
||||
consent(),
|
||||
Duration::from_secs(1),
|
||||
&CancellationToken::new(),
|
||||
))
|
||||
.expect_err("plaintext remote collector must fail");
|
||||
assert_eq!(error, OtlpForwardError::Endpoint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_otlp_lease_blocks_replay_until_the_batch_is_dropped() {
|
||||
let held = OtlpBatch::new(encoded_batch(1)).expect("held parsed batch");
|
||||
let reviewed = br#"{"spans":[],"droppedSpanCount":0}"#;
|
||||
let error = replay_trace(
|
||||
LocallyReviewedTraceArtifact::new(reviewed).expect("reviewed artifact"),
|
||||
consent(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.expect_err("cross-tool concurrency must fail");
|
||||
assert_eq!(error, TraceReplayError::Busy);
|
||||
|
||||
drop(held);
|
||||
replay_trace(
|
||||
LocallyReviewedTraceArtifact::new(reviewed).expect("reviewed artifact"),
|
||||
consent(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.expect("replay succeeds after lease release");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connect_trace_otlp_honors_stop_without_contacting_the_collector() {
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
let error = export_trace_otlp(
|
||||
Url::parse("http://127.0.0.1:9/v1/traces").expect("URL"),
|
||||
LocalOtlpHeaders::new(header::HeaderMap::new()),
|
||||
OtlpBatch::new(encoded_batch(1)).expect("valid batch"),
|
||||
consent(),
|
||||
Duration::from_secs(1),
|
||||
&cancel,
|
||||
)
|
||||
.await
|
||||
.expect_err("cancelled forward must fail");
|
||||
assert_eq!(error, OtlpForwardError::Cancelled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connect_trace_otlp_enforces_the_timeout_byte_budget_before_network_io() {
|
||||
let endpoint = Url::parse("http://127.0.0.1:9/v1/traces").expect("URL");
|
||||
let error = export_trace_otlp(
|
||||
endpoint.clone(),
|
||||
LocalOtlpHeaders::new(header::HeaderMap::new()),
|
||||
OtlpBatch::new(encoded_batch(1024)).expect("bounded actual batch"),
|
||||
consent(),
|
||||
Duration::from_millis(1),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.expect_err("one millisecond cannot send the full batch budget");
|
||||
assert_eq!(error, OtlpForwardError::InvalidBatch);
|
||||
|
||||
let error = export_trace_otlp(
|
||||
endpoint,
|
||||
LocalOtlpHeaders::new(header::HeaderMap::new()),
|
||||
OtlpBatch::new(encoded_batch(1)).expect("bounded actual batch"),
|
||||
consent(),
|
||||
Duration::from_micros(999),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.expect_err("sub-millisecond timeout has no byte budget");
|
||||
assert_eq!(error, OtlpForwardError::InvalidTimeout);
|
||||
|
||||
let error = export_trace_otlp(
|
||||
Url::parse("http://127.0.0.1:9/v1/traces").expect("URL"),
|
||||
LocalOtlpHeaders::new(header::HeaderMap::new()),
|
||||
OtlpBatch::new(encoded_named_batch(200_000)).expect("bounded actual batch"),
|
||||
LocalTelemetryConsent::new(Instant::now() + Duration::from_millis(100)).expect("short consent"),
|
||||
Duration::from_secs(30),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.expect_err("effective consent timeout limits the byte budget");
|
||||
assert_eq!(error, OtlpForwardError::InvalidBatch);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
use serial_test::serial;
|
||||
use std::io::{Cursor, Read as _};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64_simd::URL_SAFE_NO_PAD;
|
||||
use p256::ecdsa::signature::Verifier as _;
|
||||
use p256::ecdsa::{Signature, VerifyingKey};
|
||||
use p256::pkcs8::DecodePublicKey as _;
|
||||
use rustfs::connect::{
|
||||
DeviceIdentity, LocalTelemetryConsent, ObservedTelemetrySpan, TelemetryArtifactConsent, TelemetryArtifactError,
|
||||
TelemetryArtifactRequest, TelemetryDiagnosticResult, TelemetryOperation, TelemetryProducerError, TelemetryProvenance,
|
||||
TelemetrySpanStatus, TelemetryTool, TraceRecordCompletion, TraceRecordLimits, encode_signed_telemetry_export,
|
||||
record_diagnostic_result, record_trace, record_trace_bus, save_signed_telemetry_export,
|
||||
};
|
||||
use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind, trace_emit};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use zip::ZipArchive;
|
||||
|
||||
fn consent() -> LocalTelemetryConsent {
|
||||
LocalTelemetryConsent::new(Instant::now() + Duration::from_secs(5)).expect("future consent")
|
||||
}
|
||||
|
||||
fn artifact_request() -> TelemetryArtifactRequest {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("current time").as_secs() as i64;
|
||||
let organization = "organizations/019e3ae0-0000-7000-8000-000000000001";
|
||||
let cluster = format!("{organization}/clusters/019e3ae0-0000-7000-8000-000000000002");
|
||||
TelemetryArtifactRequest {
|
||||
organization_name: organization.to_owned(),
|
||||
cluster_name: cluster.clone(),
|
||||
device_name: format!("{cluster}/clusterDevices/019e3ae0-0000-7000-8000-000000000003"),
|
||||
run_uid: "019e3ae0-0000-7000-8000-000000000004".to_owned(),
|
||||
artifact_uid: "019e3ae0-0000-7000-8000-000000000005".to_owned(),
|
||||
schema_version: 1,
|
||||
consent: TelemetryArtifactConsent {
|
||||
consent_uid: "019e3ae0-0000-7000-8000-000000000006".to_owned(),
|
||||
policy_revision: 1,
|
||||
expires_at_unix: now + 120,
|
||||
confirmed: true,
|
||||
},
|
||||
produced_at_unix: now,
|
||||
expires_at_unix: now + 60,
|
||||
nonce: [0x5a; 32],
|
||||
provenance: TelemetryProvenance::new("a".repeat(40), "b".repeat(64), "1.0.0-rc.6", vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn archive_entry(archive: &mut ZipArchive<Cursor<Vec<u8>>>, name: &str) -> Vec<u8> {
|
||||
let mut entry = archive.by_name(name).expect("archive entry");
|
||||
let mut bytes = Vec::new();
|
||||
entry.read_to_end(&mut bytes).expect("read archive entry");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connect_trace_record_emits_only_the_frozen_redacted_shape() {
|
||||
let (sender, receiver) = mpsc::channel(4);
|
||||
sender
|
||||
.send(ObservedTelemetrySpan::new(
|
||||
TelemetryOperation::GetObject,
|
||||
Duration::from_micros(500),
|
||||
TelemetrySpanStatus::Ok,
|
||||
))
|
||||
.await
|
||||
.expect("open source");
|
||||
let task = tokio::spawn(async move {
|
||||
record_trace(
|
||||
receiver,
|
||||
consent(),
|
||||
TraceRecordLimits {
|
||||
duration: Duration::from_millis(15),
|
||||
max_spans: 4,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let record = task.await.expect("capture task").expect("capture succeeds");
|
||||
assert_eq!(record.completion, TraceRecordCompletion::Complete);
|
||||
let json = serde_json::to_value(&record.data).expect("serialize record");
|
||||
|
||||
assert_eq!(json["spans"][0]["operation"], "GET_OBJECT");
|
||||
assert_eq!(json["spans"][0]["durationMicros"], 500);
|
||||
assert_eq!(json["spans"][0]["status"], "OK");
|
||||
assert_eq!(json["droppedSpanCount"], 0);
|
||||
let mut root_keys = json
|
||||
.as_object()
|
||||
.expect("record object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
let mut span_keys = json["spans"][0]
|
||||
.as_object()
|
||||
.expect("span object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
root_keys.sort_unstable();
|
||||
span_keys.sort_unstable();
|
||||
assert_eq!(root_keys, ["droppedSpanCount", "spans"]);
|
||||
assert_eq!(span_keys, ["durationMicros", "operation", "status"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connect_trace_record_stops_at_span_limit_and_counts_queued_drops() {
|
||||
let (sender, receiver) = mpsc::channel(8);
|
||||
for _ in 0..3 {
|
||||
sender
|
||||
.send(ObservedTelemetrySpan::new(
|
||||
TelemetryOperation::InternalRpc,
|
||||
Duration::from_micros(1),
|
||||
TelemetrySpanStatus::Error,
|
||||
))
|
||||
.await
|
||||
.expect("open source");
|
||||
}
|
||||
let record = record_trace(
|
||||
receiver,
|
||||
consent(),
|
||||
TraceRecordLimits {
|
||||
duration: Duration::from_secs(1),
|
||||
max_spans: 1,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.expect("bounded capture succeeds");
|
||||
|
||||
assert_eq!(record.data.spans.len(), 1);
|
||||
assert_eq!(record.data.dropped_span_count, 2);
|
||||
assert_eq!(record.completion, TraceRecordCompletion::LimitExceeded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connect_trace_record_marks_a_closed_source_incomplete_after_real_observations() {
|
||||
let (sender, receiver) = mpsc::channel(1);
|
||||
sender
|
||||
.send(ObservedTelemetrySpan::new(
|
||||
TelemetryOperation::HeadObject,
|
||||
Duration::from_micros(2),
|
||||
TelemetrySpanStatus::Ok,
|
||||
))
|
||||
.await
|
||||
.expect("open source");
|
||||
drop(sender);
|
||||
let record = record_trace(
|
||||
receiver,
|
||||
consent(),
|
||||
TraceRecordLimits {
|
||||
duration: Duration::from_secs(1),
|
||||
max_spans: 2,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.expect("observed data remains usable");
|
||||
|
||||
assert_eq!(record.data.spans.len(), 1);
|
||||
assert_eq!(record.completion, TraceRecordCompletion::SourceUnavailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connect_trace_record_rejects_limits_and_honors_stop() {
|
||||
let (_sender, receiver) = mpsc::channel(1);
|
||||
let error = record_trace(
|
||||
receiver,
|
||||
consent(),
|
||||
TraceRecordLimits {
|
||||
duration: Duration::from_secs(31),
|
||||
max_spans: 1,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.expect_err("overlong capture must fail");
|
||||
assert_eq!(error, TelemetryProducerError::InvalidDuration);
|
||||
|
||||
let (_sender, receiver) = mpsc::channel(1);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
let error = record_trace(
|
||||
receiver,
|
||||
consent(),
|
||||
TraceRecordLimits {
|
||||
duration: Duration::from_secs(1),
|
||||
max_spans: 1,
|
||||
},
|
||||
&cancel,
|
||||
)
|
||||
.await
|
||||
.expect_err("cancelled capture must fail");
|
||||
assert_eq!(error, TelemetryProducerError::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_record_requires_live_local_consent() {
|
||||
assert_eq!(
|
||||
LocalTelemetryConsent::new(Instant::now()).expect_err("expired consent must fail"),
|
||||
TelemetryProducerError::ConsentExpired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_record_enforces_exact_duration_and_sample_boundaries() {
|
||||
assert!(
|
||||
TraceRecordLimits {
|
||||
duration: Duration::from_secs(30),
|
||||
max_spans: 1024,
|
||||
}
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
TraceRecordLimits {
|
||||
duration: Duration::from_secs(30) + Duration::from_nanos(1),
|
||||
max_spans: 1024,
|
||||
}
|
||||
.validate()
|
||||
.expect_err("duration N+1 must fail"),
|
||||
TelemetryProducerError::InvalidDuration
|
||||
);
|
||||
assert_eq!(
|
||||
TraceRecordLimits {
|
||||
duration: Duration::from_secs(30),
|
||||
max_spans: 1025,
|
||||
}
|
||||
.validate()
|
||||
.expect_err("sample N+1 must fail"),
|
||||
TelemetryProducerError::InvalidSpanLimit
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connect_trace_record_refuses_to_relabel_the_real_heal_bus_as_frozen_telemetry() {
|
||||
let task = tokio::spawn(async {
|
||||
record_trace_bus(
|
||||
consent(),
|
||||
TraceRecordLimits {
|
||||
duration: Duration::from_millis(80),
|
||||
max_spans: 8,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
assert!(trace_emit(|| {
|
||||
TraceEvent::new(TraceKind::Scanner, TraceFunc::ScannerHealCandidate)
|
||||
.with_bucket("SYNTHETIC_SECRET_BUCKET")
|
||||
.with_object("private/object")
|
||||
.with_duration(Duration::from_micros(41))
|
||||
.with_attr("error", "SYNTHETIC_SECRET_ERROR")
|
||||
}));
|
||||
let error = task
|
||||
.await
|
||||
.expect("capture task")
|
||||
.expect_err("heal/scanner events have no frozen telemetry semantics");
|
||||
assert_eq!(error, TelemetryProducerError::SourceUnavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_record_writes_a_verified_no_clobber_signed_archive() {
|
||||
let request = artifact_request();
|
||||
let key = DeviceIdentity::generate();
|
||||
let result = record_diagnostic_result(
|
||||
&request,
|
||||
rustfs::connect::TraceRecordCapture {
|
||||
data: rustfs::connect::RecordedTrace {
|
||||
spans: vec![rustfs::connect::TelemetrySpan {
|
||||
operation: TelemetryOperation::GetObject,
|
||||
duration_micros: 500,
|
||||
status: TelemetrySpanStatus::Ok,
|
||||
}],
|
||||
dropped_span_count: 0,
|
||||
},
|
||||
completion: TraceRecordCompletion::Complete,
|
||||
},
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
let export =
|
||||
encode_signed_telemetry_export(&request, &result, &key, &CancellationToken::new()).expect("signed telemetry export");
|
||||
let mut archive = ZipArchive::new(Cursor::new(export.archive_bytes.clone())).expect("telemetry archive");
|
||||
assert_eq!(archive.len(), 3);
|
||||
let envelope_bytes = archive_entry(&mut archive, "envelope.json");
|
||||
let signature_bytes = archive_entry(&mut archive, "envelope.sig");
|
||||
let result_bytes = archive_entry(&mut archive, "result.json");
|
||||
let envelope: serde_json::Value = serde_json::from_slice(&envelope_bytes).expect("envelope JSON");
|
||||
let signature: serde_json::Value = serde_json::from_slice(&signature_bytes).expect("signature JSON");
|
||||
let result_json: serde_json::Value = serde_json::from_slice(&result_bytes).expect("result JSON");
|
||||
assert_eq!(envelope["formatVersion"], "rustfs.connect.diagnosticEnvelope/1");
|
||||
assert_eq!(envelope["toolId"], "telemetry.record");
|
||||
assert_eq!(envelope["classification"], "L3");
|
||||
assert_eq!(
|
||||
envelope["payload"]["sha256"],
|
||||
hex_simd::encode_to_string(Sha256::digest(&result_bytes), hex_simd::AsciiCase::Lower)
|
||||
);
|
||||
assert_eq!(result_json["toolId"], "telemetry.record");
|
||||
assert_eq!(result_json["capability"], "telemetry.record@1");
|
||||
assert_eq!(result_json["outcome"], "SUCCEEDED");
|
||||
assert_eq!(result_json["provenance"]["sourceCommit"], "a".repeat(40));
|
||||
|
||||
let raw_signature = URL_SAFE_NO_PAD
|
||||
.decode_to_vec(signature["value"].as_str().expect("signature value"))
|
||||
.expect("base64url signature");
|
||||
let signature = Signature::from_slice(&raw_signature).expect("P-256 signature");
|
||||
let public = VerifyingKey::from_public_key_der(&key.public_key_der()).expect("public key");
|
||||
let mut input = b"rustfs-diagnostic-envelope-v1\0".to_vec();
|
||||
input.extend_from_slice(&envelope_bytes);
|
||||
public.verify(&input, &signature).expect("valid envelope signature");
|
||||
|
||||
let directory = tempfile::tempdir().expect("temporary output");
|
||||
let output = directory.path().join("telemetry.zip");
|
||||
save_signed_telemetry_export(&output, &export, &CancellationToken::new()).expect("saved telemetry export");
|
||||
#[cfg(unix)]
|
||||
assert_eq!(std::fs::metadata(&output).expect("output metadata").permissions().mode() & 0o777, 0o600);
|
||||
assert!(matches!(
|
||||
save_signed_telemetry_export(&output, &export, &CancellationToken::new()),
|
||||
Err(TelemetryArtifactError::AlreadyExists)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_record_rejects_tampered_or_cancelled_archives_without_a_file() {
|
||||
let request = artifact_request();
|
||||
let key = DeviceIdentity::generate();
|
||||
let result = TelemetryDiagnosticResult::succeeded(
|
||||
&request,
|
||||
TelemetryTool::Record,
|
||||
Duration::ZERO,
|
||||
rustfs::connect::RecordedTrace {
|
||||
spans: vec![],
|
||||
dropped_span_count: 0,
|
||||
},
|
||||
);
|
||||
let export =
|
||||
encode_signed_telemetry_export(&request, &result, &key, &CancellationToken::new()).expect("signed telemetry export");
|
||||
let directory = tempfile::tempdir().expect("temporary output");
|
||||
|
||||
let tampered_output = directory.path().join("tampered.zip");
|
||||
let mut tampered = export.clone();
|
||||
tampered.archive_bytes.push(0);
|
||||
assert!(matches!(
|
||||
save_signed_telemetry_export(&tampered_output, &tampered, &CancellationToken::new()),
|
||||
Err(TelemetryArtifactError::InvalidRequest)
|
||||
));
|
||||
assert!(!tampered_output.exists());
|
||||
|
||||
let cancelled_output = directory.path().join("cancelled.zip");
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(matches!(
|
||||
save_signed_telemetry_export(&cancelled_output, &export, &cancel),
|
||||
Err(TelemetryArtifactError::Cancelled)
|
||||
));
|
||||
assert!(!cancelled_output.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_record_never_publishes_an_unsupported_artifact() {
|
||||
let request = artifact_request();
|
||||
let key = DeviceIdentity::generate();
|
||||
let result =
|
||||
TelemetryDiagnosticResult::<rustfs::connect::RecordedTrace>::unsupported(&request, TelemetryTool::Record, Duration::ZERO);
|
||||
assert!(matches!(
|
||||
encode_signed_telemetry_export(&request, &result, &key, &CancellationToken::new()),
|
||||
Err(TelemetryArtifactError::InvalidRequest)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_record_never_publishes_an_incomplete_single_window() {
|
||||
let request = artifact_request();
|
||||
let key = DeviceIdentity::generate();
|
||||
for (completion, expected_reason) in [
|
||||
(TraceRecordCompletion::LimitExceeded, "COLLECTION_FAILED"),
|
||||
(TraceRecordCompletion::SourceUnavailable, "SOURCE_UNAVAILABLE"),
|
||||
] {
|
||||
let result = record_diagnostic_result(
|
||||
&request,
|
||||
rustfs::connect::TraceRecordCapture {
|
||||
data: rustfs::connect::RecordedTrace {
|
||||
spans: vec![rustfs::connect::TelemetrySpan {
|
||||
operation: TelemetryOperation::GetObject,
|
||||
duration_micros: 500,
|
||||
status: TelemetrySpanStatus::Ok,
|
||||
}],
|
||||
dropped_span_count: 1,
|
||||
},
|
||||
completion,
|
||||
},
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
let serialized = serde_json::to_value(&result).expect("failed diagnostic result");
|
||||
assert_eq!(serialized["outcome"], "FAILED");
|
||||
assert_eq!(serialized["coverage"]["requestedUnits"], 1);
|
||||
assert_eq!(serialized["coverage"]["completedUnits"], 0);
|
||||
assert_eq!(serialized["reasonCode"], expected_reason);
|
||||
assert!(serialized["data"].is_null());
|
||||
assert!(matches!(
|
||||
encode_signed_telemetry_export(&request, &result, &key, &CancellationToken::new()),
|
||||
Err(TelemetryArtifactError::InvalidRequest)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_record_rejects_a_forged_partial_complete_result() {
|
||||
let request = artifact_request();
|
||||
let key = DeviceIdentity::generate();
|
||||
let result = TelemetryDiagnosticResult::partial(
|
||||
&request,
|
||||
TelemetryTool::Record,
|
||||
Duration::ZERO,
|
||||
rustfs::connect::TelemetryReasonCode::Complete,
|
||||
rustfs::connect::RecordedTrace {
|
||||
spans: vec![],
|
||||
dropped_span_count: 0,
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
encode_signed_telemetry_export(&request, &result, &key, &CancellationToken::new()),
|
||||
Err(TelemetryArtifactError::InvalidRequest)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use serial_test::serial;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rustfs::connect::{
|
||||
LocalTelemetryConsent, LocallyReviewedTraceArtifact, TelemetryArtifactConsent, TelemetryArtifactRequest, TelemetryProvenance,
|
||||
TraceReplayError, analyze_trace, replay_trace, replay_trace_result,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn consent() -> LocalTelemetryConsent {
|
||||
LocalTelemetryConsent::new(Instant::now() + Duration::from_secs(1)).expect("future consent")
|
||||
}
|
||||
|
||||
fn artifact_request() -> TelemetryArtifactRequest {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("current time").as_secs() as i64;
|
||||
let organization = "organizations/019e3ae0-0000-7000-8000-000000000001";
|
||||
let cluster = format!("{organization}/clusters/019e3ae0-0000-7000-8000-000000000002");
|
||||
TelemetryArtifactRequest {
|
||||
organization_name: organization.to_owned(),
|
||||
cluster_name: cluster.clone(),
|
||||
device_name: format!("{cluster}/clusterDevices/019e3ae0-0000-7000-8000-000000000003"),
|
||||
run_uid: "019e3ae0-0000-7000-8000-000000000004".to_owned(),
|
||||
artifact_uid: "019e3ae0-0000-7000-8000-000000000005".to_owned(),
|
||||
schema_version: 1,
|
||||
consent: TelemetryArtifactConsent {
|
||||
consent_uid: "019e3ae0-0000-7000-8000-000000000006".to_owned(),
|
||||
policy_revision: 1,
|
||||
expires_at_unix: now + 120,
|
||||
confirmed: true,
|
||||
},
|
||||
produced_at_unix: now,
|
||||
expires_at_unix: now + 60,
|
||||
nonce: [0x5a; 32],
|
||||
provenance: TelemetryProvenance::new("a".repeat(40), "b".repeat(64), "1.0.0-rc.6", vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_replay_validates_and_digests_reviewed_bytes_without_file_io() {
|
||||
let bytes = br#"{"spans":[{"operation":"GET_OBJECT","durationMicros":500,"status":"OK"}],"droppedSpanCount":0}"#;
|
||||
let replay = replay_trace(
|
||||
LocallyReviewedTraceArtifact::new(bytes).expect("bounded artifact"),
|
||||
consent(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.expect("valid record replays");
|
||||
|
||||
assert_eq!(replay.spans.len(), 1);
|
||||
assert_eq!(
|
||||
replay.input_artifact_sha256,
|
||||
"1daadd3e226e7dd67a35dd56092ec985f464d832756e75277bc11d4a25c0291b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_replay_wraps_reviewed_bytes_in_the_frozen_result_contract() {
|
||||
let bytes = br#"{"spans":[{"operation":"GET_OBJECT","durationMicros":500,"status":"OK"}],"droppedSpanCount":0}"#;
|
||||
let result = replay_trace_result(
|
||||
&artifact_request(),
|
||||
LocallyReviewedTraceArtifact::new(bytes).expect("bounded artifact"),
|
||||
consent(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.expect("typed replay result");
|
||||
let analysis = analyze_trace(result.data().expect("replayed data"), consent(), &CancellationToken::new())
|
||||
.expect("replayed trace is locally analyzable");
|
||||
assert_eq!(analysis.span_count, 1);
|
||||
assert_eq!(analysis.total_duration_micros, 500);
|
||||
let json = serde_json::to_value(result).expect("result JSON");
|
||||
assert_eq!(json["toolId"], "telemetry.replay");
|
||||
assert_eq!(json["capability"], "telemetry.replay@1");
|
||||
assert_eq!(json["outcome"], "SUCCEEDED");
|
||||
assert_eq!(json["reasonCode"], "COMPLETE");
|
||||
assert_eq!(json["coverage"]["requestedUnits"], 1);
|
||||
assert_eq!(json["coverage"]["completedUnits"], 1);
|
||||
assert_eq!(json["data"]["spans"][0]["operation"], "GET_OBJECT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_replay_rejects_unknown_or_secret_bearing_fields() {
|
||||
let bytes = br#"{"spans":[],"droppedSpanCount":0,"authorization":"Bearer secret"}"#;
|
||||
let error = replay_trace(
|
||||
LocallyReviewedTraceArtifact::new(bytes).expect("bounded artifact"),
|
||||
consent(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.expect_err("unknown field must fail");
|
||||
assert_eq!(error, TraceReplayError::InvalidArtifact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_replay_honors_stop_before_parsing() {
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
let error = replay_trace(
|
||||
LocallyReviewedTraceArtifact::new(br#"{"spans":[],"droppedSpanCount":0}"#).expect("bounded artifact"),
|
||||
consent(),
|
||||
&cancel,
|
||||
)
|
||||
.expect_err("cancelled replay must fail");
|
||||
assert_eq!(error, TraceReplayError::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_replay_rejects_duplicate_fields_and_safe_integer_overflow() {
|
||||
for bytes in [
|
||||
br#"{"spans":[],"spans":[],"droppedSpanCount":0}"#.as_slice(),
|
||||
br#"{"spans":[{"operation":"GET_OBJECT","durationMicros":9007199254740992,"status":"OK"}],"droppedSpanCount":0}"#
|
||||
.as_slice(),
|
||||
br#"{"spans":[],"droppedSpanCount":9007199254740992}"#.as_slice(),
|
||||
] {
|
||||
let error = replay_trace(
|
||||
LocallyReviewedTraceArtifact::new(bytes).expect("bounded artifact"),
|
||||
consent(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.expect_err("invalid record must fail");
|
||||
assert_eq!(error, TraceReplayError::InvalidArtifact);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn connect_trace_replay_enforces_exact_artifact_size_boundary() {
|
||||
assert!(LocallyReviewedTraceArtifact::new(&vec![b' '; 262_144]).is_ok());
|
||||
assert_eq!(
|
||||
LocallyReviewedTraceArtifact::new(&vec![b' '; 262_145]).err(),
|
||||
Some(TraceReplayError::InvalidArtifact)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user