наблюдаемость: завершить базовый контур Community
CI / Rust Checks (push) Failing after 4m28s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped

Добавить структурированные журналы, метрики, трассировку и безопасный канал критических ошибок. Усилить границы рантайма, тесты, проверку зависимостей и сценарии развёртывания.
This commit is contained in:
2026-07-31 01:01:14 +03:00
parent 99bd05c145
commit 0e8f1ca03a
160 changed files with 13506 additions and 1499 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "crank-trace"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
publish.workspace = true
version.workspace = true
[lib]
path = "src/lib.rs"
[dependencies]
tracing.workspace = true
[dev-dependencies]
tracing-subscriber.workspace = true
+198
View File
@@ -0,0 +1,198 @@
//! Закрытый семантический контракт spans Crank.
//!
//! Этот crate не настраивает subscriber и не знает об OTLP. Он ограничивает
//! имена и атрибуты стадий статическим словарём, чтобы продуктовые crate не
//! могли случайно экспортировать пользовательские данные.
use std::future::Future;
use tracing::{Instrument, Span, field::Empty, info_span};
macro_rules! stage_span {
($name:literal) => {
info_span!(
target: "crank::trace",
$name,
outcome = Empty,
error.category = Empty,
)
};
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Stage {
McpRateLimit,
McpAccessCheck,
McpCatalogLoad,
McpToolsResolve,
ApprovalCheck,
RuntimeExecute,
RuntimeArgumentsMap,
RuntimeIdempotency,
UpstreamHttp,
RuntimeResponseTransform,
AuthResolve,
ApprovalRecovery,
HistoryWrite,
DbQuery,
}
impl Stage {
pub fn span(self) -> Span {
match self {
Self::McpRateLimit => stage_span!("mcp.rate_limit"),
Self::McpAccessCheck => stage_span!("mcp.access.check"),
Self::McpCatalogLoad => stage_span!("mcp.catalog.load"),
Self::McpToolsResolve => stage_span!("mcp.tools.resolve"),
Self::ApprovalCheck => stage_span!("approval.check"),
Self::RuntimeExecute => stage_span!("runtime.execute"),
Self::RuntimeArgumentsMap => stage_span!("runtime.arguments.map"),
Self::RuntimeIdempotency => stage_span!("runtime.idempotency"),
Self::UpstreamHttp => stage_span!("upstream.http"),
Self::RuntimeResponseTransform => stage_span!("runtime.response.transform"),
Self::AuthResolve => stage_span!("auth.resolve"),
Self::ApprovalRecovery => stage_span!("approval.recovery"),
Self::HistoryWrite => stage_span!("history.write"),
Self::DbQuery => info_span!(
target: "crank::trace",
"db.query",
outcome = Empty,
error.category = Empty,
db.system = "postgresql",
db.operation = Empty,
),
}
}
pub fn db_span(self, operation: DbOperation) -> Option<Span> {
if self != Self::DbQuery {
return None;
}
let span = self.span();
span.record("db.operation", operation.as_str());
Some(span)
}
}
pub async fn observe_db_query<T, E>(
operation: DbOperation,
future: impl Future<Output = Result<T, E>>,
) -> Result<T, E> {
let span = Stage::DbQuery
.db_span(operation)
.expect("database operation requires db.query stage");
let result = future.instrument(span.clone()).await;
match &result {
Ok(_) => StageOutcome::Success.record(&span),
Err(_) => {
StageOutcome::Error.record(&span);
ErrorCategory::Database.record(&span);
}
}
result
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StageOutcome {
Success,
Error,
Allowed,
Denied,
Required,
Replay,
Execute,
Skipped,
CacheHit,
}
impl StageOutcome {
pub fn record(self, span: &Span) {
span.record("outcome", self.as_str());
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::Error => "error",
Self::Allowed => "allowed",
Self::Denied => "denied",
Self::Required => "required",
Self::Replay => "replay",
Self::Execute => "execute",
Self::Skipped => "skipped",
Self::CacheHit => "cache_hit",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ErrorCategory {
Access,
RateLimit,
Catalog,
Approval,
Idempotency,
Schema,
Mapping,
Upstream,
Transformation,
History,
Database,
Concurrency,
Configuration,
Internal,
}
impl ErrorCategory {
pub fn record(self, span: &Span) {
span.record("error.category", self.as_str());
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Access => "access",
Self::RateLimit => "rate_limit",
Self::Catalog => "catalog",
Self::Approval => "approval",
Self::Idempotency => "idempotency",
Self::Schema => "schema",
Self::Mapping => "mapping",
Self::Upstream => "upstream",
Self::Transformation => "transformation",
Self::History => "history",
Self::Database => "database",
Self::Concurrency => "concurrency",
Self::Configuration => "configuration",
Self::Internal => "internal",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DbOperation {
MachineAccessRead,
MachineAccessTouch,
CatalogLoad,
ApprovalRead,
ApprovalWrite,
AuthProfileRead,
SecretRead,
SecretTouch,
InvocationHistoryWrite,
}
impl DbOperation {
pub const fn as_str(self) -> &'static str {
match self {
Self::MachineAccessRead => "machine_access.read",
Self::MachineAccessTouch => "machine_access.touch",
Self::CatalogLoad => "catalog.load",
Self::ApprovalRead => "approval.read",
Self::ApprovalWrite => "approval.write",
Self::AuthProfileRead => "auth_profile.read",
Self::SecretRead => "secret.read",
Self::SecretTouch => "secret.touch",
Self::InvocationHistoryWrite => "invocation_history.write",
}
}
}
+106
View File
@@ -0,0 +1,106 @@
use std::sync::{Arc, Mutex};
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome};
use tracing::{Id, Subscriber, field::Visit};
use tracing_subscriber::{Layer, layer::SubscriberExt, registry::LookupSpan};
#[test]
fn stage_names_and_attributes_are_closed() {
let captured = Arc::new(Mutex::new(Vec::new()));
let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&captured)));
tracing::subscriber::with_default(subscriber, || {
let span = Stage::RuntimeExecute.span();
StageOutcome::Success.record(&span);
ErrorCategory::Mapping.record(&span);
drop(span);
let db_span = Stage::DbQuery
.db_span(DbOperation::InvocationHistoryWrite)
.expect("db stage accepts a db operation");
StageOutcome::Error.record(&db_span);
drop(db_span);
});
let spans = captured.lock().expect("captured spans");
assert_eq!(spans[0].name, "runtime.execute");
assert_eq!(spans[0].fields["outcome"], "success");
assert_eq!(spans[0].fields["error.category"], "mapping");
assert_eq!(spans[1].name, "db.query");
assert_eq!(spans[1].fields["db.system"], "postgresql");
assert_eq!(spans[1].fields["db.operation"], "invocation_history.write");
}
#[test]
fn non_database_stage_rejects_database_attributes() {
assert!(
Stage::RuntimeExecute
.db_span(DbOperation::CatalogLoad)
.is_none()
);
}
#[derive(Clone)]
struct CaptureLayer(Arc<Mutex<Vec<CapturedSpan>>>);
#[derive(Debug)]
struct CapturedSpan {
name: &'static str,
fields: std::collections::BTreeMap<String, String>,
}
impl<S> Layer<S> for CaptureLayer
where
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
{
fn on_new_span(
&self,
attributes: &tracing::span::Attributes<'_>,
id: &Id,
context: tracing_subscriber::layer::Context<'_, S>,
) {
let mut visitor = FieldVisitor::default();
attributes.record(&mut visitor);
context
.span(id)
.expect("span exists")
.extensions_mut()
.insert(self.0.lock().expect("capture lock").len());
self.0.lock().expect("capture lock").push(CapturedSpan {
name: attributes.metadata().name(),
fields: visitor.fields,
});
}
fn on_record(
&self,
id: &Id,
values: &tracing::span::Record<'_>,
context: tracing_subscriber::layer::Context<'_, S>,
) {
let span = context.span(id).expect("span exists");
let index = *span.extensions().get::<usize>().expect("capture index");
let mut visitor = FieldVisitor::default();
values.record(&mut visitor);
self.0.lock().expect("capture lock")[index]
.fields
.extend(visitor.fields);
}
}
#[derive(Default)]
struct FieldVisitor {
fields: std::collections::BTreeMap<String, String>,
}
impl Visit for FieldVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.fields
.insert(field.name().to_owned(), value.to_owned());
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.fields
.insert(field.name().to_owned(), format!("{value:?}"));
}
}